<?php
/* ========== 最早期致命错误兜底（必须在任何其它代码之前） ==========
 * 为什么必须放在最前面：PHP 的致命错误如果发生在"注册兜底"之前（例如会话初始化
 * 阶段就崩了），页面会直接变成一片空白，而日志里什么都没有 —— 线上完全无法定位，
 * 只能靠猜。这次"index.php 白屏"就卡在这个盲区上。
 * 这里做两件事：
 *   1) 把致命错误写进 data/php_fatal.log（含请求 URI 与峰值内存）；
 *   2) 如果此刻还没有输出过任何内容，就补一个可读的错误页，而不是留白屏给用户。
 *      已经开始输出（例如正在流式下载文件）时绝不插手，避免破坏响应。 */
if (!function_exists('chat_fatal_guard')) {
    function chat_fatal_guard() {
        $e = error_get_last();
        if (!is_array($e)) return;
        $fatal = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
        if (!in_array(intval($e['type'] ?? 0), $fatal, true)) return;
        $dir = __DIR__ . '/data';
        if (!is_dir($dir)) @mkdir($dir, 0755, true);
        $rec = date('Y-m-d H:i:s')
            . ' | ' . ($e['message'] ?? '')
            . ' | ' . ($e['file'] ?? '') . ':' . ($e['line'] ?? 0)
            . ' | uri=' . ($_SERVER['REQUEST_URI'] ?? '')
            . ' | mem=' . intval(memory_get_peak_usage(true) / 1048576) . 'MB'
            . PHP_EOL;
        @file_put_contents($dir . '/php_fatal.log', $rec, FILE_APPEND | LOCK_EX);
        if (!headers_sent()) {
            @header('Content-Type: text/html; charset=utf-8', true, 500);
            echo '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">'
               . '<meta name="viewport" content="width=device-width,initial-scale=1">'
               . '<title>服务器出错了</title></head><body style="margin:0;font:15px/1.8 -apple-system,'
               . 'BlinkMacSystemFont,Segoe UI,Microsoft YaHei,sans-serif;background:#f5f6f8;color:#2b2f36">'
               . '<div style="max-width:640px;margin:70px auto;padding:26px 28px;background:#fff;'
               . 'border-radius:14px;box-shadow:0 6px 24px rgba(0,0,0,.07)">'
               . '<h2 style="margin:0 0 12px;font-size:19px">服务器出错了</h2>'
               . '<p style="margin:0 0 10px;color:#555">程序在页面渲染之前就中断了，所以刚才看到的是空白页。'
               . '原因已经写入服务器上的 <code style="background:#f0f1f4;padding:1px 6px;border-radius:5px">'
               . 'data/php_fatal.log</code>，把这个文件的内容发给开发者就能直接定位。</p>'
               . '<p style="margin:0;color:#8a8f98;font-size:13px">另外请确认站点目录下的 <b>data</b> 文件夹'
               . '可写（755），否则聊天记录与登录状态都无法保存。</p>'
               . '</div></body></html>';
        }
    }
    register_shutdown_function('chat_fatal_guard');
}
// 会话 Cookie 加固：HttpOnly 让脚本读不到会话（XSS 无法直接窃取会话），
// SameSite=Lax 大幅降低跨站伪造请求的成功率，HTTPS 下自动带 Secure。
// 注意：SameSite 是缓解手段，不能替代 CSRF 令牌（见下方 POST 来源校验）。
if (session_status() === PHP_SESSION_NONE) {
    $__https = (!empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off')
        || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
    // 让会话真正"记得住"：
    // 1) Cookie 给 30 天有效期。原来是 lifetime=0（浏览器级），关掉浏览器登录就没了；
    // 2) 服务端会话文件默认闲置 24 分钟就被 PHP 回收（session.gc_maxlifetime），
    //    这是"用着用着突然要重新登录"的主因，这里一并拉长；
    // 3) 会话文件放进 data/sessions/ 独立目录，避免共享主机上被别人的回收策略清掉。
    $__sessDir = __DIR__ . '/data/sessions';
    if (!is_dir($__sessDir)) @mkdir($__sessDir, 0755, true);
    if (is_dir($__sessDir) && is_writable($__sessDir)) session_save_path($__sessDir);
    @ini_set('session.gc_maxlifetime', (string)(30 * 86400));
    // Cookie 参数写法要兼容旧 PHP：数组形式是 PHP 7.3 才支持的，
    // 老主机上会报警告并且参数完全不生效（表现为登录状态存不住）。
    if (PHP_VERSION_ID >= 70300) {
        session_set_cookie_params([
            'lifetime' => 30 * 86400,
            'path'     => '/',
            'httponly' => true,
            'secure'   => $__https,
            'samesite' => 'Lax',
        ]);
    } else {
        // 7.3 以下拿不到 SameSite，退回位置参数；跨站请求由代码里的 POST 来源校验兜住
        session_set_cookie_params(30 * 86400, '/', '', $__https, true);
    }
    session_start();
}

define('DATA_DIR', __DIR__ . '/data/');
define('USERS_FILE', DATA_DIR . 'users.json');
// 账号附加信息（目前只存注册时间）。users.json 里存的是密码哈希，动它的结构风险太大，
// 所以注册时间单独放一个文件，"可见起点"就从这里取。
define('USER_META_FILE', DATA_DIR . 'user_meta.json');
define('MESSAGES_FILE', DATA_DIR . 'messages.json');
define('ONLINE_FILE', DATA_DIR . 'online.json');
define('AVATARS_FILE', DATA_DIR . 'avatars.json');
define('ROOMS_FILE', DATA_DIR . 'rooms.json');
define('GROUPS_FILE', DATA_DIR . 'groups.json');
define('FRIENDS_FILE', DATA_DIR . 'friends.json');
define('FRIEND_REQ_FILE', DATA_DIR . 'friend_requests.json');
define('UNREAD_FILE', DATA_DIR . 'unread.json');
define('NOTES_FILE', DATA_DIR . 'notes.json');
define('PINS_FILE', DATA_DIR . 'pins.json');
define('DELETED_FILE', DATA_DIR . 'deleted.json');
define('AVATAR_UPDATED_FILE', DATA_DIR . 'avatar_updates.json');
define('STATUS_FILE', DATA_DIR . 'statuses.json');
define('AVATAR_DIR', __DIR__ . '/avatars/');
define('GROUP_AVATAR_DIR', __DIR__ . '/group_avatars/');
define('UPLOAD_DIR', __DIR__ . '/uploads/');
define('FILES_DIR', __DIR__ . '/files/');
define('CHUNK_DIR', __DIR__ . '/chunks/');
define('LOG_FILE', DATA_DIR . 'logs.json');
define('BAN_FILE', DATA_DIR . 'banned.json');
define('GROUP_BAN_FILE', DATA_DIR . 'group_bans.json');
define('SENSITIVE_FILE', DATA_DIR . 'sensitive_words.json');
define('GROUP_FILES_FILE', DATA_DIR . 'group_files.json');
// 历史附件补录的一次性标记：存在即表示已经补录过，避免每次打开群文件都扫一遍消息库
define('GROUP_FILES_INDEX_FLAG', DATA_DIR . 'group_files_indexed.flag');
define('SIGNINS_FILE', DATA_DIR . 'signins.json');
define('STICKERS_FILE', DATA_DIR . 'stickers.json');
define('STICKERS_DIR', __DIR__ . '/stickers/');
define('GROUP_MUTES_FILE', DATA_DIR . 'group_mutes.json');
define('NOTICE_FILE', DATA_DIR . 'notice.json');
define('ADMIN_FILE', DATA_DIR . 'admin_config.json');
define('IP_FILE', DATA_DIR . 'ips.json');
define('TYPING_FILE', DATA_DIR . 'typing.json');
define('PROFILES_FILE', DATA_DIR . 'profiles.json');
define('BLACKLIST_FILE', DATA_DIR . 'blacklist.json');
define('CALLS_FILE', DATA_DIR . 'calls.json');
define('CHAT_BG_FILE', DATA_DIR . 'chat_bgs.json');
define('SITE_CONFIG_FILE', DATA_DIR . 'site_config.json');
define('IP_BAN_FILE', DATA_DIR . 'ip_bans.json');
define('KICKS_FILE', DATA_DIR . 'kicks.json');
define('CLIENT_ERR_FILE', DATA_DIR . 'client_errors.json');   // 浏览器侧错误上报（排查线上问题用）
define('ONLINE_TTL', 60);
define('RECALL_TIME_LIMIT', 120);
define('MAX_IMAGE_SIZE', 10 * 1024 * 1024);
define('MAX_FILE_SIZE', 50 * 1024 * 1024);
define('MAX_MESSAGES', 2000);
define('SYSTEM_MSG_TTL', 360);
define('CHUNK_BYTES', 4 * 1024 * 1024);   // 分块上传时每块的大小（字节），配合浏览器大文件切片使用
// ===== 通话（信令）生命周期参数 =====
// 说明：通话记录保存在 calls.json。任何一条非 ended 的记录都会"占线"，
// 因此必须有明确的生命周期边界，否则刷新/断网/关标签会留下永久占线的僵尸记录。
define('CALL_RING_TIMEOUT', 45);          // 响铃超过该秒数未接听 → timeout
define('CALL_PEER_TIMEOUT', 75);          // 参与者超过该秒数没有轮询（心跳丢失）→ disconnect
                                          // 取值需容忍浏览器后台标签页的定时器节流（1 次/分钟），
                                          // 否则"通话中切到别的标签页"会被误判掉线
define('CALL_MAX_DURATION', 3 * 3600);    // 单通电话硬上限，防止异常情况下永久占线
define('CALL_ENDED_TTL', 600);            // ended 记录保留时长，之后清理，避免文件无界增长
define('CALL_MAX_CANDIDATES', 80);        // 单通电话保留的最大 ICE 候选数（背压上限）

// 持久登录与单设备登录的存储（都在 data/ 下，已被目录级 .htaccess 挡住直接访问）
define('AUTH_SESSIONS_FILE', DATA_DIR . 'auth_sessions.json');   // 每个账号当前唯一的活跃会话
define('AUTH_TOKENS_FILE', DATA_DIR . 'auth_tokens.json');       // 长期登录凭据（只存校验子的哈希）
define('REMEMBER_COOKIE', 'chat_remember');
define('REMEMBER_DAYS', 30);
/* 是否强制"同一账号只能一台设备在线"。
 *   true  = 单设备版（默认）：在别处登录会把旧设备挤下线
 *   false = 多设备版：同一账号可在多台设备同时在线，互不影响
 * 两种行为共用同一套代码，改这一行即可切换，其余逻辑都会跟着走。 */
define('ENFORCE_SINGLE_SESSION', false);   // 多设备版：同一账号可多台设备同时在线

foreach ([DATA_DIR, AVATAR_DIR, GROUP_AVATAR_DIR, UPLOAD_DIR, FILES_DIR, CHUNK_DIR] as $dir) {
    if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
        http_response_code(500);
        echo json_encode(['error' => '无法创建目录: ' . $dir]);
        exit;
    }
}
$defaults = [
    USERS_FILE => '{}', USER_META_FILE => '{}', MESSAGES_FILE => '[]', ONLINE_FILE => '{}',
    AVATARS_FILE => '{}', ROOMS_FILE => '{}', GROUPS_FILE => '{}',
    FRIENDS_FILE => '{}', FRIEND_REQ_FILE => '[]', UNREAD_FILE => '{}',
    NOTES_FILE => '{}', PINS_FILE => '{}', DELETED_FILE => '[]',
    AVATAR_UPDATED_FILE => '{}', STATUS_FILE => '{}',
    LOG_FILE => '[]', BAN_FILE => '{}', GROUP_BAN_FILE => '{}', SENSITIVE_FILE => '[]', GROUP_FILES_FILE => '{}', SIGNINS_FILE => '{}', STICKERS_FILE => '[]', GROUP_MUTES_FILE => '{}', NOTICE_FILE => 'null', IP_FILE => '{}', TYPING_FILE => '{}', PROFILES_FILE => '{}', BLACKLIST_FILE => '{}', CALLS_FILE => '{}', CHAT_BG_FILE => '{}', SITE_CONFIG_FILE => '{"registration":true}', IP_BAN_FILE => '{}', KICKS_FILE => '{}', AUTH_SESSIONS_FILE => '{}', AUTH_TOKENS_FILE => '{}'
];
foreach ($defaults as $file => $val) {
    if (!file_exists($file)) file_put_contents($file, $val, LOCK_EX);
}

/* ---------------- 数据目录保护 ----------------
 * data/ 里是全部私聊正文（messages.json）、账号（users.json）、群与好友关系。
 * 只要 Web 服务器允许直连，别人猜到 URL 就能把整包数据下载走 —— 程序里所有的登录与
 * 权限校验都会被绕过。这里在数据目录落一份 .htaccess（同时兼容 Apache 2.2 / 2.4）
 * 加一个空白 index.html，把"直接下载文件"和"列目录"一起挡住。
 * 注意：nginx 不读 .htaccess，需要在站点配置里补
 *     location ^~ /data/ { deny all; }
 * 已经存在的 .htaccess 不会被覆盖，方便自行调整规则。 */
if (!function_exists('protectSecretDir')) {
    function protectSecretDir($dir) {
        $base = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR;
        $ht = $base . '.htaccess';
        if (!file_exists($ht)) {
            $rule = "# 禁止直接访问：本目录存放聊天记录与账号数据，仅允许 PHP 读写\n"
                  . "<IfModule mod_authz_core.c>\n    Require all denied\n</IfModule>\n"
                  . "<IfModule !mod_authz_core.c>\n    Order allow,deny\n    Deny from all\n</IfModule>\n"
                  . "Options -Indexes\n";
            @file_put_contents($ht, $rule, LOCK_EX);
        }
        $idx = $base . 'index.html';
        if (!file_exists($idx)) @file_put_contents($idx, '', LOCK_EX);
    }
}
protectSecretDir(DATA_DIR);

/* 致命错误兜底已上移到文件最前面（chat_fatal_guard）。
 * 这里的注册已移除，避免同一个致命错误被记录两次；
 * 上移的原因见文件开头的说明：放在这里会漏掉"会话初始化阶段"就崩掉的情况。 */

$rooms = json_decode(file_get_contents(ROOMS_FILE), true) ?: [];
if (!isset($rooms['公共聊天'])) {
    $rooms['公共聊天'] = ['type' => 'channel', 'created_by' => 'system', 'created_at' => time()];
    file_put_contents(ROOMS_FILE, json_encode($rooms, JSON_UNESCAPED_UNICODE), LOCK_EX);
}

/**
 * 刷新在线状态。这里是全站最热的写入点（几乎每个请求都会经过），所以做了两件事：
 * 1) 节流：同一用户 + 同一房间 3 秒内只落盘一次。原来每个请求都要加排他锁重写这个文件，
 *    叠加 2.5 秒一次的轮询和多个在线用户就会互相排队；请求在锁上堆积久了会被服务器
 *    直接切断连接（浏览器报 ERR_CONNECTION_CLOSED，用户看到的就是聊天界面突然空白）。
 * 2) 清理：过期条目原来只增不减，文件会随历史用户数无限膨胀，每次读写都要全量解码，越用越慢。
 */
function updateOnline($file, $username, $room = '公共聊天') {
    $now = time();
    // 先做一次不加锁的普通读：绝大多数请求只是因为"刚刚写过"而可以直接返回，不必再动文件
    $data = json_decode(@file_get_contents($file), true);
    if (!is_array($data)) $data = [];
    $prev = $data[$username] ?? null;
    $prevTs = is_array($prev) ? intval($prev['time'] ?? 0) : intval($prev);
    $prevRoom = is_array($prev) ? (string)($prev['room'] ?? '') : '';
    if ($prevTs > 0 && ($now - $prevTs) < 3 && $prevRoom === (string)$room) return;
    $fp = fopen($file, 'c+');
    if (!$fp) return;
    flock($fp, LOCK_EX);
    // 锁内以磁盘内容为准再合并一次，避免并发请求把别人的状态覆盖掉
    $disk = json_decode(stream_get_contents($fp), true);
    if (!is_array($disk)) $disk = [];
    $disk[$username] = ['time' => $now, 'room' => $room];
    foreach ($disk as $u => $v) {
        $t = is_array($v) ? intval($v['time'] ?? 0) : intval($v);
        if ($now - $t > 300) unset($disk[$u]);   // 5 分钟没动静的直接从文件里清掉
    }
    ftruncate($fp, 0); rewind($fp);
    fwrite($fp, json_encode($disk, JSON_UNESCAPED_UNICODE));
    fflush($fp);
    flock($fp, LOCK_UN); fclose($fp);
}
function getOnlineUsers($file) {
    $data = json_decode(@file_get_contents($file), true) ?: [];
    $now = time();
    $active = [];
    foreach ($data as $u => $v) {
        $t = is_array($v) ? ($v['time'] ?? 0) : (int)$v;
        if ($now - $t <= ONLINE_TTL) {
            $active[$u] = is_array($v) ? $v : ['time' => $t, 'room' => '公共聊天'];
        }
    }
    return $active;
}
function getPMRoom($u1, $u2) {
    $arr = [$u1, $u2]; sort($arr);
    return 'pm:' . $arr[0] . ':' . $arr[1];
}
/** 私聊房间名统一收口成 pm:A:B（字典序）。
 *  前端本来用的是 getPMRoomName()，但客户端一旦按"我:对方"的顺序拼房间名
 *  （重连后按状态恢复、或第三方页面直接拼字符串都会这样），
 *  写进去的是 pm:bob:alice，读取却按 pm:alice:bob 精确匹配，
 *  用户看到的就是"我明明发出去了，对方却说没收到"。这里两种顺序都归到同一个房间。 */
function canonicalRoom($room) {
    $room = (string)$room;
    if (strpos($room, 'pm:') !== 0) return $room;
    $parts = explode(':', substr($room, 3));
    if (count($parts) !== 2) return $room;
    if ($parts[0] === '' || $parts[1] === '') return $room;
    $arr = [$parts[0], $parts[1]];
    sort($arr);
    return 'pm:' . $arr[0] . ':' . $arr[1];
}
/** php.ini 里的 2M / 8M / 512K 写法转字节数（-1 视为不限） */
function iniBytes($v) {
    $v = trim((string)$v);
    if ($v === '') return 0;
    if ($v === '-1') return -1;
    $unit = strtolower(substr($v, -1));
    $num  = (float)$v;
    if ($unit === 'g')     $num *= 1024 * 1024 * 1024;
    elseif ($unit === 'm') $num *= 1024 * 1024;
    elseif ($unit === 'k') $num *= 1024;
    return (int)$num;
}
function bytesText($bytes) {
    $bytes = (float)$bytes;
    foreach ([' B', ' KB', ' MB', ' GB'] as $u) {
        if ($bytes < 1024) return round($bytes, $bytes < 10 ? 1 : 0) . $u;
        $bytes /= 1024;
    }
    return round($bytes, 1) . ' TB';
}
/** 上传失败原因翻译。
 *  PHP 只给一个错误码，原来一律回"图片上传失败/文件上传失败"，用户看到的是一句没有信息量的话：
 *  虚拟主机默认 upload_max_filesize=2M、post_max_size=8M，手机原图、压缩包一传就超，
 *  客户会直接判定"这功能坏了"。这里把服务器的真实上限读出来，告诉他差多少、找谁改。 */
function uploadFailMsg($field, $label) {
    $err = isset($_FILES[$field]['error']) ? intval($_FILES[$field]['error']) : UPLOAD_ERR_NO_FILE;
    $umi = iniBytes(ini_get('upload_max_filesize'));
    $pmi = iniBytes(ini_get('post_max_size'));
    $cap = 0;
    foreach ([$umi, $pmi] as $x) { if ($x > 0 && ($cap === 0 || $x < $cap)) $cap = $x; }
    $capText = $cap > 0 ? bytesText($cap) : '未知';
    if ($err === UPLOAD_ERR_INI_SIZE || $err === UPLOAD_ERR_FORM_SIZE) {
        return $label . '太大：服务器单次上传上限只有 ' . $capText
             . '。请让站长把 php.ini 里的 upload_max_filesize 和 post_max_size 调大（例如都设成 64M），'
             . '或者先把文件压缩一下再传。';
    }
    if ($err === UPLOAD_ERR_PARTIAL)  return $label . '只传了一半就断了（网络不稳定），请重新上传。';
    if ($err === UPLOAD_ERR_NO_TMP_DIR) return '服务器没有可写的临时目录（upload_tmp_dir），请联系站长。';
    if ($err === UPLOAD_ERR_CANT_WRITE) return '服务器写临时文件失败（磁盘满或权限不足），请联系站长。';
    if ($err === UPLOAD_ERR_EXTENSION) return $label . '被服务器上的 PHP 扩展拦截了，请联系站长。';
    // $_FILES 里啥都没有：多半是整个请求体超过 post_max_size，PHP 在脚本执行前就把数据丢了
    $clen = intval($_SERVER['CONTENT_LENGTH'] ?? 0);
    if ($clen > 0 && $pmi > 0 && $clen > $pmi) {
        return $label . '太大：本次 ' . bytesText($clen) . '，服务器 post_max_size 只有 ' . bytesText($pmi)
             . '。请让站长把 post_max_size 调大（不能小于 upload_max_filesize），或先压缩再传。';
    }
    return $label . '上传失败（没有收到文件数据），请重试。';
}
/**
 * 用 GD 把上传图片缩放/重编码到 $destPath。
 * 返回输出文件扩展名（jpg/png/webp）；返回 null 表示未压缩（调用方应原样保存）。
 * 未安装 GD、动画 GIF、超大图、小图小文件等场景一律返回 null，保证零风险回退。
 */
/* ===== 频率限制 =====
 * 基于文件的滑动窗口计数，用于发消息、上报等高频入口。
 * 键由调用方拼好（已含用户名/IP），各入口互不影响。
 * 注意：这是"防脚本刷接口"级别的限速，阈值取得宽松，不能影响正常用户。 */
function rateLimit($key, $limit, $windowSec) {
    $file = DATA_DIR . 'rate_limits.json';
    $now = time();
    $all = [];
    if (is_file($file)) {
        $raw = @file_get_contents($file);
        if ($raw !== false && $raw !== '') { $d = json_decode($raw, true); if (is_array($d)) $all = $d; }
    }
    $hits = isset($all[$key]) && is_array($all[$key]) ? $all[$key] : [];
    $fresh = [];
    foreach ($hits as $t) { if (($now - intval($t)) < $windowSec) $fresh[] = intval($t); }
    if (count($fresh) >= $limit) return false;
    $fresh[] = $now;
    $all[$key] = $fresh;
    // 顺手清理 1 小时前的键，避免文件无界增长
    foreach ($all as $k => $v) {
        if (!is_array($v)) { unset($all[$k]); continue; }
        $keep = [];
        foreach ($v as $t) { if (($now - intval($t)) < 3600) $keep[] = intval($t); }
        if (!$keep) unset($all[$k]); else $all[$k] = $keep;
    }
    if (count($all) > 500) $all = array_slice($all, -500, null, true);
    @file_put_contents($file, json_encode($all), LOCK_EX);
    return true;
}

/* ===== SVG 安全清洗 =====
 * SVG 是 XML，可以内嵌 <script>、on* 事件属性、<foreignObject>、javascript: 链接。
 * 表情包允许上传 SVG，一旦用户直接访问 /uploads/xxx.svg，脚本就会在**同源**下执行，
 * 等于一个存储型 XSS 入口（在 <img> 里引用时脚本不会跑，但直接打开会）。
 * 这里在落盘前做一次清洗：去声明/DTD、删可执行与可外链元素、删事件属性、改写危险协议。
 * 清洗后必须仍含 <svg 根节点，否则判为非法文件。 */
function sanitizeSvg($raw) {
    if (!is_string($raw) || $raw === '') return null;
    // 用字符类写法而不是把问号和大于号直接连在一起：效果完全等价，
    // 但源码里不会出现 PHP 结束标记的字面量，任何朴素检测都不会被误导截断。
    $raw = preg_replace('/<[?]xml[^>]*[?]>/i', '', $raw);
    $raw = preg_replace('/<!DOCTYPE[^>]*>/i', '', $raw);
    $raw = preg_replace('/<!ENTITY[^>]*>/i', '', $raw);
    $bad = 'script|foreignObject|iframe|embed|object|handler|listener|meta|link|base|form|input|button|textarea|animate|animatetransform|set';
    // 先删"成对出现"的危险元素（含其内容），再兜底删残留的单标签
    $raw = preg_replace('#<\s*(' . $bad . ')\b[^>]*>.*?<\s*/\s*\1\s*>#is', '', $raw);
    $raw = preg_replace('#<\s*(/)?\s*(' . $bad . ')\b[^>]*>#is', '', $raw);
    $raw = preg_replace('/\son[a-z\-]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $raw);
    $raw = preg_replace('/(href|xlink:href|src)\s*=\s*("|\')\s*(javascript|vbscript|data:text\/html)[^"\']*\2/i', '$1="#"', $raw);
    if (stripos($raw, '<svg') === false) return null;
    return $raw;
}

/**
 * 从一段裸 TIFF 块（不含 "Exif\0\0" 前缀）里读 Orientation（1~8，1 表示无需旋转）。
 * 只解析到 IFD0 的 0x0112 一个标签，不依赖 exif 扩展。
 */
function tiffOrientation($tiff) {
    if (strlen($tiff) < 8) return 1;
    $bo = substr($tiff, 0, 2) === 'MM';
    if (!$bo && substr($tiff, 0, 2) !== 'II') return 1;
    $u16 = function ($s) use ($bo) { return $bo ? ((ord($s[0]) << 8) | ord($s[1])) : ((ord($s[1]) << 8) | ord($s[0])); };
    $u32 = function ($s) use ($bo) {
        $b = [ord($s[0]), ord($s[1]), ord($s[2]), ord($s[3])];
        return $bo ? (($b[0] << 24) | ($b[1] << 16) | ($b[2] << 8) | $b[3])
                   : (($b[3] << 24) | ($b[2] << 16) | ($b[1] << 8) | $b[0]);
    };
    $ifd0 = $u32(substr($tiff, 4, 4));
    if ($ifd0 + 2 > strlen($tiff)) return 1;
    $count = $u16(substr($tiff, $ifd0, 2));
    for ($k = 0; $k < $count; $k++) {
        $off = $ifd0 + 2 + $k * 12;
        if ($off + 12 > strlen($tiff)) break;
        if ($u16(substr($tiff, $off, 2)) === 0x0112) {
            $v = $u16(substr($tiff, $off + 8, 2));
            return ($v >= 1 && $v <= 8) ? $v : 1;
        }
    }
    return 1;
}

/** 读取 JPEG 里的 EXIF Orientation。 */
function jpegExifOrientation($raw) {
    $len = strlen($raw);
    if ($len < 4 || substr($raw, 0, 2) !== "\xFF\xD8") return 1;
    $i = 2;
    while ($i + 4 <= $len && $raw[$i] === "\xFF") {
        $marker = ord($raw[$i + 1]);
        if ($marker === 0xD8 || $marker === 0x01 || ($marker >= 0xD0 && $marker <= 0xD7)) { $i += 2; continue; }
        if ($marker === 0xD9 || $marker === 0xDA) return 1;
        $seglen = (ord($raw[$i + 2]) << 8) | ord($raw[$i + 3]);
        if ($seglen < 2) return 1;
        if ($marker === 0xE1) {
            $payload = substr($raw, $i + 4, $seglen - 2);
            if (strncmp($payload, "Exif\0\0", 6) !== 0) return 1;
            return tiffOrientation(substr($payload, 6));
        }
        $i += 2 + $seglen;
    }
    return 1;
}

/** 读取 PNG eXIf 块里的 Orientation（块数据就是裸 TIFF）。 */
function pngExifOrientation($raw) {
    $sig = "\x89PNG\r\n\x1a\n";
    if (substr($raw, 0, 8) !== $sig) return 1;
    $len = strlen($raw);
    $i = 8;
    while ($i + 8 <= $len) {
        $clen = unpack('N', substr($raw, $i, 4))[1];
        $type = substr($raw, $i + 4, 4);
        if ($clen < 0 || $i + 12 + $clen > $len) return 1;
        if ($type === 'eXIf') return tiffOrientation(substr($raw, $i + 8, $clen));
        if ($type === 'IEND') return 1;
        $i += 12 + $clen;
    }
    return 1;
}

/** 读取 WebP EXIF 块里的 Orientation。 */
function webpExifOrientation($raw) {
    if (substr($raw, 0, 4) !== 'RIFF' || substr($raw, 8, 4) !== 'WEBP') return 1;
    $len = strlen($raw);
    $i = 12;
    while ($i + 8 <= $len) {
        $fourcc = substr($raw, $i, 4);
        $clen = unpack('V', substr($raw, $i + 4, 4))[1];
        $total = 8 + $clen + ($clen % 2);
        if ($i + $total > $len) return 1;
        if ($fourcc === 'EXIF') {
            $data = substr($raw, $i + 8, $clen);
            // 规范说块里是裸 TIFF，但部分编码器仍带 "Exif\0\0" 前缀
            if (strncmp($data, "Exif\0\0", 6) === 0) $data = substr($data, 6);
            return tiffOrientation($data);
        }
        $i += $total;
    }
    return 1;
}

/**
 * 按 EXIF 方向把像素真正转正。
 * 这一步必须做：下面会把 EXIF 整段删掉，浏览器就失去了"自己转正"的依据，
 * 不把方向烘焙进像素的话，竖着拍的照片会横过来。
 */
function applyExifOrientation($im, $orientation) {
    $ori = (int)$orientation;
    if ($ori < 2 || $ori > 8) return $im;
    $flipH = function ($img) { return function_exists('imageflip') ? imageflip($img, IMG_FLIP_HORIZONTAL) : false; };
    $flipV = function ($img) { return function_exists('imageflip') ? imageflip($img, IMG_FLIP_VERTICAL) : false; };
    $rot   = function ($img, $deg) { return function_exists('imagerotate') ? imagerotate($img, $deg, 0) : false; };
    $next = null;
    switch ($ori) {
        case 2: $flipH($im); return $im;
        case 3: $next = $rot($im, 180); break;
        case 4: $flipV($im); return $im;
        case 5: $flipH($im); $next = $rot($im, 90); break;    // 转置：左右镜像 + 逆时针 90°
        case 6: $next = $rot($im, -90); break;                // 顺时针 90°（竖拍最常见）
        case 7: $flipH($im); $next = $rot($im, -90); break;   // 反转置：左右镜像 + 顺时针 90°
        case 8: $next = $rot($im, 90); break;                 // 逆时针 90°
    }
    if (!$next) return $im;
    imagedestroy($im);
    return $next;
}

/** 只带 Orientation 一个标签的最小 EXIF 段（大端 TIFF），用于替换被删掉的原始 EXIF。 */
function minimalExifOrientationSegment($orientation) {
    $tiff = "MM\x00\x2A\x00\x00\x00\x08"           // TIFF 头：大端、魔数 42、IFD0 偏移 8
          . "\x00\x01"                             // IFD0 条目数 = 1
          . "\x01\x12\x00\x03\x00\x00\x00\x01"     // tag=Orientation、type=SHORT、count=1
          . pack('n', (int)$orientation) . "\x00\x00"
          . "\x00\x00\x00\x00";                    // 没有下一个 IFD
    $payload = "Exif\x00\x00" . $tiff;
    return "\xFF\xE1" . pack('n', strlen($payload) + 2) . $payload;
}

/**
 * JPEG：剥掉 APP1(EXIF/XMP)、APP13(IPTC)、COM 注释段，像素数据原样保留。
 * 方向标记需要保留时（$orientation > 1）重新塞回一个只含 Orientation 的最小 EXIF 段。
 */
function stripJpegMeta($raw, $orientation = 1) {
    $len = strlen($raw);
    if ($len < 4 || substr($raw, 0, 2) !== "\xFF\xD8") return null;
    $pending = ($orientation > 1) ? minimalExifOrientationSegment($orientation) : '';
    $out = "\xFF\xD8";
    $i = 2;
    while ($i + 4 <= $len) {
        if ($raw[$i] !== "\xFF") break;
        $marker = ord($raw[$i + 1]);
        if ($marker === 0xFF) { $i++; continue; }
        if ($marker === 0xD8 || $marker === 0x01 || ($marker >= 0xD0 && $marker <= 0xD7)) { $out .= substr($raw, $i, 2); $i += 2; continue; }
        if ($marker === 0xD9) { $out .= "\xFF\xD9"; return $out; }
        $seglen = (ord($raw[$i + 2]) << 8) | ord($raw[$i + 3]);
        if ($seglen < 2 || $i + 2 + $seglen > $len) return null;
        $payload = substr($raw, $i + 4, $seglen - 2);
        $drop = false;
        if ($marker === 0xE1) {
            if (strncmp($payload, "Exif\0\0", 6) === 0 || strncmp($payload, "http://ns.adobe.com/xap/1.0/\0", 29) === 0) $drop = true;
        } elseif ($marker === 0xED) {
            if (strncmp($payload, "Photoshop 3.0\0", 14) === 0) $drop = true;
        } elseif ($marker === 0xFE) {
            $drop = true;
        }
        // 方向段跟在 APP0(JFIF) 后面更规范；没有 APP0 就插在第一个段之前
        if ($pending !== '' && $marker !== 0xE0) { $out .= $pending; $pending = ''; }
        if (!$drop) $out .= substr($raw, $i, 2 + $seglen);
        if ($marker === 0xE0 && $pending !== '') { $out .= $pending; $pending = ''; }
        $i += 2 + $seglen;
        if ($marker === 0xDA) { $out .= substr($raw, $i); return $out; }
    }
    return null;
}

/** PNG：删掉 eXIf / tEXt / zTXt / iTXt / tIME 这些块（拍摄信息大多藏在这里）。 */
function stripPngMeta($raw) {
    $sig = "\x89PNG\r\n\x1a\n";
    if (substr($raw, 0, 8) !== $sig) return null;
    $len = strlen($raw);
    $out = $sig;
    $i = 8;
    while ($i + 8 <= $len) {
        $clen = unpack('N', substr($raw, $i, 4))[1];
        $type = substr($raw, $i + 4, 4);
        if ($clen < 0 || $i + 12 + $clen > $len) return null;
        if (!in_array($type, ['eXIf', 'tEXt', 'zTXt', 'iTXt', 'tIME'], true)) $out .= substr($raw, $i, 12 + $clen);
        $i += 12 + $clen;
        if ($type === 'IEND') return $out;
    }
    return null;
}

/** WebP：删掉 RIFF 里的 EXIF / XMP 块，并同步修正容器长度。 */
function stripWebpMeta($raw) {
    if (substr($raw, 0, 4) !== 'RIFF' || substr($raw, 8, 4) !== 'WEBP') return null;
    $len = strlen($raw);
    $i = 12;
    $body = '';
    while ($i + 8 <= $len) {
        $fourcc = substr($raw, $i, 4);
        $clen = unpack('V', substr($raw, $i + 4, 4))[1];
        $total = 8 + $clen + ($clen % 2);   // 块数据按偶数对齐
        if ($i + $total > $len) return null;
        if ($fourcc !== 'EXIF' && $fourcc !== 'XMP ') $body .= substr($raw, $i, $total);
        $i += $total;
    }
    if ($body === '' || strlen($body) === $len - 12) return null;
    return 'RIFF' . pack('V', 4 + strlen($body)) . 'WEBP' . $body;
}

/**
 * 清洗落盘图片里的元数据（GPS 定位、设备型号、拍摄时间、软件信息等）。
 * GD 重编码本身就会丢掉这些信息，但"小图 / 超大幅面 / 动画 GIF"走的是原图直存分支，
 * 那条路径会把拍摄地点一并存下来，所以这里对文件做一次字节级剥离：
 * 只删元数据段，像素数据一个字节都不动。返回是否真的改写了文件。
 */
function stripImageMetadata($path, $mime) {
    $raw = @file_get_contents($path);
    if ($raw === false || strlen($raw) < 32) return false;
    if ($mime === 'image/jpeg') {
        $out = stripJpegMeta($raw, jpegExifOrientation($raw));
    } elseif ($mime === 'image/png') {
        // PNG/WebP 这条路径没有"重编码烘焙方向"的通道：带方向标记时原样保留元数据，
        // 宁可少删一次也不能把图片删成躺着的（JPEG 那条路已经由 compressImageTo 转正）。
        if (pngExifOrientation($raw) > 1) return false;
        $out = stripPngMeta($raw);
    } elseif ($mime === 'image/webp') {
        if (webpExifOrientation($raw) > 1) return false;
        $out = stripWebpMeta($raw);
    } else {
        return false;   // GIF 基本不带 EXIF，SVG 由 sanitizeSvg 单独处理
    }
    if ($out === null || $out === $raw) return false;
    return @file_put_contents($path, $out, LOCK_EX) !== false;
}

/** 按扩展名推断 mime，供只拿到文件名的上传分支调用。 */
function mimeFromImageExt($ext) {
    $map = ['jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','gif'=>'image/gif','webp'=>'image/webp'];
    return $map[strtolower($ext)] ?? '';
}

function compressImageTo($src, $destPath, $maxW, $maxH, $quality = 82) {
    if (!function_exists('imagecreatefromstring')) return null;
    $info = @getimagesize($src);
    if (!$info) return null;
    $mime = $info['mime'];
    $w = (int)$info[0]; $h = (int)$info[1];
    if ($w <= 0 || $h <= 0) return null;
    // 动画 GIF 直接保留（GD 只能读第一帧，压缩会丢动画）
    if ($mime === 'image/gif') {
        $raw = @file_get_contents($src);
        if ($raw !== false && strpos($raw, 'NETSCAPE2.0') !== false) return null;
    }
    // 小图小文件无需压缩，避免无谓的质量损失
    if ($w <= $maxW && $h <= $maxH && filesize($src) <= 150 * 1024) return null;
    // 超大幅面不压缩，防止 GD 内存溢出
    if ($w * $h > 16000000) return null;
    $data = @file_get_contents($src);
    if ($data === false) return null;
    $im = @imagecreatefromstring($data);
    if (!$im) return null;
    // 手机竖拍的照片靠 EXIF 里的 Orientation 才显示正立，重编码会把这段标记丢掉，
    // 不先把方向烘焙进像素，压缩出来的照片就是躺着的（老版本一直有这个毛病）。
    if ($mime === 'image/jpeg') {
        $ori = jpegExifOrientation($data);
        if ($ori > 1) {
            $im = applyExifOrientation($im, $ori);
            $w = imagesx($im); $h = imagesy($im);
        }
    }
    $ratio = min($maxW / $w, $maxH / $h, 1);
    $nw = max(1, (int)round($w * $ratio));
    $nh = max(1, (int)round($h * $ratio));
    $resized = imagecreatetruecolor($nw, $nh);
    // 保留透明通道，避免黑底
    imagealphablending($resized, false);
    imagesavealpha($resized, true);
    $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127);
    imagefill($resized, 0, 0, $transparent);
    imagecopyresampled($resized, $im, 0, 0, 0, 0, $nw, $nh, $w, $h);
    $outExt = null;
    switch ($mime) {
        case 'image/jpeg':
            $outExt = 'jpg';
            imagejpeg($resized, $destPath, $quality);
            break;
        case 'image/png':
            $outExt = 'png';
            imagepng($resized, $destPath, 9);
            break;
        case 'image/gif':
            $outExt = 'png';
            imagepng($resized, $destPath, 9);
            break;
        case 'image/webp':
            if (function_exists('imagewebp')) {
                $outExt = 'webp';
                imagewebp($resized, $destPath, $quality);
            } else {
                // 无 webp 编码能力时转 JPEG，先铺白底
                $outExt = 'jpg';
                $white = imagecreatetruecolor($nw, $nh);
                $bg = imagecolorallocate($white, 255, 255, 255);
                imagefill($white, 0, 0, $bg);
                imagecopy($white, $resized, 0, 0, 0, 0, $nw, $nh);
                imagejpeg($white, $destPath, $quality);
                imagedestroy($white);
            }
            break;
    }
    imagedestroy($resized);
    imagedestroy($im);
    if ($outExt === null) return null;
    if (!file_exists($destPath) || filesize($destPath) <= 0) return null;
    // 压缩后反而更大则回退原图
    if (filesize($destPath) >= filesize($src)) { @unlink($destPath); return null; }
    return $outExt;
}
/**
 * 轻量多语言组件（登录页与聊天室共用同一份字典与引擎）。
 * 设计：以简体中文原文为键，切换语言时替换文本节点、placeholder、title、alt。
 * 聊天消息与用户名等用户内容（#messagesBox、.msg-name、.user-name 等）不会被翻译。
 * 选择保存在 localStorage；切换后自动刷新页面，保证旧文案不残留。
 */
function i18nScriptBlock() {
    return <<<'I18NJS'
<script>
(function () {
    var DICT = {
        en: {
            '登录': 'Sign in', '注册': 'Sign up', '登 录': 'Sign in', '注 册': 'Sign up',
            '退出登录': 'Sign out', '关闭': 'Close', '知道了': 'Got it', '取消': 'Cancel',
            '确定': 'Confirm', '保存': 'Save', '删除': 'Delete', '编辑': 'Edit', '发送': 'Send',
            '用户名': 'Username', '密码': 'Password', '确认密码': 'Confirm password',
            '用户名（最多20字）': 'Username (max 20)', '密码（至少6位）': 'Password (min 6)',
            '没有账号？': 'No account?', '已有账号？': 'Already registered?',
            '请输入用户名和密码': 'Please enter username and password',
            '在线': 'Online', '点击设置状态': 'Click to set status', '点击更换头像': 'Click to change avatar',
            '查看全部公告': 'All notices',
            '搜索用户 / 群号...': 'Search users / groups...', '搜索好友...': 'Search friends...',
            '搜索群聊 / 群号...': 'Search groups...', '+ 创建群聊': '+ New group',
            '公共聊天': 'Public chat', '好友': 'Friends', '群聊': 'Groups',
            '输入消息...': 'Type a message...', '搜索聊天记录...': 'Search messages...',
            '表情': 'Emoji', '表情包': 'Stickers', '图片': 'Image', '文件': 'File',
            '语音消息': 'Voice message', '语音通话': 'Voice call', '视频通话': 'Video call',
            '上一个': 'Previous', '下一个': 'Next',
            '全部公告': 'All notices', '暂无公告': 'No notices yet',
            '个人设置': 'Profile settings', '更换头像': 'Change avatar',
            '选择图片上传': 'Upload an image', '或选默认头像': 'Or pick a default avatar',
            '修改密码': 'Change password', '消息提示音': 'Message sound',
            '已开启': 'On', '已关闭': 'Off', '暗色模式': 'Dark mode',
            '语言': 'Language',
            '编辑资料': 'Edit profile', '黑名单管理': 'Blocklist',
            '原密码': 'Current password', '新密码（至少6位）': 'New password (min 6)',
            '确认新密码': 'Confirm new password', '群聊名称': 'Group name',
            '输入群公告内容...': 'Group announcement...', '群公告': 'Group notice',
            '清除公告': 'Clear notice', '输入备注（留空则清除）': 'Note (blank to clear)',
            '搜索好友或群聊...': 'Search friends or groups...', '搜索用户名...': 'Search username...',
            '输入群头衔（留空清除）': 'Title (blank to clear)',
            '发送文件': 'Send files', '点击这里选择文件': 'Click here to choose files',
            '也可以把文件从桌面直接拖进来': 'Or drag files here from your desktop',
            '还没有选择文件': 'No files selected yet'
        },
        ja: {
            '登录': 'ログイン', '注册': '新規登録', '登 录': 'ログイン', '注 册': '新規登録',
            '退出登录': 'ログアウト', '关闭': '閉じる', '知道了': '了解', '取消': 'キャンセル',
            '确定': '確認', '保存': '保存', '删除': '削除', '编辑': '編集', '发送': '送信',
            '用户名': 'ユーザー名', '密码': 'パスワード', '确认密码': 'パスワードの確認',
            '用户名（最多20字）': 'ユーザー名（20文字以内）', '密码（至少6位）': 'パスワード（6文字以上）',
            '没有账号？': 'アカウントをお持ちでない方は', '已有账号？': 'すでにアカウントをお持ちの方は',
            '请输入用户名和密码': 'ユーザー名とパスワードを入力してください',
            '在线': 'オンライン', '点击设置状态': 'クリックしてステータス設定', '点击更换头像': 'クリックしてアバター変更',
            '查看全部公告': 'お知らせ一覧',
            '搜索用户 / 群号...': 'ユーザー・グループを検索...', '搜索好友...': '友だちを検索...',
            '搜索群聊 / 群号...': 'グループを検索...', '+ 创建群聊': '+ グループ作成',
            '公共聊天': 'パブリックチャット', '好友': '友だち', '群聊': 'グループ',
            '输入消息...': 'メッセージを入力...', '搜索聊天记录...': 'メッセージを検索...',
            '表情': '絵文字', '表情包': 'スタンプ', '图片': '画像', '文件': 'ファイル',
            '语音消息': 'ボイスメッセージ', '语音通话': '音声通話', '视频通话': 'ビデオ通話',
            '上一个': '前へ', '下一个': '次へ',
            '全部公告': 'お知らせ一覧', '暂无公告': 'お知らせはありません',
            '个人设置': 'プロフィール設定', '更换头像': 'アバターを変更',
            '选择图片上传': '画像をアップロード', '或选默认头像': 'または既定のアバターを選択',
            '修改密码': 'パスワード変更', '消息提示音': 'メッセージ音',
            '已开启': 'オン', '已关闭': 'オフ', '暗色模式': 'ダークモード',
            '语言': '言語',
            '编辑资料': 'プロフィール編集', '黑名单管理': 'ブロックリスト',
            '原密码': '現在のパスワード', '新密码（至少6位）': '新しいパスワード（6文字以上）',
            '确认新密码': '新しいパスワードの確認', '群聊名称': 'グループ名',
            '输入群公告内容...': 'グループのお知らせ...', '群公告': 'グループのお知らせ',
            '清除公告': 'お知らせを削除', '输入备注（留空则清除）': 'メモ（空欄で削除）',
            '搜索好友或群聊...': '友だち・グループを検索...', '搜索用户名...': 'ユーザー名を検索...',
            '输入群头衔（留空清除）': '称号（空欄で削除）',
            '发送文件': 'ファイルを送信', '点击这里选择文件': 'ここをクリックしてファイルを選択',
            '也可以把文件从桌面直接拖进来': 'デスクトップからドラッグしても追加できます',
            '还没有选择文件': 'ファイルがまだ選択されていません'
        }
    };
    var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, TEXTAREA: 1, INPUT: 1, SELECT: 1, OPTION: 1, CODE: 1 };
    var SKIP_SEL = '#messagesBox, .msg-name, .user-name, .friend-name, .group-name, [data-no-i18n]';

    window.LANG = localStorage.getItem('chatroom_lang') || 'zh';
    window.t = function (s) {
        var m = DICT[window.LANG];
        return (m && m[s]) || s;
    };
    window.setLang = function (lang) {
        localStorage.setItem('chatroom_lang', lang);
        location.reload();
    };

    function skipEl(el) {
        if (!el) return true;
        if (SKIP_TAGS[el.tagName]) return true;
        return !!(el.closest && el.closest(SKIP_SEL));
    }
    window.applyI18n = function (root) {
        if (window.LANG === 'zh') return;
        var scope = root || document.body;
        if (!scope) return;
        var walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT, {
            acceptNode: function (n) {
                var v = n.nodeValue;
                if (!v || !v.trim()) return NodeFilter.FILTER_REJECT;
                if (skipEl(n.parentElement)) return NodeFilter.FILTER_REJECT;
                return NodeFilter.FILTER_ACCEPT;
            }
        });
        var nodes = [], n;
        while ((n = walker.nextNode())) nodes.push(n);
        for (var i = 0; i < nodes.length; i++) {
            var raw = nodes[i].nodeValue, key = raw.trim(), rep = window.t(key);
            if (rep !== key) nodes[i].nodeValue = raw.replace(key, rep);
        }
        var attrs = ['placeholder', 'title', 'alt'];
        for (var a = 0; a < attrs.length; a++) {
            var list = scope.querySelectorAll ? scope.querySelectorAll('[' + attrs[a] + ']') : [];
            for (var j = 0; j < list.length; j++) {
                var el = list[j];
                if (SKIP_TAGS[el.tagName] && attrs[a] !== 'placeholder') continue;
                var cur = el.getAttribute(attrs[a]) || '', k2 = cur.trim(), r2 = window.t(k2);
                if (r2 !== k2) el.setAttribute(attrs[a], r2);
            }
        }
    };

    // 动态渲染的界面（弹窗、面板）自动补翻译；只监听节点新增，不会与文本替换互相触发。
    if (window.LANG !== 'zh') {
        var timer = null;
        var obs = new MutationObserver(function (muts) {
            for (var i = 0; i < muts.length; i++) {
                var m = muts[i];
                if (m.type === 'childList' && m.addedNodes && m.addedNodes.length) {
                    clearTimeout(timer);
                    timer = setTimeout(function () { window.applyI18n(); }, 0);
                    return;
                }
            }
        });
        var start = function () {
            obs.observe(document.body, { childList: true, subtree: true });
            window.applyI18n();
        };
        if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
        else start();
    }
})();
</script>
I18NJS;
}
function areFriends($u1, $u2) {
    $friends = json_decode(file_get_contents(FRIENDS_FILE), true) ?: [];
    return in_array($u2, $friends[$u1] ?? []);
}
function writeLog($user, $action, $detail = '') {
    $logs = json_decode(@file_get_contents(LOG_FILE), true);
    if (!is_array($logs)) $logs = [];
    // detail 里会带文件名、群名等外部输入，先清洗：非法字节会让 json_encode 失败，
    // 而写入 false 会把整份日志清空（和群文件清单是同一个坑）
    $logs[] = [
        'time' => time(),
        'user' => cleanUtf8($user ?: 'system'),
        'action' => cleanUtf8($action),
        'detail' => mb_substr(cleanUtf8($detail), 0, 300),
        'ip' => clientIp()
    ];
    if (count($logs) > 8000) $logs = array_slice($logs, -8000);
    saveJson(LOG_FILE, $logs);
}
function ipv4From($ip) {
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip;
    if (strpos($ip, '::ffff:') === 0) {
        $tail = substr($ip, 7);
        if (filter_var($tail, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $tail;
    }
    return null;
}
function clientIp() {
    $cands = [];
    foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR'] as $h) {
        if (!empty($_SERVER[$h])) {
            foreach (explode(',', $_SERVER[$h]) as $p) {
                $p = trim($p);
                if (filter_var($p, FILTER_VALIDATE_IP)) $cands[] = $p;
            }
        }
    }
    if (!empty($_SERVER['REMOTE_ADDR']) && filter_var($_SERVER['REMOTE_ADDR'], FILTER_VALIDATE_IP)) $cands[] = $_SERVER['REMOTE_ADDR'];
    foreach ($cands as $c) { $v4 = ipv4From($c); if ($v4) return $v4; }
    return $cands[0] ?? '未知';
}
function recordIp($user) {
    if ($user === '') return;
    $ips = json_decode(@file_get_contents(IP_FILE), true) ?: [];
    $ips[$user] = ['ip' => clientIp(), 'time' => time()];
    @file_put_contents(IP_FILE, json_encode($ips, JSON_UNESCAPED_UNICODE), LOCK_EX);
}
function isBanned($user) {
    $banned = json_decode(@file_get_contents(BAN_FILE), true) ?: [];
    return $banned[$user] ?? null;
}
function removeBan($user) {
    $banned = json_decode(@file_get_contents(BAN_FILE), true) ?: [];
    if (isset($banned[$user])) unset($banned[$user]);
    @file_put_contents(BAN_FILE, json_encode($banned, JSON_UNESCAPED_UNICODE), LOCK_EX);
}
function isGroupBanned($gname) {
    $gban = json_decode(@file_get_contents(GROUP_BAN_FILE), true) ?: [];
    return $gban[$gname] ?? null;
}
/**
 * 附件 url（群文件与消息里的图片/文件通用）-> 磁盘路径。
 * 只接受 "uploads/单层文件名" 或 "files/单层文件名"，名字里只要出现斜杠
 * （含 ../ 这类）一律拒绝，从根上堵住目录穿越。
 */
function groupFileDiskPath($url, $mustExist = true) {
    $url = (string)$url;
    if (strpos($url, 'uploads/') === 0) { $dir = UPLOAD_DIR; $rel = substr($url, 8); }
    elseif (strpos($url, 'files/') === 0) { $dir = FILES_DIR; $rel = substr($url, 6); }
    else return null;
    if ($rel === '' || $rel !== basename($rel)) return null;
    $path = $dir . $rel;
    if ($mustExist && !is_file($path)) return null;
    return $path;
}
/**
 * 把外部输入清洗成合法 UTF-8。
 * 文件名、消息内容这些都会原样进 JSON，只要里面混了非法字节（某些系统上传的文件名
 * 不是 UTF-8），json_encode 会直接返回 false。而 false 交给 file_put_contents 写下去
 * 就是空字符串 —— 整个文件被清空，比如整群的群文件清单会一次性消失。
 */
function cleanUtf8($s) {
    $s = (string)$s;
    if ($s === '' || mb_check_encoding($s, 'UTF-8')) return $s;
    $out = @iconv('UTF-8', 'UTF-8//IGNORE', $s);
    if ($out === false) $out = preg_replace('/[\x80-\xFF]/', '', $s);
    return (string)$out;
}

/**
 * 安全落盘 JSON：编码失败直接拒绝写，写入走"临时文件 + rename"原子替换。
 * 原来直接 file_put_contents($f, json_encode($d)) 有两个隐患：
 * 1) json_encode 失败时写入空内容，把原文件整个清掉（数据不可恢复）；
 * 2) 写到一半被中断（超时、磁盘满）会留下半截 JSON，读出来是空。
 * 返回 true/false，调用方必须检查 —— 否则"上传成功"只是假象。
 */
function saveJson($path, $data) {
    $json = json_encode($data, JSON_UNESCAPED_UNICODE);
    if ($json === false) return false;
    $tmp = $path . '.' . getmypid() . '.tmp';
    if (@file_put_contents($tmp, $json, LOCK_EX) === false) { @unlink($tmp); return false; }
    if (!@rename($tmp, $path)) {
        // 少数环境不允许在目录里改名（例如目录只给了文件写权限），退回直接覆盖写：
        // 失去原子性，但总好过功能直接不可用
        @unlink($tmp);
        return @file_put_contents($path, $json, LOCK_EX) !== false;
    }
    return true;
}

/**
 * 读取消息库，失败时返回 null（与"确实是空"区分开）。
 * 写消息用的是"flock + ftruncate + rewind + fwrite"的就地改写，清空与写回之间存在
 * 极短的空窗；而这里是普通读、不拿锁，正好撞进空窗就会读到空内容或半截 JSON，
 * json_decode 直接失败 —— 表现就是"某一次轮询聊天记录全空"，用户看到消息整片消失。
 * 空窗只有几十微秒，所以失败后短暂等待再重试一次几乎必定成功；
 * 真连读两次都不行，才返回 null 让调用方决定怎么办，而不是把故障放大成空列表。
 */
function loadMessagesOrNull() {
    for ($i = 0; $i < 2; $i++) {
        $raw = @file_get_contents(MESSAGES_FILE);
        if ($raw !== false && $raw !== '') {
            $d = json_decode($raw, true);
            if (is_array($d)) return $d;
        }
        if ($i === 0) usleep(15000);   // 15ms，足够跨过写入空窗
    }
    return null;
}
function loadMessages() {
    $d = loadMessagesOrNull();
    return is_array($d) ? $d : [];
}

/**
 * group_files.json 的写入统一入口：先用独立的 .lock 文件互斥，再做"读-改-写"。
 * 为什么不用 flock 锁数据文件本身：落盘是"写临时文件 + rename"的原子替换，
 * rename 之后旧句柄指向的是旧 inode，等在锁上的写者会拿旧内容覆盖别人的更新。
 * 回调返回数组则写回，返回 null 表示这次不改动。
 */
function withGroupFilesLock(callable $mutator) {
    $lock = @fopen(GROUP_FILES_FILE . '.lock', 'c');
    if (!$lock) return false;
    if (!flock($lock, LOCK_EX)) { fclose($lock); return false; }
    $all = json_decode(@file_get_contents(GROUP_FILES_FILE), true);
    if (!is_array($all)) $all = [];
    $next = null;
    try { $next = $mutator($all); } catch (Throwable $e) { $next = null; }
    $ok = ($next === null) ? true : saveJson(GROUP_FILES_FILE, $next);
    flock($lock, LOCK_UN); fclose($lock);
    return $ok;
}

/**
 * 把群聊里发出的附件登记到群文件清单：图片进"群相册"，文件进"群文件"。
 * 以前群文件清单只收录从"群文件弹窗"上传的东西，所以大家在群里发的照片、
 * 文件在群文件/群相册里根本看不到，都以为是丢了。
 * 同一个 url 只登记一次（重发、编辑都不会产生重复条目）；这里失败不影响消息本身已发出。
 */
function archiveGroupAttachment($groupName, $user, $image, $file, $fileName, $fileSize) {
    $groupName = (string)$groupName;
    if ($groupName === '') return false;
    $entries = [];
    if ($image !== '') {
        $entries[] = ['name' => cleanUtf8(basename($image)), 'url' => $image, 'size' => 0, 'type' => 'image'];
    }
    if ($file !== '') {
        $name = $fileName !== '' ? $fileName : basename($file);
        $entries[] = ['name' => cleanUtf8($name), 'url' => $file, 'size' => intval($fileSize), 'type' => 'file'];
    }
    if (!$entries) return true;
    $added = 0;
    $ok = withGroupFilesLock(function ($all) use ($groupName, $user, $entries, &$added) {
        if (!isset($all[$groupName]) || !is_array($all[$groupName])) $all[$groupName] = [];
        $exist = [];
        foreach ($all[$groupName] as $f) { if (isset($f['url'])) $exist[(string)$f['url']] = true; }
        foreach ($entries as $e) {
            if (isset($exist[$e['url']])) continue;      // 已经收录过，不重复登记
            $e['user'] = $user;
            $e['time'] = time();
            $all[$groupName][] = $e;
            $exist[$e['url']] = true;
            $added++;
        }
        if ($added === 0) return null;                    // 没有新东西，不必写盘
        if (count($all[$groupName]) > 200) $all[$groupName] = array_slice($all[$groupName], -200);
        return $all;
    });
    if (!$ok) writeLog($user, 'group_archive_fail', $groupName . ' / 群聊附件归档失败');
    return $ok;
}

/**
 * 一次性补录：把历史群聊消息里的附件补进群文件清单。
 * 以前群文件只收录"从群文件弹窗上传"的东西，所以大家在群里发过的照片、文件全都不在清单里。
 * 上线这个改动后只对"新发的"生效，这里把已经在消息里的也补上，新旧内容都能看到。
 * 只做一次（用标记文件），否则每次打开清单都要扫一遍消息库。
 */
function rebuildGroupFilesOnce() {
    $flag = GROUP_FILES_INDEX_FLAG;
    if (file_exists($flag)) return;
    $messages = loadMessagesOrNull();
    // 读不到就先别往下走：这个函数一旦执行完就会打标记，之后再也不会补录。
    // 若因一次读取失败就把标记打上，历史附件将永久进不了群文件清单。
    if (!is_array($messages)) return;
    $count = 0;
    withGroupFilesLock(function ($all) use ($messages, &$count) {
        $exist = [];
        foreach ($all as $g => $list) {
            if (!is_array($list)) continue;
            foreach ($list as $f) { if (isset($f['url'])) $exist[$g . '|' . $f['url']] = true; }
        }
        foreach ($messages as $m) {
            $room = (string)($m['room'] ?? '');
            if (strpos($room, 'group:') !== 0) continue;
            $g = substr($room, 6);
            if ($g === '') continue;
            $img = (string)($m['image'] ?? '');
            $fl  = (string)($m['file'] ?? '');
            $items = [];
            if ($img !== '') $items[] = ['name'=>cleanUtf8(basename($img)), 'url'=>$img, 'size'=>0, 'type'=>'image'];
            if ($fl !== '') $items[] = ['name'=>cleanUtf8((string)($m['file_name'] ?? '') !== '' ? $m['file_name'] : basename($fl)), 'url'=>$fl, 'size'=>intval($m['file_size'] ?? 0), 'type'=>'file'];
            foreach ($items as $it) {
                $key = $g . '|' . $it['url'];
                if (isset($exist[$key])) continue;
                if (!isset($all[$g]) || !is_array($all[$g])) $all[$g] = [];
                $it['user'] = (string)($m['username'] ?? '');
                $it['time'] = intval($m['timestamp'] ?? 0);
                $all[$g][] = $it;
                $exist[$key] = true;
                $count++;
            }
        }
        if ($count === 0) return null;
        foreach ($all as $g => $list) {
            if (is_array($list) && count($list) > 200) $all[$g] = array_slice($list, -200);
        }
        return $all;
    });
    // 标记文件同时留一份结果，方便排查"怎么没补上"
    @file_put_contents($flag, date('Y-m-d H:i:s') . ' 补录附件 ' . $count . ' 个（消息 ' . count($messages) . ' 条）' . PHP_EOL, LOCK_EX);
}

/**
 * 在"成员/管理员"这类名单里查人。
 * 统一把两边转成字符串再比较：名单的写入路径有好几条（建群、邀请、申请通过、
 * 免申请加入、设置管理员），而纯数字用户名（例如"123"）经过 JSON 往返后有可能变成整数。
 * 一旦类型不一致，严格比较就会把同一个人判成"不在名单里"——表现是群文件列不出来、
 * 下载被拒、踢人时提示"对方不在群中"，但上传却是好的（那条路径用的是宽松比较），
 * 所以看起来像"时好时坏"，很难定位。
 */
function listHasUser($list, $user) {
    if (!is_array($list)) return false;
    $user = (string)$user;
    foreach ($list as $m) {
        if (is_scalar($m) && (string)$m === $user) return true;
    }
    return false;
}

function isGroupMember($group, $user) {
    if (!is_array($group)) return false;
    return listHasUser($group['members'] ?? [], $user);
}

/**
 * 群文件条目查询 + 成员鉴权。返回该文件在清单里的记录（含原始文件名），
 * 不是群成员、或 url 不在这个群的清单里，都返回 null。
 */
function groupFileEntry($groupName, $url, $user) {
    $groupName = (string)$groupName;
    $url = (string)$url;
    if ($groupName === '' || $url === '') return null;
    $groups = json_decode(@file_get_contents(GROUPS_FILE), true) ?: [];
    if (!is_array($groups) || !isGroupMember($groups[$groupName] ?? null, $user)) return null;
    $all = json_decode(@file_get_contents(GROUP_FILES_FILE), true) ?: [];
    foreach (($all[$groupName] ?? []) as $f) {
        if (($f['url'] ?? '') === $url) return $f;
    }
    return null;
}
// 群文件下载响应头：中文名走 filename*（RFC 5987），同时给一个 ASCII 兜底名
function sendGroupFileHeaders($name, $size, $mime = 'application/octet-stream') {
    while (ob_get_level()) { ob_end_clean(); }
    $ascii = preg_replace('/[^\x20-\x7e]/', '_', (string)$name);
    $ascii = str_replace(['"', '\\'], '_', $ascii);
    if ($ascii === '' || trim($ascii, '_') === '') $ascii = 'download';
    header('Content-Type: ' . $mime);
    if ($size > 0) header('Content-Length: ' . $size);
    header('Content-Disposition: attachment; filename="' . $ascii . '"; filename*=UTF-8\'\'' . rawurlencode((string)$name));
    header('Cache-Control: no-store');
}
// 房间访问控制：私聊必须是参与者、群聊必须是成员。通过返回 null，否则返回 [code, msg]。
function roomAccessError($room, $user) {
    $room = (string)$room;
    if (strpos($room, 'pm:') === 0) {
        $parts = explode(':', substr($room, 3));
        if (count($parts) !== 2 || !in_array($user, $parts, true)) return ['code'=>'room_denied', 'msg'=>'无权访问该会话'];
    } elseif (strpos($room, 'group:') === 0) {
        $gName = substr($room, 6);
        $groups = json_decode(@file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isGroupMember($groups[$gName] ?? null, $user)) return ['code'=>'room_denied', 'msg'=>'你不在该群中'];
    }
    return null;
}
/**
 * 头像 URL 附带版本号（取头像文件的修改时间）。
 * 头像文件名是固定的（avatars/avatar_用户名.ext、group_avatars/group_md5.ext），
 * 换头像时 URL 不变，浏览器就会拿缓存里的旧图：表现就是"会话列表还是旧头像、
 * 消息区却已经是新的"，后台看到的那张也跟聊天室对不上。
 * 所有对外下发头像的地方统一走这里，URL 随文件一变就自然绕开缓存。
 */
function avatarUrl($path) {
    $path = (string)$path;
    if ($path === '') return $path;
    // 只认自己目录下的相对路径，避免这个函数被用来探测任意文件
    if (strpos($path, 'avatars/') !== 0 && strpos($path, 'group_avatars/') !== 0) return $path;
    if (strpos($path, '..') !== false) return $path;
    $abs = __DIR__ . '/' . $path;
    if (!is_file($abs)) return $path;
    return $path . '?t=' . (int)@filemtime($abs);
}
/* ===== 消息可见性（对齐 QQ / 微信）=====
 * 规则一句话：**你只能看到"你进来之后"的消息**。
 * - 群聊：从加入该群的时间开始可见（群主可打开 history_visible 放开这条限制）
 * - 公共聊天等频道：从注册时间开始可见
 * - 私聊：只有双方的消息，不存在"别人的历史"，不限制
 * 所有"没有记录"的情况一律返回 0 = 不限制 —— 上线时老账号、老群成员都没有时间戳，
 * 必须让它们保持原样，否则老用户会突然看不到自己的聊天记录。 */
function getUserRegisteredAt($user) {
    $meta = json_decode(@file_get_contents(USER_META_FILE), true) ?: [];
    return intval($meta[(string)$user]['registered_at'] ?? 0);
}
// 记录注册时间（每次注册都重写）。只被 register 调用，而 register 会先拒绝已存在的用户名，
// 所以同名账号再次注册只可能是"旧账号已被删除"——那就该按新账号重新计时，
// 不能让它继承旧账号的起点去翻旧聊天记录。
function markUserRegistered($user, $ts = null) {
    $u = (string)$user;
    if ($u === '') return;
    $meta = json_decode(@file_get_contents(USER_META_FILE), true) ?: [];
    $meta[$u] = ['registered_at' => $ts !== null ? intval($ts) : time()];
    @file_put_contents(USER_META_FILE, json_encode($meta, JSON_UNESCAPED_UNICODE), LOCK_EX);
}
// 房间的"可见起点"时间戳，0 表示不做限制
function roomVisibleFrom($room, $user) {
    $room = (string)$room;
    if (strpos($room, 'pm:') === 0) return 0;
    if (strpos($room, 'group:') === 0) {
        $gName = substr($room, 6);
        $groups = json_decode(@file_get_contents(GROUPS_FILE), true) ?: [];
        $info = $groups[$gName] ?? null;
        if (!is_array($info)) return 0;
        if (!empty($info['history_visible'])) return 0;   // 群主放开了历史
        return intval($info['joined_at'][$user] ?? 0);
    }
    return getUserRegisteredAt($user);
}
/**
 * 把一组文件（[['path'=>..,'name'=>..], ..]）发给浏览器：多个打成一个 zip，单个直接输出。
 * 服务器没装 zip 扩展时退回"只发第一个"，至少不会让用户面对一个点了没反应的按钮。
 */
function sendFilesAsArchive($picked, $zipBase) {
    if (empty($picked)) { http_response_code(404); echo '没有可下载的文件'; return; }
    if (count($picked) === 1 || !class_exists('ZipArchive')) {
        $one = $picked[0];
        sendGroupFileHeaders($one['name'], filesize($one['path']));
        readfile($one['path']);
        return;
    }
    $tmp = tempnam(sys_get_temp_dir(), 'fzip');
    $zip = new ZipArchive();
    if ($zip->open($tmp, ZipArchive::OVERWRITE) !== true) { http_response_code(500); echo '打包失败，请稍后重试'; return; }
    $used = [];
    foreach ($picked as $f) {
        $n = $f['name'];
        $ext = pathinfo($n, PATHINFO_EXTENSION);
        $base = pathinfo($n, PATHINFO_FILENAME);
        $i = 1;
        // 同名文件在 zip 里会互相覆盖，这里加序号区分
        while (isset($used[$n])) { $n = $base . '(' . (++$i) . ')' . ($ext !== '' ? '.' . $ext : ''); }
        $used[$n] = true;
        $zip->addFile($f['path'], $n);
    }
    $zip->close();
    $zipName = $zipBase . '_' . date('Ymd_His') . '.zip';
    sendGroupFileHeaders($zipName, filesize($tmp), 'application/zip');
    readfile($tmp);
    @unlink($tmp);
}
function filterSensitive($text) {
    $words = json_decode(@file_get_contents(SENSITIVE_FILE), true) ?: [];
    if (!is_array($words) || empty($words)) return $text;
    foreach ($words as $w) {
        $w = trim((string)$w);
        if ($w === '') continue;
        $len = mb_strlen($w);
        $mask = str_repeat('*', $len);
        $text = str_ireplace($w, $mask, $text);
    }
    return $text;
}
// 群禁言：返回 群名 => [用户名 => until]，until=0 表示永久
function getGroupMutes($groupName) {
    $all = json_decode(@file_get_contents(GROUP_MUTES_FILE), true) ?: [];
    if (!is_array($all)) $all = [];
    $mutes = $all[$groupName] ?? [];
    return is_array($mutes) ? $mutes : [];
}
function getGroupMute($groupName, $username) {
    $m = getGroupMutes($groupName);
    if (!isset($m[$username])) return 0;
    $until = intval($m[$username]);
    if ($until === 0) return 0; // 0 表示永久禁言
    if ($until <= time()) { // 过期自动清除
        unset($m[$username]);
        $all = json_decode(@file_get_contents(GROUP_MUTES_FILE), true) ?: [];
        $all[$groupName] = $m;
        file_put_contents(GROUP_MUTES_FILE, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
        return 0;
    }
    return $until;
}
// 在消息文件中以独占锁追加一条消息并返回新 id；$data 通常不含 id
function appendMessageLocked($data) {
    $fp = fopen(MESSAGES_FILE, 'c+');
    if (!$fp) return 0;
    flock($fp, LOCK_EX);
    $msgs = json_decode(stream_get_contents($fp), true) ?: [];
    $maxId = 0;
    foreach ($msgs as $m) { if (isset($m['id']) && $m['id'] > $maxId) $maxId = $m['id']; }
    $data['id'] = $maxId + 1;
    $msgs[] = $data;
    if (count($msgs) > MAX_MESSAGES) $msgs = array_slice($msgs, -MAX_MESSAGES);
    ftruncate($fp, 0); rewind($fp);
    fwrite($fp, json_encode($msgs, JSON_UNESCAPED_UNICODE));
    fflush($fp);
    flock($fp, LOCK_UN); fclose($fp);
    return $data['id'];
}
function getUnreadCounts($user) {
    $unread = json_decode(file_get_contents(UNREAD_FILE), true) ?: [];
    return $unread[$user] ?? [];
}
function incrementUnread($sender, $room) {
    $online = getOnlineUsers(ONLINE_FILE);
    $unread = json_decode(file_get_contents(UNREAD_FILE), true) ?: [];
    $changed = false;
    foreach ($online as $u => $info) {
        if ($u === $sender) continue;
        $currentRoom = $info['room'] ?? '公共聊天';
        if ($currentRoom !== $room) {
            if (!isset($unread[$u])) $unread[$u] = [];
            if (!isset($unread[$u][$room])) $unread[$u][$room] = 0;
            $unread[$u][$room]++;
            $changed = true;
        }
    }
    if ($changed) {
        file_put_contents(UNREAD_FILE, json_encode($unread, JSON_UNESCAPED_UNICODE), LOCK_EX);
    }
}
// @全体成员：给群里所有不在本群房间的成员增加未读，实现全群提醒
function atAllNotify($groupName, $sender) {
    $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
    if (!isset($groups[$groupName])) return;
    $members = $groups[$groupName]['members'] ?? [];
    $room = 'group:' . $groupName;
    $online = getOnlineUsers(ONLINE_FILE);
    $unread = json_decode(file_get_contents(UNREAD_FILE), true) ?: [];
    $changed = false;
    foreach ($members as $m) {
        if ($m === $sender) continue;
        $cur = $online[$m]['room'] ?? '';
        if ($cur === $room) continue;
        if (!isset($unread[$m])) $unread[$m] = [];
        if (!isset($unread[$m][$room])) $unread[$m][$room] = 0;
        $unread[$m][$room]++;
        $changed = true;
    }
    if ($changed) file_put_contents(UNREAD_FILE, json_encode($unread, JSON_UNESCAPED_UNICODE), LOCK_EX);
}

// 语音通话辅助：默认房间（私聊）与房间校验
function callDefaultRoom($u1, $u2) {
    $arr = [$u1, $u2]; sort($arr);
    return 'pm:' . $arr[0] . ':' . $arr[1];
}
function callRoomValid($room, $from, $to) {
    if (strpos($room, 'pm:') === 0) {
        $parts = explode(':', $room);
        $p1 = (string)($parts[1] ?? ''); $p2 = (string)($parts[2] ?? '');
        return ($p1 === $from && $p2 === $to) || ($p1 === $to && $p2 === $from);
    }
    if (strpos($room, 'group:') === 0) {
        $gName = substr($room, 6);
        $groups = json_decode(@file_get_contents(GROUPS_FILE), true) ?: [];
        $members = $groups[$gName]['members'] ?? [];
        return in_array($from, $members) && in_array($to, $members);
    }
    return false;
}
// 向聊天房间注入通话气泡（type=call）
function addCallMessage($room, $actor, $event, $peer, $duration = 0, $type = 'voice') {
    if ($room === '' || !is_string($room)) return;
    $dur = max(0, intval($duration));
    $kind = ($type === 'video') ? '视频通话' : '语音通话';
    switch ($event) {
        case 'start':  $text = $actor . ' 发起了' . $kind; break;
        case 'reject': $text = $actor . ' 拒绝了' . $kind; break;
        case 'cancel': $text = $actor . ' 取消了' . $kind; break;
        case 'end':    $text = $actor . ' 挂断了' . $kind . ($dur > 0 ? '（时长 ' . gmdate('i:s', $dur) . '）' : ''); break;
        case 'timeout':$text = $actor . ' 未接听'; break;
        case 'noanswer': $text = '通话未接通'; break;
        default: return;
    }
    appendMessageLocked([
        'room'      => $room,
        'type'      => 'call',
        'username'  => 'system',
        'message'   => $text,
        'time'      => date('H:i:s'),
        'timestamp' => time(),
        'call_event'=> $event,
        'call_type' => $type
    ]);
    incrementUnread($actor, $room);
}

// ===== 通话生命周期治理 =====
// 记录参与者心跳：每一次 get_call / signal / respond 都刷新，用于判断对端是否还在线。
function callTouch(&$c, $user, $now = null) {
    if ($now === null) $now = time();
    if (!isset($c['seen']) || !is_array($c['seen'])) $c['seen'] = [];
    $c['seen'][$user] = $now;
    $c['last_activity'] = $now;
}
// 结束一条通话记录（幂等），必要时注入聊天结束气泡
function callEnd(&$c, $reason) {
    if (($c['status'] ?? '') === 'ended') return false;
    $room = (string)($c['room'] ?? '');
    $c['status'] = 'ended';
    $c['end_reason'] = $reason;
    // 记录结束时刻：ended 记录的保留时长必须从"结束"开始算。
    // 否则通话时长本身超过 TTL 的那通电话，记录会在结束的同一刻被回收，
    // 对端就永远读不到终态，界面会一直挂在通话中。
    $c['ended_at'] = time();
    if ($room !== '' && empty($c['bubble']) && in_array($reason, ['timeout','disconnect'], true)) {
        $c['bubble'] = true;
        addCallMessage($room, $c['from'] ?? 'system', $reason === 'timeout' ? 'timeout' : 'noanswer', $c['to'] ?? '', 0, $c['type'] ?? 'voice');
    }
    return true;
}
/**
 * 清理并回收通话记录。这是修复"永久占线"的关键：
 *  1) ringing 超时       → timeout
 *  2) 参与者心跳丢失      → disconnect（刷新/关标签/断网/息屏都会触发）
 *  3) 超过硬上限          → timeout
 *  4) 已结束的记录         超过 TTL 后物理删除（防止文件无界增长）
 * 返回本次是否有改动（调用方据此决定是否落盘）。
 */
function sweepCalls(array &$calls) {
    $now = time();
    $changed = false;
    foreach ($calls as $id => &$c) {
        $status = $c['status'] ?? 'ended';
        if ($status === 'ended') {
            $endedAt = intval($c['ended_at'] ?? 0);
            if ($endedAt <= 0) $endedAt = intval($c['ts'] ?? 0);   // 兼容没有 ended_at 的旧记录
            if ($now - $endedAt > CALL_ENDED_TTL) { unset($calls[$id]); $changed = true; }
            continue;
        }
        $ts = intval($c['ts'] ?? 0);
        // 1) 响铃超时
        if ($status === 'ringing' && ($now - $ts) > CALL_RING_TIMEOUT) { callEnd($c, 'timeout'); $changed = true; continue; }
        // 3) 硬上限
        if (($now - intval($c['accepted_at'] ?? $ts)) > CALL_MAX_DURATION) { callEnd($c, 'timeout'); $changed = true; continue; }
        // 2) 心跳丢失：只对已建立的连接判定，避免响铃阶段误杀（响铃由 ringing 超时兜底）
        if ($status === 'accepted') {
            $seen = is_array($c['seen'] ?? null) ? $c['seen'] : [];
            foreach ([$c['from'] ?? '', $c['to'] ?? ''] as $u) {
                if ($u === '') continue;
                $last = intval($seen[$u] ?? 0);
                // 从未上报过心跳时，以 ts 为基准，给足一轮宽限期
                $base = $last > 0 ? $last : intval($c['accepted_at'] ?? $ts);
                if (($now - $base) > CALL_PEER_TIMEOUT) { callEnd($c, 'disconnect'); $changed = true; break; }
            }
        }
    }
    unset($c);
    return $changed;
}
// 单条通话的候选背压：只保留最近 N 条，避免 calls.json 无限膨胀
function callTrimCandidates(&$c) {
    if (!isset($c['candidates']) || !is_array($c['candidates'])) { $c['candidates'] = []; return; }
    $n = count($c['candidates']);
    if ($n > CALL_MAX_CANDIDATES) $c['candidates'] = array_slice($c['candidates'], $n - CALL_MAX_CANDIDATES);
}
// 判断某用户是否被"活跃通话"占用（僵尸记录已在 sweep 中回收，这里只认真实占线）
function callBusyOwner(array $calls, $user) {
    foreach ($calls as $id => $c) {
        if (($c['status'] ?? '') === 'ended') continue;
        if (($c['from'] ?? '') === $user || ($c['to'] ?? '') === $user) return $id;
    }
    return null;
}
/* ---- 通话握手码：邀请码 / 回执码 ----
 * 语义：
 *   邀请码 invite_code  —— 主叫发起时生成，标识"这一通电话"的凭证；被叫接听界面会自动录入。
 *   回执码 receipt_code —— 被叫接听时生成，自动回传给主叫，表示"我已接受这通邀请"。
 * 约束：
 *   1) 两个码都由服务端生成，客户端只负责展示，不接受客户端上传的码；
 *   2) 同一通电话内码恒定不变，重复 accept 只返回既有值，不重新生成（幂等）；
 *   3) 通话结束后一律不再下发（callHandshakeCodes 直接返回 null），避免旧码被复用。
 * 说明：码的授权作用仅限"展示 + 人工核对"。真正的权限判定始终按 from/to 身份做，
 *      因此即使极小概率出现重码，也不会导致通话被他人插入。
 * 字母表去掉了 0/O/1/I/L 等易混字符，方便肉眼核对。 */
function genCallCode() {
    $alphabet = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
    $max = strlen($alphabet) - 1;
    $code = '';
    for ($i = 0; $i < 6; $i++) $code .= $alphabet[random_int(0, $max)];
    return $code;
}
// 取出该通话的邀请码/回执码；ended 之后一律返回 null，避免旧码被复用
function callHandshakeCodes(array $c) {
    if (($c['status'] ?? '') === 'ended') return ['invite_code' => null, 'receipt_code' => null];
    return [
        'invite_code'  => $c['invite_code'] ?? null,
        'receipt_code' => $c['receipt_code'] ?? null
    ];
}
// 取出"对端发来、且序号大于客户端游标"的 ICE 候选，同时返回最新游标（增量下发，可安全截断）
// 取出该通话里"对端"上报的摄像头/麦克风开关。
// WebRTC 不会把"对端关了摄像头"告诉本端（本端只会收到黑帧，track 依然是 live 且未静音），
// 所以这个状态必须由对端主动上报，见 call_signal 的 state 分支。
function callPeerMedia(array $c, $me) {
    $peer = (($c['from'] ?? '') === $me) ? ($c['to'] ?? '') : ($c['from'] ?? '');
    $m = $c['media'][$peer] ?? null;
    if (!is_array($m)) return null;
    return ['cam' => !empty($m['cam']), 'mic' => !empty($m['mic'])];
}
function callPickCandidates(array $c, $me, $since) {
    $out = []; $next = $since;
    foreach (($c['candidates'] ?? []) as $cd) {
        $seq = intval($cd['seq'] ?? 0);
        if ($seq > $next) $next = $seq;
        if (($cd['from'] ?? '') === $me) continue;
        if ($seq <= $since) continue;
        $out[] = ['seq'=>$seq, 'c'=>$cd['candidate'] ?? null];
    }
    return [$out, $next];
}
/**
 * 组装前端 RTCPeerConnection 的 ICE 配置。
 * 公网 STUN 只能做地址发现；双方处于对称 NAT / 运营商级 NAT / 公司防火墙时，
 * 必须经由 TURN 中继转发媒体才能接通——这是"外地朋友打不通、一直卡在连接中"的根因。
 * TURN 由后台"安全设置 → 通话中继"配置，写入 data/site_config.json 的 ice_servers。
 */
function getIceConfigForClient() {
    $cfg = json_decode(@file_get_contents(SITE_CONFIG_FILE), true) ?: [];
    $servers = [[
        'urls' => [
            'stun:stun.miwifi.com:3478', 'stun:stun.chat.bilibili.com:3478',
            'stun:stun1.l.google.com:19302', 'stun:stun2.l.google.com:19302',
            'stun:stun.cloudflare.com:3478'
        ]
    ]];
    foreach (($cfg['ice_servers'] ?? []) as $s) {
        if (!is_array($s)) continue;
        if (empty($s['enabled'])) continue;
        $urls = $s['urls'] ?? '';
        if (is_string($urls)) $urls = preg_split('/[\s,]+/', $urls, -1, PREG_SPLIT_NO_EMPTY);
        if (!is_array($urls)) continue;
        $ok = [];
        foreach ($urls as $u) {
            $u = trim((string)$u);
            if ($u !== '' && preg_match('#^(stun|stuns|turn|turns):#i', $u)) $ok[] = $u;
        }
        if (!$ok) continue;
        $entry = ['urls' => array_values($ok)];
        if (isset($s['username']) && $s['username'] !== '') $entry['username'] = (string)$s['username'];
        if (isset($s['credential']) && $s['credential'] !== '') $entry['credential'] = (string)$s['credential'];
        $servers[] = $entry;
    }
    return ['iceCandidatePoolSize' => 4, 'iceServers' => $servers];
}

// 聊天设置：文本字数限制（max_chars，0=无限制）+ 界面主题（theme.accent 主题色 / theme.bg 背景色）
// 由后台"聊天设置"页写入 data/site_config.json，前端页面加载时注入 JS/CSS 生效。
function getChatSettingsForClient() {
    $cfg = json_decode(@file_get_contents(SITE_CONFIG_FILE), true) ?: [];
    $max = isset($cfg['max_chars']) ? intval($cfg['max_chars']) : 100;
    $max = max(0, min(5000, $max));
    return [
        'max_chars' => $max,
        'theme' => is_array($cfg['theme'] ?? null) ? $cfg['theme'] : []
    ];
}

// 依据主题配置生成 :root CSS 变量覆盖块；未配置主题时返回空串（保持默认配色）
function buildThemeCss() {
    $t = getChatSettingsForClient()['theme'];
    $accent = strtolower(trim((string)($t['accent'] ?? '')));
    if ($accent === '' || !preg_match('/^#[0-9a-f]{6}$/', $accent)) return '';
    $r = hexdec(substr($accent, 1, 2)); $g = hexdec(substr($accent, 3, 2)); $b = hexdec(substr($accent, 5, 2));
    // accent2 自动加深约 10%，保持 hover 层次
    $a2 = '#' . str_pad(dechex(max(0, (int)($r * 0.9))), 2, '0', STR_PAD_LEFT)
        . str_pad(dechex(max(0, (int)($g * 0.9))), 2, '0', STR_PAD_LEFT)
        . str_pad(dechex(max(0, (int)($b * 0.9))), 2, '0', STR_PAD_LEFT);
    $css = ':root{--accent:' . $accent . ';--accent2:' . $a2 . ';--accent-rgb:' . $r . ',' . $g . ',' . $b
        . ';--accent-glow:rgba(' . $r . ',' . $g . ',' . $b . ',0.1);--accent-strong:rgba(' . $r . ',' . $g . ',' . $b . ',0.12);';
    $bg = strtolower(trim((string)($t['bg'] ?? '')));
    if ($bg !== '' && preg_match('/^#[0-9a-f]{6}$/', $bg)) $css .= '--bg:' . $bg . ';';
    return $css . '}';
}

// 签到 / 积分 / 经验 / 等级 相关的读写辅助函数已随等级系统下架一并移除。
// data/signins.json 仍保留在服务器上（内容未删除），日后恢复玩法时可直接读回。

/* ==================== 持久登录 与 单设备登录 ====================
 * 要解决两件事：
 *   1) 以前关掉浏览器、或闲置一会儿就要重新登录（会话 Cookie 是浏览器级 + 服务端会话 24 分钟被回收）；
 *   2) 同一账号可以在多台设备同时在线。现在改成"后登录的顶掉先登录的"。
 *
 * 凭据按最小权限、可撤销原则设计：
 *   - 长期凭据是"选择子:校验子"两段随机串，磁盘上只存校验子的哈希，文件泄露也无法直接登录；
 *   - 每次用凭据自动登录都轮换校验子（一次性），旧值再次出现即视为泄露，整条凭据作废；
 *   - 退出登录、管理员强制下线、封号、修改密码都会吊销凭据，堵住"被踢了却靠记住登录继续进来"。
 */

/** 账号会话表必须整体加锁：两台设备同时登录时，谁后写入谁才是唯一活跃会话。
 *  不加锁会出现两边都认为自己在线（都刚写完自己的 sid），单设备约束就失效了。 */
function withAuthSessionsLock(callable $fn) {
    $lockFile = AUTH_SESSIONS_FILE . '.lock';
    $fp = @fopen($lockFile, 'c');
    if (!$fp) { return $fn(authJsonRead(AUTH_SESSIONS_FILE), false); }
    @flock($fp, LOCK_EX);
    try {
        $all = authJsonRead(AUTH_SESSIONS_FILE);
        $out = $fn($all, true);
        if (is_array($out)) saveJson(AUTH_SESSIONS_FILE, $out);   // 回调返回 null = 本次无需落盘
        return $out;
    } finally {
        @flock($fp, LOCK_UN);
        @fclose($fp);
    }
}
function authJsonRead($file) {
    $d = json_decode(@file_get_contents($file), true);
    return is_array($d) ? $d : [];
}
function authTokenHash($validator) { return hash('sha256', $validator); }

/** 记住登录 Cookie：HttpOnly 防脚本读取，SameSite=Lax 降低跨站携带，HTTPS 下带 Secure。
 *  同样要兼容 PHP 7.3 以下（数组形式在旧版本上会失败，导致"记住我"静默失效）。 */
function setRememberCookie($value, $maxAge) {
    $secure = (!empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off')
        || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
    $expires = $maxAge > 0 ? time() + $maxAge : time() - 3600;
    if (PHP_VERSION_ID >= 70300) {
        setcookie(REMEMBER_COOKIE, $value, [
            'expires'  => $expires,
            'path'     => '/',
            'httponly' => true,
            'secure'   => $secure,
            'samesite' => 'Lax',
        ]);
    } else {
        setcookie(REMEMBER_COOKIE, $value, $expires, '/', '', $secure, true);
    }
}
/** 签发长期凭据，返回 "选择子:校验子"（只有它能登录，磁盘上留的是哈希）。 */
function issueRememberToken($user, $sid) {
    try {
        $selector  = bin2hex(random_bytes(9));
        $validator = bin2hex(random_bytes(32));
    } catch (Throwable $e) {
        $selector  = substr(hash('sha256', uniqid('sel', true)), 0, 18);
        $validator = hash('sha256', uniqid('val', true));
    }
    $tokens = authJsonRead(AUTH_TOKENS_FILE);
    $now = time();
    foreach ($tokens as $k => $t) { if (intval($t['expires'] ?? 0) < $now) unset($tokens[$k]); }   // 清过期，避免文件无界增长
    $tokens[$selector] = [
        'user'    => (string)$user,
        'hash'    => authTokenHash($validator),
        'sid'     => (string)$sid,
        'created' => $now,
        'expires' => $now + REMEMBER_DAYS * 86400,
        'ua'      => substr(hash('sha256', $_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 16),
    ];
    if (!saveJson(AUTH_TOKENS_FILE, $tokens)) return '';
    return $selector . ':' . $validator;
}
/** 吊销某账号的全部长期凭据（被强制下线、封号、改密码时调用）。 */
function revokeRememberTokens($user) {
    if ($user === '') return;
    $tokens = authJsonRead(AUTH_TOKENS_FILE);
    $changed = false;
    foreach ($tokens as $k => $t) { if (($t['user'] ?? '') === (string)$user) { unset($tokens[$k]); $changed = true; } }
    if ($changed) saveJson(AUTH_TOKENS_FILE, $tokens);
    setRememberCookie('', 0);
}
/** 只吊销"本机这一个浏览器"的长期凭据（退出登录时用）。
 *  按当前 Cookie 里的选择子精确定位，因此退出登录不会连带注销其它设备上的登录 ——
 *  多设备版下这点尤其重要；单设备版本来也只有一个凭据，效果相同。 */
function revokeMyRememberToken() {
    $raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? '');
    if ($raw !== '' && strpos($raw, ':') !== false) {
        $selector = explode(':', $raw, 2)[0];
        if ($selector !== '') {
            $tokens = authJsonRead(AUTH_TOKENS_FILE);
            if (isset($tokens[$selector])) { unset($tokens[$selector]); saveJson(AUTH_TOKENS_FILE, $tokens); }
        }
    }
    setRememberCookie('', 0);
}
/** 建立登录态：轮换会话 ID（防会话固定），并把"本次会话"标记出来。 */
function authBeginSession($user) {
    if (session_status() === PHP_SESSION_ACTIVE) @session_regenerate_id(true);
    $_SESSION['username'] = $user;
    $_SESSION['sid'] = (string)session_id();
    return $_SESSION['sid'];
}
/** 登录成功时登记"我就是这个账号当前唯一的活跃会话"，返回被顶掉的旧会话（用于日志）。 */
function authRegisterActiveSession($user, $sid) {
    $prev = null;
    withAuthSessionsLock(function ($all) use ($user, $sid, &$prev) {
        $old = $all[$user] ?? null;
        if (is_array($old) && ($old['sid'] ?? '') !== '' && ($old['sid'] ?? '') !== $sid) $prev = $old;
        $all[$user] = [
            'sid'      => (string)$sid,
            'ip'       => clientIp(),
            'ua'       => mb_substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 120),
            'login_at' => time(),
            'last_seen'=> time(),
            'prev_sid' => $prev ? (string)($prev['sid'] ?? '') : '',
            'prev_ip'  => $prev ? (string)($prev['ip'] ?? '') : '',
        ];
        return $all;
    });
    return $prev;
}
/**
 * 单设备约束的强制点：每次已登录请求都核对"我这个会话是否还是该账号的活跃会话"。
 * 返回 null 表示正常；返回数组则表示已被别处登录顶掉。
 * 兼容性：升级前建立的老会话没有 sid，第一次请求会被接管成为活跃会话 ——
 * 这样上线新版本不会把正在聊天的用户一次性踢下线。
 */
function authCheckActiveSession($user) {
    $sid = (string)($_SESSION['sid'] ?? '');
    $bad = null;
    withAuthSessionsLock(function ($all) use ($user, $sid, &$bad) {
        $rec = $all[$user] ?? null;
        if (!is_array($rec) || ($rec['sid'] ?? '') === '') {
            $use = $sid !== '' ? $sid : (string)session_id();
            $_SESSION['sid'] = $use;
            $all[$user] = [
                'sid' => $use, 'ip' => clientIp(), 'ua' => mb_substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 120),
                'login_at' => time(), 'last_seen' => time(), 'prev_sid' => '', 'prev_ip' => '',
            ];
            return $all;
        }
        if ($sid !== '' && $sid !== (string)$rec['sid']) { $bad = $rec; return null; }
        if ($sid === '') $_SESSION['sid'] = (string)$rec['sid'];
        // 活跃时间最多 1 分钟落盘一次：轮询 2.5 秒一次，每次写会让会话表长期被占
        if (time() - intval($rec['last_seen'] ?? 0) < 60) return null;
        $all[$user]['last_seen'] = time();
        return $all;
    });
    return $bad;
}
/**
 * 用长期凭据自动登录（浏览器重开、或服务端会话文件被回收后依然保持登录）。
 * 校验：选择子 → 校验子哈希 → 有效期 → 仍是该账号活跃会话 → 轮换校验子并顺延。
 * 任一步失败都按未登录处理，并清掉这份已不可信的 Cookie。
 */
function authTryRememberLogin() {
    if (isset($_SESSION['username'])) return;
    $raw = (string)($_COOKIE[REMEMBER_COOKIE] ?? '');
    if ($raw === '' || strpos($raw, ':') === false) return;
    list($selector, $validator) = explode(':', $raw, 2);
    if ($selector === '' || $validator === '') { setRememberCookie('', 0); return; }
    $tokens = authJsonRead(AUTH_TOKENS_FILE);
    $t = $tokens[$selector] ?? null;
    if (!is_array($t)) { setRememberCookie('', 0); return; }
    // 哈希不匹配：可能是伪造，也可能是"已轮换过的旧值"被重放 —— 两种情况都作废整条凭据
    if (!hash_equals((string)($t['hash'] ?? ''), authTokenHash($validator))) {
        unset($tokens[$selector]);
        saveJson(AUTH_TOKENS_FILE, $tokens);
        setRememberCookie('', 0);
        writeLog((string)($t['user'] ?? ''), 'remember_invalid', '长期登录凭据校验失败，已作废该凭据');
        return;
    }
    if (intval($t['expires'] ?? 0) < time()) {
        unset($tokens[$selector]); saveJson(AUTH_TOKENS_FILE, $tokens); setRememberCookie('', 0);
        return;
    }
    $user = (string)($t['user'] ?? '');
    $users = json_decode(@file_get_contents(USERS_FILE), true) ?: [];
    if ($user === '' || !isset($users[$user])) {
        unset($tokens[$selector]); saveJson(AUTH_TOKENS_FILE, $tokens); setRememberCookie('', 0);
        return;
    }
    // 单设备版才需要这道校验：凭据对应的会话必须仍是该账号的活跃会话，
    // 否则说明已在别的设备上登录过，本机不该再自动登录。
    // 多设备版必须跳过 —— 否则在另一台设备登录后，本机的"记住登录"就会失效。
    if (ENFORCE_SINGLE_SESSION) {
        $stillActive = false;
        withAuthSessionsLock(function ($all) use ($user, $t, &$stillActive) {
            $rec = $all[$user] ?? null;
            if (is_array($rec) && ($rec['sid'] ?? '') !== '' && (string)$rec['sid'] === (string)($t['sid'] ?? '')) {
                $stillActive = true;
            }
            return null;
        });
        if (!$stillActive) {
            unset($tokens[$selector]); saveJson(AUTH_TOKENS_FILE, $tokens); setRememberCookie('', 0);
            writeLog($user, 'remember_superseded', '长期登录凭据已被其他设备登录取代，本机清退');
            return;
        }
    }
    try { $newValidator = bin2hex(random_bytes(32)); }
    catch (Throwable $e) { $newValidator = hash('sha256', uniqid('val', true)); }
    $tokens[$selector]['hash']    = authTokenHash($newValidator);
    $tokens[$selector]['expires'] = time() + REMEMBER_DAYS * 86400;   // 滑动顺延
    saveJson(AUTH_TOKENS_FILE, $tokens);
    authBeginSession($user);
    $_SESSION['sid'] = (string)($t['sid'] ?? '');   // 沿用原 sid，单设备约束继续成立
    setRememberCookie($selector . ':' . $newValidator, REMEMBER_DAYS * 86400);
    writeLog($user, 'remember_login', '使用长期登录凭据自动登录');
}

// 自动登录必须在任何动作分发与页面输出之前完成，否则"记住登录"就只在刷新页面时生效
authTryRememberLogin();

if (isset($_GET['action'])) {
    header('Content-Type: application/json; charset=utf-8');

    if ($_GET['action'] === 'register') {
        $siteCfg = json_decode(@file_get_contents(SITE_CONFIG_FILE), true) ?: [];
        if (empty($siteCfg['registration'])) { http_response_code(403); echo json_encode(['error'=>'管理员已关闭新用户注册', 'code'=>'reg_closed']); exit; }
        $username = trim($_POST['username'] ?? '');
        $password = trim($_POST['password'] ?? '');
        $confirm  = trim($_POST['confirm'] ?? '');
        if ($username === '' || $password === '') { http_response_code(400); echo json_encode(['error'=>'用户名和密码不能为空']); exit; }
        if (mb_strlen($username) > 20) { http_response_code(400); echo json_encode(['error'=>'用户名最多20个字符']); exit; }
        if (strlen($password) < 6) { http_response_code(400); echo json_encode(['error'=>'密码至少6位']); exit; }
        if ($password !== $confirm) { http_response_code(400); echo json_encode(['error'=>'两次密码不一致']); exit; }
        $users = json_decode(file_get_contents(USERS_FILE), true) ?: [];
        if (isset($users[$username])) { http_response_code(400); echo json_encode(['error'=>'用户名已存在']); exit; }
        $users[$username] = password_hash($password, PASSWORD_DEFAULT);
        file_put_contents(USERS_FILE, json_encode($users, JSON_UNESCAPED_UNICODE), LOCK_EX);
        // 注册时间就是这位用户在公共聊天里的"可见起点"：他看不到注册之前的聊天记录
        markUserRegistered($username);
        // 注册即登录：同样登记活跃会话并签发长期凭据，避免"刚注册完又被登出"
        $sid = authBeginSession($username);
        authRegisterActiveSession($username, $sid);
        $tok = issueRememberToken($username, $sid);
        if ($tok !== '') setRememberCookie($tok, REMEMBER_DAYS * 86400);
        session_write_close();
        recordIp($username);
        writeLog($username, 'register', '注册新账号');
        echo json_encode(['success'=>true]); exit;
    }

    // CSRF 纵深防御：所有状态变更（POST）校验来源域名，拒绝来自其他站点的请求。
    // 与 Cookie 的 SameSite=Lax 叠加，覆盖老旧浏览器不支持 SameSite 的情况。
    // 未携带 Origin/Referer 时放行，避免误伤正常客户端。
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
        $__host = $_SERVER['HTTP_HOST'] ?? '';
        $__src = ($_SERVER['HTTP_ORIGIN'] ?? '') !== '' ? $_SERVER['HTTP_ORIGIN'] : ($_SERVER['HTTP_REFERER'] ?? '');
        if ($__src !== '' && $__host !== '') {
            $__srcHost = parse_url($__src, PHP_URL_HOST);
            $__hostOnly = preg_replace('/:\d+$/', '', $__host);
            if ($__srcHost && strcasecmp($__srcHost, $__hostOnly) !== 0) {
                writeLog('', 'csrf_blocked', '跨站 POST 被拒绝，来源：' . mb_substr($__src, 0, 120));
                http_response_code(403); echo json_encode(['error'=>'请求来源不被允许', 'code'=>'csrf']); exit;
            }
        }
    }

    if ($_GET['action'] === 'login') {
        $username = trim($_POST['username'] ?? '');
        $password = trim($_POST['password'] ?? '');
        if ($username === '' || $password === '') { http_response_code(400); echo json_encode(['error'=>'请输入用户名和密码']); exit; }
        // 登录限速：同一 IP + 用户名 连续失败 5 次则锁定 10 分钟，遏制暴力破解（原先可无限次尝试）
        $attFile = DATA_DIR . 'login_attempts.json';
        $attempts = json_decode(@file_get_contents($attFile), true) ?: [];
        $ak = clientIp() . '|' . mb_strtolower($username);
        $rec = $attempts[$ak] ?? ['n' => 0, 'ts' => 0];
        if ((time() - intval($rec['ts'])) >= 600) $rec = ['n' => 0, 'ts' => 0];   // 窗口已过，重置计数
        if (intval($rec['n']) >= 5) {
            $left = (int)ceil((600 - (time() - intval($rec['ts']))) / 60);
            writeLog($username, 'login_ratelimited', '登录尝试过多，剩余锁定约 ' . $left . ' 分钟');
            http_response_code(429); echo json_encode(['error'=>'尝试次数过多，请约 ' . $left . ' 分钟后再试']); exit;
        }
        $users = json_decode(file_get_contents(USERS_FILE), true) ?: [];
        if (!isset($users[$username]) || !password_verify($password, $users[$username])) {
            $rec['n'] = intval($rec['n']) + 1; $rec['ts'] = time();
            $attempts[$ak] = $rec;
            $cut = time() - 86400;                                                // 清理 1 天前的记录，避免文件无限增长
            foreach ($attempts as $k => $v) { if (intval($v['ts'] ?? 0) < $cut) unset($attempts[$k]); }
            @file_put_contents($attFile, json_encode($attempts, JSON_UNESCAPED_UNICODE), LOCK_EX);
            writeLog($username, 'login_fail', '用户名或密码错误');
            http_response_code(400); echo json_encode(['error'=>'用户名或密码错误']); exit;
        }
        $ban = isBanned($username);
        if ($ban) {
            writeLog($username, 'login_blocked', '账号已被封禁：' . ($ban['reason'] ?? ''));
            http_response_code(403); echo json_encode(['error'=>'账号已被封禁：' . ($ban['reason'] ?? '无')]); exit;
        }
        if (isset($attempts[$ak])) { unset($attempts[$ak]); @file_put_contents($attFile, json_encode($attempts, JSON_UNESCAPED_UNICODE), LOCK_EX); }
        // 会话固定防护（authBeginSession 内部会轮换会话 ID）+ 单设备登录：
        // 把本次会话登记为该账号的活跃会话；单设备版下这会让之前那台设备的会话随即失效
        $sid = authBeginSession($username);
        $prev = authRegisterActiveSession($username, $sid);
        if ($prev) {
            // 多设备版不会真的把旧会话踢掉，日志里必须说清楚，否则排查时会被误导
            writeLog($username, ENFORCE_SINGLE_SESSION ? 'session_replaced_other' : 'login_another_device',
                (ENFORCE_SINGLE_SESSION ? '新登录顶掉旧会话，旧会话来自 ' : '另一台设备登录，旧会话来自 ')
                . ($prev['ip'] ?? '') . '（' . ($prev['ua'] ?? '') . '）');
        }
        // "记住我"：签发可轮换、可撤销的长期凭据；
        // 不勾选时只清掉"本机这个浏览器"的旧凭据 ——
        // 不能吊销该账号全部凭据，否则你在其它设备上的"记住登录"会被这次登录顺手废掉。
        if (!empty($_POST['remember'])) {
            $tok = issueRememberToken($username, $sid);
            if ($tok !== '') setRememberCookie($tok, REMEMBER_DAYS * 86400);
        } else {
            revokeMyRememberToken();
        }
        session_write_close();
        recordIp($username);
        writeLog($username, 'login', '登录成功');
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'logout') {
        $lu = (string)($_SESSION['username'] ?? '');
        writeLog($lu, 'logout', '退出登录');
        // 只吊销本机这个浏览器的长期凭据：既保证"退出后不会被自动登录回来"，
        // 又不会把你在其它设备上的登录一并注销（多设备版尤其重要）。
        revokeMyRememberToken();
        // 会话登记表里若正好是我这条就清掉；别处的新登录不能被我的退出请求误删
        $mySid = (string)($_SESSION['sid'] ?? '');
        if ($lu !== '' && $mySid !== '') {
            withAuthSessionsLock(function ($all) use ($lu, $mySid) {
                if (isset($all[$lu]) && (string)($all[$lu]['sid'] ?? '') === $mySid) {
                    unset($all[$lu]);
                    return $all;
                }
                return null;
            });
        }
        $_SESSION = [];
        session_destroy();
        echo json_encode(['success'=>true]); exit;
    }

    if (!isset($_SESSION['username'])) { http_response_code(403); echo json_encode(['error'=>'请先登录']); exit; }
    $currentUser = $_SESSION['username'];
    // 已封禁用户在每次受保护操作前统一拦截
    $ban = isBanned($currentUser);
    if ($ban) {
        session_write_close();
        revokeRememberTokens($currentUser);   // 封号必须连带吊销长期凭据，否则"记住登录"会一直把他带回来
        http_response_code(403);
        echo json_encode(['error'=>'账号已被封禁：' . ($ban['reason'] ?? '无'), 'code'=>'banned']); exit;
    }
    // IP 封禁拦截（每次受保护操作）
    $ipNow = clientIp();
    $ipBans = json_decode(@file_get_contents(IP_BAN_FILE), true) ?: [];
    if ($ipNow !== '' && isset($ipBans[$ipNow])) {
        session_write_close();
        http_response_code(403);
        echo json_encode(['error'=>'您的IP已被封禁：' . ($ipBans[$ipNow]['reason'] ?? '无'), 'code'=>'ip_banned']); exit;
    }
    // 强制下线：命中踢出记录则销毁会话，且为一次性（踢出后自动清除，可重新登录）
    $kicks = json_decode(@file_get_contents(KICKS_FILE), true) ?: [];
    if (isset($kicks[$currentUser])) {
        unset($kicks[$currentUser]);
        @file_put_contents(KICKS_FILE, json_encode($kicks, JSON_UNESCAPED_UNICODE), LOCK_EX);
        // 同时吊销长期凭据：不吊销的话，被踢的设备下一次请求就会被自动登录回来，等于踢了个寂寞
        revokeRememberTokens($currentUser);
        $kicksMine = (string)($_SESSION['sid'] ?? '');
        withAuthSessionsLock(function ($all) use ($currentUser, $kicksMine) {
            if (isset($all[$currentUser]) && (string)($all[$currentUser]['sid'] ?? '') === $kicksMine) { unset($all[$currentUser]); return $all; }
            return null;
        });
        $_SESSION = [];
        session_destroy();
        http_response_code(403);
        echo json_encode(['error'=>'您已被管理员强制下线', 'code'=>'kicked']); exit;
    }
    // 单设备登录：若我这个会话已被别处的新登录顶掉，立刻退出并说明原因。
    // 多设备版（ENFORCE_SINGLE_SESSION=false）整段跳过，允许同一账号同时在线。
    if (ENFORCE_SINGLE_SESSION) {
        $replacedBy = authCheckActiveSession($currentUser);
        if ($replacedBy) {
            writeLog($currentUser, 'session_replaced', '本会话已被其他设备的新登录顶下线，来源 ' . ($replacedBy['ip'] ?? ''));
            $_SESSION = [];
            session_destroy();
            setRememberCookie('', 0);   // 本机这份凭据已失效，直接清掉，避免反复尝试
            http_response_code(403);
            echo json_encode(['error'=>'该账号已在其他设备登录，你已被下线', 'code'=>'session_replaced']); exit;
        }
    }

    if ($_GET['action'] === 'upload_avatar') {
        session_write_close();
        if (!isset($_FILES['avatar']) || $_FILES['avatar']['error'] !== UPLOAD_ERR_OK) {
            http_response_code(400); echo json_encode(['error'=>uploadFailMsg('avatar', '头像')]); exit;
        }
        $file = $_FILES['avatar'];
        $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
        $mime = '';
        if (function_exists('finfo_open')) {
            $finfo = finfo_open(FILEINFO_MIME_TYPE);
            $mime = finfo_file($finfo, $file['tmp_name']);
            finfo_close($finfo);
        } else {
            $mime = $file['type'];
            if (empty($mime)) {
                $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
                $map = ['jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','gif'=>'image/gif','webp'=>'image/webp'];
                $mime = $map[$ext] ?? '';
            }
        }
        if (!in_array($mime, $allowed)) { http_response_code(400); echo json_encode(['error'=>'仅支持JPG/PNG/GIF/WebP']); exit; }
        $extMap = ['image/jpeg'=>'jpg','image/png'=>'png','image/gif'=>'gif','image/webp'=>'webp'];
        $ext = $extMap[$mime];
        $base = 'avatar_' . $currentUser;
        // 自动压缩：头像统一缩到 256x256 内，返回 null 时保持原图
        $tmpOut = AVATAR_DIR . $base . '_tmp.' . $ext;
        $outExt = @compressImageTo($file['tmp_name'], $tmpOut, 256, 256, 82);
        if ($outExt !== null) {
            @unlink($file['tmp_name']);
            $filename = $base . '.' . $outExt;
            $dest = AVATAR_DIR . $filename;
            if (!@copy($tmpOut, $dest)) {
                @unlink($tmpOut);
                http_response_code(500); echo json_encode(['error'=>'保存失败']); exit;
            }
            @unlink($tmpOut);
        } else {
            @unlink($tmpOut);
            $filename = $base . '.' . $ext;
            $dest = AVATAR_DIR . $filename;
            if (!@move_uploaded_file($file['tmp_name'], $dest)) {
                http_response_code(500); echo json_encode(['error'=>'保存失败']); exit;
            }
            stripImageMetadata($dest, $mime);
        }
        foreach (['jpg','png','gif','webp'] as $oldExt) {
            $oldFile = AVATAR_DIR . 'avatar_' . $currentUser . '.' . $oldExt;
            if ($oldFile !== $dest && file_exists($oldFile)) @unlink($oldFile);
        }
        $avatars = json_decode(file_get_contents(AVATARS_FILE), true) ?: [];
        $avatars[$currentUser] = 'avatars/' . $filename;
        file_put_contents(AVATARS_FILE, json_encode($avatars, JSON_UNESCAPED_UNICODE), LOCK_EX);
        $avatarUpdates = json_decode(file_get_contents(AVATAR_UPDATED_FILE), true) ?: [];
        $avatarUpdates[$currentUser] = time();
        file_put_contents(AVATAR_UPDATED_FILE, json_encode($avatarUpdates, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'upload_avatar', '更换头像');
        echo json_encode(['success'=>true, 'avatar_url'=>'avatars/'.$filename]); exit;
    }

    // 恢复默认头像：清掉自定义头像记录与文件，前端回落到"灰白人像剪影"占位
    if ($_GET['action'] === 'reset_avatar') {
        session_write_close();
        $root = realpath(AVATAR_DIR);
        foreach (['jpg','png','gif','webp'] as $ext) {
            $f = AVATAR_DIR . 'avatar_' . $currentUser . '.' . $ext;
            // 路径收敛：确认待删文件确实落在头像目录内，避免用户名里的特殊字符导致越界删除
            $dir = realpath(dirname($f));
            if ($root === false || $dir === false || strpos($dir, $root) !== 0) continue;
            if (is_file($f)) @unlink($f);
        }
        $avatars = json_decode(@file_get_contents(AVATARS_FILE), true) ?: [];
        unset($avatars[$currentUser]);
        file_put_contents(AVATARS_FILE, json_encode($avatars, JSON_UNESCAPED_UNICODE), LOCK_EX);
        $avatarUpdates = json_decode(@file_get_contents(AVATAR_UPDATED_FILE), true) ?: [];
        $avatarUpdates[$currentUser] = time();
        file_put_contents(AVATAR_UPDATED_FILE, json_encode($avatarUpdates, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'reset_avatar', '恢复默认头像');
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'upload_group_avatar') {
        session_write_close();
        if (!isset($_FILES['avatar']) || $_FILES['avatar']['error'] !== UPLOAD_ERR_OK) {
            http_response_code(400); echo json_encode(['error'=>uploadFailMsg('avatar', '群头像')]); exit;
        }
        $file = $_FILES['avatar'];
        $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
        $mime = '';
        if (function_exists('finfo_open')) {
            $finfo = finfo_open(FILEINFO_MIME_TYPE);
            $mime = finfo_file($finfo, $file['tmp_name']);
            finfo_close($finfo);
        } else {
            $mime = $file['type'];
            if (empty($mime)) {
                $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
                $map = ['jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','gif'=>'image/gif','webp'=>'image/webp'];
                $mime = $map[$ext] ?? '';
            }
        }
        if (!in_array($mime, $allowed)) { http_response_code(400); echo json_encode(['error'=>'仅支持JPG/PNG/GIF/WebP']); exit; }
        $extMap = ['image/jpeg'=>'jpg','image/png'=>'png','image/gif'=>'gif','image/webp'=>'webp'];
        $ext = $extMap[$mime];
        $groupId = trim($_POST['group'] ?? '');
        if ($groupId === '') { $groupId = 'tmp_' . uniqid(); }
        $base = 'group_' . md5($groupId);
        // 自动压缩：群头像统一缩到 256x256 内，返回 null 时保持原图
        $tmpOut = GROUP_AVATAR_DIR . $base . '_tmp.' . $ext;
        $outExt = @compressImageTo($file['tmp_name'], $tmpOut, 256, 256, 82);
        if ($outExt !== null) {
            @unlink($file['tmp_name']);
            $filename = $base . '.' . $outExt;
            $dest = GROUP_AVATAR_DIR . $filename;
            if (!@copy($tmpOut, $dest)) {
                @unlink($tmpOut);
                http_response_code(500); echo json_encode(['error'=>'保存失败']); exit;
            }
            @unlink($tmpOut);
        } else {
            @unlink($tmpOut);
            $filename = $base . '.' . $ext;
            $dest = GROUP_AVATAR_DIR . $filename;
            if (!@move_uploaded_file($file['tmp_name'], $dest)) {
                http_response_code(500); echo json_encode(['error'=>'保存失败']); exit;
            }
            stripImageMetadata($dest, $mime);
        }
        echo json_encode(['success'=>true, 'avatar_url'=>'group_avatars/'.$filename, 'tmp_id'=>$groupId]); exit;
    }

    if ($_GET['action'] === 'get_avatar') {
        $user = trim($_GET['user'] ?? '');
        $avatars = json_decode(file_get_contents(AVATARS_FILE), true) ?: [];
        echo json_encode(['avatar'=>$avatars[$user] ?? null]); exit;
    }

    if ($_GET['action'] === 'online') {
        session_write_close();
        updateOnline(ONLINE_FILE, $currentUser, $_GET['room'] ?? '公共聊天');
        $online = getOnlineUsers(ONLINE_FILE);
        echo json_encode(['online'=>count($online), 'users'=>array_keys($online)]); exit;
    }

    if ($_GET['action'] === 'get_status') {
        $statuses = json_decode(file_get_contents(STATUS_FILE), true) ?: [];
        echo json_encode(['statuses'=>$statuses]); exit;
    }
    if ($_GET['action'] === 'set_status') {
        $status = trim($_POST['status'] ?? '');
        $statuses = json_decode(file_get_contents(STATUS_FILE), true) ?: [];
        if ($status === '') {
            unset($statuses[$currentUser]);
        } else {
            $statuses[$currentUser] = mb_substr($status, 0, 30);
        }
        file_put_contents(STATUS_FILE, json_encode($statuses, JSON_UNESCAPED_UNICODE), LOCK_EX);
        echo json_encode(['success'=>true, 'status'=>$statuses[$currentUser] ?? '']); exit;
    }

    if ($_GET['action'] === 'upload_chat_image') {
        session_write_close();
        if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
            http_response_code(400); echo json_encode(['error'=>uploadFailMsg('image', '图片')]); exit;
        }
        $file = $_FILES['image'];
        if ($file['size'] > MAX_IMAGE_SIZE) { http_response_code(400); echo json_encode(['error'=>'图片不能超过10MB']); exit; }
        $allowed = ['image/jpeg','image/png','image/gif','image/webp'];
        $mime = '';
        if (function_exists('finfo_open')) {
            $finfo = finfo_open(FILEINFO_MIME_TYPE);
            $mime = finfo_file($finfo, $file['tmp_name']);
            finfo_close($finfo);
        } else { $mime = $file['type']; }
        if (!in_array($mime, $allowed)) { http_response_code(400); echo json_encode(['error'=>'仅支持JPG/PNG/GIF/WebP']); exit; }
        $extMap = ['image/jpeg'=>'jpg','image/png'=>'png','image/gif'=>'gif','image/webp'=>'webp'];
        $ext = $extMap[$mime];
        $base = uniqid('img_', true);
        // 自动压缩：聊天图片统一缩到 1920x1920 内，返回 null 时保持原图
        $tmpOut = UPLOAD_DIR . $base . '_tmp.' . $ext;
        $outExt = @compressImageTo($file['tmp_name'], $tmpOut, 1920, 1920, 82);
        if ($outExt !== null) {
            @unlink($file['tmp_name']);
            $filename = $base . '.' . $outExt;
            $dest = UPLOAD_DIR . $filename;
            if (!@copy($tmpOut, $dest)) {
                @unlink($tmpOut);
                http_response_code(500); echo json_encode(['error'=>'保存失败']); exit;
            }
            @unlink($tmpOut);
        } else {
            @unlink($tmpOut);
            $filename = $base . '.' . $ext;
            $dest = UPLOAD_DIR . $filename;
            if (!@move_uploaded_file($file['tmp_name'], $dest)) {
                http_response_code(500); echo json_encode(['error'=>'保存失败']); exit;
            }
            stripImageMetadata($dest, $mime);
        }
        echo json_encode(['success'=>true, 'image_url'=>'uploads/'.$filename]); exit;
    }

    if ($_GET['action'] === 'upload_voice') {
        session_write_close();
        if (!isset($_FILES['voice']) || $_FILES['voice']['error'] !== UPLOAD_ERR_OK) {
            http_response_code(400); echo json_encode(['error'=>uploadFailMsg('voice', '语音')]); exit;
        }
        $file = $_FILES['voice'];
        if ($file['size'] > 20 * 1024 * 1024) { http_response_code(400); echo json_encode(['error'=>'语音不能超过20MB']); exit; }
        $mime = $file['type'];
        $allowed = ['audio/webm','audio/ogg','audio/mp4','audio/mpeg','audio/wav','audio/x-wav','video/webm'];
        if (!in_array($mime, $allowed)) { http_response_code(400); echo json_encode(['error'=>'不支持的语音格式']); exit; }
        $extMap = ['audio/webm'=>'webm','video/webm'=>'webm','audio/ogg'=>'ogg','audio/mp4'=>'m4a','audio/mpeg'=>'mp3','audio/wav'=>'wav','audio/x-wav'=>'wav'];
        $ext = $extMap[$mime] ?? 'webm';
        $filename = uniqid('voice_', true) . '.' . $ext;
        $dest = UPLOAD_DIR . $filename;
        if (!@move_uploaded_file($file['tmp_name'], $dest)) {
            http_response_code(500); echo json_encode(['error'=>'保存失败']); exit;
        }
        $duration = max(1, min(300, intval($_POST['duration'] ?? 0)));
        writeLog($currentUser, 'upload_voice', $filename . ' (' . $duration . 's)');
        echo json_encode(['success'=>true, 'voice_url'=>'uploads/'.$filename, 'duration'=>$duration]); exit;
    }

    if ($_GET['action'] === 'get_stickers') {
        $stickers = [];
        $files = @glob(STICKERS_DIR . '*.svg');
        if ($files) {
            foreach ($files as $f) {
                $stickers[] = ['url' => 'stickers/' . basename($f), 'type' => 'builtin'];
            }
        }
        $userStk = json_decode(@file_get_contents(STICKERS_FILE), true) ?: [];
        if (is_array($userStk)) {
            foreach ($userStk as $s) {
                if (is_array($s) && isset($s['url'])) {
                    $stickers[] = ['id' => $s['id'] ?? '', 'url' => $s['url'], 'type' => 'user', 'user' => $s['user'] ?? ''];
                }
            }
        }
        echo json_encode(['success'=>true, 'stickers'=>$stickers]); exit;
    }

    if ($_GET['action'] === 'upload_sticker') {
        session_write_close();
        if (!isset($_FILES['sticker']) || $_FILES['sticker']['error'] !== UPLOAD_ERR_OK) {
            http_response_code(400); echo json_encode(['error'=>uploadFailMsg('sticker', '表情')]); exit;
        }
        $file = $_FILES['sticker'];
        if ($file['size'] > 5 * 1024 * 1024) { http_response_code(400); echo json_encode(['error'=>'表情不能超过5MB']); exit; }
        $mime = '';
        if (function_exists('finfo_open')) { $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); }
        else { $mime = $file['type']; }
        $allowed = ['image/png','image/jpeg','image/gif','image/webp','image/svg+xml'];
        if (!in_array($mime, $allowed)) { http_response_code(400); echo json_encode(['error'=>'仅支持图片格式']); exit; }
        $extMap = ['image/png'=>'png','image/jpeg'=>'jpg','image/gif'=>'gif','image/webp'=>'webp','image/svg+xml'=>'svg'];
        $ext = $extMap[$mime];
        $base = 'stk_' . uniqid();
        // 自动压缩：表情统一缩到 512x512 内（SVG/动画GIF等自动保持原图）
        $tmpOut = UPLOAD_DIR . $base . '_tmp.' . $ext;
        $outExt = @compressImageTo($file['tmp_name'], $tmpOut, 512, 512, 90);
        if ($outExt !== null) {
            @unlink($file['tmp_name']);
            $filename = $base . '.' . $outExt;
            $dest = UPLOAD_DIR . $filename;
            if (!@copy($tmpOut, $dest)) { @unlink($tmpOut); http_response_code(500); echo json_encode(['error'=>'保存失败']); exit; }
            @unlink($tmpOut);
        } else {
            @unlink($tmpOut);
            $filename = $base . '.' . $ext;
            $dest = UPLOAD_DIR . $filename;
            if ($ext === 'svg') {
                // SVG 不能原样落盘：先做安全清洗。否则用户直接访问 /uploads/xxx.svg 时，
                // 里面的脚本会在同源下执行，就是一个存储型 XSS。
                $clean = sanitizeSvg(@file_get_contents($file['tmp_name']));
                @unlink($file['tmp_name']);
                if ($clean === null || @file_put_contents($dest, $clean, LOCK_EX) === false) {
                    http_response_code(400); echo json_encode(['error'=>'SVG 不合法或包含不安全内容']); exit;
                }
            } elseif (!@move_uploaded_file($file['tmp_name'], $dest)) {
                http_response_code(500); echo json_encode(['error'=>'保存失败']); exit;
            }
            if ($ext !== 'svg') stripImageMetadata($dest, $mime);
        }
        $list = json_decode(@file_get_contents(STICKERS_FILE), true) ?: [];
        if (!is_array($list)) $list = [];
        $newItem = ['id' => uniqid(), 'user' => $currentUser, 'url' => 'uploads/' . $filename, 'time' => time()];
        $list[] = $newItem;
        file_put_contents(STICKERS_FILE, json_encode($list, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'upload_sticker', $filename);
        echo json_encode(['success'=>true, 'sticker'=>$newItem]); exit;
    }

    if ($_GET['action'] === 'delete_sticker') {
        $id = trim($_POST['id'] ?? '');
        $list = json_decode(@file_get_contents(STICKERS_FILE), true) ?: [];
        if (is_array($list)) {
            $list = array_values(array_filter($list, function($s) use ($id, $currentUser) {
                return !(is_array($s) && ($s['id'] ?? '') === $id && ($s['user'] ?? '') === $currentUser);
            }));
            file_put_contents(STICKERS_FILE, json_encode($list, JSON_UNESCAPED_UNICODE), LOCK_EX);
        }
        writeLog($currentUser, 'delete_sticker', $id);
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'upload_file') {
        session_write_close();
        if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
            http_response_code(400); echo json_encode(['error'=>uploadFailMsg('file', '文件')]); exit;
        }
        $file = $_FILES['file'];
        if ($file['size'] > MAX_FILE_SIZE) { http_response_code(400); echo json_encode(['error'=>'文件不能超过50MB']); exit; }
        $filename = preg_replace('/[^\w\.\-]/u', '_', $file['name']);
        $filename = time() . '_' . $filename;
        $dest = FILES_DIR . $filename;
        if (!@move_uploaded_file($file['tmp_name'], $dest)) {
            http_response_code(500); echo json_encode(['error'=>'保存失败']); exit;
        }
        writeLog($currentUser, 'upload_file', $filename . ' (' . $file['size'] . 'B)');
        echo json_encode(['success'=>true, 'file_url'=>'files/'.$filename, 'file_name'=>$file['name'], 'file_size'=>$file['size']]); exit;
    }

    // 大文件分块上传：浏览器把文件切成小块，逐块 POST，前端可显示真实进度条，从而支持几 GB 甚至更大的文件
    if ($_GET['action'] === 'upload_chunk') {
        session_write_close();
        $uploadId = preg_replace('/[^A-Za-z0-9]/', '', $_POST['upload_id'] ?? '');
        $index = intval($_POST['index'] ?? 0);
        $total = max(1, intval($_POST['total'] ?? 1));
        $name  = $_POST['name'] ?? 'file';
        if ($uploadId === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        if (!isset($_FILES['chunk']) || $_FILES['chunk']['error'] !== UPLOAD_ERR_OK) {
            http_response_code(400); echo json_encode(['error'=>'分块数据缺失']); exit;
        }
        if (!is_dir(CHUNK_DIR) && !@mkdir(CHUNK_DIR, 0755, true)) { http_response_code(500); echo json_encode(['error'=>'无法创建上传目录']); exit; }
        $part = CHUNK_DIR . $uploadId . '.part';
        $data = file_get_contents($_FILES['chunk']['tmp_name']);
        if ($data === false) { http_response_code(400); echo json_encode(['error'=>'读取分块失败']); exit; }
        $f = @fopen($part, 'ab');
        if (!$f) { http_response_code(500); echo json_encode(['error'=>'写入失败']); exit; }
        fwrite($f, $data);
        fclose($f);
        if ($index >= $total - 1) {
            $san = preg_replace('/[^\w\.\-]/u', '_', $name);
            $san = mb_substr($san, 0, 200);
            $filename = time() . '_' . $san;
            $dest = FILES_DIR . $filename;
            if (!@rename($part, $dest)) {
                @unlink($part);
                http_response_code(500); echo json_encode(['error'=>'文件保存失败']); exit;
            }
            $size = filesize($dest);
            writeLog($currentUser, 'upload_file_chunked', $filename . ' (' . $size . 'B)');
            echo json_encode(['success'=>true, 'file_url'=>'files/'.$filename, 'file_name'=>$name, 'file_size'=>$size]); exit;
        }
        echo json_encode(['success'=>true, 'chunk'=>($index + 1), 'total'=>$total]); exit;
    }

    // 群文件 / 群相册：共享到群的文件（type=file 存 files/，type=image 存 uploads/）
    if ($_GET['action'] === 'upload_group_file') {
        session_write_close();
        $groupName = trim($_POST['group'] ?? '');
        $type = ($_POST['type'] ?? 'file') === 'image' ? 'image' : 'file';
        if ($groupName === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        if (!isGroupMember($groups[$groupName] ?? null, $currentUser)) { http_response_code(403); echo json_encode(['error'=>'仅群成员可上传']); exit; }
        if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
            http_response_code(400); echo json_encode(['error'=>uploadFailMsg('file', '文件')]); exit;
        }
        $file = $_FILES['file'];
        if ($file['size'] > MAX_FILE_SIZE) { http_response_code(400); echo json_encode(['error'=>'文件不能超过50MB']); exit; }
        // 文件名会原样进 JSON：先清洗成合法 UTF-8，
        // 否则非法字节会让 json_encode 失败，进而把整个群文件清单写成空文件
        $origName = cleanUtf8($file['name']);
        if ($origName === '') $origName = 'file';
        $ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION));
        $isImg = $type === 'image' || in_array($ext, ['jpg','jpeg','png','gif','webp','bmp','svg']);
        $san = preg_replace('/[^\w\.\-]/u', '_', $origName);
        $san = mb_substr((string)$san, 0, 200);
        if ($san === '') $san = 'file';
        $filename = time() . '_' . mt_rand(100, 999) . '_' . $san;
        $dest = $isImg ? (UPLOAD_DIR . $filename) : (FILES_DIR . $filename);
        if (!@move_uploaded_file($file['tmp_name'], $dest)) {
            http_response_code(500); echo json_encode(['error'=>'保存失败']); exit;
        }
        if ($isImg) stripImageMetadata($dest, mimeFromImageExt($ext));
        $url = $isImg ? ('uploads/' . $filename) : ('files/' . $filename);
        $total = 0; $countBefore = 0;
        $ok = withGroupFilesLock(function ($all) use ($groupName, $origName, $url, $isImg, $file, $currentUser, &$total, &$countBefore) {
            if (!isset($all[$groupName]) || !is_array($all[$groupName])) $all[$groupName] = [];
            $countBefore = count($all[$groupName]);
            $all[$groupName][] = ['name'=>$origName, 'url'=>$url, 'size'=>intval($file['size']), 'type'=>$isImg?'image':'file', 'user'=>$currentUser, 'time'=>time()];
            if (count($all[$groupName]) > 200) $all[$groupName] = array_slice($all[$groupName], -200);
            $total = count($all[$groupName]);
            return $all;
        });
        // 必须检查写入结果：以前不管成没成都返回 success，
        // 清单没写进去时用户看到"上传成功"，列表里却什么都没有
        if (!$ok) {
            @unlink($dest);
            writeLog($currentUser, 'upload_group_file_fail', $groupName . ' / 清单写入失败 / 原条目数=' . $countBefore);
            http_response_code(500); echo json_encode(['error'=>'文件清单写入失败，请重试']); exit;
        }
        writeLog($currentUser, 'upload_group_file', $groupName . ' / ' . $origName . ' / 现有' . $total . '个');
        echo json_encode(['success'=>true, 'count'=>$total]); exit;
    }

    if ($_GET['action'] === 'get_group_files') {
        session_write_close();
        $groupName = trim($_GET['group'] ?? '');
        if ($groupName === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        // 群文件列表也要鉴权：否则任何登录用户知道群名就能列出该群的文件
        $groups = json_decode(@file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isGroupMember($groups[$groupName] ?? null, $currentUser)) {
            writeLog($currentUser, 'room_denied', '越权读取群文件：' . $groupName);
            http_response_code(403); echo json_encode(['error'=>'你不在该群中']); exit;
        }
        $all = json_decode(@file_get_contents(GROUP_FILES_FILE), true);
        if (!is_array($all)) {
            // 清单读不出来（空文件/损坏）时留一条记录：否则"群文件列表一直空"完全没有线索
            writeLog($currentUser, 'group_files_unreadable', '群文件清单无法解析，已按空处理：' . $groupName);
            $all = [];
        }
        $list = $all[$groupName] ?? [];
        if (!is_array($list)) $list = [];
        // 首次访问群文件时补录一遍历史附件（把大家在群里发过的照片/文件补进清单）。
        // 标记文件存在后这里只多一次 file_exists，开销可忽略。
        if (!file_exists(GROUP_FILES_INDEX_FLAG)) {
            rebuildGroupFilesOnce();
            $all = json_decode(@file_get_contents(GROUP_FILES_FILE), true);
            if (!is_array($all)) $all = [];
            $list = $all[$groupName] ?? [];
            if (!is_array($list)) $list = [];
        }
        $list = array_reverse($list);
        echo json_encode(['files'=>$list]); exit;
    }

    if ($_GET['action'] === 'delete_group_file') {
        session_write_close();
        $groupName = trim($_POST['group'] ?? '');
        $url = trim($_POST['url'] ?? '');
        if ($groupName === '' || $url === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        $isOwner = isset($groups[$groupName]) && ($groups[$groupName]['created_by'] ?? '') === $currentUser;
        $isAdmin = listHasUser($groups[$groupName]['admins'] ?? [], $currentUser);
        $found = null;
        $ok = withGroupFilesLock(function ($all) use ($groupName, $url, $currentUser, $isOwner, $isAdmin, &$found) {
            $list = $all[$groupName] ?? [];
            if (!is_array($list)) $list = [];
            $newList = [];
            foreach ($list as $f) {
                if (($f['url'] ?? '') === $url && (($f['user'] ?? '') === $currentUser || $isOwner || $isAdmin)) { $found = $f; continue; }
                $newList[] = $f;
            }
            if (!$found) return null;        // 文件不存在或无权删：不改动任何数据
            $all[$groupName] = $newList;
            return $all;
        });
        if (!$found) { http_response_code(403); echo json_encode(['error'=>'无权删除该文件']); exit; }
        if (!$ok) { http_response_code(500); echo json_encode(['error'=>'保存失败，请重试']); exit; }
        if (strpos($url, 'files/') === 0) @unlink(FILES_DIR . substr($url, 6));
        if (strpos($url, 'uploads/') === 0) @unlink(UPLOAD_DIR . substr($url, 8));
        writeLog($currentUser, 'delete_group_file', $groupName . ' / ' . ($found['name'] ?? $url));
        echo json_encode(['success'=>true]); exit;
    }

    // 批量删除群文件：一次请求处理多个 url，逐条做权限判断
    // （群主/管理员可删全部，普通成员只能删自己上传的），返回成功与无权的条数
    if ($_GET['action'] === 'batch_delete_group_file') {
        session_write_close();
        $groupName = trim($_POST['group'] ?? '');
        $urls = $_POST['urls'] ?? [];
        if (!is_array($urls)) $urls = [$urls];
        $urls = array_values(array_unique(array_filter(array_map('strval', $urls))));
        if ($groupName === '' || empty($urls)) { http_response_code(400); echo json_encode(['error'=>'请先选择要删除的文件']); exit; }
        if (count($urls) > 200) { http_response_code(400); echo json_encode(['error'=>'一次最多删除 200 个文件']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isGroupMember($groups[$groupName] ?? null, $currentUser)) {
            http_response_code(403); echo json_encode(['error'=>'你不在该群中']); exit;
        }
        $isOwner = ($groups[$groupName]['created_by'] ?? '') === $currentUser;
        $isAdmin = listHasUser($groups[$groupName]['admins'] ?? [], $currentUser);
        $targets = array_flip($urls);
        $deleted = 0; $denied = 0; $removedFiles = [];
        $ok = withGroupFilesLock(function ($all) use ($groupName, $targets, $currentUser, $isOwner, $isAdmin, &$deleted, &$denied, &$removedFiles) {
            $list = $all[$groupName] ?? [];
            if (!is_array($list)) $list = [];
            $newList = [];
            foreach ($list as $f) {
                $u = $f['url'] ?? '';
                if (isset($targets[$u])) {
                    if (($f['user'] ?? '') === $currentUser || $isOwner || $isAdmin) {
                        $deleted++;
                        $removedFiles[] = [$u, $f['name'] ?? ''];
                        continue;   // 从清单里摘掉
                    }
                    $denied++;
                }
                $newList[] = $f;
            }
            if ($deleted === 0) return null;      // 一个都没删成，不必写盘
            $all[$groupName] = $newList;
            return $all;
        });
        if ($deleted > 0 && !$ok) { http_response_code(500); echo json_encode(['error'=>'保存失败，请重试']); exit; }
        // 磁盘文件在锁外删，避免把慢 IO 压在清单锁上
        foreach ($removedFiles as $rf) {
            $p = groupFileDiskPath($rf[0], false);
            if ($p) @unlink($p);
        }
        foreach ($removedFiles as $rf) writeLog($currentUser, 'delete_group_file', $groupName . ' / ' . $rf[1]);
        echo json_encode(['success'=>true, 'deleted'=>$deleted, 'denied'=>$denied]); exit;
    }

    // 群文件下载（单个）：由 PHP 输出文件流，而不是让浏览器直接取 files/ 静态路径。
    // 好处：静态目录被服务器禁止访问时仍可下载（"能上传不能下载"通常就是这个原因），
    // 并且下载请求也走成员鉴权。
    if ($_GET['action'] === 'download_group_file') {
        session_write_close();
        $groupName = trim($_GET['group'] ?? $_POST['group'] ?? '');
        $url = trim($_GET['url'] ?? $_POST['url'] ?? '');
        $entry = groupFileEntry($groupName, $url, $currentUser);
        if ($entry === null) { writeLog($currentUser, 'download_denied', '越权下载群文件：' . $groupName . ' / ' . $url); http_response_code(403); echo '无权下载该文件'; exit; }
        $path = groupFileDiskPath($url, true);
        if ($path === null) { http_response_code(404); echo '文件不存在或已被删除'; exit; }
        $name = $entry['name'] ?? basename($path);
        sendGroupFileHeaders($name, filesize($path));
        readfile($path);
        exit;
    }

    // 群文件下载（批量）：打包成一个 zip 返回。服务器没装 zip 扩展时退回单文件直传，
    // 至少不会卡在一个点了没反应的按钮上。
    if ($_GET['action'] === 'download_group_files') {
        session_write_close();
        $groupName = trim($_POST['group'] ?? '');
        $urls = $_POST['urls'] ?? [];
        if (!is_array($urls)) $urls = [$urls];
        $urls = array_values(array_unique(array_filter(array_map('strval', $urls))));
        if ($groupName === '' || empty($urls)) { http_response_code(400); echo '请先选择要下载的文件'; exit; }
        if (count($urls) > 200) { http_response_code(400); echo '一次最多下载 200 个文件'; exit; }
        $picked = [];
        foreach ($urls as $u) {
            $entry = groupFileEntry($groupName, $u, $currentUser);
            if ($entry === null) continue;
            $p = groupFileDiskPath($u, true);
            if ($p === null) continue;
            $picked[] = ['path'=>$p, 'name'=>($entry['name'] ?? basename($p))];
        }
        if (empty($picked)) { http_response_code(404); echo '所选文件均不存在或无权下载'; exit; }
        sendFilesAsArchive($picked, '群文件');
        exit;
    }

    // 下载消息里的附件（图片/文件）。只接受消息 id + 房间，附件路径一律由服务端按 id 回查
    // messages.json 得到——如果让客户端直接传路径，任何人都能构造请求把任意文件拖下来。
    if ($_GET['action'] === 'download_message_files') {
        session_write_close();
        $room = canonicalRoom(trim($_POST['room'] ?? ''));
        $ids = $_POST['ids'] ?? [];
        if (!is_array($ids)) $ids = [$ids];
        $ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
        if ($room === '' || empty($ids)) { http_response_code(400); echo '请先选择带附件的消息'; exit; }
        if (count($ids) > 200) { http_response_code(400); echo '一次最多下载 200 条消息的附件'; exit; }
        $accErr = roomAccessError($room, $currentUser);
        if ($accErr !== null) {
            writeLog($currentUser, 'room_denied', '越权下载消息附件：' . $room);
            http_response_code(403); echo $accErr['msg']; exit;
        }
        $messages = loadMessages();
        $want = array_flip($ids);
        $picked = [];
        foreach ($messages as $m) {
            $mid = $m['id'] ?? 0;
            if (!isset($want[$mid])) continue;
            if (($m['room'] ?? '') !== $room) continue;
            $ts = (int)($m['timestamp'] ?? 0);
            if (!empty($m['image'])) {
                $p = groupFileDiskPath($m['image'], true);
                if ($p) {
                    // 图片在磁盘上是 时间戳_随机数_原名，名字不直观，换成"图片_时间.扩展名"
                    $ext = pathinfo($p, PATHINFO_EXTENSION);
                    $picked[] = ['path'=>$p, 'name'=>('图片_' . ($ts > 0 ? date('Ymd_His', $ts) : $mid) . ($ext !== '' ? '.' . $ext : '.jpg'))];
                }
            }
            if (!empty($m['file'])) {
                $p = groupFileDiskPath($m['file'], true);
                if ($p) $picked[] = ['path'=>$p, 'name'=>($m['file_name'] ?? basename($p))];
            }
        }
        if (empty($picked)) { http_response_code(404); echo '所选消息里没有可下载的附件'; exit; }
        sendFilesAsArchive($picked, '聊天文件');
        exit;
    }

    // 输入状态：心跳写入 + 查询，用于"对方正在输入…"提示
    if ($_GET['action'] === 'set_typing') {
        session_write_close();
        $room = canonicalRoom(trim($_POST['room'] ?? '公共聊天'));
        if ($room === '') $room = '公共聊天';
        $all = json_decode(@file_get_contents(TYPING_FILE), true) ?: [];
        if (!isset($all[$room]) || !is_array($all[$room])) $all[$room] = [];
        $all[$room][$currentUser] = time();
        foreach ($all as $r => $users) {
            foreach ($users as $u => $t) {
                if (time() - $t > 8) unset($all[$r][$u]);
            }
            if (empty($all[$r])) unset($all[$r]);
        }
        file_put_contents(TYPING_FILE, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'get_typing') {
        session_write_close();
        $room = canonicalRoom(trim($_GET['room'] ?? '公共聊天'));
        $all = json_decode(@file_get_contents(TYPING_FILE), true) ?: [];
        $users = [];
        foreach (($all[$room] ?? []) as $u => $t) {
            if ($u !== $currentUser && time() - $t <= 6) $users[] = $u;
        }
        echo json_encode(['users'=>$users]); exit;
    }

    // 消息转发：把某条消息复制到目标房间，并标记"转发自"
    if ($_GET['action'] === 'forward_message') {
        session_write_close();
        $msgId = intval($_POST['msg_id'] ?? 0);
        $srcRoom = canonicalRoom(trim($_POST['src_room'] ?? ''));
        $targetRoom = canonicalRoom(trim($_POST['target_room'] ?? ''));
        // 多选批量转发走 msg_ids（逗号分隔）。单次上限 50 条：转发接口是写文件的，
        // 不设上限等于给了一个"一条请求写满整个聊天记录"的口子。
        $msgIds = [];
        if (trim((string)($_POST['msg_ids'] ?? '')) !== '') {
            foreach (explode(',', (string)$_POST['msg_ids']) as $one) {
                $one = intval(trim($one));
                if ($one > 0) $msgIds[] = $one;
            }
            $msgIds = array_slice(array_values(array_unique($msgIds)), 0, 50);
        } elseif ($msgId > 0) {
            $msgIds[] = $msgId;
        }
        if (!$msgIds || $srcRoom === '' || $targetRoom === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        // 源房间必须是自己有权访问的：否则只要猜消息 id，就能把别人私聊里的内容转发出来
        $srcErr = roomAccessError($srcRoom, $currentUser);
        if ($srcErr !== null) {
            writeLog($currentUser, 'room_denied', '越权转发：' . $srcRoom);
            http_response_code(403); echo json_encode(['error'=>'无权读取该会话的消息', 'code'=>'room_denied']); exit;
        }
        // 只能转发"自己看得到"的消息：新成员不能靠转发绕过入群前的可见性限制
        $srcVisibleFrom = roomVisibleFrom($srcRoom, $currentUser);
        $messages = loadMessages();
        $origMap = [];
        foreach ($messages as $m) {
            $mid = intval($m['id'] ?? 0);
            if ($mid <= 0 || !in_array($mid, $msgIds, true) || ($m['room'] ?? '') !== $srcRoom) continue;
            if ($srcVisibleFrom > 0 && intval($m['timestamp'] ?? 0) < $srcVisibleFrom) continue;
            $origMap[$mid] = $m;
        }
        if (!$origMap) { http_response_code(404); echo json_encode(['error'=>'原消息不存在或不在你的可见范围内']); exit; }
        // 保持用户勾选顺序，跳过已撤回的；整批都不可转发时明确报错，不做静默成功
        $origList = [];
        foreach ($msgIds as $mid) {
            if (isset($origMap[$mid]) && ($origMap[$mid]['type'] ?? '') !== 'recalled') $origList[] = $origMap[$mid];
        }
        if (!$origList) { http_response_code(400); echo json_encode(['error'=>'已撤回的消息无法转发']); exit; }
        if (strpos($targetRoom, 'pm:') === 0) {
            $parts = explode(':', $targetRoom);
            $pmUser = ($parts[1] === $currentUser) ? ($parts[2] ?? '') : ($parts[1] ?? '');
            $friends = json_decode(file_get_contents(FRIENDS_FILE), true) ?: [];
            if (!in_array($pmUser, $friends[$currentUser] ?? [])) { http_response_code(400); echo json_encode(['error'=>'对方还不是你的好友']); exit; }
        }
        if (strpos($targetRoom, 'group:') === 0) {
            $gName = substr($targetRoom, 6);
            if (isGroupBanned($gName)) { http_response_code(400); echo json_encode(['error'=>'该群已被封禁，无法转发']); exit; }
            $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
            if (!isGroupMember($groups[$gName] ?? null, $currentUser)) { http_response_code(403); echo json_encode(['error'=>'你不在该群中，无法转发']); exit; }
        }
        $fp = fopen(MESSAGES_FILE, 'c+');
        if (!$fp) { http_response_code(500); echo json_encode(['error'=>'服务器错误']); exit; }
        flock($fp, LOCK_EX);
        $all = json_decode(stream_get_contents($fp), true) ?: [];
        $maxId = 0;
        foreach ($all as $m) { if (isset($m['id']) && $m['id'] > $maxId) $maxId = $m['id']; }
        $newId = $maxId + 1;
        $newIds = [];
        foreach ($origList as $orig) {
            $id = $newId++;
            $all[] = [
                'id'        => $id,
                'room'      => $targetRoom,
                'username'  => $currentUser,
                'message'   => filterSensitive(mb_substr($orig['message'] ?? '', 0, 500)),
                'image'     => $orig['image'] ?? null,
                'file'      => $orig['file'] ?? null,
                'file_name' => $orig['file_name'] ?? null,
                'file_size' => $orig['file_size'] ?? null,
                'voice'     => $orig['voice'] ?? null,
                'forward'   => ['from_user' => $orig['username'] ?? '', 'from_room' => $srcRoom],
                'time'      => date('H:i:s'),
                'timestamp' => time()
            ];
            $newIds[] = $id;
        }
        if (count($all) > MAX_MESSAGES) $all = array_slice($all, -MAX_MESSAGES);
        ftruncate($fp, 0); rewind($fp);
        fwrite($fp, json_encode($all, JSON_UNESCAPED_UNICODE));
        fflush($fp);
        flock($fp, LOCK_UN); fclose($fp);
        updateOnline(ONLINE_FILE, $currentUser, $targetRoom);
        // 未读按"条"计，批量转发 N 条就要加 N 次，否则侧栏角标会比实际少
        foreach ($newIds as $_) incrementUnread($currentUser, $targetRoom);
        writeLog($currentUser, 'forward_message', $srcRoom . ' -> ' . $targetRoom . ' #' . implode(',', $newIds));
        echo json_encode(['success'=>true, 'id'=>$newIds[0], 'ids'=>$newIds, 'count'=>count($newIds)]); exit;
    }

    // 资料卡装扮：个性签名 / 头像框 / 主页背景
    $FRAMES = ['none','gold','blue','green','red','rainbow','purple'];
    if ($_GET['action'] === 'save_profile') {
        session_write_close();
        $signature = trim($_POST['signature'] ?? '');
        $frame = trim($_POST['frame'] ?? 'none');
        $bg = trim($_POST['bg'] ?? 'none');
        if (!in_array($frame, $FRAMES)) $frame = 'none';
        if ($bg !== 'none' && strpos($bg, 'uploads/') !== 0 && !in_array($bg, ['light','dark','blue','grad1','grad2','grad3'])) $bg = 'none';
        $signature = mb_substr($signature, 0, 60);
        $all = json_decode(@file_get_contents(PROFILES_FILE), true) ?: [];
        $p = $all[$currentUser] ?? [];
        $p['signature'] = $signature;
        $p['frame'] = $frame;
        $p['bg'] = $bg;
        $all[$currentUser] = $p;
        file_put_contents(PROFILES_FILE, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'save_profile', '签名/头像框/背景 更新');
        echo json_encode(['success'=>true, 'profile'=>$p]); exit;
    }

    if ($_GET['action'] === 'get_profile') {
        session_write_close();
        $target = trim($_GET['user'] ?? $currentUser);
        $all = json_decode(@file_get_contents(PROFILES_FILE), true) ?: [];
        $p = $all[$target] ?? [];
        $avatars = json_decode(@file_get_contents(AVATARS_FILE), true) ?: [];
        echo json_encode([
            'user'=>$target,
            'signature'=>$p['signature'] ?? '',
            'frame'=>$p['frame'] ?? 'none',
            'bg'=>$p['bg'] ?? 'none',
            'avatar'=>isset($avatars[$target]) ? avatarUrl($avatars[$target]) : null
        ]); exit;
    }

    if ($_GET['action'] === 'upload_profile_bg') {
        session_write_close();
        if (!isset($_FILES['bg']) || $_FILES['bg']['error'] !== UPLOAD_ERR_OK) { http_response_code(400); echo json_encode(['error'=>uploadFailMsg('bg', '背景图')]); exit; }
        $f = $_FILES['bg'];
        if ($f['size'] > MAX_IMAGE_SIZE) { http_response_code(400); echo json_encode(['error'=>'图片不能超过10MB']); exit; }
        $ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION));
        if (!in_array($ext, ['jpg','jpeg','png','gif','webp'])) $ext = 'png';
        $base = 'bg_' . time() . '_' . mt_rand(100,999);
        // 自动压缩：资料背景统一缩到 1920x1920 内，返回 null 时保持原图
        $tmpOut = UPLOAD_DIR . $base . '_tmp.' . $ext;
        $outExt = @compressImageTo($f['tmp_name'], $tmpOut, 1920, 1920, 82);
        if ($outExt !== null) {
            @unlink($f['tmp_name']);
            $filename = $base . '.' . $outExt;
            if (!@copy($tmpOut, UPLOAD_DIR . $filename)) { @unlink($tmpOut); http_response_code(500); echo json_encode(['error'=>'保存失败']); exit; }
            @unlink($tmpOut);
        } else {
            @unlink($tmpOut);
            $filename = $base . '.' . $ext;
            if (!@move_uploaded_file($f['tmp_name'], UPLOAD_DIR . $filename)) { http_response_code(500); echo json_encode(['error'=>'保存失败']); exit; }
        }
        stripImageMetadata(UPLOAD_DIR . $filename, mimeFromImageExt($ext));
        echo json_encode(['success'=>true, 'url'=>'uploads/' . $filename]); exit;
    }

    // 黑名单：拉黑后双方都无法互发私聊消息
    if ($_GET['action'] === 'set_blacklist') {
        session_write_close();
        $target = trim($_POST['target'] ?? '');
        $action = ($_POST['action'] ?? '') === 'remove' ? 'remove' : 'add';
        if ($target === '' || $target === $currentUser) { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $all = json_decode(@file_get_contents(BLACKLIST_FILE), true) ?: [];
        $list = $all[$currentUser] ?? [];
        if ($action === 'add') { if (!in_array($target, $list)) $list[] = $target; }
        else { $list = array_values(array_diff($list, [$target])); }
        $all[$currentUser] = $list;
        file_put_contents(BLACKLIST_FILE, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'blacklist_' . $action, $target);
        echo json_encode(['success'=>true, 'list'=>$list]); exit;
    }

    if ($_GET['action'] === 'get_blacklist') {
        session_write_close();
        $all = json_decode(@file_get_contents(BLACKLIST_FILE), true) ?: [];
        echo json_encode(['list'=>$all[$currentUser] ?? []]); exit;
    }

    // 语音/视频通话：通过服务器交换信令（WebRTC）
    if ($_GET['action'] === 'start_call') {
        session_write_close();
        $to = trim($_POST['to'] ?? '');
        $room = trim($_POST['room'] ?? '');
        $type = ($_POST['type'] ?? 'voice') === 'video' ? 'video' : 'voice';
        $attempt = max(1, intval($_POST['attempt'] ?? 1));   // 第几次尝试发起（客户端重连可见性）
        if ($to === '' || $to === $currentUser) { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        if ($room === '') $room = callDefaultRoom($currentUser, $to);
        if (!callRoomValid($room, $currentUser, $to)) { http_response_code(400); echo json_encode(['error'=>'通话房间无效']); exit; }
        $users = json_decode(@file_get_contents(USERS_FILE), true) ?: [];
        if (!isset($users[$to])) { http_response_code(400); echo json_encode(['error'=>'用户不存在']); exit; }
        $fp = @fopen(CALLS_FILE, 'c+');
        if (!$fp) { http_response_code(503); echo json_encode(['error'=>'服务繁忙，请稍后重试', 'code'=>'busy_io']); exit; }
        flock($fp, LOCK_EX);
        $calls = json_decode(stream_get_contents($fp), true);
        if (!is_array($calls)) $calls = [];
        // 先回收僵尸记录（响铃超时/心跳丢失/超硬上限/过期已结束），再判断占线
        sweepCalls($calls);
        $now = time();
        $busyMe = callBusyOwner($calls, $currentUser);
        $busyPeer = callBusyOwner($calls, $to);
        if ($busyMe !== null) {
            ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($calls, JSON_UNESCAPED_UNICODE)); fflush($fp);
            flock($fp, LOCK_UN); fclose($fp);
            http_response_code(409); echo json_encode(['error'=>'您有未结束的通话，请先挂断后再试', 'code'=>'busy_self']); exit;
        }
        if ($busyPeer !== null) {
            ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($calls, JSON_UNESCAPED_UNICODE)); fflush($fp);
            flock($fp, LOCK_UN); fclose($fp);
            http_response_code(409); echo json_encode(['error'=>$to . ' 正在通话中，请稍后再拨', 'code'=>'busy_peer']); exit;
        }
        $callId = 'call_' . $now . '_' . mt_rand(1000, 9999);
        // 邀请码在发起这一刻就定下来，并随通话记录一起持久化；
        // 对方接听界面会自动录入它，无需任何手工复制粘贴。
        $calls[$callId] = ['from'=>$currentUser, 'to'=>$to, 'type'=>$type, 'status'=>'ringing', 'ts'=>$now, 'room'=>$room, 'end_reason'=>null, 'offer'=>null, 'answer'=>null, 'candidates'=>[], 'seen'=>[], 'attempt'=>$attempt, 'invite_code'=>genCallCode(), 'receipt_code'=>null];
        callTouch($calls[$callId], $currentUser, $now);
        ftruncate($fp, 0); rewind($fp);
        fwrite($fp, json_encode($calls, JSON_UNESCAPED_UNICODE)); fflush($fp);
        flock($fp, LOCK_UN); fclose($fp);
        writeLog($currentUser, 'call_start', $type . ' → ' . $to . ' @ ' . $room);
        // 在聊天界面注入"发起通话"气泡
        addCallMessage($room, $currentUser, 'start', $to, 0, $type);
        echo json_encode(['success'=>true, 'call_id'=>$callId, 'invite_code'=>$calls[$callId]['invite_code']]); exit;
    }

    if ($_GET['action'] === 'get_call') {
        session_write_close();
        $now = time();
        // 只返回"本轮新增"的 ICE 候选：客户端带上已消费到的序号（游标），避免每 800ms 重放全量候选
        $since = max(0, intval($_GET['cand_since'] ?? 0));
        $fp = @fopen(CALLS_FILE, 'c+');
        // 读不到文件时显式返回错误，客户端据此跳过本轮，而不是误判为"通话已结束"
        if (!$fp) { echo json_encode(['error'=>'busy', 'mine'=>null, 'incoming'=>null]); exit; }
        flock($fp, LOCK_EX);
        $calls = json_decode(stream_get_contents($fp), true);
        if (!is_array($calls)) $calls = [];
        $changed = sweepCalls($calls);
        $mine = null; $incoming = null;
        $mineEnded = null; $incomingEnded = null;
        foreach ($calls as $id => &$c) {
            $isEnded = (($c['status'] ?? '') === 'ended');
            // 心跳最多每 5 秒落盘一次，避免 800ms 轮询变成高频磁盘写
            if (($c['from'] ?? '') === $currentUser) {
                if ($isEnded) { if (!$mineEnded) $mineEnded = $id; }
                elseif (!$mine) {
                    $mine = $id;
                    if (($now - intval($c['seen'][$currentUser] ?? 0)) >= 5) { callTouch($c, $currentUser, $now); $changed = true; }
                }
            }
            if (($c['to'] ?? '') === $currentUser) {
                if ($isEnded) { if (!$incomingEnded) $incomingEnded = $id; }
                elseif (!$incoming) {
                    $incoming = $id;
                    if (($now - intval($c['seen'][$currentUser] ?? 0)) >= 5) { callTouch($c, $currentUser, $now); $changed = true; }
                }
            }
        }
        unset($c);
        // 没有活跃记录、只剩刚结束的记录时，也要把"已结束"回报给参与者。
        // 以前这里直接跳过 ended 记录，导致对端挂断后本端收不到任何终态信号，
        // 界面就一直停在通话中（被叫侧尤其明显：它没有任何别的收尾路径）。
        if (!$mine && $mineEnded) $mine = $mineEnded;
        if (!$incoming && $incomingEnded) $incoming = $incomingEnded;
        $out = ['mine'=>null, 'incoming'=>null];
        if ($mine) {
            $c = $calls[$mine];
            $out['mine'] = array_merge(['call_id'=>$mine, 'status'=>$c['status'], 'type'=>$c['type'], 'peer'=>$c['to'], 'room'=>(string)($c['room'] ?? ''), 'end_reason'=>$c['end_reason'] ?? null, 'peer_seen'=>intval(($c['seen'][$c['to']] ?? 0) ? ($now - $c['seen'][$c['to']]) : -1)], callHandshakeCodes($c));
            $pm = callPeerMedia($c, $currentUser);
            if ($pm !== null) $out['mine']['peer_media'] = $pm;
            // 已结束的记录不再下发信令：客户端会先按终态收尾，不需要也不应再处理 SDP
            if (($c['status'] ?? '') !== 'ended') {
                if (($c['answer'] ?? null) !== null) $out['answer'] = $c['answer'];
                list($cands, $next) = callPickCandidates($c, $currentUser, $since);
                if (count($cands)) $out['candidates'] = $cands;
                if ($next > 0) $out['cand_next'] = $next;
            }
        }
        if ($incoming) {
            $c = $calls[$incoming];
            $out['incoming'] = array_merge(['call_id'=>$incoming, 'status'=>$c['status'], 'type'=>$c['type'], 'peer'=>$c['from'], 'room'=>(string)($c['room'] ?? ''), 'end_reason'=>$c['end_reason'] ?? null, 'peer_seen'=>intval(($c['seen'][$c['from']] ?? 0) ? ($now - $c['seen'][$c['from']]) : -1)], callHandshakeCodes($c));
            $pm = callPeerMedia($c, $currentUser);
            if ($pm !== null) $out['incoming']['peer_media'] = $pm;
            if (($c['status'] ?? '') !== 'ended') {
                if (($c['offer'] ?? null) !== null) $out['offer'] = $c['offer'];
                list($cands, $next) = callPickCandidates($c, $currentUser, $since);
                if (count($cands)) $out['candidates'] = $cands;
                if ($next > 0) $out['cand_next'] = $next;
            }
        }
        if ($changed) { ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($calls, JSON_UNESCAPED_UNICODE)); fflush($fp); }
        flock($fp, LOCK_UN); fclose($fp);
        echo json_encode($out); exit;
    }

    if ($_GET['action'] === 'respond_call') {
        session_write_close();
        $callId = trim($_POST['call_id'] ?? '');
        $action = $_POST['action'] ?? '';
        if (!in_array($action, ['accept','reject','cancel','end'])) { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $fp = @fopen(CALLS_FILE, 'c+');
        if (!$fp) { http_response_code(503); echo json_encode(['error'=>'服务繁忙，请稍后重试', 'code'=>'busy_io']); exit; }
        flock($fp, LOCK_EX);
        $calls = json_decode(stream_get_contents($fp), true);
        if (!is_array($calls)) $calls = [];
        sweepCalls($calls);   // 先回收僵尸记录：过期的响铃不能"接听"
        if (!isset($calls[$callId])) {
            flock($fp, LOCK_UN); fclose($fp);
            http_response_code(400); echo json_encode(['error'=>'通话不存在', 'code'=>'gone']); exit;
        }
        $c = &$calls[$callId];
        $room = (string)($c['room'] ?? '');
        $handshake = null;   // 仅 accept 时回填：把两个码一起返回给接听方
        if ($action === 'accept') {
            if ($c['to'] !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'无权限']); exit; }
            if (($c['status'] ?? '') === 'ended') { http_response_code(400); echo json_encode(['error'=>'通话已结束', 'code'=>'gone']); exit; }
            // 老记录可能没有邀请码，补发一个（只补一次，幂等）
            if (empty($c['invite_code'])) $c['invite_code'] = genCallCode();
            // 回执码只生成一次：客户端重试 accept 时必须拿到同一个码，
            // 否则主叫端会看到回执码跳变，无法确认握手到底完成没有
            if (empty($c['receipt_code'])) $c['receipt_code'] = genCallCode();
            $c['status'] = 'accepted';
            $c['accepted_at'] = time();           // 记录接通时间，用于服务端计算通话时长
            callTouch($c, $currentUser);
            $handshake = callHandshakeCodes($c);
        } elseif ($action === 'reject') {
            if ($c['to'] !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'无权限']); exit; }
            $was = $c['status'] ?? '';
            $c['status'] = 'ended'; $c['end_reason'] = 'reject';
            $c['ended_at'] = time();
            // 同一通电话只注入一次结束气泡，避免双方同时挂断时重复
            if ($was !== 'ended' && empty($c['bubble'])) { $c['bubble'] = true; addCallMessage($room, $currentUser, 'reject', $c['from'], 0, $c['type'] ?? 'voice'); }
        } else {
            if ($c['from'] !== $currentUser && $c['to'] !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'无权限']); exit; }
            $was = $c['status'] ?? '';
            $c['status'] = 'ended';
            $c['ended_at'] = time();
            if ($action === 'cancel') {
                $c['end_reason'] = 'cancel';
                if ($was !== 'ended' && empty($c['bubble'])) { $c['bubble'] = true; addCallMessage($room, $currentUser, 'cancel', $c['to'], 0, $c['type'] ?? 'voice'); }
            } else {
                $c['end_reason'] = 'end';
                if ($was !== 'ended' && empty($c['bubble'])) {
                    $c['bubble'] = true;
                    // 优先按服务端接通时间计算时长，避免客户端计时残留导致时长错误
                    $dur = 0;
                    if (!empty($c['accepted_at'])) $dur = max(0, time() - intval($c['accepted_at']));
                    if ($dur <= 0) $dur = intval($_POST['duration'] ?? 0);
                    addCallMessage($room, $currentUser, 'end', $c['to'], $dur, $c['type'] ?? 'voice');
                }
            }
        }
        ftruncate($fp, 0); rewind($fp);
        fwrite($fp, json_encode($calls, JSON_UNESCAPED_UNICODE)); fflush($fp);
        flock($fp, LOCK_UN); fclose($fp);
        writeLog($currentUser, 'call_' . $action, $callId);
        echo json_encode(array_merge(['success'=>true], is_array($handshake) ? $handshake : [])); exit;
    }

    if ($_GET['action'] === 'call_signal') {
        session_write_close();
        $callId = trim($_POST['call_id'] ?? '');
        $kind = $_POST['kind'] ?? '';
        // 信令是短连接下的"半可靠通道"：写入失败必须显式失败，客户端才能重发
        $fp = @fopen(CALLS_FILE, 'c+');
        if (!$fp) { http_response_code(503); echo json_encode(['error'=>'服务繁忙，请稍后重试', 'code'=>'busy_io']); exit; }
        flock($fp, LOCK_EX);
        $calls = json_decode(stream_get_contents($fp), true);
        if (!is_array($calls)) $calls = [];
        if (!isset($calls[$callId])) {
            flock($fp, LOCK_UN); fclose($fp);
            http_response_code(400); echo json_encode(['error'=>'通话不存在', 'code'=>'gone']); exit;
        }
        $c = &$calls[$callId];
        if (($c['status'] ?? '') === 'ended') {
            flock($fp, LOCK_UN); fclose($fp);
            http_response_code(400); echo json_encode(['error'=>'通话已结束', 'code'=>'gone']); exit;
        }
        if ($kind === 'offer') {
            if ($c['from'] !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'无权限']); exit; }
            $c['offer'] = json_decode($_POST['sdp'] ?? '{}', true);
            $c['offer_at'] = time();
        } elseif ($kind === 'answer') {
            if ($c['to'] !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'无权限']); exit; }
            $c['answer'] = json_decode($_POST['sdp'] ?? '{}', true);
            $c['answer_at'] = time();
        } elseif ($kind === 'candidate') {
            if ($c['from'] !== $currentUser && $c['to'] !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'无权限']); exit; }
            $cand = json_decode($_POST['candidate'] ?? '{}', true);
            if (is_array($cand) && !empty($cand['candidate'])) {
                $c['cand_seq'] = intval($c['cand_seq'] ?? 0) + 1;
                $c['candidates'][] = ['from'=>$currentUser, 'seq'=>$c['cand_seq'], 'candidate'=>$cand];
                callTrimCandidates($c);   // 背压：候选不设上限会让 calls.json 无界增长
            }
        } elseif ($kind === 'state') {
            // 摄像头/麦克风开关是"带外状态"：WebRTC 不会把"我关了摄像头"通知对端，
            // 对端只会收到黑帧、track 也不会变成 muted。所以必须由本端主动上报，
            // 服务端只负责按参与者存储并转发给对端（见 callPeerMedia）。
            if ($c['from'] !== $currentUser && $c['to'] !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'无权限']); exit; }
            if (!isset($c['media']) || !is_array($c['media'])) $c['media'] = [];
            // 只能写自己的状态：键用当前登录用户，客户端无法伪造对端的开关
            $c['media'][$currentUser] = [
                'cam' => (($_POST['cam'] ?? '0') === '1'),
                'mic' => (($_POST['mic'] ?? '0') === '1'),
                'at'  => time()
            ];
        } else { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        callTouch($c, $currentUser);
        ftruncate($fp, 0); rewind($fp);
        fwrite($fp, json_encode($calls, JSON_UNESCAPED_UNICODE)); fflush($fp);
        flock($fp, LOCK_UN); fclose($fp);
        echo json_encode(['success'=>true, 'cand_seq'=>intval($c['cand_seq'] ?? 0)]); exit;
    }

    // 聊天背景：每个用户对每个聊天单独设置
    $CHAT_BGS = ['none','light','blue','green','dark','grad1','grad2','grad3'];
    if ($_GET['action'] === 'save_chat_bg') {
        session_write_close();
        $room = canonicalRoom(trim($_POST['room'] ?? ''));
        $bg = trim($_POST['bg'] ?? 'none');
        if ($room === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        if ($bg !== 'none' && strpos($bg, 'uploads/') !== 0 && !in_array($bg, $CHAT_BGS)) $bg = 'none';
        $all = json_decode(@file_get_contents(CHAT_BG_FILE), true) ?: [];
        $rooms = $all[$currentUser] ?? [];
        if ($bg === 'none') unset($rooms[$room]);
        else $rooms[$room] = $bg;
        $all[$currentUser] = $rooms;
        file_put_contents(CHAT_BG_FILE, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'chat_bg', $room . ' → ' . $bg);
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'get_chat_bg') {
        session_write_close();
        $room = canonicalRoom(trim($_GET['room'] ?? ''));
        $all = json_decode(@file_get_contents(CHAT_BG_FILE), true) ?: [];
        echo json_encode(['bg'=>$all[$currentUser][$room] ?? 'none']); exit;
    }

    if ($_GET['action'] === 'upload_chat_bg') {
        session_write_close();
        if (!isset($_FILES['bg']) || $_FILES['bg']['error'] !== UPLOAD_ERR_OK) { http_response_code(400); echo json_encode(['error'=>uploadFailMsg('bg', '聊天背景')]); exit; }
        $f = $_FILES['bg'];
        if ($f['size'] > MAX_IMAGE_SIZE) { http_response_code(400); echo json_encode(['error'=>'图片不能超过10MB']); exit; }
        $ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION));
        if (!in_array($ext, ['jpg','jpeg','png','gif','webp'])) $ext = 'png';
        $base = 'cbg_' . time() . '_' . mt_rand(100,999);
        // 自动压缩：聊天背景统一缩到 1920x1920 内，返回 null 时保持原图
        $tmpOut = UPLOAD_DIR . $base . '_tmp.' . $ext;
        $outExt = @compressImageTo($f['tmp_name'], $tmpOut, 1920, 1920, 82);
        if ($outExt !== null) {
            @unlink($f['tmp_name']);
            $filename = $base . '.' . $outExt;
            if (!@copy($tmpOut, UPLOAD_DIR . $filename)) { @unlink($tmpOut); http_response_code(500); echo json_encode(['error'=>'保存失败']); exit; }
            @unlink($tmpOut);
        } else {
            @unlink($tmpOut);
            $filename = $base . '.' . $ext;
            if (!@move_uploaded_file($f['tmp_name'], UPLOAD_DIR . $filename)) { http_response_code(500); echo json_encode(['error'=>'保存失败']); exit; }
        }
        stripImageMetadata(UPLOAD_DIR . $filename, mimeFromImageExt($ext));
        echo json_encode(['success'=>true, 'url'=>'uploads/' . $filename]); exit;
    }

    // 获取全局公告（后台发送的站内公告，所有人可见）；notices 为历史列表（最新在前）
    if ($_GET['action'] === 'get_global_notice') {
        session_write_close();
        $n = json_decode(@file_get_contents(NOTICE_FILE), true);
        if (isset($n['text'])) $n = [$n];            // 兼容旧的单条对象格式
        if (!is_array($n)) $n = [];
        echo json_encode(['notice'=>isset($n[0]) ? $n[0] : null, 'notices'=>$n]); exit;
    }

    // 群成员（含群主）随时可把其他用户拉进已经存在的群，模仿 QQ/微信的拉人逻辑
    if ($_GET['action'] === 'add_group_members') {
        $groupName = trim($_POST['group'] ?? '');
        $members = json_decode($_POST['members'] ?? '[]', true) ?: [];
        if ($groupName === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        if (!isGroupMember($groups[$groupName] ?? null, $currentUser)) { http_response_code(403); echo json_encode(['error'=>'仅群成员可邀请']); exit; }
        $users = json_decode(file_get_contents(USERS_FILE), true) ?: [];
        $added = [];
        foreach ($members as $m) {
            $m = (string)$m;
            if ($m === '' || $m === $currentUser) continue;
            if (!isset($users[$m])) continue;
            if (!isGroupMember($groups[$groupName] ?? null, $m)) {
                $groups[$groupName]['members'][] = $m;
                // 入群时间 = 这位成员在本群的"可见起点"，他看不到入群之前的聊天记录
                $groups[$groupName]['joined_at'][$m] = time();
                $added[] = $m;
            }
        }
        file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
        if ($added) {
            writeLog($currentUser, 'add_group_members', $groupName . ' 邀请 ' . implode('、', $added));
            $grRoom = 'group:' . $groupName;
            $msg = [
                'room' => $grRoom, 'username' => 'system', 'type' => 'system',
                'message' => $currentUser . ' 邀请了 ' . implode('、', $added) . ' 加入群聊',
                'time' => date('H:i:s'), 'timestamp' => time()
            ];
            appendMessageLocked($msg);
        }
        echo json_encode(['success'=>true, 'added'=>$added]); exit;
    }

    if ($_GET['action'] === 'get_avatar_updates') {
        $since = intval($_GET['since'] ?? 0);
        $updates = json_decode(file_get_contents(AVATAR_UPDATED_FILE), true) ?: [];
        $result = [];
        foreach ($updates as $user => $ts) {
            if ($ts > $since) $result[$user] = $ts;
        }
        echo json_encode(['updates'=>$result]); exit;
    }

    if ($_GET['action'] === 'get_all_users') {
        session_write_close();
        ob_start();
        try {
            $users = json_decode(@file_get_contents(USERS_FILE), true) ?: [];
            $online = getOnlineUsers(ONLINE_FILE);
            $avatars = json_decode(@file_get_contents(AVATARS_FILE), true) ?: [];
            $friends = json_decode(@file_get_contents(FRIENDS_FILE), true) ?: [];
            $myFriends = is_array($friends) ? ($friends[$currentUser] ?? []) : [];
            $frReqs = json_decode(@file_get_contents(FRIEND_REQ_FILE), true) ?: [];
            
            $result = [];
            if (is_array($users)) {
                foreach ($users as $uname => $pwd) {
                    $uname = (string)$uname;
                    if ($uname === $currentUser) continue;
                    $isFriend = in_array($uname, $myFriends);
                    $reqSent = false;
                    $reqReceived = false;
                    if (is_array($frReqs)) {
                        foreach ($frReqs as $req) {
                            if (!is_array($req)) continue;
                            if (($req['status'] ?? 'pending') !== 'pending') continue;
                            if (($req['from'] ?? '') === $currentUser && ($req['to'] ?? '') === $uname) $reqSent = true;
                            if (($req['from'] ?? '') === $uname && ($req['to'] ?? '') === $currentUser) $reqReceived = true;
                        }
                    }
                    $result[] = [
                        'name' => $uname,
                        'online' => isset($online[$uname]),
                        'avatar' => isset($avatars[$uname]) ? avatarUrl($avatars[$uname]) : null,
                        'is_friend' => $isFriend,
                        'request_sent' => $reqSent,
                        'request_received' => $reqReceived,
                    ];
                }
            }
            shuffle($result);
            ob_clean();
            echo json_encode(['users'=>$result, 'myself'=>$currentUser]);
        } catch (Throwable $e) {
            ob_clean();
            echo json_encode(['users'=>[], 'myself'=>$currentUser, 'error'=>$e->getMessage()]);
        }
        exit;
    }

    if ($_GET['action'] === 'get_rooms') {
        session_write_close();
        updateOnline(ONLINE_FILE, $currentUser, $_GET['room'] ?? '公共聊天');
        $rooms = json_decode(file_get_contents(ROOMS_FILE), true) ?: [];
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        $users = json_decode(file_get_contents(USERS_FILE), true) ?: [];
        $friends = json_decode(file_get_contents(FRIENDS_FILE), true) ?: [];
        $frReqs = json_decode(file_get_contents(FRIEND_REQ_FILE), true) ?: [];
        $online = getOnlineUsers(ONLINE_FILE);
        $avatars = json_decode(file_get_contents(AVATARS_FILE), true) ?: [];
        $unread = getUnreadCounts($currentUser);
        $notes = json_decode(file_get_contents(NOTES_FILE), true) ?: [];
        $pins = json_decode(file_get_contents(PINS_FILE), true) ?: [];
        $myNotes = $notes[$currentUser] ?? [];
        $myPins = $pins[$currentUser] ?? [];

        $myFriends = $friends[$currentUser] ?? [];

        $channels = [];
        foreach ($rooms as $name => $info) {
            if ($info['type'] === 'channel') {
                $channels[] = ['name'=>$name, 'type'=>'channel', 'unread'=>$unread[$name] ?? 0];
            }
        }
        $channelOnline = [];
        foreach ($online as $u => $oinfo) {
            $r = $oinfo['room'] ?? '公共聊天';
            if (!isset($channelOnline[$r])) $channelOnline[$r] = 0;
            $channelOnline[$r]++;
        }

        $friendList = [];
        foreach ($myFriends as $fname) {
            $pmRoom = getPMRoom($currentUser, $fname);
            $friendList[] = [
                'name' => $fname,
                'type' => 'friend',
                'online' => isset($online[$fname]),
                'avatar' => isset($avatars[$fname]) ? avatarUrl($avatars[$fname]) : null,
                'unread' => $unread[$pmRoom] ?? 0,
                'note' => $myNotes[$fname] ?? null,
                'pinned' => in_array($fname, $myPins)
            ];
        }

        $groupList = [];
        $gbanList = json_decode(@file_get_contents(GROUP_BAN_FILE), true) ?: [];
        foreach ($groups as $gname => $ginfo) {
            if (isGroupMember($ginfo, $currentUser)) {
                $grRoom = 'group:' . $gname;
                $members = $ginfo['members'] ?? [];
                $gOnline = 0;
                foreach ($members as $m) {
                    if (isset($online[$m])) $gOnline++;
                }
                $gban = $gbanList[$gname] ?? null;
                $groupList[] = ['name'=>$gname, 'type'=>'group', 'group_id'=>$ginfo['group_id'] ?? '', 'members'=>$ginfo['members'], 'member_count'=>count($members), 'online_count'=>$gOnline, 'request_count'=>count($ginfo['requests'] ?? []), 'join_mode'=>$ginfo['join_mode'] ?? 'apply', 'history_visible'=>!empty($ginfo['history_visible']), 'created_by'=>$ginfo['created_by'] ?? '', 'unread'=>$unread[$grRoom] ?? 0, 'avatar'=>$ginfo['avatar'] ?? null, 'banned'=>$gban ? true : false, 'ban_reason'=>$gban['reason'] ?? '', 'pinned_msg'=>$ginfo['pinned_msg'] ?? null, 'announcement'=>$ginfo['announcement'] ?? null, 'admins'=>$ginfo['admins'] ?? []];
            }
        }

        $pendingReqs = [];
        foreach ($frReqs as $req) {
            if ($req['to'] === $currentUser && (!isset($req['status']) || $req['status'] === 'pending')) {
                $pendingReqs[] = ['from'=>$req['from'], 'time'=>$req['time'] ?? 0];
            }
        }

        echo json_encode([
            'channels' => $channels,
            'friends' => $friendList,
            'groups' => $groupList,
            'pending_requests' => $pendingReqs,
            'online_count' => count($online),
            'online_users' => array_keys($online),
            'channel_online' => $channelOnline
        ]); exit;
    }

    if ($_GET['action'] === 'search_users') {
        $q = trim($_GET['q'] ?? '');
        $users = json_decode(file_get_contents(USERS_FILE), true) ?: [];
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        $friends = json_decode(file_get_contents(FRIENDS_FILE), true) ?: [];
        $frReqs = json_decode(file_get_contents(FRIEND_REQ_FILE), true) ?: [];
        $online = getOnlineUsers(ONLINE_FILE);
        $avatars = json_decode(file_get_contents(AVATARS_FILE), true) ?: [];
        $myFriends = $friends[$currentUser] ?? [];

        $results = [];
        foreach ($users as $uname => $pwd) {
            $uname = (string)$uname;
            if ($uname === $currentUser) continue;
            if ($q !== '' && mb_stripos($uname, $q) === false) continue;

            $isFriend = in_array($uname, $myFriends);
            $reqSent = false;
            $reqReceived = false;
            foreach ($frReqs as $req) {
                if (($req['status'] ?? 'pending') !== 'pending') continue;
                if ($req['from'] === $currentUser && $req['to'] === $uname) $reqSent = true;
                if ($req['from'] === $uname && $req['to'] === $currentUser) $reqReceived = true;
            }

            $results[] = [
                'name' => $uname,
                'online' => isset($online[$uname]),
                'avatar' => isset($avatars[$uname]) ? avatarUrl($avatars[$uname]) : null,
                'is_friend' => $isFriend,
                'request_sent' => $reqSent,
                'request_received' => $reqReceived
            ];
        }

        // 支持按群名/群号搜索群（含自己已加入的群，群主可搜到自己的群）
        $groupResults = [];
        $gbanList = json_decode(@file_get_contents(GROUP_BAN_FILE), true) ?: [];
        foreach ($groups as $gname => $ginfo) {
            $gid = (string)($ginfo['group_id'] ?? '');
            if ($q !== '' && mb_stripos($gname, $q) === false && mb_stripos($gid, $q) === false) continue;
            $members = $ginfo['members'] ?? [];
            $gOnline = 0;
            foreach ($members as $m) {
                if (isset($online[$m])) $gOnline++;
            }
            $gban = $gbanList[$gname] ?? null;
            $groupResults[] = [
                'name' => $gname,
                'group_id' => $gid,
                'created_by' => $ginfo['created_by'] ?? '',
                'member_count' => count($members),
                'online_count' => $gOnline,
                'is_member' => listHasUser($members, $currentUser),
                'avatar' => isset($ginfo['avatar']) ? avatarUrl($ginfo['avatar']) : null,
                'banned' => $gban ? true : false,
                'ban_reason' => $gban['reason'] ?? ''
            ];
        }
        echo json_encode(['users'=>$results, 'groups'=>$groupResults]); exit;
    }

    if ($_GET['action'] === 'send_friend_request') {
        $target = trim($_POST['target'] ?? '');
        if ($target === '' || $target === $currentUser) { http_response_code(400); echo json_encode(['error'=>'无效的用户']); exit; }
        $users = json_decode(file_get_contents(USERS_FILE), true) ?: [];
        if (!isset($users[$target])) { http_response_code(400); echo json_encode(['error'=>'用户不存在']); exit; }
        $friends = json_decode(file_get_contents(FRIENDS_FILE), true) ?: [];
        if (in_array($target, $friends[$currentUser] ?? [])) { http_response_code(400); echo json_encode(['error'=>'已经是好友']); exit; }
        $reqs = json_decode(file_get_contents(FRIEND_REQ_FILE), true) ?: [];
        foreach ($reqs as $r) {
            if (($r['status'] ?? 'pending') === 'pending' && $r['from'] === $currentUser && $r['to'] === $target) {
                http_response_code(400); echo json_encode(['error'=>'已发送过好友请求']); exit;
            }
            if (($r['status'] ?? 'pending') === 'pending' && $r['from'] === $target && $r['to'] === $currentUser) {
                $r['status'] = 'accepted';
                if (!isset($friends[$currentUser])) $friends[$currentUser] = [];
                if (!isset($friends[$target])) $friends[$target] = [];
                if (!in_array($target, $friends[$currentUser])) $friends[$currentUser][] = $target;
                if (!in_array($currentUser, $friends[$target])) $friends[$target][] = $currentUser;
                file_put_contents(FRIENDS_FILE, json_encode($friends, JSON_UNESCAPED_UNICODE), LOCK_EX);
                file_put_contents(FRIEND_REQ_FILE, json_encode($reqs, JSON_UNESCAPED_UNICODE), LOCK_EX);
                echo json_encode(['success'=>true, 'auto_accepted'=>true]); exit;
            }
        }
        $reqs[] = ['from'=>$currentUser, 'to'=>$target, 'time'=>time(), 'status'=>'pending'];
        file_put_contents(FRIEND_REQ_FILE, json_encode($reqs, JSON_UNESCAPED_UNICODE), LOCK_EX);
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'get_friend_requests') {
        $reqs = json_decode(file_get_contents(FRIEND_REQ_FILE), true) ?: [];
        $incoming = [];
        foreach ($reqs as $r) {
            if ($r['to'] === $currentUser && ($r['status'] ?? 'pending') === 'pending') {
                $incoming[] = $r;
            }
        }
        echo json_encode(['requests'=>$incoming]); exit;
    }

    if ($_GET['action'] === 'accept_friend_request') {
        $from = trim($_POST['from'] ?? '');
        $reqs = json_decode(file_get_contents(FRIEND_REQ_FILE), true) ?: [];
        $found = false;
        foreach ($reqs as &$r) {
            if ($r['from'] === $from && $r['to'] === $currentUser && ($r['status'] ?? 'pending') === 'pending') {
                $r['status'] = 'accepted';
                $found = true;
                break;
            }
        }
        if (!$found) { http_response_code(400); echo json_encode(['error'=>'请求不存在']); exit; }
        $friends = json_decode(file_get_contents(FRIENDS_FILE), true) ?: [];
        if (!isset($friends[$currentUser])) $friends[$currentUser] = [];
        if (!isset($friends[$from])) $friends[$from] = [];
        if (!in_array($from, $friends[$currentUser])) $friends[$currentUser][] = $from;
        if (!in_array($currentUser, $friends[$from])) $friends[$from][] = $currentUser;
        file_put_contents(FRIENDS_FILE, json_encode($friends, JSON_UNESCAPED_UNICODE), LOCK_EX);
        file_put_contents(FRIEND_REQ_FILE, json_encode($reqs, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'accept_friend', '与 ' . $from . ' 成为好友');
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'reject_friend_request') {
        $from = trim($_POST['from'] ?? '');
        $reqs = json_decode(file_get_contents(FRIEND_REQ_FILE), true) ?: [];
        foreach ($reqs as &$r) {
            if ($r['from'] === $from && $r['to'] === $currentUser && ($r['status'] ?? 'pending') === 'pending') {
                $r['status'] = 'rejected';
                break;
            }
        }
        file_put_contents(FRIEND_REQ_FILE, json_encode($reqs, JSON_UNESCAPED_UNICODE), LOCK_EX);
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'cancel_friend_request') {
        $target = trim($_POST['target'] ?? '');
        $reqs = json_decode(file_get_contents(FRIEND_REQ_FILE), true) ?: [];
        foreach ($reqs as &$r) {
            if ($r['from'] === $currentUser && $r['to'] === $target && ($r['status'] ?? 'pending') === 'pending') {
                $r['status'] = 'cancelled';
                break;
            }
        }
        file_put_contents(FRIEND_REQ_FILE, json_encode($reqs, JSON_UNESCAPED_UNICODE), LOCK_EX);
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'get_unread') {
        $unread = getUnreadCounts($currentUser);
        $friends = json_decode(file_get_contents(FRIENDS_FILE), true) ?: [];
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        $myFriends = $friends[$currentUser] ?? [];
        $validUnread = [];
        foreach ($unread as $room => $count) {
            if (strpos($room, 'pm:') === 0) {
                $parts = explode(':', $room);
                $other = $parts[1] === $currentUser ? ($parts[2] ?? '') : ($parts[1] ?? '');
                if ($other && in_array($other, $myFriends)) {
                    $validUnread[$room] = $count;
                }
            } elseif (strpos($room, 'group:') === 0) {
                $gname = substr($room, 6);
                if (isset($groups[$gname]) && isGroupMember($groups[$gname], $currentUser)) {
                    $validUnread[$room] = $count;
                }
            } else {
                $validUnread[$room] = $count;
            }
        }
        $total = array_sum($validUnread);
        echo json_encode(['unread'=>$validUnread, 'total'=>(int)$total]); exit;
    }

    if ($_GET['action'] === 'mark_read') {
        // 未读数是以"归一化后的房间名"为键写入的，这里必须同样归一化，
        // 否则用 pm:bob:alice 去清 pm:alice:bob 的未读会一直清不掉。
        $room = canonicalRoom(trim($_POST['room'] ?? ''));
        if ($room === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $unread = json_decode(file_get_contents(UNREAD_FILE), true) ?: [];
        if (isset($unread[$currentUser][$room])) {
            $unread[$currentUser][$room] = 0;
            file_put_contents(UNREAD_FILE, json_encode($unread, JSON_UNESCAPED_UNICODE), LOCK_EX);
        }
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'create_group') {
        $groupName = trim($_POST['name'] ?? '');
        $members = json_decode($_POST['members'] ?? '[]', true) ?: [];
        $avatar = trim($_POST['avatar'] ?? '');
        if ($groupName === '') { http_response_code(400); echo json_encode(['error'=>'群名称不能为空']); exit; }
        if (!listHasUser($members, $currentUser)) $members[] = $currentUser;
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群名称已存在']); exit; }
        // 自动生成唯一 6 位数字群号（类似 QQ 群号）
        do {
            $gid = (string)mt_rand(100000, 999999);
            $dup = false;
            foreach ($groups as $g) {
                if (($g['group_id'] ?? '') === $gid) { $dup = true; break; }
            }
        } while ($dup);
        // 初始成员（含群主）的入群时间就是群的创建时间，因此他们能看到群里的全部消息
        $createdAt = time();
        $joinedAt = [];
        foreach ($members as $m) { $joinedAt[$m] = $createdAt; }
        $groups[$groupName] = ['members'=>$members, 'created_by'=>$currentUser, 'created_at'=>$createdAt, 'group_id'=>$gid, 'join_mode'=>'apply', 'joined_at'=>$joinedAt];
        if ($avatar !== '' && strpos($avatar, 'group_avatars/') === 0) {
            $groups[$groupName]['avatar'] = $avatar;
        }
        file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'create_group', $groupName . ' / 群号:' . $gid . ' / 成员:' . implode('、', $members));
        echo json_encode(['success'=>true, 'group'=>$groupName, 'group_id'=>$gid]); exit;
    }

    if ($_GET['action'] === 'set_group_avatar') {
        $groupName = trim($_POST['group'] ?? '');
        $avatar = trim($_POST['avatar'] ?? '');
        if ($groupName === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        if (($groups[$groupName]['created_by'] ?? '') !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'仅群主可修改']); exit; }
        $groups[$groupName]['avatar'] = $avatar;
        file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
        echo json_encode(['success'=>true]); exit;
    }

    // checkin / get_user_meta / get_rank 三个接口已随等级系统一并下架。
    // data/signins.json 中的数据保留未删，日后若要恢复等级玩法可直接复用。

    if ($_GET['action'] === 'get_messages') {
        session_write_close();
        $room = canonicalRoom(trim($_GET['room'] ?? '公共聊天'));
        // ================= 房间访问控制（关键安全校验，不可省略）=================
        // 私聊房间名是可预测的（pm:用户A:用户B），群聊房间名是 group:群名。
        // 原先只按房间名过滤，任何登录用户只要猜出房间名就能读到别人的私聊与未加入群的消息；
        // 更严重的是下面"标记已读"的写入会顺带篡改他人的未读状态。
        // 这里与上传/转发/邀请等接口保持一致，统一按参与者/成员身份鉴权。
        if (strpos($room, 'pm:') === 0) {
            $pmParts = explode(':', substr($room, 3));
            if (count($pmParts) !== 2 || !in_array($currentUser, $pmParts, true)) {
                writeLog($currentUser, 'room_denied', '越权读取私聊：' . $room);
                http_response_code(403); echo json_encode(['error'=>'无权访问该会话', 'code'=>'room_denied']); exit;
            }
        } elseif (strpos($room, 'group:') === 0) {
            $gName = substr($room, 6);
            $groupsAll = json_decode(@file_get_contents(GROUPS_FILE), true) ?: [];
            if (!isGroupMember($groupsAll[$gName] ?? null, $currentUser)) {
                writeLog($currentUser, 'room_denied', '越权读取群聊：' . $gName);
                http_response_code(403); echo json_encode(['error'=>'你不在该群中', 'code'=>'room_denied']); exit;
            }
        }
        updateOnline(ONLINE_FILE, $currentUser, $room);
        $lastId = intval($_GET['last_id'] ?? 0);
        // 读取带重试：写侧是"清空+写回"的就地改写，撞上空窗时这里会解出空内容，
        // 于是那一次轮询返回 0 条消息、界面消息整片消失。loadMessages() 会跨过这个空窗。
        $messages = loadMessages();
        // 私聊已读/未读：谁在查看这个私聊房间，谁的消息就被标记已读；同时返回我发出且已被对方读过的消息 id。
        // 关键：只有"确实还有对方的未读消息"时才去动文件。
        // 原来是每次请求都 fopen + 排他锁 + 全量解码 + 重写整个消息库（哪怕一条都不用改），
        // 私聊里 2.5 秒一次的轮询会把全站消息文件长期占住，其它请求全排在这把锁后面，
        // 堆积久了被服务器切断连接 —— 这就是"聊天界面突然空白"的直接原因。
        $readIds = [];
        if (strpos($room, 'pm:') === 0) {
            $needMark = false;
            foreach ($messages as $m) {
                if (($m['room'] ?? '') !== $room) continue;
                if (($m['username'] ?? '') === $currentUser) {
                    if (!empty($m['read'])) $readIds[] = $m['id'];
                } elseif (empty($m['read'])) {
                    $needMark = true;
                }
            }
            if ($needMark) {
                $fp = fopen(MESSAGES_FILE, 'c+');
                if ($fp) {
                    flock($fp, LOCK_EX);
                    $all = json_decode(stream_get_contents($fp), true);
                    if (!is_array($all)) $all = [];
                    $ch = false;
                    foreach ($all as &$mm) {
                        if (($mm['room'] ?? '') === $room && ($mm['username'] ?? '') !== $currentUser && empty($mm['read'])) { $mm['read'] = true; $ch = true; }
                    }
                    unset($mm);
                    if ($ch) { ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($all, JSON_UNESCAPED_UNICODE)); fflush($fp); }
                    flock($fp, LOCK_UN); fclose($fp);
                }
            }
        }
        $newMessages = [];
        $maxId = $lastId;
        // 可见起点：早于这个时间的消息不下发（新用户/新群成员看不到"自己进来之前"的聊天记录）。
        // 0 表示不限制，老账号走这条分支，行为和以前完全一致。
        $visibleFrom = roomVisibleFrom($room, $currentUser);
        // 增量同步（撤回/编辑/回应）也走同一个可见起点：编辑后的正文一旦下发，
        // 就等于把"入群前的内容"直接泄露出去了，所以这里不能省。
        $visibleMsg = function ($msg) use ($visibleFrom) {
            return $visibleFrom <= 0 || intval($msg['timestamp'] ?? 0) >= $visibleFrom;
        };
        // 倒序遍历，消息按 id 递增排列，遇到 <= lastId 的就可以停了
        for ($i = count($messages) - 1; $i >= 0; $i--) {
            $msg = $messages[$i];
            $msgRoom = $msg['room'] ?? '公共聊天';
            if (!isset($msg['id'])) continue;
            if ($msg['id'] <= $lastId) break;
            if ($msgRoom !== $room) continue;
            // 水位（返回给客户端的 last_id）要取该房间真实的最新 id，不能受可见性影响：
            // 否则被过滤掉的消息会让客户端一直从旧位置重复请求同一段内容
            if ($msg['id'] > $maxId) $maxId = $msg['id'];
            if ($visibleFrom > 0 && intval($msg['timestamp'] ?? 0) < $visibleFrom) continue;
            $newMessages[] = $msg;
        }
        $newMessages = array_reverse($newMessages);
        // 为消息补充头像框（等级角标已随等级系统下架，这里不再读取 signins.json）
        $frameCache = [];
        $profiles = json_decode(@file_get_contents(PROFILES_FILE), true) ?: [];
        foreach ($newMessages as &$nm) {
            $u = (string)($nm['username'] ?? '');
            if ($u !== '' && !isset($frameCache[$u])) {
                $frameCache[$u] = ($profiles[$u]['frame'] ?? 'none') ?: 'none';
            }
            $nm['frame'] = $frameCache[$u] ?? 'none';
        }
        unset($nm);
        $deletedAll = json_decode(file_get_contents(DELETED_FILE), true) ?: [];
        $deletedIds = [];
        $now = time();
        foreach ($deletedAll as $d) {
            if ($now - ($d['ts'] ?? 0) < SYSTEM_MSG_TTL) {
                $deletedIds[] = $d['id'];
            }
        }
        $recalledSince = intval($_GET['recalled_since'] ?? 0);
        $recalledMessages = [];
        foreach ($messages as $msg) {
            $msgRoom = $msg['room'] ?? '公共聊天';
            if ($msgRoom === $room && ($msg['type'] ?? '') === 'recalled' && ($msg['recalled_at'] ?? 0) > $recalledSince && $visibleMsg($msg)) {
                $recalledMessages[] = ['id'=>$msg['id'], 'message'=>$msg['message'], 'time'=>$msg['time']];
            }
        }
        $editedSince = intval($_GET['edited_since'] ?? 0);
        $editedMessages = [];
        foreach ($messages as $msg) {
            $msgRoom = $msg['room'] ?? '公共聊天';
            if ($msgRoom === $room && ($msg['edited_at'] ?? 0) > $editedSince && $visibleMsg($msg)) {
                $editedMessages[] = ['id'=>$msg['id'], 'message'=>$msg['message'] ?? '', 'edited_at'=>$msg['edited_at'] ?? 0];
            }
        }
        // 表情回应增量：谁点了回应都会写 reacted_at，其它在线客户端据此把回应同步到已渲染的气泡上
        // （包含"最后一个回应被取消"的情况：reactions 为空也要下发，让客户端清除徽章）
        $reactSince = intval($_GET['reactions_since'] ?? 0);
        $reactionUpdates = [];
        foreach ($messages as $msg) {
            $msgRoom = $msg['room'] ?? '公共聊天';
            if ($msgRoom === $room && ($msg['reacted_at'] ?? 0) > $reactSince && $visibleMsg($msg)) {
                $reactionUpdates[] = ['id'=>$msg['id'], 'reactions'=>$msg['reactions'] ?? []];
            }
        }
        $avatarSince = intval($_GET['avatar_since'] ?? 0);
        $avatarUpdates = json_decode(file_get_contents(AVATAR_UPDATED_FILE), true) ?: [];
        $newAvatarUpdates = [];
        foreach ($avatarUpdates as $user => $ts) {
            if ($ts > $avatarSince) {
                $avatars = json_decode(file_get_contents(AVATARS_FILE), true) ?: [];
                $newAvatarUpdates[$user] = $avatars[$user] ?? null;
            }
        }
        $online = getOnlineUsers(ONLINE_FILE);
        echo json_encode([
            'messages'=>$newMessages, 'last_id'=>$maxId,
            'online'=>count($online), 'users'=>array_keys($online),
            'visible_from'=>$visibleFrom,   // 0 = 不限；>0 时客户端可说明"早于此时刻的记录不显示"
            'deleted_ids'=>$deletedIds,
            'recalled_messages'=>$recalledMessages,
            'edited_messages'=>$editedMessages,
            'reaction_updates'=>$reactionUpdates,
            'reactions_since'=>time(),
            'read_ids'=>$readIds,
            'avatar_updates'=>$newAvatarUpdates,
            'avatar_ts'=>time()
        ]); exit;
    }

    if ($_GET['action'] === 'report_error') {
        session_write_close();
        // 客户端错误上报：以前排查线上问题只能靠用户截图，现在浏览器侧的报错会自己落到服务端。
        // 限流 + 长度上限，避免这里被当成"随便写文件"的口子。
        $ip = clientIp();
        if (!rateLimit('cerr:' . $ip, 30, 60)) { http_response_code(429); echo json_encode(['error'=>'too many']); exit; }
        $raw = file_get_contents('php://input');
        if ($raw === false || strlen($raw) > 20000) { http_response_code(400); echo json_encode(['error'=>'bad']); exit; }
        $in = json_decode($raw, true);
        if (!is_array($in)) { http_response_code(400); echo json_encode(['error'=>'bad']); exit; }
        $cut = function ($s, $n) { return mb_substr((string)$s, 0, $n); };
        $rec = [
            'at'    => time(),
            'user'  => $currentUser,
            'ip'    => $ip,
            'kind'  => $cut($in['kind'] ?? 'error', 20),
            'msg'   => $cut($in['msg'] ?? '', 300),
            'src'   => $cut($in['src'] ?? '', 300),
            'line'  => intval($in['line'] ?? 0),
            'stack' => $cut($in['stack'] ?? '', 2000),
            'url'   => $cut($in['url'] ?? '', 300),
            'ua'    => $cut($_SERVER['HTTP_USER_AGENT'] ?? '', 200),
        ];
        $list = json_decode((string)@file_get_contents(CLIENT_ERR_FILE), true);
        if (!is_array($list)) $list = [];
        // 同一条错误重复出现时只保留最新一条（更新时间戳），避免同类错误把列表刷满
        $sig = md5($rec['kind'] . '|' . $rec['msg'] . '|' . $rec['line']);
        $list = array_values(array_filter($list, function ($r) use ($sig) { return ($r['sig'] ?? '') !== $sig; }));
        $rec['sig'] = $sig;
        array_unshift($list, $rec);
        if (count($list) > 200) $list = array_slice($list, 0, 200);
        @file_put_contents(CLIENT_ERR_FILE, json_encode($list), LOCK_EX);
        echo json_encode(['success' => true]); exit;
    }

    if ($_GET['action'] === 'send_message') {
        session_write_close();
        // 发消息限速：同一用户 60 秒内最多 40 条。阈值刻意宽松，
        // 正常聊天碰不到，但脚本刷屏/灌水会被挡住。
        if (!rateLimit('send:' . $currentUser, 40, 60)) {
            http_response_code(429); echo json_encode(['error' => '发送太频繁了，缓一下再发']); exit;
        }
        $message = trim($_POST['message'] ?? '');
        $room    = canonicalRoom(trim($_POST['room'] ?? '公共聊天'));
        $quote   = trim($_POST['quote'] ?? '');
        $image   = trim($_POST['image'] ?? '');
        $file    = trim($_POST['file'] ?? '');
        $fileName = trim($_POST['file_name'] ?? '');
        $fileSize = trim($_POST['file_size'] ?? '');
        $voice   = trim($_POST['voice'] ?? '');
        $voiceDuration = max(1, min(300, intval($_POST['voice_duration'] ?? 0)));
        // 声纹包络：客户端采集的一串 0-9 数字（每位=一个采样点的响度档位）。
        // 可选字段：只校验长度与字符集，非法或超长直接丢弃，不影响语音本身发出。
        $voiceWave = trim($_POST['voice_wave'] ?? '');
        if ($voiceWave !== '' && !preg_match('/^[0-9]{1,64}$/', $voiceWave)) $voiceWave = '';
        if ($message === '' && $image === '' && $file === '' && $voice === '') { http_response_code(400); echo json_encode(['error'=>'消息不能为空']); exit; }
        if ($image !== '' && strpos($image, 'uploads/') !== 0) { http_response_code(400); echo json_encode(['error'=>'图片路径无效']); exit; }
        if ($file !== '' && strpos($file, 'files/') !== 0) { http_response_code(400); echo json_encode(['error'=>'文件路径无效']); exit; }
        if ($voice !== '' && strpos($voice, 'uploads/') !== 0) { http_response_code(400); echo json_encode(['error'=>'语音路径无效']); exit; }
        if (strpos($room, 'pm:') === 0) {
            $parts = explode(':', $room);
            $pmUser = ($parts[1] === $currentUser) ? ($parts[2] ?? '') : ($parts[1] ?? '');
            $friends = json_decode(file_get_contents(FRIENDS_FILE), true) ?: [];
            $myFriends = $friends[$currentUser] ?? [];
            if (!in_array($pmUser, $myFriends)) {
                http_response_code(400);
                echo json_encode(['error'=>'对方还不是你的好友，无法发送消息', 'code'=>'not_friend']);
                exit;
            }
            $bl = json_decode(@file_get_contents(BLACKLIST_FILE), true) ?: [];
            if (in_array($pmUser, $bl[$currentUser] ?? [])) {
                http_response_code(400);
                echo json_encode(['error'=>'你已拉黑对方，无法发送消息', 'code'=>'blacklisted']);
                exit;
            }
            if (in_array($currentUser, $bl[$pmUser] ?? [])) {
                http_response_code(400);
                echo json_encode(['error'=>'对方已将你拉黑，无法发送消息', 'code'=>'blocked_by']);
                exit;
            }
        }
        if (strpos($room, 'group:') === 0) {
            $gName = substr($room, 6);
            $gBan = isGroupBanned($gName);
            if ($gBan) {
                http_response_code(400);
                echo json_encode(['error'=>'该群已被封禁，无法发送消息', 'code'=>'group_banned', 'reason'=>$gBan['reason'] ?? '']);
                exit;
            }
            $muteUntil = getGroupMute($gName, $currentUser);
            if ($muteUntil !== 0) {
                $remain = $muteUntil > 0 ? $muteUntil - time() : -1;
                $msg = $remain < 0 ? '你已被永久禁言，无法发言' : '你已被禁言，剩余 ' . ceil($remain / 60) . ' 分钟';
                http_response_code(400);
                echo json_encode(['error'=>$msg, 'code'=>'muted', 'mute_until'=>$muteUntil]);
                exit;
            }
        }
        $quoteData = null;
        if ($quote !== '') {
            $decoded = json_decode($quote, true);
            if (is_array($decoded) && isset($decoded['id'], $decoded['username'], $decoded['message'])) $quoteData = $decoded;
        }
        // 文本字数限制：后台"聊天设置"配置，0=无限制（技术保护上限 5000），>0 时超限直接拒绝、不截断
        $cfg = json_decode(@file_get_contents(SITE_CONFIG_FILE), true) ?: [];
        $maxChars = isset($cfg['max_chars']) ? max(0, min(5000, intval($cfg['max_chars']))) : 100;
        if ($maxChars > 0 && mb_strlen($message) > $maxChars) {
            http_response_code(400);
            echo json_encode(['error' => '消息超过字数限制（最多 ' . $maxChars . ' 个字），请精简后重试', 'code' => 'too_long', 'max' => $maxChars]);
            exit;
        }
        $message = filterSensitive(mb_substr($message, 0, $maxChars > 0 ? $maxChars : 5000));
        $fp = fopen(MESSAGES_FILE, 'c+');
        if (!$fp) { http_response_code(500); echo json_encode(['error'=>'服务器错误']); exit; }
        flock($fp, LOCK_EX);
        $messages = json_decode(stream_get_contents($fp), true) ?: [];
        $maxId = 0;
        foreach ($messages as $m) { if (isset($m['id']) && $m['id'] > $maxId) $maxId = $m['id']; }
        $newId = $maxId + 1;
        // 语音对象按需拼装：wave 是后加的可选字段，没有就不写，
        // 老消息/老客户端依旧只有 url+duration，读取端缺字段会自动回退。
        $voicePayload = null;
        if ($voice !== '') {
            $voicePayload = ['url'=>$voice, 'duration'=>$voiceDuration];
            if ($voiceWave !== '') $voicePayload['wave'] = $voiceWave;
        }
        $newMsg = [
            'id'        => $newId,
            'room'      => $room,
            'username'  => $currentUser,
            'message'   => $message,
            'image'     => $image ?: null,
            'file'      => $file ?: null,
            'file_name' => $file ? $fileName : null,
            'file_size' => $file ? $fileSize : null,
            'voice'     => $voicePayload,
            'quote'     => $quoteData,
            'time'      => date('H:i:s'),
            'timestamp' => time()
        ];
        $messages[] = $newMsg;
        if (count($messages) > MAX_MESSAGES) $messages = array_slice($messages, -MAX_MESSAGES);
        ftruncate($fp, 0); rewind($fp);
        fwrite($fp, json_encode($messages, JSON_UNESCAPED_UNICODE));
        fflush($fp);
        flock($fp, LOCK_UN); fclose($fp);

        // 群里发的图片/文件同时归档进群文件清单（图片进"群相册"）。
        // 否则大家在群里发的照片、文件在群文件/群相册里根本看不到，都以为是丢了。
        if (strpos($room, 'group:') === 0 && ($image !== '' || $file !== '')) {
            archiveGroupAttachment(substr($room, 6), $currentUser, $image, $file, $fileName, $fileSize);
        }

        updateOnline(ONLINE_FILE, $currentUser, $room);
        incrementUnread($currentUser, $room);
        // @全体成员：消息包含 @全体成员 时提醒群内所有不在本群房间的成员
        if (strpos($room, 'group:') === 0 && $message !== '' && mb_strpos($message, '@全体成员') !== false) {
            atAllNotify(substr($room, 6), $currentUser);
        }
        // 等级系统已下架：不再每条消息读写一次 signins.json（省掉热路径上的一次文件 IO）
        writeLog($currentUser, 'send_message', $room . ' / ' . ($file ? '文件:' . $fileName : ($image ? '图片' : ($voice ? '语音:' . $voiceDuration . 's' : mb_substr($message, 0, 60)))));

        echo json_encode(['success'=>true, 'id'=>$newId]); exit;
    }

    if ($_GET['action'] === 'toggle_reaction') {
        session_write_close();
        $room  = canonicalRoom(trim($_POST['room'] ?? '公共聊天'));
        $id    = intval($_POST['id'] ?? 0);
        $emoji = trim($_POST['emoji'] ?? '');
        if ($id <= 0 || $emoji === '' || mb_strlen($emoji) > 8) { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        // 与 send_message 相同的房间鉴权：私聊必须是参与者，群聊必须是成员
        if (strpos($room, 'pm:') === 0) {
            $pmParts = explode(':', substr($room, 3));
            if (count($pmParts) !== 2 || !in_array($currentUser, $pmParts, true)) {
                http_response_code(403); echo json_encode(['error'=>'无权操作该会话']); exit;
            }
        } elseif (strpos($room, 'group:') === 0) {
            $gName = substr($room, 6);
            $groupsAll = json_decode(@file_get_contents(GROUPS_FILE), true) ?: [];
            if (!isGroupMember($groupsAll[$gName] ?? null, $currentUser)) {
                http_response_code(403); echo json_encode(['error'=>'你不在该群中']); exit;
            }
        }
        if (!rateLimit('react:' . $currentUser, 60, 60)) {
            http_response_code(429); echo json_encode(['error'=>'操作太频繁了，缓一下再试']); exit;
        }
        $fp = fopen(MESSAGES_FILE, 'c+');
        if (!$fp) { http_response_code(500); echo json_encode(['error'=>'服务器错误']); exit; }
        flock($fp, LOCK_EX);
        $messages = json_decode(stream_get_contents($fp), true) ?: [];
        $found = false;
        $reactions = [];
        foreach ($messages as &$m) {
            if (($m['id'] ?? 0) === $id && ($m['room'] ?? '') === $room) {
                // 已撤回/系统消息不允许回应
                if (($m['type'] ?? '') === 'recalled' || ($m['type'] ?? '') === 'system') {
                    flock($fp, LOCK_UN); fclose($fp);
                    http_response_code(400); echo json_encode(['error'=>'该消息不能回应']); exit;
                }
                $found = true;
                $reactions = is_array($m['reactions'] ?? null) ? $m['reactions'] : [];
                // 每个用户对同一表情只能回应一次：存在就移除（取消回应），不存在就加上
                $list = isset($reactions[$emoji]) && is_array($reactions[$emoji]) ? $reactions[$emoji] : [];
                $idx = array_search($currentUser, $list, true);
                if ($idx === false) { $list[] = $currentUser; }
                else { array_splice($list, $idx, 1); }
                if (empty($list)) unset($reactions[$emoji]);
                else $reactions[$emoji] = array_values($list);
                if (empty($reactions)) unset($m['reactions']);
                else $m['reactions'] = $reactions;
                // 无论增删都刷新 reacted_at，让其它客户端能同步"最后一个回应被取消"的变化
                $m['reacted_at'] = time();
                break;
            }
        }
        unset($m);
        if (!$found) {
            flock($fp, LOCK_UN); fclose($fp);
            http_response_code(404); echo json_encode(['error'=>'消息不存在或已不在当前房间']); exit;
        }
        ftruncate($fp, 0); rewind($fp);
        fwrite($fp, json_encode($messages, JSON_UNESCAPED_UNICODE));
        fflush($fp);
        flock($fp, LOCK_UN); fclose($fp);
        writeLog($currentUser, 'toggle_reaction', $room . ' / msg#' . $id . ' / ' . $emoji);
        echo json_encode(['success'=>true, 'reactions'=>$reactions]); exit;
    }

    if ($_GET['action'] === 'delete_message') {
        session_write_close();
        $room = trim($_POST['room'] ?? '公共聊天');
        $id = intval($_POST['id'] ?? 0);
        if ($id <= 0) { http_response_code(400); echo json_encode(['error'=>'无效ID']); exit; }
        $fp = fopen(MESSAGES_FILE, 'c+');
        if (!$fp) { http_response_code(500); echo json_encode(['error'=>'服务器错误']); exit; }
        flock($fp, LOCK_EX);
        $messages = json_decode(stream_get_contents($fp), true) ?: [];
        $found = false;
        $newMessages = [];
        $msgTimestamp = 0;
        $recalledId = 0;
        foreach ($messages as $msg) {
            if ($msg['id'] == $id && $msg['username'] === $currentUser) {
                $found = true;
                $msgTimestamp = $msg['timestamp'] ?? 0;
                $msg['type'] = 'recalled';
                $msg['message'] = "{$currentUser} 撤回了一条消息";
                $msg['recalled_by'] = $currentUser;
                $msg['recalled_at'] = time();
                // 注意：绝不能把原文/原图/原文件留在消息对象里。
                // 这些字段会随 get_messages 一起下发给房间里所有人，
                // "撤回"看起来生效了，实际原文还在客户端手里（一抓包就看到）。
                unset($msg['image'], $msg['file'], $msg['file_name'], $msg['file_size'], $msg['quote'], $msg['voice']);
                $recalledId = $msg['id'];
                $newMessages[] = $msg;
                continue;
            }
            $newMessages[] = $msg;
        }
        if ($found) {
            ftruncate($fp, 0); rewind($fp);
            fwrite($fp, json_encode($newMessages, JSON_UNESCAPED_UNICODE));
            fflush($fp);
            flock($fp, LOCK_UN); fclose($fp);
            echo json_encode(['success'=>true, 'recalled_id'=>$recalledId, 'recall_msg'=>"{$currentUser} 撤回了一条消息"]);
        } else {
            flock($fp, LOCK_UN); fclose($fp);
            http_response_code(403); echo json_encode(['error'=>'无权撤回']); exit;
        }
        exit;
    }

    if ($_GET['action'] === 'delete_friend') {
        $target = trim($_POST['target'] ?? '');
        if ($target === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $friends = json_decode(file_get_contents(FRIENDS_FILE), true) ?: [];
        if (!in_array($target, $friends[$currentUser] ?? [])) { http_response_code(400); echo json_encode(['error'=>'不是好友']); exit; }
        $friends[$currentUser] = array_values(array_diff($friends[$currentUser], [$target]));
        if (isset($friends[$target])) {
            $friends[$target] = array_values(array_diff($friends[$target], [$currentUser]));
        }
        file_put_contents(FRIENDS_FILE, json_encode($friends, JSON_UNESCAPED_UNICODE), LOCK_EX);
        $notes = json_decode(file_get_contents(NOTES_FILE), true) ?: [];
        if (isset($notes[$currentUser][$target])) unset($notes[$currentUser][$target]);
        file_put_contents(NOTES_FILE, json_encode($notes, JSON_UNESCAPED_UNICODE), LOCK_EX);
        $pmRoom = getPMRoom($currentUser, $target);
        $unread = json_decode(file_get_contents(UNREAD_FILE), true) ?: [];
        if (isset($unread[$currentUser][$pmRoom])) unset($unread[$currentUser][$pmRoom]);
        if (isset($unread[$target][$pmRoom])) unset($unread[$target][$pmRoom]);
        file_put_contents(UNREAD_FILE, json_encode($unread, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'delete_friend', '删除好友 ' . $target);
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'set_note') {
        $target = trim($_POST['target'] ?? '');
        $note = trim($_POST['note'] ?? '');
        if ($target === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $friends = json_decode(file_get_contents(FRIENDS_FILE), true) ?: [];
        if (!in_array($target, $friends[$currentUser] ?? [])) { http_response_code(400); echo json_encode(['error'=>'不是好友']); exit; }
        $notes = json_decode(file_get_contents(NOTES_FILE), true) ?: [];
        if (!isset($notes[$currentUser])) $notes[$currentUser] = [];
        if ($note === '') {
            unset($notes[$currentUser][$target]);
        } else {
            $notes[$currentUser][$target] = $note;
        }
        file_put_contents(NOTES_FILE, json_encode($notes, JSON_UNESCAPED_UNICODE), LOCK_EX);
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'toggle_pin') {
        $target = trim($_POST['target'] ?? '');
        if ($target === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $friends = json_decode(file_get_contents(FRIENDS_FILE), true) ?: [];
        if (!in_array($target, $friends[$currentUser] ?? [])) { http_response_code(400); echo json_encode(['error'=>'不是好友']); exit; }
        $pins = json_decode(file_get_contents(PINS_FILE), true) ?: [];
        if (!isset($pins[$currentUser])) $pins[$currentUser] = [];
        if (in_array($target, $pins[$currentUser])) {
            $pins[$currentUser] = array_values(array_diff($pins[$currentUser], [$target]));
        } else {
            $pins[$currentUser][] = $target;
        }
        file_put_contents(PINS_FILE, json_encode($pins, JSON_UNESCAPED_UNICODE), LOCK_EX);
        echo json_encode(['success'=>true, 'pinned'=>in_array($target, $pins[$currentUser])]); exit;
    }

    if ($_GET['action'] === 'get_group_info') {
        $groupName = trim($_GET['group'] ?? '');
        if ($groupName === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        $ginfo = $groups[$groupName];
        $requests = [];
        foreach (($ginfo['requests'] ?? []) as $ru => $rinfo) {
            // 兼容新旧两种存储格式：新格式为 用户名=>['time'=>..]，旧格式为纯用户名列表
            if (is_array($rinfo)) {
                $requests[] = ['user'=>(string)$ru, 'time'=>$rinfo['time'] ?? 0];
            } else {
                $requests[] = ['user'=>(string)$rinfo, 'time'=>0];
            }
        }
        echo json_encode([
            'name'=>$groupName,
            'group_id'=>$ginfo['group_id'] ?? '',
            'members'=>$ginfo['members'],
            'created_by'=>$ginfo['created_by'] ?? '',
            'created_at'=>$ginfo['created_at'] ?? 0,
            'join_mode'=>$ginfo['join_mode'] ?? 'apply',
            'admins'=>$ginfo['admins'] ?? [],
            'titles'=>$ginfo['titles'] ?? (object)[],
            'mutes'=>getGroupMutes($groupName),
            'requests'=>$requests,
            'pinned_msg'=>$ginfo['pinned_msg'] ?? null,
            'announcement'=>$ginfo['announcement'] ?? null,
            'is_member'=>isGroupMember($ginfo, $currentUser),
            'is_creator'=>($ginfo['created_by'] ?? '') === $currentUser,
            'is_admin'=>listHasUser($ginfo['admins'] ?? [], $currentUser),
            'my_mute'=>getGroupMute($groupName, $currentUser)
        ]); exit;
    }

    if ($_GET['action'] === 'kick_member') {
        $groupName = trim($_POST['group'] ?? '');
        $target = trim($_POST['target'] ?? '');
        if ($groupName === '' || $target === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        if (($groups[$groupName]['created_by'] ?? '') !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'仅群主可踢人']); exit; }
        if ($target === $currentUser) { http_response_code(400); echo json_encode(['error'=>'不能踢自己']); exit; }
        $groups[$groupName]['members'] = array_values(array_diff($groups[$groupName]['members'], [$target]));
        unset($groups[$groupName]['joined_at'][$target]);   // 入群时间一起清掉：重新进群要重新计时
        file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'kick_member', $groupName . ' 踢出 ' . $target);
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'set_group_admin') {
        $groupName = trim($_POST['group'] ?? '');
        $target = trim($_POST['target'] ?? '');
        $makeAdmin = $_POST['make'] === '1';
        if ($groupName === '' || $target === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        if (($groups[$groupName]['created_by'] ?? '') !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'仅群主可设置管理员']); exit; }
        if ($target === $currentUser) { http_response_code(400); echo json_encode(['error'=>'不能对自己操作']); exit; }
        if (!listHasUser($groups[$groupName]['members'] ?? [], $target)) { http_response_code(400); echo json_encode(['error'=>'对方不在群中']); exit; }
        $admins = $groups[$groupName]['admins'] ?? [];
        if (!is_array($admins)) $admins = [];
        if ($makeAdmin) {
            if (!in_array($target, $admins)) $admins[] = $target;
        } else {
            $admins = array_values(array_diff($admins, [$target]));
        }
        $groups[$groupName]['admins'] = $admins;
        file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, $makeAdmin ? 'set_admin' : 'unset_admin', $groupName . ' / ' . $target);
        echo json_encode(['success'=>true, 'admins'=>$admins]); exit;
    }

    if ($_GET['action'] === 'set_group_title') {
        $groupName = trim($_POST['group'] ?? '');
        $target = trim($_POST['target'] ?? '');
        $title = trim($_POST['title'] ?? '');
        if ($groupName === '' || $target === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        if (($groups[$groupName]['created_by'] ?? '') !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'仅群主可设置头衔']); exit; }
        if (!listHasUser($groups[$groupName]['members'] ?? [], $target)) { http_response_code(400); echo json_encode(['error'=>'对方不在群中']); exit; }
        $titles = $groups[$groupName]['titles'] ?? [];
        if (!is_array($titles)) $titles = [];
        if ($title === '') unset($titles[$target]);
        else $titles[$target] = mb_substr($title, 0, 20);
        $groups[$groupName]['titles'] = $titles;
        file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'set_title', $groupName . ' / ' . $target . ' = ' . $title);
        echo json_encode(['success'=>true, 'titles'=>$titles]); exit;
    }

    if ($_GET['action'] === 'mute_group_member') {
        $groupName = trim($_POST['group'] ?? '');
        $target = trim($_POST['target'] ?? '');
        $action = $_POST['act'] === 'unmute' ? 'unmute' : 'mute';
        if ($groupName === '' || $target === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        $isCreator = ($groups[$groupName]['created_by'] ?? '') === $currentUser;
        $isAdmin = listHasUser($groups[$groupName]['admins'] ?? [], $currentUser);
        if (!$isCreator && !$isAdmin) { http_response_code(403); echo json_encode(['error'=>'仅群主或管理员可禁言']); exit; }
        if ($target === ($groups[$groupName]['created_by'] ?? '')) { http_response_code(400); echo json_encode(['error'=>'不能禁言群主']); exit; }
        if (!listHasUser($groups[$groupName]['members'] ?? [], $target)) { http_response_code(400); echo json_encode(['error'=>'对方不在群中']); exit; }
        $all = json_decode(@file_get_contents(GROUP_MUTES_FILE), true) ?: [];
        if (!is_array($all)) $all = [];
        if (!isset($all[$groupName])) $all[$groupName] = [];
        if ($action === 'unmute') {
            unset($all[$groupName][$target]);
            $msg = '已解除禁言';
        } else {
            $minutes = max(0, intval($_POST['minutes'] ?? 0));
            $all[$groupName][$target] = $minutes > 0 ? time() + $minutes * 60 : 0;
            $msg = $minutes > 0 ? "已禁言 {$minutes} 分钟" : '已永久禁言';
        }
        file_put_contents(GROUP_MUTES_FILE, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, $action === 'mute' ? 'mute_member' : 'unmute_member', $groupName . ' / ' . $target . ($action === 'mute' ? ' / ' . ($minutes ?? 0) . 'min' : ''));
        echo json_encode(['success'=>true, 'message'=>$msg, 'mutes'=>$all[$groupName]]); exit;
    }

    if ($_GET['action'] === 'leave_group') {
        $groupName = trim($_POST['group'] ?? '');
        if ($groupName === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        $groups[$groupName]['members'] = array_values(array_diff($groups[$groupName]['members'], [$currentUser]));
        unset($groups[$groupName]['joined_at'][$currentUser]);   // 退群后重新加入，可见起点重新计算
        if (empty($groups[$groupName]['members'])) {
            unset($groups[$groupName]);
        }
        file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
        $grRoom = 'group:' . $groupName;
        $unread = json_decode(file_get_contents(UNREAD_FILE), true) ?: [];
        if (isset($unread[$currentUser][$grRoom])) unset($unread[$currentUser][$grRoom]);
        file_put_contents(UNREAD_FILE, json_encode($unread, JSON_UNESCAPED_UNICODE), LOCK_EX);
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'delete_group') {
        $groupName = trim($_POST['group'] ?? '');
        if ($groupName === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        if (($groups[$groupName]['created_by'] ?? '') !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'仅群主可删除群']); exit; }
        unset($groups[$groupName]);
        file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
        $gban = json_decode(@file_get_contents(GROUP_BAN_FILE), true) ?: [];
        if (isset($gban[$groupName])) { unset($gban[$groupName]); @file_put_contents(GROUP_BAN_FILE, json_encode($gban, JSON_UNESCAPED_UNICODE), LOCK_EX); }
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'join_group') {
        $groupName = trim($_POST['group'] ?? '');
        if ($groupName === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        if (isGroupBanned($groupName)) { http_response_code(400); echo json_encode(['error'=>'该群已被封禁，无法加入']); exit; }
        if (listHasUser($groups[$groupName]['members'] ?? [], $currentUser)) { http_response_code(400); echo json_encode(['error'=>'你已在群中']); exit; }
        if (($groups[$groupName]['join_mode'] ?? 'apply') === 'free') {
            $groups[$groupName]['members'][] = $currentUser;
            $groups[$groupName]['joined_at'][$currentUser] = time();   // 入群时间 = 可见起点
            file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
            writeLog($currentUser, 'join_group', $groupName . '（免申请直接入群）');
            echo json_encode(['success'=>true, 'joined'=>true]); exit;
        }
        $groups[$groupName]['requests'][$currentUser] = ['time'=>time()];
        file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'apply_group', $groupName);
        echo json_encode(['success'=>true, 'joined'=>false, 'applied'=>true]); exit;
    }

    if ($_GET['action'] === 'handle_group_request') {
        $groupName = trim($_POST['group'] ?? '');
        $target = trim($_POST['target'] ?? '');
        $action = trim($_POST['action'] ?? '');
        if ($groupName === '' || $target === '' || !in_array($action, ['approve', 'reject'])) { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        if (($groups[$groupName]['created_by'] ?? '') !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'仅群主可处理申请']); exit; }
        if ($action === 'approve') {
            if (!listHasUser($groups[$groupName]['members'] ?? [], $target)) {
                $groups[$groupName]['members'][] = $target;
                $groups[$groupName]['joined_at'][$target] = time();   // 通过申请的这一刻起才可见群消息
            }
        }
        unset($groups[$groupName]['requests'][$target]);
        file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'group_request_' . $action, $groupName . ' / ' . $target);
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'set_group_join_mode') {
        $groupName = trim($_POST['group'] ?? '');
        $mode = trim($_POST['mode'] ?? '');
        if ($groupName === '' || !in_array($mode, ['free', 'apply'])) { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        if (($groups[$groupName]['created_by'] ?? '') !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'仅群主可设置']); exit; }
        $groups[$groupName]['join_mode'] = $mode;
        file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'set_group_join_mode', $groupName . ' / ' . $mode);
        echo json_encode(['success'=>true]); exit;
    }

    // 群主开关：新成员能否看到"入群之前"的群聊记录。默认禁止，与 QQ 群的默认行为一致。
    if ($_GET['action'] === 'set_group_history_visible') {
        $groupName = trim($_POST['group'] ?? '');
        $visible = ($_POST['visible'] ?? '0') === '1';
        if ($groupName === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        if (($groups[$groupName]['created_by'] ?? '') !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'仅群主可设置']); exit; }
        $groups[$groupName]['history_visible'] = $visible;
        file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'set_group_history_visible', $groupName . ' / ' . ($visible ? '允许' : '禁止') . '新成员查看入群前记录');
        echo json_encode(['success'=>true, 'history_visible'=>$visible]); exit;
    }

    if ($_GET['action'] === 'change_password') {
        $old = $_POST['old'] ?? '';
        $new = $_POST['new'] ?? '';
        $confirm = $_POST['confirm'] ?? '';
        $users = json_decode(file_get_contents(USERS_FILE), true) ?: [];
        if (!isset($users[$currentUser]) || !password_verify($old, $users[$currentUser])) {
            http_response_code(400); echo json_encode(['error'=>'原密码错误']); exit;
        }
        if (strlen($new) < 6) { http_response_code(400); echo json_encode(['error'=>'新密码至少6位']); exit; }
        if ($new !== $confirm) { http_response_code(400); echo json_encode(['error'=>'两次密码不一致']); exit; }
        $users[$currentUser] = password_hash($new, PASSWORD_DEFAULT);
        file_put_contents(USERS_FILE, json_encode($users, JSON_UNESCAPED_UNICODE), LOCK_EX);
        // 改密码是"凭据泄露后的自救动作"：必须吊销该账号所有长期凭据，
        // 否则此前在别处登录得到的长期凭据仍然有效，改密码就白改了。
        revokeRememberTokens($currentUser);
        writeLog($currentUser, 'password_changed', '修改密码，已吊销该账号全部长期登录凭据');
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'edit_message') {
        session_write_close();
        $room = trim($_POST['room'] ?? '公共聊天');
        $id = intval($_POST['id'] ?? 0);
        $newText = trim($_POST['message'] ?? '');
        if ($id <= 0 || $newText === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $newText = filterSensitive(mb_substr($newText, 0, 500));
        $fp = fopen(MESSAGES_FILE, 'c+');
        if (!$fp) { http_response_code(500); echo json_encode(['error'=>'服务器错误']); exit; }
        flock($fp, LOCK_EX);
        $messages = json_decode(stream_get_contents($fp), true) ?: [];
        $found = false;
        foreach ($messages as &$msg) {
            if ($msg['id'] == $id && $msg['username'] === $currentUser) {
                if (time() - ($msg['timestamp'] ?? 0) > RECALL_TIME_LIMIT) {
                    flock($fp, LOCK_UN); fclose($fp);
                    http_response_code(400); echo json_encode(['error'=>'超过2分钟无法编辑']); exit;
                }
                $msg['message'] = $newText;
                $msg['edited'] = true;
                $msg['edited_at'] = time();
                $found = true;
                break;
            }
        }
        unset($msg);
        if (!$found) {
            flock($fp, LOCK_UN); fclose($fp);
            http_response_code(403); echo json_encode(['error'=>'无权编辑']); exit;
        }
        ftruncate($fp, 0); rewind($fp);
        fwrite($fp, json_encode($messages, JSON_UNESCAPED_UNICODE));
        fflush($fp);
        flock($fp, LOCK_UN); fclose($fp);
        echo json_encode(['success'=>true, 'edited_id'=>$id, 'new_message'=>$newText]); exit;
    }

    if ($_GET['action'] === 'set_announcement') {
        $groupName = trim($_POST['group'] ?? '');
        $content = trim($_POST['content'] ?? '');
        if ($groupName === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        if (($groups[$groupName]['created_by'] ?? '') !== $currentUser) { http_response_code(403); echo json_encode(['error'=>'仅群主可设置公告']); exit; }
        if ($content === '') {
            unset($groups[$groupName]['announcement']);
        } else {
            $groups[$groupName]['announcement'] = ['content'=>$content, 'set_by'=>$currentUser, 'set_at'=>time()];
        }
        file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'get_announcement') {
        $groupName = trim($_GET['group'] ?? '');
        if ($groupName === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        $ann = $groups[$groupName]['announcement'] ?? null;
        echo json_encode(['announcement'=>$ann]); exit;
    }

    if ($_GET['action'] === 'set_pin_msg') {
        session_write_close();
        $groupName = trim($_POST['group'] ?? '');
        $msgId = intval($_POST['id'] ?? 0);
        $content = trim($_POST['content'] ?? '');
        $username = trim($_POST['username'] ?? '');
        if ($groupName === '' || $msgId <= 0) { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        $isCreator = ($groups[$groupName]['created_by'] ?? '') === $currentUser;
        $isAdmin = listHasUser($groups[$groupName]['admins'] ?? [], $currentUser);
        if (!$isCreator && !$isAdmin) { http_response_code(403); echo json_encode(['error'=>'仅群主或管理员可置顶消息']); exit; }
        $groups[$groupName]['pinned_msg'] = [
            'id'=>$msgId,
            'username'=>$username,
            'content'=>mb_substr($content, 0, 200),
            'by'=>$currentUser,
            'time'=>time()
        ];
        file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'pin_msg', $groupName . ' 置顶消息 #' . $msgId);
        echo json_encode(['success'=>true]); exit;
    }

    if ($_GET['action'] === 'remove_pin_msg') {
        session_write_close();
        $groupName = trim($_POST['group'] ?? '');
        if ($groupName === '') { http_response_code(400); echo json_encode(['error'=>'参数错误']); exit; }
        $groups = json_decode(file_get_contents(GROUPS_FILE), true) ?: [];
        if (!isset($groups[$groupName])) { http_response_code(400); echo json_encode(['error'=>'群不存在']); exit; }
        $isCreator = ($groups[$groupName]['created_by'] ?? '') === $currentUser;
        $isAdmin = listHasUser($groups[$groupName]['admins'] ?? [], $currentUser);
        if (!$isCreator && !$isAdmin) { http_response_code(403); echo json_encode(['error'=>'仅群主或管理员可取消置顶']); exit; }
        unset($groups[$groupName]['pinned_msg']);
        file_put_contents(GROUPS_FILE, json_encode($groups, JSON_UNESCAPED_UNICODE), LOCK_EX);
        writeLog($currentUser, 'unpin_msg', $groupName . ' 取消置顶');
        echo json_encode(['success'=>true]); exit;
    }

    http_response_code(400); echo json_encode(['error'=>'无效操作']); exit;
}

// 页面加载路径同样要执行单设备校验：否则被顶下线的设备只要刷新一下，
// 又能看到完整的聊天界面（轮询虽然会把他踢掉，但那一瞬间界面是好的，观感很糟）。
$authNotice = '';
if (ENFORCE_SINGLE_SESSION && isset($_SESSION['username'])) {
    $__pageReplaced = authCheckActiveSession($_SESSION['username']);
    if ($__pageReplaced) {
        writeLog($_SESSION['username'], 'session_replaced', '页面加载时发现本会话已被其他设备的新登录顶下线');
        // 只清本机的会话与凭据。这里绝不能吊销该账号的全部凭据 ——
        // 那会把"刚在另一台设备上登录成功"的新会话也一并废掉，等于把用户锁在门外。
        $_SESSION = [];
        session_destroy();
        setRememberCookie('', 0);
        $authNotice = '该账号已在其他设备登录，你已被下线';
    }
}
if (!isset($_SESSION['username'])):
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234a6cf7'/%3E%3Cpath d='M7 12a5 5 0 0 1 5-5h8a5 5 0 0 1 5 5v4a5 5 0 0 1-5 5h-5l-4 3v-3.2A5 5 0 0 1 7 16z' fill='%23fff'/%3E%3C/svg%3E">
    <title>登录 - 聊天室</title>
    <style>
        :root { --accent:#12b7f5; --accent2:#0ea5e0; }
        * { margin:0; padding:0; box-sizing:border-box; }
        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; background:#f0f2f5; display:flex; align-items:center; justify-content:center; height:100vh; }
        .auth-box { background:#fff; border-radius:12px; padding:36px 28px; width:360px; box-shadow:0 2px 12px rgba(0,0,0,0.08); }
        .auth-box h2 { font-size:20px; color:#1a1a1a; text-align:center; margin-bottom:24px; font-weight:600; }
        .auth-box input { width:100%; padding:12px 14px; margin:8px 0; border:1px solid #e0e0e0; border-radius:8px; font-size:15px; outline:none; transition:border .2s; background:#f5f5f5; color:#1a1a1a; }
        .auth-box input:focus { border-color:var(--accent); background:#fff; }
        .auth-box button { width:100%; padding:12px; background:var(--accent); color:#fff; border:none; border-radius:8px; font-size:15px; font-weight:600; cursor:pointer; margin-top:12px; transition:opacity .2s; }
        .auth-box button:hover { opacity:0.88; }
        .auth-box button:disabled { opacity:0.5; cursor:not-allowed; }
        .auth-box .hint { text-align:center; margin-top:16px; font-size:13px; color:#999; }
        .auth-box .hint a { color:var(--accent); cursor:pointer; text-decoration:none; }
        .auth-box .hint a:hover { text-decoration:underline; }
        .auth-box .remember { display:flex; align-items:center; gap:6px; font-size:13px; color:#666; margin:6px 0 2px; cursor:pointer; user-select:none; }
        .auth-box .remember input { width:auto; margin:0; padding:0; }
        .error { color:#e74c3c; font-size:13px; margin:4px 0; text-align:center; min-height:20px; }
    </style>
    <style id="themeVars"><?php echo buildThemeCss(); ?></style>
    <?php echo i18nScriptBlock(); ?>
</head>
<body>
<div class="auth-box" id="loginBox">
    <h2>登录</h2>
    <input type="text" id="login-username" placeholder="用户名" autocomplete="username">
    <input type="password" id="login-password" placeholder="密码" autocomplete="current-password">
    <label class="remember"><input type="checkbox" id="login-remember" checked> 记住我（30 天内免登录）</label>
    <div class="error" id="msg"><?php echo htmlspecialchars($authNotice, ENT_QUOTES, 'UTF-8'); ?></div>
    <button id="loginBtn" onclick="login()">登 录</button>
    <div class="hint">没有账号？<a onclick="switchToRegister()">注册</a></div>
</div>
<div class="auth-box" id="registerBox" style="display:none;">
    <h2>注册</h2>
    <input type="text" id="reg-username" placeholder="用户名（最多20字）" autocomplete="off">
    <input type="password" id="reg-password" placeholder="密码（至少6位）" autocomplete="new-password">
    <input type="password" id="reg-confirm" placeholder="确认密码" autocomplete="new-password">
    <div class="error" id="regMsg"></div>
    <button id="regBtn" onclick="register()">注 册</button>
    <div class="hint">已有账号？<a onclick="switchToLogin()">登录</a></div>
</div>
<script>
// 被挤下线 / 被封禁跳回登录页时，把原因带到登录页显示出来，
// 否则用户只会看到自己莫名其妙回到了登录页，不知道发生了什么
(function () {
    try {
        var n = sessionStorage.getItem('chat_auth_notice');
        if (n) {
            sessionStorage.removeItem('chat_auth_notice');
            var m = document.getElementById('msg');
            // 服务端已经写了原因（比如"已被下线"）就不要覆盖
            if (m && !m.textContent) m.textContent = n;
        }
    } catch (e) {}
})();
function switchToRegister() { document.getElementById('loginBox').style.display='none'; document.getElementById('registerBox').style.display=''; document.getElementById('msg').textContent=''; }
function switchToLogin() { document.getElementById('registerBox').style.display='none'; document.getElementById('loginBox').style.display=''; document.getElementById('regMsg').textContent=''; }
async function login() {
    const u=document.getElementById('login-username').value.trim(), p=document.getElementById('login-password').value.trim();
    if(!u||!p) return document.getElementById('msg').textContent='请输入用户名和密码';
    const btn=document.getElementById('loginBtn'); btn.disabled=true; btn.textContent='登录中...';
    try {
        const rm = document.getElementById('login-remember');
        const res=await fetch('?action=login',{method:'POST',body:new URLSearchParams({username:u,password:p,remember:(rm&&rm.checked)?'1':'0'})});
        const d=await res.json();
        if(d.success) location.reload();
        else document.getElementById('msg').textContent=d.error;
    } catch(e) { document.getElementById('msg').textContent='网络错误，请重试'; }
    finally { btn.disabled=false; btn.textContent='登 录'; }
}
async function register() {
    const u=document.getElementById('reg-username').value.trim(), p=document.getElementById('reg-password').value.trim(), c=document.getElementById('reg-confirm').value.trim();
    if(!u||!p||!c) return document.getElementById('regMsg').textContent='请填写完整';
    if(p.length<6) return document.getElementById('regMsg').textContent='密码至少6位';
    if(p!==c) return document.getElementById('regMsg').textContent='两次密码不一致';
    const btn=document.getElementById('regBtn'); btn.disabled=true; btn.textContent='注册中...';
    try {
        const res=await fetch('?action=register',{method:'POST',body:new URLSearchParams({username:u,password:p,confirm:c})});
        const d=await res.json();
        if(d.success) location.reload();
        else document.getElementById('regMsg').textContent=d.error;
    } catch(e) { document.getElementById('regMsg').textContent='网络错误，请重试'; }
    finally { btn.disabled=false; btn.textContent='注 册'; }
}
document.querySelectorAll('.auth-box input').forEach(input=>{
    input.addEventListener('keypress',e=>{ if(e.key==='Enter') input.closest('.auth-box').querySelector('button').click(); });
});
</script>
</body>
</html>
<?php exit; endif;

$loggedUser = $_SESSION['username'];
session_write_close();
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
    <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234a6cf7'/%3E%3Cpath d='M7 12a5 5 0 0 1 5-5h8a5 5 0 0 1 5 5v4a5 5 0 0 1-5 5h-5l-4 3v-3.2A5 5 0 0 1 7 16z' fill='%23fff'/%3E%3C/svg%3E">
    <title>ChatRoom - 聊天室</title>
    <style>
        :root {
            --bg: #f5f6f7;
            --surface: #fff;
            --surface2: #f5f5f5;
            --surface3: #ebebeb;
            --surface4: #e0e0e0;
            --text: #1a1a1a;
            --text2: #666;
            --text3: #999;
            --accent: #12b7f5;
            --accent2: #0ea5e0;
            --accent-rgb: 18,183,245;
            --accent-glow: rgba(var(--accent-rgb),0.1);
            --accent-strong: rgba(var(--accent-rgb),0.12);
            --danger: #e74c3c;
            --danger2: #c0392b;
            --online: #07c160;
            --badge: #ff3b30;
            --border: #e5e5e5;
            --border2: #ddd;
            --sidebar-w: 280px;
            --radius: 10px;
            --radius-sm: 8px;
            --radius-lg: 14px;
            --radius-xl: 18px;
            --shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
            --shadow: 0 2px 8px rgba(0,0,0,0.1);
            --shadow-lg: 0 8px 24px rgba(0,0,0,0.15);
        }
        * { margin:0; padding:0; box-sizing:border-box; }
        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; background:var(--bg); height:100vh; overflow:hidden; display:flex; user-select:none; -webkit-user-select:none; color:var(--text); }
        *, *::before, *::after { -webkit-tap-highlight-color:transparent; -webkit-tap-highlight-color:rgba(0,0,0,0); }
        button, [role="button"], input, textarea, select, a, div[onclick], .nav-item, .search-result-item, .friend-context-menu div, .bubble-popup div, .group-member-item, .user-check-item { -webkit-tap-highlight-color:transparent; }
        button:focus, input:focus, textarea:focus, select:focus, a:focus, div:focus { outline:none; }
        @media (hover:hover) { button:focus-visible { outline:2px solid var(--accent); outline-offset:2px; } }
        .bubble, .bubble * { user-select:text; -webkit-user-select:text; }
        .modal-box input, .sidebar-search input, #messageInput { user-select:text; -webkit-user-select:text; }
        .nav-section-title, .sidebar-logo, .chat-header, .msg-name, .msg-time, .msg-footer, .sys-notice, .quote-bar, .room-title, .room-subtitle, .sidebar-footer, .nav-item, .input-area, .date-divider, .nav-name, .nav-preview, .nav-note, .modal-box h3, .modal-box p, .online-text, .header-right, .preview-title, .preview-info, .file-name, .file-size, .clear-chat-btn, .create-group-btn, .back-to-bottom, .friend-context-menu, .hamburger-btn, .section-badge, .pin-icon, .preview-btns button, .search-result-item, .friend-req-item, .user-check-item, .tool-btn, .send-btn, .sys-close, .group-menu-btn { user-select:none; -webkit-user-select:none; }
        ::-webkit-scrollbar { width:8px; height:8px; }
        ::-webkit-scrollbar-track { background:transparent; }
        ::-webkit-scrollbar-thumb { background:var(--border2); border-radius:4px; border:2px solid transparent; background-clip:content-box; }
        ::-webkit-scrollbar-thumb:hover { background:var(--text3); background-clip:content-box; }

        .sidebar { width:var(--sidebar-w); height:100vh; background:var(--surface); border-right:1px solid var(--border); display:flex; flex-direction:column; flex-shrink:0; z-index:10; transition:left .3s; }
        .sidebar-search { padding:18px 16px 14px; position:relative; }
        .sidebar-search input { width:100%; padding:8px 14px 8px 34px; border:1px solid var(--border); border-radius:8px; font-size:13px; outline:none; background:var(--surface2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' fill='%23999' viewBox='0 0 16 16'%3E%3Cpath d='M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85zm-5.242.156a5 5 0 1 1 0-10 5 5 0 0 1 0 10z'/%3E%3C/svg%3E") 10px center no-repeat; transition:all .2s; color:var(--text); }
        .sidebar-search input:focus { border-color:var(--accent); background:var(--surface); }
        .sidebar-nav { flex:1; overflow-y:auto; padding:0 10px; }
        .nav-section { margin-bottom:8px; }
        .nav-section-title { font-size:11px; color:var(--text3); font-weight:600; padding:8px 4px 4px; letter-spacing:1px; display:flex; align-items:center; }
        .nav-section-title .nav-count { font-size:10px; color:var(--text3); font-weight:400; margin-left:4px; background:var(--surface3); padding:0 6px; border-radius:7px; line-height:14px; }
        .section-search { width:100%; padding:5px 10px; margin:2px 0 6px; border:1px solid transparent; border-radius:6px; font-size:12px; outline:none; background:var(--surface2); color:var(--text); transition:all .2s; box-sizing:border-box; }
        .section-search:focus { border-color:var(--accent); }
        .section-search:empty { display:none; }
        .nav-scroll { max-height:38vh; overflow-y:auto; }
        .nav-item { display:flex; align-items:center; gap:10px; padding:8px 10px; border-radius:8px; cursor:pointer; transition:all .12s; margin:1px 0; position:relative; }
        .nav-item:hover { background:var(--surface2); }
        .nav-item.active { background:rgba(var(--accent-rgb),0.08); }
        .nav-avatar { width:36px; height:36px; border-radius:50%; flex-shrink:0; background-size:cover; background-position:center; display:flex; align-items:center; justify-content:center; font-weight:700; color:#fff; font-size:15px; }
        .nav-item .nav-info { flex:1; min-width:0; }
        .nav-item .nav-name { font-size:14px; color:var(--text); font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; }
        .nav-item .nav-preview { font-size:12px; color:var(--text3); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:1px; }
        .nav-item .nav-badge { display:inline-block; background:var(--badge); color:#fff; font-size:10px; min-width:18px; height:18px; line-height:18px; text-align:center; border-radius:9px; padding:0 5px; flex-shrink:0; font-weight:600; }
        .nav-item .nav-online { display:inline-block; color:var(--text3); font-size:10px; flex-shrink:0; margin-left:4px; opacity:0.7; }
        .nav-item .nav-note { font-size:11px; color:var(--text3); font-style:italic; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
        .nav-item .pin-icon { font-size:10px; flex-shrink:0; margin-left:2px; opacity:0.7; }
        .nav-item .nav-channel-icon { width:32px; height:32px; border-radius:50%; background:var(--surface2); display:flex; align-items:center; justify-content:center; flex-shrink:0; color:var(--text3); }
        .nav-item.active .nav-channel-icon { background:var(--accent); color:#fff; }
        .nav-item .nav-channel-icon svg { fill:currentColor; }
        .nav-item.active .nav-name { color:var(--accent); }
        .create-group-btn { display:block; width:100%; padding:9px; margin:4px 0; border:1.5px dashed var(--border2); border-radius:8px; background:none; color:var(--text2); font-size:13px; cursor:pointer; transition:all .2s; }
        .create-group-btn:hover { border-color:var(--accent); color:var(--accent); background:rgba(var(--accent-rgb),0.04); }
        .side-bell-btn { position:relative; width:30px; height:30px; flex-shrink:0; margin-left:auto;
            display:flex; align-items:center; justify-content:center; border:none; border-radius:8px; cursor:pointer;
            color:var(--text2); background:transparent; transition:background .15s,color .15s; }
        .side-bell-btn:hover { background:rgba(var(--accent-rgb),0.1); color:var(--accent); }
        .side-bell-dot { position:absolute; top:4px; right:4px; width:8px; height:8px; border-radius:50%;
            background:var(--danger); box-shadow:0 0 0 2px var(--surface); animation:noticeTagPulse 1.6s infinite; }
        @keyframes noticeTagPulse { 0%,100%{ opacity:1 } 50%{ opacity:.55 } }
        .sidebar-footer { padding:12px 14px; border-top:1px solid var(--border); display:flex; align-items:center; gap:8px; background:var(--surface); }
        .sidebar-footer .status-text { font-size:11px; cursor:pointer; padding:2px 10px; border-radius:10px; transition:all .15s; white-space:nowrap; max-width:100px; overflow:hidden; text-overflow:ellipsis; font-weight:500; }
        .sidebar-footer .status-text:hover { background:var(--surface2); opacity:0.85; }
        .sidebar-footer .user-name { font-size:13px; color:var(--text); font-weight:500; cursor:pointer; }
        .sidebar-footer .user-name:hover { color:var(--accent); }
        .sidebar-footer .my-avatar { width:30px; height:30px; border-radius:50%; flex-shrink:0; background-size:cover; background-position:center; display:flex; align-items:center; justify-content:center; font-weight:700; color:#fff; font-size:13px; cursor:pointer; }

        .main { flex:1; display:flex; flex-direction:column; height:100vh; min-width:0; background:var(--surface); position:relative; }
        .loading-bar { position:absolute; top:0; left:0; right:0; height:2px; z-index:999; pointer-events:none; opacity:0; transition:opacity .3s; }
        .loading-bar.active { opacity:1; }
        .loading-bar .bar-inner { height:100%; width:0; background:linear-gradient(90deg, #12b7f5, #0ea5e0, #12b7f5); background-size:200% 100%; animation:loadingBar 1.5s ease-in-out infinite; }
        @keyframes loadingBar { 0% { width:0; background-position:0 0; } 50% { width:60%; background-position:100% 0; } 100% { width:100%; background-position:0 0; } }

/* ---- 统一加载指示：转圈 + 文字 ----
   之前各处的"加载中..."是裸文本，样式各不相同（有的 12px、有的 13px、有的居中有的左对齐）。
   统一成一个组件：细环旋转，颜色跟随主题强调色，深浅色模式都不用额外适配。 */
.ld-box { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px;
    padding:26px 12px; color:var(--text3); font-size:12.5px; }
.ld-box.ld-fill { flex:1; padding:0; }          /* 放进消息区时撑满并垂直居中 */
.ld-ring { width:26px; height:26px; border-radius:50%; flex-shrink:0;
    border:2.5px solid var(--border); border-top-color:var(--accent);
    animation:ldSpin .72s linear infinite; }
.ld-box.ld-fill .ld-ring { width:30px; height:30px; }
@keyframes ldSpin { to { transform:rotate(360deg) } }
        .chat-header { padding:14px 20px; background:var(--surface); border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; }
        .chat-header .room-title { font-size:16px; font-weight:600; color:var(--text); }
        .chat-header .room-subtitle { font-size:11px; color:var(--text3); margin-top:1px; }
        .chat-header .header-right { display:flex; align-items:center; gap:10px; font-size:11px; color:var(--text3); }
        .messages { flex:1; overflow-y:auto; padding:20px 24px; display:flex; flex-direction:column; gap:12px; background:var(--bg); }
        .date-divider { display:flex; align-items:center; justify-content:center; margin:18px 0 8px; color:var(--text3); font-size:11px; }
        .date-divider span { background:var(--surface); padding:4px 14px; border-radius:999px; border:1px solid var(--border); letter-spacing:.3px; }
        /* 空房间提示：居中、文案轻，别看起来像报错 */
        .room-empty-hint { margin:auto; padding:24px 18px; max-width:78%; text-align:center; color:var(--text3); font-size:12.5px; line-height:1.9; }
        /* 断线提示：转着圈说明"还在自动重试"，代替原来的一片空白 */
        .msg-conn-lost { margin:auto; padding:24px 18px; text-align:center; color:var(--text3); font-size:12.5px; line-height:1.9; }
        .msg-conn-lost::before { content:''; display:block; width:18px; height:18px; margin:0 auto 10px; border:2px solid var(--border2); border-top-color:var(--accent); border-radius:50%; animation:ldSpin .9s linear infinite; }
        .msg-row { display:flex; gap:10px; max-width:70%; align-items:flex-start; }
        .msg-row.self { flex-direction:row-reverse; align-self:flex-end; }
        .msg-row.system { align-self:center; width:100%; display:flex; justify-content:center; margin:1px 0; }
        .msg-row.system .sys-notice { display:inline-flex; align-items:center; gap:4px; color:var(--text3); font-size:11px; padding:0; animation:sysMsgIn .25s ease; }
        @keyframes sysMsgIn { from { opacity:0; transform:translateY(-4px); } to { opacity:1; transform:translateY(0); } }
        .msg-row.system .sys-close { cursor:pointer; line-height:1; color:var(--text3); padding:2px 4px; border-radius:50%; transition:all .15s; flex-shrink:0; opacity:0; display:flex; align-items:center; }
        .msg-row.system .sys-notice:hover .sys-close { opacity:1; }
        .msg-row.system .sys-close:hover { background:var(--surface3); color:var(--text); }
        .msg-row.system .sys-close svg { fill:currentColor; }
        /* ---- 未设置头像时的统一占位：灰白人像剪影（浅色/深色主题各自一套，无需 JS 适配）---- */
        :root {
            --avatar-bg: #e9ecf1;
            --avatar-img: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='26' r='11' fill='%23b7bec9'/%3E%3Cpath d='M32 41c-12.7 0-23 7.6-23 17v6h46v-6c0-9.4-10.3-17-23-17z' fill='%23b7bec9'/%3E%3C/svg%3E");
        }
        [data-theme="dark"] {
            --avatar-bg: #31353c;
            --avatar-img: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='26' r='11' fill='%236f7883'/%3E%3Cpath d='M32 41c-12.7 0-23 7.6-23 17v6h46v-6c0-9.4-10.3-17-23-17z' fill='%236f7883'/%3E%3C/svg%3E");
        }
        .msg-avatar { width:34px; height:34px; border-radius:50%; flex-shrink:0; background-size:cover; background-position:center; display:flex; align-items:center; justify-content:center; font-weight:700; color:#fff; font-size:14px; box-shadow:0 1px 3px rgba(0,0,0,.10); }
        .msg-body { display:flex; flex-direction:column; min-width:0; }
        .msg-row.self .msg-body { align-items:flex-end; }
        .msg-name { font-size:11.5px; color:var(--text3); margin-bottom:3px; padding:0 4px; display:flex; align-items:center; gap:4px; }
        .bubble { padding:10px 14px; border-radius:14px; font-size:14px; line-height:1.55; word-break:break-word; position:relative; }
        .msg-row:not(.self) .bubble { background:#fff; border:1px solid var(--border); border-top-left-radius:2px; color:var(--text); box-shadow:0 1px 2px rgba(0,0,0,0.04); }
        .msg-row:not(.self) .bubble:hover { background:#fafafa; }
        .self .bubble { background:var(--accent); border-top-right-radius:2px; color:#fff; box-shadow:0 1px 2px rgba(var(--accent-rgb),0.2); }
        .self .bubble:hover { background:var(--accent2); }
        .msg-row.self .bubble.recalled { background:transparent; border:1px dashed var(--border2); color:var(--text3); cursor:default; }
        .msg-row.self .bubble.recalled:hover { background:transparent; }
        .msg-row:not(.self) .bubble.recalled { background:transparent; border:1px dashed var(--border2); color:var(--text3); cursor:default; }
        .msg-row:not(.self) .bubble.recalled:hover { background:transparent; }
        .bubble img { max-width:240px; max-height:240px; border-radius:8px; cursor:pointer; display:block; margin:6px 0; }
        /* 纯图片消息：隐藏聊天气泡外壳，图片直接呈现，不受主题/夜间模式影响 */
        .bubble.img-only, .msg-row:not(.self) .bubble.img-only, .self .bubble.img-only,
        [data-theme="dark"] .msg-row:not(.self) .bubble.img-only, [data-theme="dark"] .self .bubble.img-only {
            padding:0 !important; background:transparent !important; background-color:transparent !important;
            border:none !important; box-shadow:none !important; border-radius:0 !important;
        }
        .bubble.img-only:hover { background:transparent !important; }
        .bubble.img-only img {
            margin:0; border-radius:10px;
            max-width:min(280px, 62vw); max-height:min(280px, 62vw);
            box-shadow:0 1px 4px rgba(0,0,0,0.12);
        }
        .file-attach { display:flex; align-items:center; gap:8px; background:rgba(0,0,0,0.04); padding:8px 10px; border-radius:6px; margin:4px 0; cursor:pointer; transition:background .15s; }
        .file-attach:hover { background:rgba(0,0,0,0.08); }
        .self .file-attach { background:rgba(255,255,255,0.15); }
        .self .file-attach:hover { background:rgba(255,255,255,0.25); }
        .file-attach .file-icon { flex-shrink:0; width:32px; height:32px; border-radius:6px; background:rgba(0,0,0,0.06); display:flex; align-items:center; justify-content:center; }
        .file-attach .file-icon svg { width:16px; height:16px; fill:var(--text2); }
        .self .file-attach .file-icon { background:rgba(255,255,255,0.2); }
        .self .file-attach .file-icon svg { fill:#fff; }
        .file-attach .file-info { flex:1; min-width:0; }
        .file-attach .file-name { font-size:13px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
        .file-attach .file-size { font-size:11px; opacity:0.6; }
        .self .file-attach .file-name, .self .file-attach .file-size { color:#fff; }
        .voice-attach { display:flex; align-items:center; gap:10px; background:rgba(0,0,0,0.04); padding:8px 14px 8px 8px; border-radius:999px; margin:4px 0; cursor:pointer; transition:background .15s, transform .15s; min-width:176px; max-width:260px; }
        .voice-attach:hover { background:rgba(0,0,0,0.08); }
        .voice-attach:active { transform:scale(.985); }
        .self .voice-attach { background:rgba(255,255,255,0.15); }
        .self .voice-attach:hover { background:rgba(255,255,255,0.25); }
        .voice-attach .voice-play { flex-shrink:0; width:32px; height:32px; border-radius:50%; background:var(--accent); display:flex; align-items:center; justify-content:center; transition:transform .15s; }
        .voice-attach .voice-play svg { width:13px; height:13px; fill:#fff; }
        .self .voice-attach .voice-play { background:rgba(255,255,255,0.9); }
        .self .voice-attach .voice-play svg { fill:var(--accent); }
        .voice-attach .voice-body { flex:1; min-width:0; display:flex; flex-direction:column; gap:5px; }
        .voice-attach .voice-wave { display:flex; align-items:center; gap:2.5px; height:22px; }
        .voice-attach .voice-wave i { display:block; width:3px; border-radius:2px; background:var(--text3); transform-origin:center; transition:background .2s; }
        .self .voice-attach .voice-wave i { background:rgba(255,255,255,0.85); }
        /* 进度轨：播放时按已播时长填充，配合波形起伏，播放状态一目了然 */
        .voice-attach .voice-track { display:block; height:3px; border-radius:2px; background:rgba(0,0,0,0.10); overflow:hidden; }
        .self .voice-attach .voice-track { background:rgba(255,255,255,0.30); }
        .voice-attach .voice-track i { display:block; height:100%; width:0; border-radius:2px; background:var(--accent); transition:width .2s linear; }
.self .voice-attach .voice-track i { background:#fff; }
/* 播放速度小标签：点击循环 1× → 1.5× → 2× */
.voice-attach .voice-rate { flex-shrink:0; font-size:10.5px; font-weight:600; line-height:1;
    padding:4px 6px; border-radius:7px; color:var(--text3); background:rgba(0,0,0,0.06);
    cursor:pointer; transition:background .15s, color .15s; font-variant-numeric:tabular-nums; }
.voice-attach .voice-rate:hover { background:rgba(0,0,0,0.12); }
.voice-attach .voice-rate.fast { color:#fff; background:var(--accent); }
.self .voice-attach .voice-rate { color:rgba(255,255,255,0.85); background:rgba(255,255,255,0.18); }
.self .voice-attach .voice-rate.fast { color:var(--accent); background:rgba(255,255,255,0.95); }
/* 未读分隔线：进房间时标出"从哪一条开始是新的" */
.unread-divider { display:flex; align-items:center; gap:10px; margin:10px 0 6px;
    color:var(--danger); font-size:11px; font-weight:500; user-select:none; }
.unread-divider span { flex-shrink:0; }
.unread-divider::before, .unread-divider::after { content:''; flex:1; height:1px; background:currentColor; opacity:.34; }
        .voice-attach .voice-dur { font-size:12px; flex-shrink:0; color:var(--text2); font-variant-numeric:tabular-nums; }
        .self .voice-attach .voice-dur { color:#fff; }
        /* 播放中：播放键脉冲 + 波形起伏。
           波形起伏改由 JS 逐帧驱动（见 startVoicePlayAnim）：系统开启"减少动效"时
           CSS 动画会被整体禁用，导致播放时声纹一动不动，所以这里不再依赖 CSS 动画。 */
        .voice-attach.playing .voice-play { animation:voicePulse 1.4s ease-in-out infinite; }
        .voice-attach.playing .voice-wave i { background:var(--accent); will-change:transform; }
        .self .voice-attach.playing .voice-wave i { background:#fff; }
        /* 纯语音消息：不套聊天气泡，语音条自身就是胶囊形状，直接贴在聊天流里 */
        .bubble.voice-only, .msg-row:not(.self) .bubble.voice-only, .self .bubble.voice-only,
        [data-theme="dark"] .msg-row:not(.self) .bubble.voice-only, [data-theme="dark"] .self .bubble.voice-only {
            padding:0 !important; background:transparent !important; background-color:transparent !important;
            border:none !important; box-shadow:none !important; border-radius:0 !important;
        }
        .bubble.voice-only:hover { background:transparent !important; }
        .bubble.voice-only .voice-attach {
            margin:0; padding:9px 16px 9px 9px; border-radius:999px;
            min-width:204px; max-width:min(320px,68vw);
            background:var(--surface2) !important; color:var(--text) !important;
            border:1px solid var(--border) !important;
            box-shadow:0 1px 2px rgba(0,0,0,.04);
        }
        .bubble.voice-only .voice-attach:hover { background:var(--surface3) !important; }
        .bubble.voice-only .voice-attach .voice-play { background:var(--accent) !important; }
        .bubble.voice-only .voice-attach .voice-play svg { fill:#fff !important; }
        .bubble.voice-only .voice-attach .voice-wave i { background:var(--text3) !important; }
        .bubble.voice-only .voice-attach.playing .voice-wave i { background:var(--accent) !important; }
        .bubble.voice-only .voice-attach .voice-track { background:var(--surface3) !important; }
        .bubble.voice-only .voice-attach .voice-track i { background:var(--accent) !important; }
        .bubble.voice-only .voice-attach .voice-dur { color:var(--text2) !important; }
        @keyframes voicePulse { 0%,100% { box-shadow:0 0 0 0 rgba(var(--accent-rgb),0.4);} 50% { box-shadow:0 0 0 6px rgba(var(--accent-rgb),0);} }
        /* ===== 语音输入：微信式「按住说话」 ===== */
        /* 语音模式下：收起文字输入框/字数/发送键，中间让位给「按住说话」条 */
        .input-group.voice-mode #messageInput,
        .input-group.voice-mode .char-counter,
        .input-group.voice-mode .send-btn { display:none !important; }
        .voice-hold-btn { display:none; flex:1; min-width:0; height:38px; margin:0 2px;
            border:1px solid var(--border2); border-radius:999px; background:var(--surface2); color:var(--text);
            font-size:14px; font-family:inherit; font-weight:500; letter-spacing:2px; cursor:pointer;
            user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; touch-action:none;
            transition:background .12s, color .12s, border-color .12s, transform .12s; }
        .input-group.voice-mode .voice-hold-btn { display:flex; align-items:center; justify-content:center; }
        .voice-hold-btn:hover { background:var(--surface3); }
        .voice-hold-btn.holding { background:var(--accent); border-color:var(--accent); color:#fff; transform:scale(.985); }
        /* 麦克风 / 键盘图标按模式切换 */
        .mic-btn .ic-kbd { display:none; }
        .input-group.voice-mode .mic-btn .ic-mic { display:none; }
        .input-group.voice-mode .mic-btn .ic-kbd { display:block; }
        .input-group.voice-mode .mic-btn { color:var(--accent); }

        /* 录音浮层：深色面板 + 实时声纹 + 提示条；上滑进取消区整体变红 */
        .rec-overlay { position:fixed; left:50%; top:0; bottom:0; transform:translateX(-50%);
            width:min(84vw,320px); display:none; z-index:242; pointer-events:none;
            flex-direction:column; align-items:center; justify-content:center; gap:14px; }
        .rec-overlay.active { display:flex; animation:recPop .16s ease; }
        .rec-panel { width:100%; background:rgba(24,24,26,.90); border-radius:18px;
            padding:18px 18px 14px; display:flex; flex-direction:column; align-items:center; gap:12px;
            box-shadow:0 18px 50px rgba(0,0,0,.34); transition:background .18s, box-shadow .18s; }
        .rec-icon { color:#fff; opacity:.92; }
        .rec-icon svg { width:26px; height:26px; display:block; }
        .rec-ic-cancel { display:none; }
        /* 实时声纹：柱高由麦克风实时音量驱动，说话即跳动 */
        .rec-wave { width:100%; height:46px; display:flex; align-items:center; justify-content:center; gap:3px; }
        .rec-wave i { display:block; width:3px; min-height:3px; border-radius:2px; background:#fff;
            height:8%; transition:height .07s linear; }
        .rec-timer { color:rgba(255,255,255,.88); font-size:13px; font-weight:600;
            font-variant-numeric:tabular-nums; letter-spacing:.5px; }
        .rec-hint { padding:7px 16px; border-radius:999px; background:rgba(24,24,26,.78);
            color:#fff; font-size:12.5px; white-space:nowrap; transition:background .18s; }
        /* 滑到取消区：面板、声纹、提示条一起转红，图标换成叉 */
        .rec-overlay.cancel .rec-panel { background:rgba(206,58,58,.94); box-shadow:0 18px 50px rgba(206,58,58,.34); }
        .rec-overlay.cancel .rec-hint { background:rgba(206,58,58,.95); }
        .rec-overlay.cancel .rec-ic-mic { display:none; }
        .rec-overlay.cancel .rec-ic-cancel { display:block; }
        @keyframes recPop { from{ transform:translate(-50%,10px) scale(.96); opacity:0 } to{ transform:translate(-50%,0) scale(1); opacity:1 } }
        .mic-btn.recording { color:#e74c3c !important; background:rgba(231,76,60,.16) !important; }
        .quote-block { background:rgba(0,0,0,0.04); padding:6px 10px; margin-bottom:6px; border-radius:4px; font-size:12px; color:var(--text2); }
.self .quote-block { background:rgba(255,255,255,0.15); color:rgba(255,255,255,0.85); }
.fwd-tag { font-size:11px; color:var(--text3); margin-bottom:4px; }
.self .fwd-tag { color:rgba(255,255,255,0.7); }
        .msg-link { color:var(--accent); text-decoration:underline; word-break:break-all; }
        .self .msg-link { color:#fff; text-decoration:underline; }
        /* 时间行：已读/未读标记与时间戳同排，标记在时间的左侧 */
        .msg-time { font-size:11px; color:var(--text3); margin-top:4px; padding:0 4px; display:flex; align-items:center; gap:6px; line-height:1.4; }
        .self .msg-time { justify-content:flex-end; }
        .msg-footer { display:flex; gap:4px; margin-top:4px; padding:0 4px; flex-wrap:wrap; opacity:0; transition:opacity .15s; }
        .msg-row.self .msg-footer { justify-content:flex-end; }
        /* 鼠标悬停显示；键盘 Tab 进入时也要显示，否则键盘用户无法操作 */
        .msg-row:hover .msg-footer, .msg-row:focus-within .msg-footer { opacity:1; }
        .msg-footer button { background:transparent; border:none; color:var(--text3); font-size:11px; cursor:pointer; padding:4px 9px; border-radius:6px; transition:background .15s,color .15s,transform .1s; white-space:nowrap; min-height:26px; }
        .msg-footer button:hover { background:var(--surface3); color:var(--text); }
        .msg-footer button:active { transform:scale(.96); }
        /* 触屏设备没有 hover：操作按钮必须常显，否则手机上永远点不到"编辑/撤回/引用" */
        @media (hover: none) {
            .msg-footer { opacity:1; }
            .msg-footer button { font-size:12px; padding:6px 10px; min-height:34px; background:var(--surface2); }
        }
        .highlight-mention { background:rgba(255,200,0,0.2); border-radius:3px; padding:0 2px; color:#e6a800; }
        .highlight-atall { background:rgba(229,57,53,0.12); border-radius:3px; padding:0 2px; color:var(--danger); font-weight:600; }

        /* ===== 表情回应 ===== */
        .msg-reactions { display:flex; flex-wrap:wrap; gap:4px; margin-top:5px; padding:0 4px; max-width:100%; }
        .msg-row.self .msg-reactions { justify-content:flex-end; }
        .rx-chip { display:inline-flex; align-items:center; gap:3px; font-size:13px; line-height:1; padding:4px 8px; border-radius:999px; background:var(--surface2); border:1px solid var(--border); color:var(--text2); cursor:pointer; transition:border-color .15s,color .15s,background .15s; user-select:none; }
        .rx-chip em { font-style:normal; font-size:11px; color:var(--text3); }
        .rx-chip:hover { border-color:var(--accent); color:var(--accent); }
        .rx-chip.active { background:var(--accent-strong); border-color:var(--accent); color:var(--accent); }
        .rx-chip.active em { color:var(--accent); }
        .reaction-picker { position:fixed; z-index:260; display:none; background:var(--surface); border:1px solid var(--border); border-radius:12px; box-shadow:0 6px 24px rgba(0,0,0,.14); padding:8px 10px; }
        .reaction-picker.active { display:flex; gap:2px; flex-wrap:wrap; max-width:300px; }
        .reaction-picker span { font-size:20px; line-height:1; padding:6px; border-radius:8px; cursor:pointer; transition:background .12s,transform .12s; }
        .reaction-picker span:hover { background:var(--surface3); transform:scale(1.18); }

        /* ===== 消息收藏 ===== */
        .fav-badge { position:absolute; top:-4px; right:-6px; min-width:14px; height:14px; border-radius:7px; background:var(--danger); color:#fff; font-size:9px; line-height:14px; text-align:center; padding:0 3px; box-sizing:border-box; }
        .fav-item { display:flex; align-items:center; gap:8px; padding:10px 12px; border-bottom:1px solid var(--border); cursor:pointer; }
        .fav-item:hover { background:var(--surface2); }
        .fav-main { flex:1; min-width:0; }
        .fav-top { display:flex; justify-content:space-between; gap:8px; align-items:center; }
        .fav-user { color:var(--accent); font-size:12px; font-weight:500; }
        .fav-time { color:var(--text3); font-size:11px; flex-shrink:0; }
        .fav-text { font-size:13px; color:var(--text); margin-top:3px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
        .fav-room { font-size:11px; color:var(--text3); margin-top:3px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
        .fav-del { width:26px; height:26px; border-radius:50%; border:none; background:transparent; color:var(--text3); cursor:pointer; flex-shrink:0; display:flex; align-items:center; justify-content:center; transition:background .12s,color .12s; }
        .fav-del:hover { background:var(--surface3); color:var(--danger); }
        .fav-del svg { fill:currentColor; }

        /* 好友卡片的"⋯"按钮：改为始终显示（原来只在手机端显示，桌面端看不到菜单入口） */
        .friend-more-btn { display:flex; width:28px; height:28px; border-radius:50%; border:none; background:transparent; cursor:pointer; flex-shrink:0; align-items:center; justify-content:center; transition:background .12s; }
        .friend-more-btn:hover { background:var(--surface3); }
        .friend-more-btn svg { width:16px; height:16px; fill:var(--text3); }
        .group-more-btn { display:flex; width:28px; height:28px; border-radius:50%; border:none; background:transparent; cursor:pointer; flex-shrink:0; align-items:center; justify-content:center; transition:background .12s; opacity:.55; }
        .group-more-btn:hover { background:var(--surface3); opacity:1; }
        .group-more-btn svg { width:16px; height:16px; fill:var(--text3); }
        .nav-item-actions { display:flex; align-items:center; gap:2px; flex-shrink:0; }

        .menu-icon { width:14px; height:14px; margin-right:6px; vertical-align:middle; flex-shrink:0; }
        .friend-context-menu div, .bubble-popup div { display:flex; align-items:center; }
        .friend-context-menu div svg.menu-icon, .bubble-popup div svg.menu-icon { fill:currentColor; }

        .quote-bar { display:none; padding:8px 16px; background:var(--surface2); border-bottom:1px solid var(--border); align-items:center; gap:10px; font-size:12px; color:var(--text2); }
        .quote-bar.active { display:flex; }
        .quote-bar .quote-text { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
        .quote-bar .quote-close { cursor:pointer; color:var(--text3); background:none; border:none; line-height:1; padding:2px 4px; transition:color .15s; display:flex; align-items:center; }
        .quote-bar .quote-close:hover { color:var(--danger); }
        .quote-bar .quote-close svg { fill:currentColor; }

        .input-area { display:flex; gap:6px; padding:10px 16px; background:var(--surface); border-top:1px solid var(--border); align-items:center; position:relative; }
        .input-area .tool-btn { width:36px; height:36px; border-radius:50%; border:none; cursor:pointer; display:flex; align-items:center; justify-content:center; background:transparent; flex-shrink:0; transition:all .15s; }
        .input-area .tool-btn:hover { background:var(--surface2); }
        .input-area .tool-btn svg { width:20px; height:20px; }
        .input-area input { flex:1; padding:9px 14px; border:1px solid var(--border); border-radius:20px; background:var(--surface2); font-size:14px; outline:none; transition:all .2s; color:var(--text); }
        .input-area input:focus { border-color:var(--accent); background:var(--surface); }
        .input-area input::placeholder { color:var(--text3); }
        .send-btn { width:36px; height:36px; border-radius:50%; border:none; background:var(--accent); cursor:pointer; display:flex; align-items:center; justify-content:center; flex-shrink:0; transition:all .15s; }
        .send-btn:hover { background:var(--accent2); }
        .send-btn svg { width:18px; height:18px; fill:#fff; }
        .send-btn:active { transform:scale(.94); }
        .input-area .tool-btn:active { transform:scale(.92); }
        .input-area .tool-btn, .send-btn { transition:background .15s, transform .1s; }
        /* 输入框为空时发送按钮视觉降级，让"能不能发"一眼可见 */
        .send-btn:disabled { background:var(--surface3); cursor:not-allowed; opacity:.65; transform:none; }
        /* ---- 输入行：结构照参考系统（语音 + 胶囊[输入框 + 表情 + 加号 + 发送]）---- */
        .input-area { flex-direction:column; align-items:stretch; gap:0; }
        .input-row { display:flex; align-items:center; }
        /* 胶囊不铺底色：只留一圈细描边，避免出现"一长条蓝色方块"的观感；
           聚焦时只是描边微微加深，用户靠占位文字"输入消息..."就知道这里可以打字 */
        .input-group { flex:1; min-width:0; display:flex; align-items:center; gap:2px;
            background:transparent; border:1px solid var(--border);
            border-radius:24px; padding:4px 6px 4px 8px; transition:border-color .15s; }
        /* 聚焦形态与未聚焦保持一致：不换主题蓝描边，避免出现"一长条蓝色方块"。
           用户靠占位文字"输入消息..."就能知道这里可以打字 */
        .input-group:focus-within { background:transparent; border-color:var(--border); }
        /* 输入区内按钮的键盘焦点环收敛为细描边，不用高饱和主题色（键盘用户仍可见焦点） */
        .input-area button:focus-visible { outline:1px solid var(--border2); outline-offset:1px; }
        /* ---- 侧栏三分区切换（群聊 / 好友 / 公共聊天）---- */
        .sidebar-tabs { display:flex; gap:6px; margin:0 0 15px; }
        .sidebar-tab { flex:1; padding:7px 4px; border:1px solid var(--border); border-radius:10px;
            background:transparent; color:var(--text2); font-size:12px; font-family:inherit;
            cursor:pointer; transition:background .15s, color .15s, border-color .15s; }
        .sidebar-tab:hover { background:var(--surface2); color:var(--text); }
        .sidebar-tab.active { background:var(--accent); border-color:var(--accent); color:#fff; }
/* 分类未读角标：贴在该分类标签的左上角，显示这个分类的未读总数。
   用 flex 居中而不是 line-height，保证无论 1 位还是 3 位数，"数字"始终在红点正中，
   不会因为字宽变化而偏移。 */
.sidebar-tab { position:relative; }
/* 角标放在标签右下角并缩小一号。
   原来的左上角位置（top:-7px）会超出滚动容器的上边界，而 .sidebar-nav 是
   overflow-y:auto，溢出部分会被直接裁掉——所以看起来"上面被挡住了、只显示一半"。
   右下角仍在内容/内边距范围内，无论怎么滚动都不会被裁。 */
.tab-badge { position:absolute; bottom:-6px; right:-4px; display:none;
    align-items:center; justify-content:center; box-sizing:border-box;
    min-width:15px; height:15px; padding:0 4px; border-radius:8px;
    background:var(--badge); color:#fff; font-size:9.5px; font-weight:600; line-height:1;
    font-variant-numeric:tabular-nums; letter-spacing:0;
    box-shadow:0 0 0 2px var(--surface); pointer-events:none; }
.tab-badge.show { display:flex; }
        /* 只显示当前分区 */
        .sidebar[data-tab="group"] .nav-section[data-sec="friend"],
        .sidebar[data-tab="group"] .nav-section[data-sec="channel"],
        .sidebar[data-tab="friend"] .nav-section[data-sec="group"],
        .sidebar[data-tab="friend"] .nav-section[data-sec="channel"],
        .sidebar[data-tab="channel"] .nav-section[data-sec="group"],
        .sidebar[data-tab="channel"] .nav-section[data-sec="friend"] { display:none; }
        /* 底栏高度由脚本按"输入行实际高度"动态算好后写入 --composer-h，
           这样侧栏的分隔线与输入框上方那条线必然在同一水平线上 */
        .sidebar-footer { min-height:var(--composer-h, 66px); }
        /* ---- 表情 / 表情包面板（重做版）：页签在上、内容在下，一次只显示一个分类 ---- */
        .picker-panel { display:none; width:100%; margin:8px 0 0 0; padding:10px;
            background:var(--surface); border:1px solid var(--border); border-radius:16px;
            box-shadow:0 14px 32px -22px rgba(0,0,0,.5); }
        .picker-panel.active { display:block; }
        /* 分类切换栏放在内容之上 */
        .picker-tabs { display:flex; gap:8px; padding-bottom:8px; margin-bottom:8px;
            border-bottom:1px solid var(--border); }
        .picker-tab { flex:1; padding:7px 10px; border:1px solid var(--border); border-radius:10px;
            background:transparent; color:var(--text2); font-size:12px; font-family:inherit;
            cursor:pointer; transition:background .15s, color .15s, border-color .15s; }
        .picker-tab:hover { background:var(--surface2); color:var(--text); }
        .picker-tab.active { background:var(--accent); border-color:var(--accent); color:#fff; }
        /* 内容区：固定高度（两个分类一致），只通过上下滚动查看，不因内容多少而变矮 */
        .picker-body { height:196px; overflow-y:auto; overflow-x:hidden; }
        .picker-grid { display:grid; grid-template-columns:repeat(7,1fr); gap:2px; padding:2px; }
        .picker-grid .pi { height:34px; display:flex; align-items:center; justify-content:center;
            border-radius:10px; cursor:pointer; font-size:21px; line-height:1;
            transition:background .15s; }
        /* 悬停只变亮、不放大：放大时边角装饰会被相邻格子裁掉 */
        .picker-grid .pi:hover { background:var(--surface2); }
        .picker-grid .pi img { width:100%; height:100%; object-fit:contain; }
        /* 表情包用图片格子：略大一些，方便点选 */
        .picker-grid.pic-grid { grid-template-columns:repeat(5,1fr); gap:6px; }
        .picker-grid .pi.pi-pic { height:auto; aspect-ratio:1/1; padding:2px; }
        /* 上传按钮：与表情包格子同样大小的一个正方格，固定在左上角第一个位置 */
        .picker-grid .pi.pi-add { height:auto; aspect-ratio:1/1; font-size:20px; font-weight:300;
            color:var(--text2); border:1px dashed var(--border); }
        .picker-grid .pi.pi-add:hover { color:var(--accent); border-color:var(--accent); background:var(--surface2); }
        .picker-hint { grid-column:1/-1; padding:18px; text-align:center; color:var(--text3); font-size:12px; }
        /* ---- 表情"加入常用"的星标：平时隐藏，悬停才出现，不干扰纯 emoji 的观感 ---- */
        .picker-grid .pi { position:relative; }
        .pi-star { position:absolute; top:0; right:0; width:15px; height:15px; padding:0; border:none;
            background:var(--surface); border-radius:50%; cursor:pointer; color:var(--text3);
            display:flex; align-items:center; justify-content:center; opacity:0;
            transition:opacity .12s, color .12s; }
        .picker-grid .pi:hover .pi-star { opacity:1; }
        .pi-star svg { width:10px; height:10px; fill:currentColor; }
        .pi-star:hover, .pi-star.on { color:#f0b400; }
        .pi-star.on { opacity:1; }
        .picker-section-title { font-size:11px; color:var(--text3); padding:6px 2px 2px; }
        @media (max-width:768px) {
            .picker-panel { padding:8px; }
            .picker-body { height:164px; }
            .picker-grid { grid-template-columns:repeat(6,1fr); }
            .picker-grid .pi { height:31px; font-size:19px; }
            .picker-grid.pic-grid { grid-template-columns:repeat(4,1fr); }
        }
        /* 输入框本体同样不留底色与蓝色焦点框（文本输入框在鼠标点击时也会命中 :focus-visible） */
        .input-area .input-group input,
        .input-area .input-group input:focus,
        .input-area .input-group input:focus-visible {
            outline:none !important; background:transparent !important; box-shadow:none !important; border:none !important; }
        /* 输入框本体去掉自身边框与底色，由胶囊统一承载 */
        .input-area .input-group input, .input-area .input-group input:focus {
            flex:1; min-width:0; border:none !important; background:transparent !important;
            border-radius:0; box-shadow:none; padding:9px 6px; font-size:14.5px; outline:none; }
        .input-area .input-group .mic-btn { width:38px; height:38px; flex-shrink:0;
            /* 长按录音：禁止长按选中/弹出系统菜单，触摸手势交给脚本处理 */
            user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; touch-action:none; }
        .input-tools { display:flex; align-items:center; flex-shrink:0; }
        .input-area .input-tools .tool-btn { width:34px; height:34px; }
        .input-group .char-counter { flex-shrink:0; padding:0 4px; }
        /* 加号激活时旋转 45°（＋ 变 ×），与参考系统一致 */
        .plus-btn { transition:transform .3s ease, background .15s, color .15s; }
        .plus-btn.active { transform:rotate(45deg); color:var(--accent); background:var(--surface3); }
        /* ---- 加号面板：在输入行下方向下撑开的 3 列网格（参考系统同款展开动效）---- */
        .plus-panel { display:grid; grid-template-columns:repeat(3,1fr); gap:10px;
            max-height:0; overflow:hidden; opacity:0; margin-top:0;
            transition:max-height .34s cubic-bezier(.19,1,.22,1), opacity .24s ease, margin-top .34s ease; }
        .plus-panel.show { max-height:240px; opacity:1; margin-top:10px; }
        .plus-item { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:7px;
            height:76px; border-radius:16px; cursor:pointer; font-family:inherit; font-size:11.5px;
            background:var(--surface2); border:1px solid var(--border); color:var(--text2);
            transition:transform .3s ease, background .3s ease, color .3s ease; }
        .plus-item:hover { transform:translateY(-3px); background:var(--surface3); color:var(--text); }
        .plus-item svg { width:22px; height:22px; transition:transform .3s ease; }
        .plus-item:hover svg { transform:scale(1.15); }
        .plus-item:disabled { opacity:.45; cursor:not-allowed; transform:none; }
        /* 各功能悬停强调色，沿用参考系统的分类配色 */
        .plus-panel .t-image:hover svg { color:#2f9e6a; }
        .plus-panel .t-file:hover svg { color:#2f6fb5; }
        .plus-panel .t-call:hover svg { color:#7a4fb0; }
        .plus-panel .t-vcall:hover svg { color:#c44569; }
        /* 移动端：字号保持 16px 以免 iOS 聚焦时整页放大 */
        @media (max-width:768px) {
            .input-area .input-group input { font-size:16px; padding:10px 6px; }
            .input-group { padding:3px 5px 3px 6px; }
            .input-area .input-group .mic-btn { width:40px; height:40px; }
            .plus-panel { gap:8px; }
            .plus-item { height:68px; border-radius:14px; font-size:11px; }
        }
        /* 键盘可访问性：统一焦点环，键盘用户才能看到焦点位置 */
        :focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:6px; }
        .input-area input:focus-visible { outline:none; }
        /* 尊重系统"减少动效"设置：关闭装饰性动画 */
        @media (prefers-reduced-motion: reduce) {
            .voice-attach.playing .voice-play,
            .rec-overlay.active { animation:none !important; }
            .rec-wave i { transition:none !important; }
            .call-ball.ringing { animation:none !important; }
            .call-banner { transition:none !important; }
            * { scroll-behavior:auto !important; }
        }
        .char-counter { font-size:11px; color:var(--text3); white-space:nowrap; flex-shrink:0; }
        .char-counter.over { color:var(--danger); font-weight:600; }
        .emoji-panel { display:none; position:absolute; bottom:56px; left:16px; background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:8px; grid-template-columns:repeat(7,1fr); gap:3px; box-shadow:var(--shadow-lg); z-index:50; }
        .emoji-panel.active { display:grid; }
        /* 表情面板：照参考系统——在输入框下方内联展开、铺满宽度、限高可滚动 */
        .input-area .emoji-panel { position:static; left:auto; right:auto; bottom:auto;
            width:100%; max-width:100%; margin:8px 0 0 0; padding:10px;
            border-radius:16px; max-height:200px; overflow-y:auto; overflow-x:hidden; }
        /* 表情格子尺寸与参考系统一致（34×34 / 圆角 10 / 字号 21） */
        .input-area .emoji-panel span { width:34px; height:34px; line-height:34px;
            border-radius:10px; font-size:21px; padding:0; text-align:center; }
        @media (max-width:768px) {
            .input-area .emoji-panel { max-height:168px; padding:8px; }
            .input-area .emoji-panel span { width:31px; height:31px; line-height:31px; font-size:19px; }
        }
        /* 表情抽屉页脚（表情包入口）：随表情面板一起出现，用相邻兄弟选择器同步，无需 JS 配合 */
        .emoji-drawer-foot { display:none; position:static; height:auto; margin-top:6px; padding:6px 10px;
            align-items:center; gap:8px; background:var(--surface2); border:1px solid var(--border); border-radius:12px; }
        .emoji-panel.active + .emoji-drawer-foot { display:flex; }
        .input-area .emoji-drawer-foot .tool-btn { width:auto; height:30px; padding:0 10px; gap:6px;
            flex-direction:row; border-radius:8px; background:transparent; }
        .input-area .emoji-drawer-foot .tool-btn:hover { background:var(--surface3); }
        .input-area .emoji-drawer-foot .tool-btn svg { width:17px; height:17px; }
        .input-area .emoji-drawer-foot .tool-btn::after { content:attr(title); font-size:12px; color:var(--text2); white-space:nowrap; }
        .emoji-panel span { font-size:22px; cursor:pointer; text-align:center; padding:5px; border-radius:6px; transition:background .15s; }
        .emoji-panel span:hover { background:var(--surface2); }
        .sticker-panel { display:none; position:absolute; bottom:56px; left:56px; background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:10px; box-shadow:var(--shadow-lg); z-index:50; width:312px; max-height:300px; overflow-y:auto; }
        .sticker-panel.active { display:block; }
        .sticker-panel .stk-grid { display:grid; grid-template-columns:repeat(5,1fr); gap:6px; }
        .sticker-panel .stk-item { cursor:pointer; border-radius:8px; transition:background .15s; display:flex; align-items:center; justify-content:center; padding:4px; position:relative; }
        .sticker-panel .stk-item:hover { background:var(--surface2); }
        .sticker-panel .stk-item img { width:52px; height:52px; }
        .sticker-panel .stk-add { display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px dashed var(--border); border-radius:8px; color:var(--text3); font-size:22px; height:60px; transition:all .15s; }
        .sticker-panel .stk-add:hover { color:var(--accent); border-color:var(--accent); }
        .sticker-panel .stk-section { font-size:12px; color:var(--text3); margin:10px 2px 4px; }
        .sticker-panel .stk-section:first-child { margin-top:0; }
        .sticker-panel .stk-del { position:absolute; top:0; right:0; width:16px; height:16px; border-radius:50%; background:rgba(0,0,0,0.5); color:#fff; font-size:11px; line-height:16px; text-align:center; display:none; cursor:pointer; }
        .sticker-panel .stk-item:hover .stk-del { display:block; }

        .back-to-bottom { display:none; position:fixed; bottom:90px; right:24px; width:38px; height:38px; border-radius:50%; background:var(--surface); border:1px solid var(--border); cursor:pointer; align-items:center; justify-content:center; z-index:30; box-shadow:var(--shadow); transition:all .2s; }
        .back-to-bottom.show { display:flex; }
        .back-to-bottom:hover { background:var(--surface2); transform:translateY(-2px); box-shadow:var(--shadow-lg); }
        .back-to-bottom svg { width:18px; height:18px; fill:var(--text2); }
        .back-to-bottom .count-badge { position:absolute; top:-6px; right:-6px; background:var(--badge); color:#fff; font-size:10px; min-width:18px; height:18px; line-height:18px; text-align:center; border-radius:9px; padding:0 4px; font-weight:600; display:none; }
        .back-to-bottom .count-badge.show { display:block; }

        .image-preview-modal { display:none; position:fixed; top:0;left:0;right:0;bottom:0; background:rgba(0,0,0,0.5); z-index:110; justify-content:center; align-items:center; flex-direction:column; }
        .image-preview-modal.active { display:flex; }
        .image-preview-modal .preview-box { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:24px; max-width:90vw; max-height:80vh; display:flex; flex-direction:column; align-items:center; box-shadow:var(--shadow-lg); }
        .image-preview-modal .preview-box img { max-width:70vw; max-height:50vh; border-radius:8px; object-fit:contain; }
        .image-preview-modal .preview-title { font-size:15px; font-weight:600; color:var(--text); margin-bottom:12px; }
        .image-preview-modal .preview-info { font-size:12px; color:var(--text2); margin-top:8px; }
        .image-preview-modal .preview-btns { display:flex; gap:12px; margin-top:16px; }
        .image-preview-modal .preview-btns button { padding:10px 28px; border:none; border-radius:8px; font-size:14px; cursor:pointer; font-weight:500; transition:all .2s; }
        .image-preview-modal .preview-btns button:hover { opacity:0.85; }
        .image-preview-modal .btn-send { background:var(--accent); color:#fff; }
        .image-preview-modal .btn-cancel-preview { background:var(--surface2); color:var(--text2); }
        /* 文件发送弹窗：文件名长、列表多，给它比图片预览更大的可用面积 */
        .image-preview-modal .file-preview-box { width:min(92vw, 680px); max-width:92vw; align-items:stretch; padding:26px 28px; }
        .file-preview-box .preview-title { text-align:center; font-size:17px; margin-bottom:16px; }
        .file-preview-box .file-drop-zone { padding:30px 18px; margin:4px 0 14px; }
        .file-preview-box .file-drop-zone .fdz-title { font-size:15px; }
        .file-preview-box .file-drop-zone .fdz-sub { font-size:12.5px; }
        .file-preview-box .batch-upload-item { padding:11px 0; }
        .file-preview-box .batch-upload-sum { text-align:center; margin-top:8px; }
        .file-preview-box .preview-btns { justify-content:center; margin-top:18px; }
        .file-preview-box .preview-btns button { padding:11px 34px; font-size:15px; }
        @media (min-width: 1200px) {
            .image-preview-modal .file-preview-box { width:min(86vw, 780px); }
        }

        .clear-chat-btn { background:transparent; border:none; color:var(--text3); font-size:11px; cursor:pointer; padding:4px 10px; border-radius:4px; transition:all .15s; }
        .clear-chat-btn:hover { background:var(--surface2); color:var(--danger); }

        .group-menu-btn { background:transparent; border:none; cursor:pointer; padding:4px 8px; border-radius:6px; color:var(--text2); transition:all .15s; display:none; line-height:1; }
        .group-menu-btn:hover { background:var(--surface2); color:var(--text); }
        .group-menu-btn.show { display:inline-flex; align-items:center; justify-content:center; }
        .group-menu-btn svg { fill:currentColor; }

        .hamburger-btn { position:relative; width:38px; height:38px; border-radius:50%; border:none; background:transparent; cursor:pointer; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
        .hamburger-btn:hover { background:var(--surface2); }
        .hamburger-btn .hamburger-lines { display:flex; flex-direction:column; gap:4px; }
        .hamburger-btn .hamburger-lines span { display:block; width:18px; height:2px; background:var(--text); border-radius:1px; transition:all .2s; }
        .hamburger-badge { position:absolute; top:-2px; right:-2px; background:var(--badge); color:#fff; font-size:10px; min-width:18px; height:18px; line-height:18px; text-align:center; border-radius:9px; padding:0 5px; font-weight:600; display:none; }
        .hamburger-badge.show { display:block; }

        .header-dropdown { display:none; position:absolute; top:100%; right:0; background:var(--surface); border:1px solid var(--border2); border-radius:var(--radius); box-shadow:var(--shadow-lg); z-index:200; padding:4px; min-width:160px; }
        .header-dropdown.active { display:block; }
        .header-dropdown div { padding:8px 14px; font-size:13px; cursor:pointer; border-radius:6px; white-space:nowrap; color:var(--text); transition:background .12s; display:flex; align-items:center; gap:6px; }
        .header-dropdown div:hover { background:var(--surface3); }
        .header-dropdown .menu-danger { color:var(--danger); }
        .header-dropdown .menu-sep { height:1px; background:var(--border); margin:4px 8px; padding:0 !important; pointer-events:none; }
        .header-right { position:relative; display:flex; align-items:center; gap:4px; }

        .file-download-modal { display:none; position:fixed; top:0;left:0;right:0;bottom:0; background:rgba(0,0,0,0.4); z-index:110; justify-content:center; align-items:center; }
        .file-download-modal.active { display:flex; }
        .file-download-modal .dl-box { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:32px 28px 26px; min-width:320px; max-width:560px; width:92%; box-shadow:var(--shadow-lg); text-align:center; }
        .file-download-modal .dl-icon { margin-bottom:16px; }
        .file-download-modal .dl-icon svg { fill:var(--accent); width:60px; height:60px; }
        .file-download-modal .dl-name { font-size:17px; font-weight:600; color:var(--text); margin-bottom:6px; word-break:break-all; line-height:1.4; }
        .file-download-modal .dl-size { font-size:13px; color:var(--text3); margin-bottom:22px; }
        .file-download-modal .dl-btns { display:flex; gap:12px; justify-content:center; }
        .file-download-modal .dl-btns button { padding:12px 36px; border:none; border-radius:8px; font-size:15px; cursor:pointer; font-weight:500; transition:all .2s; }
        .file-download-modal .dl-btns button:hover { opacity:0.85; }
        .file-download-modal .btn-download { background:var(--accent); color:#fff; }
        .file-download-modal .btn-cancel-dl { background:var(--surface2); color:var(--text2); }

        .file-attach { cursor:pointer; }

        /* ===== Toast ===== */
        .toast { position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); background:rgba(0,0,0,0.75); color:#fff; padding:10px 24px; border-radius:8px; z-index:999; animation:toast 2s forwards; pointer-events:none; font-size:14px; }
        @keyframes toast { 0%,100%{opacity:0;} 10%,90%{opacity:1;} }

        /* 群文件 / 群相册 */
.gf-box { max-width:520px; width:94%; }
.gf-tabs { display:flex; gap:4px; margin:2px 0 12px; border-bottom:1px solid var(--border); }
.gf-tab { background:transparent; border:none; border-bottom:2px solid transparent; border-radius:0; color:var(--text3); font-size:14px; padding:8px 16px; cursor:pointer; font-weight:500; }
.gf-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.gf-body { max-height:52vh; overflow-y:auto; }
.gf-album { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; }
.gf-photo { position:relative; aspect-ratio:1; border-radius:8px; overflow:hidden; cursor:pointer; background:var(--surface2); }
.gf-photo img { width:100%; height:100%; object-fit:cover; display:block; }
.gf-del { position:absolute; top:4px; right:4px; width:22px; height:22px; border-radius:50%; border:none; background:rgba(0,0,0,.55); color:#fff; font-size:12px; line-height:22px; text-align:center; display:none; cursor:pointer; }
.gf-photo:hover .gf-del { display:block; }
.gf-file { display:flex; align-items:center; gap:10px; padding:10px 8px; border-radius:8px; transition:background .15s; }
.gf-file:hover { background:var(--surface2); }
.gf-file-ico { width:38px; height:38px; border-radius:8px; background:var(--accent-strong); color:var(--accent); display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.gf-file-info { flex:1; min-width:0; }
.gf-file-name { font-size:13px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.gf-file-meta { font-size:11px; color:var(--text3); margin-top:2px; }
.gf-file-op { display:flex; align-items:center; gap:2px; }
.gf-op-btn { width:28px; height:28px; border-radius:6px; border:none; background:transparent; color:var(--text3); cursor:pointer; display:flex; align-items:center; justify-content:center; }
.gf-op-btn:hover { background:var(--surface3); color:var(--accent); }
.gf-op-btn.danger:hover { color:var(--danger); }
.gf-foot { display:flex; justify-content:flex-end; gap:10px; margin-top:14px; align-items:center; }
.gf-upload { display:inline-flex; align-items:center; gap:6px; }
.gf-empty { padding:30px 0; text-align:center; color:var(--text3); font-size:13px; }
/* 群文件批量管理：选择态的高亮与勾选框 */
.gf-sel-toggle { margin-left:auto; background:transparent; border:none; color:var(--accent); font-size:13px; cursor:pointer; padding:8px 12px; border-radius:6px; font-weight:500; }
.gf-sel-toggle:hover { background:var(--surface2); }
.gf-sel-toggle.on { color:var(--text3); }
.gf-selbar { display:flex; align-items:center; gap:6px; padding:8px 10px; margin-bottom:10px; background:var(--surface2); border:1px solid var(--border); border-radius:8px; font-size:12px; color:var(--text2); flex-wrap:wrap; }
.gf-selbar .gf-selcount { flex:1; min-width:60px; }
.gf-selbar button { background:transparent; border:none; color:var(--accent); font-size:12px; cursor:pointer; padding:5px 10px; border-radius:6px; font-weight:500; }
.gf-selbar button:hover { background:var(--surface3); }
.gf-selbar button.gf-sel-danger { color:var(--danger); }
.gf-check { width:18px; height:18px; border-radius:5px; border:1.5px solid var(--border2); flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; box-sizing:border-box; cursor:pointer; transition:background .12s,border-color .12s; }
.gf-check svg { width:12px; height:12px; fill:#fff; display:none; }
.gf-check.on { background:var(--accent); border-color:var(--accent); }
.gf-check.on svg { display:block; }
.gf-file.selectable { cursor:pointer; }
.gf-file.selected { background:var(--accent-strong); }
.gf-photo.selectable { cursor:pointer; }
.gf-photo.selected { outline:2px solid var(--accent); outline-offset:-2px; }
.gf-photo .gf-check { position:absolute; left:5px; top:5px; z-index:2; box-shadow:0 1px 3px rgba(0,0,0,.28); }
.gf-file-op .gf-check { margin-right:2px; }
/* 大屏放宽群文件弹窗。必须放在 .gf-box/.gf-body/.gf-album 基础规则之后——
   媒体查询本身不提升优先级，写在前面会被后面的同权重规则覆盖 */
@media (min-width: 1200px) {
    .gf-box { max-width:860px; }
    .gf-body { max-height:64vh; }
    .gf-album { grid-template-columns:repeat(6,1fr); }
}
.gf-preview { display:none; position:fixed; inset:0; background:rgba(0,0,0,.85); z-index:120; align-items:center; justify-content:center; }
.gf-preview.active { display:flex; }
.gf-preview img { max-width:90vw; max-height:90vh; border-radius:8px; }
.gf-preview-close { position:fixed; top:16px; right:16px; color:#fff; background:none; border:none; cursor:pointer; }
.fwd-item { display:flex; align-items:center; gap:10px; padding:10px 8px; border-radius:8px; cursor:pointer; transition:background .15s; color:var(--text); font-size:13px; }
.fwd-item:hover { background:var(--surface2); }
.fwd-item .fwd-ico { width:34px; height:34px; border-radius:8px; background:var(--surface2); color:var(--text3); display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.fwd-item .fwd-ico svg { width:18px; height:18px; }
/* 头像框 */
.avf-gold { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px #f5c518; }
.avf-blue { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px #12b7f5; }
.avf-green { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px #07c160; }
.avf-red { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px #e74c3c; }
.avf-purple { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px #8e44ad; }
.avf-rainbow { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px #f00, 0 0 0 6px #ff8000, 0 0 0 8px #ff0, 0 0 0 10px #0c0, 0 0 0 12px #00f, 0 0 0 14px #90f; }
/* 资料卡 */
.profile-card { width:320px; border-radius:18px; overflow:hidden; background:var(--surface); box-shadow:var(--shadow-lg); position:relative; }
.pc-bg { height:110px; width:100%; }
.pc-bg.bg-none, .pc-bg.bg-light { background:linear-gradient(135deg,#eef6fd,#f7fbfe); }
.pc-bg.bg-dark { background:linear-gradient(135deg,#2c3e50,#4ca1af); }
.pc-bg.bg-blue { background:linear-gradient(135deg,#12b7f5,#0ea5e0); }
.pc-bg.bg-grad1 { background:linear-gradient(135deg,#f093fb,#f5576c); }
.pc-bg.bg-grad2 { background:linear-gradient(135deg,#4facfe,#00f2fe); }
.pc-bg.bg-grad3 { background:linear-gradient(135deg,#43e97b,#38f9d7); }
.pc-bg.bg-img { background-size:cover; background-position:center; }
.pc-x { position:absolute; top:10px; right:10px; z-index:5; background:rgba(255,255,255,.6); }
.pc-avatar-wrap { width:88px; height:88px; margin:-44px auto 0; position:relative; border-radius:50%; background:var(--surface); padding:4px; box-sizing:border-box; }
.pc-avatar { width:100%; height:100%; border-radius:50%; background-size:cover; background-position:center; display:flex; align-items:center; justify-content:center; color:#fff; font-size:34px; font-weight:600; }
.pc-name { text-align:center; font-size:17px; font-weight:600; color:var(--text); margin-top:10px; }
.pc-sig { text-align:center; font-size:12px; color:var(--text3); margin-top:4px; padding:0 20px; min-height:16px; word-break:break-all; }
.pc-btns { display:flex; gap:10px; padding:16px 20px 20px; justify-content:center; }
.pc-btns button { padding:8px 22px; border:none; border-radius:20px; font-size:13px; cursor:pointer; }
/* 装扮选择器 */
.deco-row { display:flex; flex-wrap:wrap; gap:8px; margin:6px 0 4px; }
.deco-swatch { width:34px; height:34px; border-radius:50%; cursor:pointer; border:2px solid transparent; position:relative; flex-shrink:0; }
.deco-swatch.sel { border-color:var(--accent); box-shadow:0 0 0 2px var(--surface), 0 0 0 4px var(--accent); }
.deco-swatch .ds-inner { width:100%; height:100%; border-radius:50%; background-size:cover; background-position:center; display:flex; align-items:center; justify-content:center; color:#fff; font-size:14px; }
.deco-swatch.bg-light { background:linear-gradient(135deg,#eef6fd,#f7fbfe); border-color:var(--border2); }
.deco-swatch.bg-dark { background:linear-gradient(135deg,#2c3e50,#4ca1af); }
.deco-swatch.bg-blue { background:linear-gradient(135deg,#12b7f5,#0ea5e0); }
.deco-swatch.bg-grad1 { background:linear-gradient(135deg,#f093fb,#f5576c); }
.deco-swatch.bg-grad2 { background:linear-gradient(135deg,#4facfe,#00f2fe); }
.deco-swatch.bg-grad3 { background:linear-gradient(135deg,#43e97b,#38f9d7); }
.deco-swatch.bg-img { background-size:cover; background-position:center; }
.blk-item { display:flex; align-items:center; gap:10px; padding:8px 6px; border-radius:8px; }
.blk-item:hover { background:var(--surface2); }
.blk-item .blk-name { flex:1; font-size:13px; color:var(--text); }
/* 语音/视频通话 */
#callModal { background:rgba(8,10,18,.72); }
#callModal .call-overlay-inner { width:100%; height:100%; display:flex; align-items:center; justify-content:center; }
.call-screen { text-align:center; color:#fff; width:min(92vw,420px); }
.call-avatar { width:96px; height:96px; border-radius:50%; margin:0 auto 18px; background-size:cover; background-position:center; display:flex; align-items:center; justify-content:center; font-size:38px; font-weight:700; color:#fff; box-shadow:0 8px 30px rgba(0,0,0,.35); animation:callPulse 1.6s ease-in-out infinite; }
@keyframes callPulse { 0%,100%{ box-shadow:0 8px 30px rgba(0,0,0,.35); } 50%{ box-shadow:0 8px 30px rgba(var(--accent-rgb),.55); } }
.call-name { font-size:20px; font-weight:600; }
.call-status { font-size:14px; color:rgba(255,255,255,.82); margin-top:8px; min-height:20px; }
.call-actions { display:flex; flex-direction:column; align-items:center; gap:24px; margin-top:36px; }
/* 照 QQ/微信：功能键一排 + 挂断单独一排居中更大 */
.call-fn-row { display:flex; gap:22px; justify-content:center; align-items:center; flex-wrap:wrap; row-gap:14px; }
.call-end-row { display:flex; justify-content:center; align-items:center; }
.call-end-row .call-btn { width:76px; height:76px; font-size:12px; }
.call-btn { width:66px; height:66px; border-radius:50%; border:none; cursor:pointer; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px; font-size:11px; color:#fff; transition:transform .15s, box-shadow .15s; }
.call-btn:hover { transform:scale(1.07); }
.call-btn svg { width:24px; height:24px; fill:#fff; }
.call-btn.accept { background:linear-gradient(135deg,#07c160,#43e97b); }
.call-btn.reject { background:linear-gradient(135deg,#e74c3c,#f5576c); }
.call-btn.hangup { background:linear-gradient(135deg,#e74c3c,#f5576c); }
/* 视频通话界面 */
.call-screen.video { width:100%; height:100%; position:relative; overflow:hidden; background:#000; }
/* 占位层（头像）只是"还没收到画面帧"时的底衬，必须待在视频下面。
   注意它是绝对定位 + 不透明渐变背景：按 CSS 绘制顺序，绝对定位元素会盖在
   静态流内的 <video> 之上。原来没有指定层级，结果就是接通后对方的画面
   被头像整个遮住 —— 看起来像"对方没有画面"。这里把层级显式排清楚：
   底衬(0) < 视频(1) < 状态浮层(2) < 拖动把手(3)。 */
.call-screen.video .call-video-placeholder { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; background:linear-gradient(160deg,#1a2332,#0d1117); z-index:0; pointer-events:none; }
/* "接通了但对方画面还没到"的说明文字：光是一个头像会让人以为通话坏了 */
.call-wait-hint { margin-top:14px; font-size:13px; color:rgba(255,255,255,.62); }
.call-screen.video .call-video-placeholder .call-avatar { animation:none; }
/* 视频通话的两块画面：默认"对方铺满 + 自己在小窗"，点"切换画面"（或双击小窗）把两者对调。
   只切类名、不移动 video 节点 —— 移动 DOM 会让播放流重建、画面闪黑。 */
.call-pane { position:absolute; overflow:hidden; background:#000; }
/* 视频必须显式抬到占位层之上（相对定位 + z-index=1），
   否则会被头像底衬整个盖住，用户永远看不到对方的画面 */
.call-pane video { position:relative; z-index:1; width:100%; height:100%; display:block; }
.call-pane.full { inset:0; z-index:1; }
.call-pane.full video { object-fit:contain; }
/* 小窗：尺寸与大画面明显区分，可拖动 */
.call-pane.pip { inset:auto; right:14px; top:60px; width:min(30vw,150px); aspect-ratio:3/4; border-radius:12px; border:2px solid rgba(255,255,255,.38); z-index:2; box-shadow:0 6px 22px rgba(0,0,0,.45); cursor:grab; touch-action:none; transition:border-color .16s, transform .16s, width .18s; }
/* 双击小窗放大一档：想看清自己（或对方）时不用去够底部按钮 */
.call-pane.pip.big { width:min(46vw,260px); }
.call-pane.pip:hover { border-color:rgba(255,255,255,.66); }
.call-pane.pip.dragging { cursor:grabbing; border-color:var(--accent); transform:scale(1.02); }
/* 拖动提示的小把手：平时不显示，悬停才出现，不干扰画面 */
.call-pane.pip::after { content:''; position:absolute; left:6px; top:6px; width:18px; height:18px; border-radius:6px; opacity:0; transition:opacity .16s; pointer-events:none; z-index:3;
    background:rgba(0,0,0,.45) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23fff'%3E%3Ccircle cx='9' cy='6' r='1.7'/%3E%3Ccircle cx='15' cy='6' r='1.7'/%3E%3Ccircle cx='9' cy='12' r='1.7'/%3E%3Ccircle cx='15' cy='12' r='1.7'/%3E%3Ccircle cx='9' cy='18' r='1.7'/%3E%3Ccircle cx='15' cy='18' r='1.7'/%3E%3C/svg%3E") no-repeat center / 12px 12px; }
.call-pane.pip:hover::after, .call-pane.pip.dragging::after { opacity:1; }
.call-pane.pip video { object-fit:cover; pointer-events:none; }
/* 小窗很窄：提示文字和头像都要收小，否则挤成一团 */
.call-pane.pip .call-local-off span, .call-pane.pip .call-remote-off span { display:none; }
.call-pane.pip .call-local-off svg, .call-pane.pip .call-remote-off svg { width:22px; height:22px; }
.call-pane.pip .call-avatar { width:44px; height:44px; font-size:17px; margin:0; }
/* 两个"没有画面"的状态浮层要压在视频之上（z-index=2），否则会反过来被视频盖住 */
.call-local-off { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:6px; color:rgba(255,255,255,.7); font-size:11px; background:linear-gradient(160deg,#1a2332,#0d1117); z-index:2; }
.call-local-off svg { width:22px; height:22px; }
.call-remote-off { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; color:rgba(255,255,255,.75); font-size:13px; background:linear-gradient(160deg,#1a2332,#0d1117); z-index:2; }
.call-remote-off svg { width:34px; height:34px; }
/* 顶部标题条只是渐变背景（内部无按钮），让它不吃指针事件，
   否则本地画面小窗停在右上角时，上半部分会被它挡住、按不下去拖动。
   底部操作条仍保留事件：拖到最底部时抓不到小窗，等于给挂断键留了保护区。 */
.call-screen.video .call-video-top { position:absolute; left:0; right:0; top:0; padding:16px; text-align:center; z-index:3; pointer-events:none; background:linear-gradient(180deg,rgba(0,0,0,.45),transparent); }
.call-screen.video .call-video-top .call-name { text-shadow:0 1px 4px rgba(0,0,0,.5); }
.call-screen.video .call-actions { position:absolute; left:0; right:0; bottom:26px; z-index:3; margin-top:0; gap:16px; padding:0 12px; box-sizing:border-box; }
.call-screen.video .call-fn-row { gap:14px; row-gap:10px; }
/* 视频通话按钮更多（摄像头/翻转/切换画面/静音/挂断），窄屏上收紧尺寸和间距，避免挤出屏幕 */
.call-screen.video .call-btn { width:58px; height:58px; font-size:10px; background:rgba(255,255,255,.16); backdrop-filter:blur(6px); }
.call-screen.video .call-btn svg { width:22px; height:22px; }
.call-screen.video .call-btn.hangup { background:linear-gradient(135deg,#e74c3c,#f5576c); }
/* 挂断单独一排，比功能键更大更醒目，且悬停时有红色光晕提示危险操作 */
.call-screen.video .call-end-row .call-btn { width:66px; height:66px; font-size:11px; box-shadow:0 6px 18px rgba(231,76,60,.45); }
.call-screen.video .call-end-row .call-btn:hover { box-shadow:0 8px 24px rgba(231,76,60,.65); }
/* 最小化按钮：全屏通话界面右上角，点一下收起成横幅/悬浮窗 */
.call-screen { position:relative; }
.call-min-btn { position:absolute; top:10px; right:10px; z-index:6; width:34px; height:34px; border-radius:50%;
    border:1px solid rgba(255,255,255,.28); background:rgba(255,255,255,.14); color:#fff; cursor:pointer;
    display:flex; align-items:center; justify-content:center; backdrop-filter:blur(6px);
    transition:background .15s, transform .15s; }
.call-min-btn:hover { background:rgba(255,255,255,.26); transform:scale(1.06); }
.call-min-btn svg { width:18px; height:18px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
.call-screen.video .call-min-btn { top:12px; right:12px; }

/* ---- 来电横幅：不在与来电方聊天的界面时从顶栏滑下 ---- */
.call-banner { position:fixed; left:50%; top:0; transform:translate(-50%,-130%);
    width:min(94vw,420px); margin-top:10px; z-index:290;
    display:flex; align-items:center; gap:10px; padding:10px 12px;
    background:var(--surface); border:1px solid var(--border); border-radius:16px;
    box-shadow:0 14px 40px rgba(0,0,0,.28);
    transition:transform .26s cubic-bezier(.2,.8,.3,1); }
.call-banner.show { transform:translate(-50%,0); }
.cb-avatar { width:40px; height:40px; border-radius:50%; flex-shrink:0; background-size:cover; background-position:center;
    display:flex; align-items:center; justify-content:center; color:#fff; font-weight:700; font-size:16px; }
.cb-info { flex:1; min-width:0; }
.cb-name { font-size:14px; font-weight:600; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.cb-sub { font-size:11.5px; color:var(--text3); margin-top:2px; white-space:nowrap; }
.cb-actions { display:flex; gap:8px; flex-shrink:0; }
.cb-btn { width:38px; height:38px; border-radius:50%; border:none; cursor:pointer;
    display:flex; align-items:center; justify-content:center; transition:transform .14s, filter .14s; }
.cb-btn svg { width:19px; height:19px; fill:#fff; }
.cb-btn:hover { transform:scale(1.08); }
.cb-btn.reject { background:linear-gradient(135deg,#e74c3c,#f5576c); }
.cb-btn.accept { background:linear-gradient(135deg,#07c160,#43e97b); }

/* ---- 通话悬浮窗：音频为圆圈（图标+计时），视频为圆角矩形（直接显示对方画面） ---- */
.call-ball { position:fixed; right:16px; bottom:98px; z-index:290; display:none; cursor:pointer;
    width:62px; height:62px; border-radius:50%; overflow:hidden;
    flex-direction:column; align-items:center; justify-content:center; gap:1px;
    background:linear-gradient(135deg,#2f3a4d,#1b2230); color:#fff;
    box-shadow:0 10px 28px rgba(0,0,0,.34);
    transition:width .2s, height .2s, border-radius .2s, transform .14s; }
.call-ball:hover { transform:scale(1.05); }
.call-ball.show { display:flex; }
.call-ball.video { width:98px; height:134px; border-radius:14px; }
/* 悬浮小窗的尺寸档位：点右上角按钮在小/大之间切换（看对方画面时大一点更实用） */
.call-ball.big { width:132px; height:132px; }
.call-ball.video.big { width:184px; height:246px; }
.call-ball-size { position:absolute; right:4px; top:4px; z-index:4; width:22px; height:22px; padding:0;
    border-radius:50%; border:none; background:rgba(0,0,0,.45); color:#fff; cursor:pointer;
    display:flex; align-items:center; justify-content:center; }
.call-ball-size:hover { background:rgba(0,0,0,.68); }
.call-ball-size svg { width:13px; height:13px; fill:currentColor; }
.call-ball video { display:none; position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
.call-ball.video video { display:block; }
.call-ball-ico { width:19px; height:19px; flex-shrink:0; }
.call-ball.video .call-ball-ico { display:none; }
.call-ball-time { font-size:9.5px; font-weight:600; line-height:1.4; letter-spacing:0;
    font-variant-numeric:tabular-nums; white-space:nowrap; }
.call-ball.video .call-ball-time { position:absolute; left:50%; bottom:5px; transform:translateX(-50%);
    padding:1px 6px; border-radius:8px; background:rgba(0,0,0,.55); font-size:10px; }
.call-ball.ringing { animation:ballRing 1.3s ease-in-out infinite; }
@keyframes ballRing { 0%,100%{ box-shadow:0 10px 28px rgba(0,0,0,.34); } 50%{ box-shadow:0 10px 30px rgba(7,193,96,.8); } }
/* 通话开关按钮（摄像头/麦克风） */
.call-btn.toggle { background:rgba(255,255,255,.18); }
.call-btn.toggle.off { background:linear-gradient(135deg,#e74c3c,#f5576c); }
/* 通话确认弹窗 */
.confirm-call-avatar { width:64px; height:64px; border-radius:50%; margin:0 auto 12px; background-size:cover; background-position:center; display:flex; align-items:center; justify-content:center; font-size:28px; font-weight:700; color:#fff; box-shadow:0 6px 18px rgba(0,0,0,.2); }
.confirm-call-name { font-size:16px; font-weight:600; color:var(--text); margin-bottom:4px; word-break:break-all; }
.confirm-call-hint { font-size:12px; color:var(--text3); margin-bottom:20px; }
/* 通话记录气泡（type=call）— 模仿 QQ 通话记录样式 */
.msg-row.call { align-self:center; width:100%; display:flex; justify-content:center; margin:2px 0; }
.msg-row.call .sys-notice { display:inline-flex; align-items:center; gap:7px; background:var(--surface2); border:1px solid var(--border); border-radius:16px; padding:5px 14px 5px 6px; color:var(--text2); font-size:11px; animation:sysMsgIn .25s ease; box-shadow:0 1px 3px rgba(0,0,0,.05); }
.msg-row.call .sys-notice .call-ico { display:flex; align-items:center; justify-content:center; width:22px; height:22px; border-radius:50%; background:linear-gradient(135deg,#34d399,#10b981); color:#fff; flex-shrink:0; }
.msg-row.call .sys-notice .call-ico svg { width:12px; height:12px; }
.msg-row.call .sys-notice .call-txt { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:72vw; }
/* 群聊选人通话 */
.cp-item { display:flex; align-items:center; gap:12px; padding:9px 8px; border-radius:10px; cursor:pointer; transition:background .15s; }
.cp-item:hover { background:var(--surface2); }
.cp-avatar { width:38px; height:38px; border-radius:50%; background-size:cover; background-position:center; display:flex; align-items:center; justify-content:center; color:#fff; font-size:15px; font-weight:600; flex-shrink:0; }
.cp-name { flex:1; font-size:14px; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.cp-icon { color:var(--accent); display:flex; align-items:center; flex-shrink:0; }

.modal-overlay { display:none; position:fixed; top:0;left:0;right:0;bottom:0; background:rgba(0,0,0,0.4); z-index:100; justify-content:center; align-items:center; }
        .modal-overlay.active { display:flex; }
        #confirmModal { z-index:200 !important; }
        #groupManageModal { z-index:100; }
        .modal-box { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:24px; min-width:300px; max-width:560px; width:92%; max-height:84vh; overflow-y:auto; box-shadow:var(--shadow-lg); }
        /* 大屏下弹窗跟着放大：以前固定 440px，在宽屏上又矮又窄，列表类弹窗要滚很久 */
        @media (min-width: 1200px) {
            .modal-box { max-width: 640px; max-height: 86vh; }
            .file-download-modal .dl-box { max-width: 620px; }
        }
        .modal-box h3 { font-size:16px; margin-bottom:16px; color:var(--text); font-weight:600; }
        .modal-x { width:28px; height:28px; border-radius:50%; border:none; background:transparent; color:var(--text3); cursor:pointer; flex-shrink:0; display:flex; align-items:center; justify-content:center; transition:background .15s,color .15s; }
        .modal-x:hover { background:var(--surface3); color:var(--danger); }
        .modal-box input { width:100%; padding:10px 14px; margin:6px 0; border:1px solid var(--border); border-radius:8px; font-size:14px; outline:none; background:var(--surface2); color:var(--text); transition:border .2s; }
        .modal-box input:focus { border-color:var(--accent); }
        .modal-box .btns { display:flex; gap:10px; justify-content:flex-end; margin-top:16px; }
        .modal-box button { padding:9px 22px; border:none; border-radius:6px; font-size:14px; cursor:pointer; font-weight:500; transition:all .15s; }
        .btn-cancel { background:var(--surface2); color:var(--text2); }
        .btn-cancel:hover { background:var(--surface3); }
        .btn-primary { background:var(--accent); color:#fff; }
        .btn-primary:hover { background:var(--accent2); }
        .btn-danger { background:var(--danger); color:#fff; }
        .btn-danger:hover { background:var(--danger2); }
        .btn { display:inline-flex; align-items:center; justify-content:center; }
        .btn-sm { padding:6px 12px !important; font-size:12px !important; border-radius:5px !important; }
        .btn-plain { background:var(--surface2); color:var(--text); border:1px solid var(--border) !important; }
        .btn-plain:hover { background:var(--surface3); color:var(--accent); border-color:var(--accent) !important; }
        .user-check-list { max-height:200px; overflow-y:auto; margin:8px 0; }
        .user-check-item { display:flex; align-items:center; gap:8px; padding:8px 10px; border-radius:var(--radius-sm); cursor:pointer; color:var(--text); font-size:14px; transition:background .15s; }
        .user-check-item:hover { background:var(--surface2); }
        .user-check-item input { width:auto; margin:0; accent-color:var(--accent); }
        .default-avatars { display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin:10px 0; }
        .default-avatar { width:44px; height:44px; border-radius:50%; cursor:pointer; border:2px solid transparent; transition:all .2s; }
        .default-avatar:hover { border-color:var(--accent); transform:scale(1.1); }
        .friend-req-item { display:flex; align-items:center; gap:10px; padding:10px 12px; border-radius:var(--radius); margin:4px 0; background:var(--surface2); }
        .friend-req-item .req-info { flex:1; }
        .friend-req-item .req-name { font-size:14px; font-weight:500; color:var(--text); }
        .friend-req-item .req-time { font-size:11px; color:var(--text3); }
        .friend-req-item .req-actions { display:flex; gap:6px; }
        .friend-req-item .req-actions button { padding:6px 14px; border:none; border-radius:6px; font-size:13px; cursor:pointer; font-weight:500; }
        .search-result-item { display:flex; align-items:center; gap:10px; padding:10px 12px; border-radius:var(--radius); margin:2px 0; transition:background .15s; }
        .search-result-item:hover { background:var(--surface2); }
        .search-result-item .add-btn { padding:5px 14px; background:var(--surface3); color:var(--text2); border:1px solid var(--border); border-radius:6px; font-size:12px; cursor:pointer; white-space:nowrap; display:flex; align-items:center; gap:4px; transition:all .15s; }
        .search-result-item .add-btn:hover { background:var(--surface2); color:var(--accent); border-color:var(--accent); }
        .search-result-item .add-btn svg { fill:currentColor; flex-shrink:0; }
        .search-result-item .added-label { font-size:12px; color:var(--text3); white-space:nowrap; }

        .friend-context-menu { display:none; position:fixed; background:var(--surface); border:1px solid var(--border2); border-radius:var(--radius); box-shadow:var(--shadow-lg); z-index:200; padding:4px; min-width:130px; }
        .friend-context-menu.active { display:block; }
        .friend-context-menu div { padding:8px 14px; font-size:13px; cursor:pointer; border-radius:6px; white-space:nowrap; color:var(--text); transition:background .12s; }
        .friend-context-menu div:hover { background:var(--surface3); }
        .friend-context-menu .menu-danger { color:var(--danger); }
        .friend-context-menu .menu-sep { height:1px; background:var(--border); margin:4px 8px; padding:0 !important; pointer-events:none; }

        .bubble-popup { display:none; position:fixed; background:var(--surface); border:1px solid var(--border2); border-radius:var(--radius); box-shadow:var(--shadow-lg); z-index:200; padding:4px; min-width:120px; }
        .bubble-popup.active { display:block; }
        .bubble-popup div { padding:8px 14px; font-size:13px; cursor:pointer; border-radius:6px; white-space:nowrap; color:var(--text); transition:background .12s; }
        .bubble-popup div:hover { background:var(--surface3); }
        .bubble-popup .menu-danger { color:var(--danger); }
        .bubble-popup .menu-sep { height:1px; background:var(--border); margin:4px 8px; padding:0 !important; pointer-events:none; }
        .bubble-popup .status-option.active { background:var(--accent-glow); color:var(--accent); font-weight:500; }

        .group-member-item { display:flex; align-items:center; gap:10px; padding:8px 10px; border-radius:var(--radius-sm); margin:2px 0; color:var(--text); font-size:14px; transition:background .15s; }
        .group-member-item:hover { background:var(--surface2); }
        .group-member-item .member-avatar { width:30px; height:30px; border-radius:50%; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-weight:700; color:#fff; font-size:13px; background-size:cover; background-position:center; }
        .group-member-item .member-name { flex:1; }
        .group-member-item .member-badge { font-size:10px; background:var(--accent); color:#fff; padding:1px 6px; border-radius:4px; font-weight:600; }
        .group-member-item .member-kick { font-size:11px; color:var(--danger); cursor:pointer; padding:2px 8px; border-radius:4px; background:transparent; border:none; transition:all .15s; }
        .group-member-item .member-kick:hover { background:rgba(224,85,106,0.15); }
        .group-member-item .member-act { font-size:11px; color:var(--text2); cursor:pointer; padding:2px 7px; border-radius:4px; background:transparent; border:none; transition:all .15s; white-space:nowrap; }
        .group-member-item .member-act:hover { background:var(--surface3); }
        .group-member-item .member-title { font-size:10px; color:#f5a623; border:1px solid rgba(245,166,35,0.5); padding:0 6px; border-radius:4px; font-weight:500; white-space:nowrap; }

        @media (max-width:768px) {
            * { -webkit-tap-highlight-color: transparent !important; -webkit-touch-callout: none; }
            button, a, div, span, input, textarea, select { -webkit-tap-highlight-color: transparent !important; }
            button:active, a:active, .nav-item:active, .friend-more-btn:active, .send-btn:active, .tool-btn:active, .clear-chat-btn:active, .group-menu-btn:active, .hamburger-btn:active, .create-group-btn:active, .friend-context-menu div:active, .bubble-popup div:active, .status-text:active, .search-result-item:active, .user-check-item:active, .group-member-item:active { background-color: transparent !important; }
            .sidebar { position:fixed; left:-100%; top:0; bottom:0; z-index:50; width:85vw; max-width:320px; }
            .sidebar.open { left:0; box-shadow:8px 0 40px rgba(0,0,0,0.6); }
            .mobile-overlay { display:none; position:fixed; top:0;left:0;right:0;bottom:0; background:rgba(0,0,0,0.5); z-index:49; }
            .mobile-overlay.active { display:block; }
            .hamburger-btn { display:flex !important; }
            .chat-header { padding:10px 14px; }
            .chat-header .room-title { font-size:15px; }
            .chat-header .room-subtitle { font-size:10px; }
            .messages { padding:14px 12px; gap:10px; }
            .msg-row { max-width:88%; }
            .msg-row.self { max-width:88%; }
            .msg-row.system { max-width:92%; }
            .msg-avatar { width:30px; height:30px; font-size:12px; }
            .bubble { padding:9px 12px; font-size:14px; }
            .bubble img { max-width:180px; max-height:180px; }
            .msg-row:not(.self) .bubble { border-top-left-radius:2px; }
            .self .bubble { border-top-right-radius:2px; }
            .msg-name { font-size:10px; }
            .msg-time { font-size:10px; }
            .msg-footer button { font-size:12px; padding:6px 10px; min-height:34px; }
            .input-area { padding:8px 10px; gap:8px; }
            .input-area input { padding:10px 14px; font-size:16px; min-width:80px; }
            .input-area .tool-btn { width:42px; height:42px; }
            .input-area .tool-btn svg { width:22px; height:22px; }
            .send-btn { width:44px; height:44px; }
            .send-btn svg { width:20px; height:20px; }
            .emoji-panel { bottom:56px; left:8px; right:8px; grid-template-columns:repeat(8,1fr); }
            .modal-box { width:92%; padding:20px; }
            .back-to-bottom { bottom:70px; right:14px; }
        }
        @media (max-width:360px) {
            /* 超小屏：进一步压缩工具栏，保证输入框宽度 */
            .input-area { padding:8px; gap:6px; }
            .input-area .tool-btn { width:38px; height:38px; }
            .input-area .tool-btn svg { width:20px; height:20px; }
            .input-area input { padding:10px 12px; font-size:16px; min-width:60px; }
            .send-btn { width:40px; height:40px; }
            .send-btn svg { width:18px; height:18px; }
            .char-counter { font-size:10px; }
        }
        @media (min-width:769px) { .hamburger-btn { display:none; } }

        [data-theme="dark"] {
            --bg: #0d0d0d;
            --surface: #1a1a1a;
            --surface2: #222;
            --surface3: #2a2a2a;
            --surface4: #333;
            --text: #e0e0e0;
            --text2: #aaa;
            --text3: #777;
            --accent: #4fc3f7;
            --accent2: #29b6f6;
            --accent-glow: rgba(79,195,247,0.1);
            --accent-strong: rgba(79,195,247,0.15);
            --border: #2a2a2a;
            --border2: #333;
            --shadow-sm: 0 1px 3px rgba(0,0,0,0.5);
            --shadow: 0 2px 8px rgba(0,0,0,0.6);
            --shadow-lg: 0 8px 24px rgba(0,0,0,0.7);
        }
        [data-theme="dark"] .msg-row:not(.self) .bubble { background:var(--surface2); border-color:var(--border); box-shadow:none; border-top-left-radius:2px; }
        [data-theme="dark"] .msg-row:not(.self) .bubble:hover { background:var(--surface3); }
        [data-theme="dark"] .self .bubble { box-shadow:0 1px 2px rgba(79,195,247,0.3); border-top-right-radius:2px; }
        [data-theme="dark"] .messages { background:var(--bg); }
        [data-theme="dark"] .modal-overlay { background:rgba(0,0,0,0.7); }
        [data-theme="dark"] .emoji-panel span:hover { background:var(--surface3); }
        [data-theme="dark"] .msg-row.self .bubble.recalled { background:transparent; }
        [data-theme="dark"] .msg-row:not(.self) .bubble.recalled { background:transparent; }
        [data-theme="dark"] .highlight-mention { background:rgba(255,200,0,0.25); color:#ffd54f; }

        /* ---- 聊天背景（每聊天单独设置） ---- */
        .messages.msg-bg-light { background:linear-gradient(135deg,#eef6fd,#f7fbfe); }
        .messages.msg-bg-blue { background:linear-gradient(135deg,#12b7f5,#0ea5e0); }
        .messages.msg-bg-green { background:linear-gradient(135deg,#0fb56a,#2ee6a8); }
        .messages.msg-bg-dark { background:linear-gradient(135deg,#2c3e50,#4ca1af); }
        .messages.msg-bg-grad1 { background:linear-gradient(135deg,#f093fb,#f5576c); }
        .messages.msg-bg-grad2 { background:linear-gradient(135deg,#4facfe,#00f2fe); }
        .messages.msg-bg-grad3 { background:linear-gradient(135deg,#43e97b,#38f9d7); }
        .messages.msg-bg-img { background-size:cover; background-position:center; background-repeat:no-repeat; }
        .cb-swatch { width:52px; height:52px; border-radius:12px; cursor:pointer; border:2px solid var(--border2); position:relative; flex-shrink:0; background-size:cover; background-position:center; display:flex; align-items:center; justify-content:center; overflow:hidden; }
        .cb-swatch:hover { border-color:var(--accent); }
        .cb-swatch.sel { border-color:var(--accent); box-shadow:0 0 0 2px var(--surface), 0 0 0 4px var(--accent); }
        .cb-swatch.none { background:var(--surface3); }
        .cb-swatch.light { background:linear-gradient(135deg,#eef6fd,#f7fbfe); border-color:var(--border2); }
        .cb-swatch.blue { background:linear-gradient(135deg,#12b7f5,#0ea5e0); }
        .cb-swatch.green { background:linear-gradient(135deg,#0fb56a,#2ee6a8); }
        .cb-swatch.dark { background:linear-gradient(135deg,#2c3e50,#4ca1af); }
        .cb-swatch.grad1 { background:linear-gradient(135deg,#f093fb,#f5576c); }
        .cb-swatch.grad2 { background:linear-gradient(135deg,#4facfe,#00f2fe); }
        .cb-swatch.grad3 { background:linear-gradient(135deg,#43e97b,#38f9d7); }
        .cb-swatch .cb-label { font-size:11px; color:var(--text3); }

        .announcement-bar { display:none; padding:10px 16px; background:linear-gradient(135deg, #fff8e1, #fff3cd); border-bottom:1px solid #ffe082; font-size:13px; color:#8d6e00; align-items:center; gap:10px; }
        .announcement-bar.active { display:flex; }
        .announcement-bar .ann-icon { flex-shrink:0; display:flex; align-items:center; }
        .announcement-bar .ann-content { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
        .announcement-bar .ann-meta { font-size:11px; color:#bcaa4a; flex-shrink:0; }
        .announcement-bar .ann-close { cursor:pointer; color:#bcaa4a; background:none; border:none; line-height:1; padding:2px 4px; flex-shrink:0; display:flex; align-items:center; }
        .announcement-bar .ann-close:hover { color:#8d6e00; }
        .announcement-bar .ann-close svg { fill:currentColor; }
        [data-theme="dark"] .announcement-bar { background:linear-gradient(135deg, #3d3520, #4a4020); border-color:#5a5020; color:#e0c060; }
        [data-theme="dark"] .announcement-bar .ann-close { color:#c0a040; }
        [data-theme="dark"] .announcement-bar .ann-close:hover { color:#e0c060; }
        [data-theme="dark"] .announcement-bar .ann-meta { color:#a09040; }
        /* ---- 群置顶消息 / 群公告 顶部条 ---- */
        .room-pin-bar, .room-ann-bar { display:none; padding:7px 14px; background:var(--surface2); border-bottom:1px solid var(--border); font-size:12.5px; color:var(--text); align-items:center; gap:8px; }
        .room-pin-bar.active, .room-ann-bar.active { display:flex; animation:pinBarIn .18s ease; }
        @keyframes pinBarIn { from { opacity:0; transform:translateY(-4px); } to { opacity:1; transform:none; } }
        .room-pin-bar .pin-tag, .room-ann-bar .ann-tag { flex-shrink:0; font-size:11px; line-height:16px; padding:0 7px; border-radius:9px; background:var(--accent); color:#fff; }
        .room-ann-bar .ann-tag { background:#e6a23c; }
        .room-pin-bar .pin-content, .room-ann-bar .ann-content { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; cursor:pointer; }
        .room-pin-bar .pin-content .pin-name { color:var(--accent); margin-right:6px; }
        .room-pin-bar .pin-content .pin-text { color:var(--text2); }
        .room-ann-bar .ann-content { color:#8d6e00; }
        [data-theme="dark"] .room-ann-bar .ann-content { color:#e0c060; }
        .room-pin-bar .bar-close, .room-ann-bar .bar-close { cursor:pointer; color:var(--text3); display:flex; align-items:center; background:none; border:none; padding:2px; line-height:1; flex-shrink:0; }
        .room-pin-bar .bar-close:hover, .room-ann-bar .bar-close:hover { color:var(--text); }
        .room-ban-bar { display:none; padding:9px 16px; background:linear-gradient(135deg, #fdecea, #fbe3e1); border-bottom:1px solid #f5b5b0; font-size:13px; color:#c0392b; align-items:center; gap:9px; }
        .room-ban-bar svg { flex-shrink:0; }
        [data-theme="dark"] .room-ban-bar { background:linear-gradient(135deg, #3a2525, #422a28); border-color:#5a3530; color:#e08074; }
        .banned-tag { display:inline-block; font-size:10px; line-height:1; padding:2px 5px; margin-left:6px; border-radius:3px; background:#c0392b; color:#fff; vertical-align:middle; }
        .banned-tag.active { background:#27ae60; }

        .msg-search-bar { display:none; padding:8px 16px; background:var(--surface); border-bottom:1px solid var(--border); align-items:center; gap:8px; position:relative; }
        .msg-search-bar.active { display:flex; }
        .msg-search-bar input { flex:1; padding:7px 10px; border:1px solid var(--border); border-radius:6px; font-size:13px; outline:none; background:var(--surface2); color:var(--text); transition:border .2s; }
        .msg-search-bar input:focus { border-color:var(--accent); }
        .msg-search-bar .search-nav { display:flex; gap:4px; }
        .msg-search-bar .search-nav button { background:var(--surface2); border:1px solid var(--border); border-radius:5px; padding:4px 8px; cursor:pointer; color:var(--text2); transition:all .15s; display:flex; align-items:center; justify-content:center; }
        .msg-search-bar .search-nav button:hover { background:var(--surface3); color:var(--text); }
        .msg-search-bar .search-nav button svg { fill:currentColor; }
        .msg-search-bar .search-count { font-size:12px; color:var(--text3); white-space:nowrap; }
        .msg-search-bar .search-close { cursor:pointer; color:var(--text3); background:none; border:none; line-height:1; display:flex; align-items:center; }
        .msg-search-bar .search-close:hover { color:var(--text); }
        .msg-search-bar .search-close svg { fill:currentColor; }

        /* 搜索结果面板：贴在搜索栏下方，列出命中的消息（发送人 / 时间 / 摘要），点一条直接跳过去。
           只靠"上一条/下一条"盲翻命中项，在几百条结果里等于没有搜索。 */
        .sr-panel { display:none; position:absolute; top:100%; left:0; right:0; z-index:80; max-height:min(52vh, 380px); overflow-y:auto; background:var(--surface); border:1px solid var(--border2); border-top:none; border-radius:0 0 10px 10px; box-shadow:var(--shadow-lg); padding:6px; }
        .sr-panel.active { display:block; }
        .sr-item { padding:8px 10px; border-radius:7px; cursor:pointer; display:flex; gap:9px; align-items:baseline; transition:background .12s; }
        .sr-item:hover, .sr-item.active { background:var(--surface2); }
        .sr-item .sr-user { font-size:12px; font-weight:600; color:var(--text); flex-shrink:0; max-width:110px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
        .sr-item .sr-time { font-size:11px; color:var(--text3); flex-shrink:0; }
        .sr-item .sr-text { font-size:12.5px; color:var(--text2); flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
        .sr-item .sr-text mark { background:rgba(var(--accent-rgb),0.24); color:inherit; border-radius:2px; padding:0 1px; }
        .sr-note { padding:7px 10px; font-size:11.5px; color:var(--text3); }
        .sr-empty { padding:14px; text-align:center; font-size:12.5px; color:var(--text3); }

        /* 多选模式：整行可点，气泡内的可点元素（图片/文件/语音/操作条）在多选时全部让位给行本身 */
        #messagesBox.select-mode .msg-row { cursor:pointer; }
        #messagesBox.select-mode .msg-row .bubble,
        #messagesBox.select-mode .msg-row .bubble *,
        #messagesBox.select-mode .msg-row .msg-footer,
        #messagesBox.select-mode .msg-row .msg-footer * { pointer-events:none; }
        .msg-check { display:none; width:18px; height:18px; border-radius:50%; border:1.5px solid var(--border2); flex-shrink:0; margin-top:5px; position:relative; box-sizing:border-box; transition:background .12s, border-color .12s; }
        #messagesBox.select-mode .msg-row .msg-check { display:block; }
        .msg-row.selected .msg-check { background:var(--accent); border-color:var(--accent); }
        .msg-row.selected .msg-check::after { content:''; position:absolute; left:5px; top:1.5px; width:4px; height:8px; border:2px solid #fff; border-top:none; border-left:none; transform:rotate(45deg); }
        .msg-row.selected .bubble { box-shadow:0 0 0 2px var(--accent); }
        .msg-row.selected .sys-notice { box-shadow:0 0 0 2px var(--accent); }

        /* 多选操作条 */
        .select-bar { display:none; align-items:center; gap:8px; padding:9px 16px; background:var(--surface); border-top:1px solid var(--border); flex-wrap:wrap; }
        .select-bar.active { display:flex; }
        .select-bar .sel-count { font-size:13px; color:var(--text); }
        .select-bar .sel-count b { color:var(--accent); font-size:14px; }
        .select-bar .sel-spacer { flex:1; }
        .select-bar button { background:var(--surface2); border:1px solid var(--border); color:var(--text); font-size:12.5px; padding:6px 12px; border-radius:7px; cursor:pointer; font-family:inherit; transition:border-color .15s, color .15s; }
        .select-bar button:hover { border-color:var(--accent); color:var(--accent); }
        .select-bar button:disabled { opacity:.45; cursor:default; border-color:var(--border); color:var(--text3); }
        .select-bar button.sel-danger:hover { border-color:var(--danger); color:var(--danger); }
        .search-highlight { background:#ffeb3b; padding:0 2px; border-radius:2px; }
        [data-theme="dark"] .search-highlight { background:#ffeb3b; color:#1a1a1a; }

        .mention-dropdown { display:none; position:absolute; bottom:100%; left:58px; right:58px; background:var(--surface); border:1px solid var(--border); border-radius:8px; box-shadow:var(--shadow-lg); z-index:60; max-height:180px; overflow-y:auto; }
        .mention-dropdown.active { display:block; }
        .mention-dropdown .mention-item { padding:8px 12px; cursor:pointer; font-size:13px; color:var(--text); transition:background .12s; display:flex; align-items:center; gap:8px; }
        .mention-dropdown .mention-item:hover, .mention-dropdown .mention-item.selected { background:var(--surface2); }
        .mention-dropdown .mention-avatar { width:24px; height:24px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-weight:700; color:#fff; font-size:11px; }

        .edit-message-modal { display:none; position:fixed; top:0;left:0;right:0;bottom:0; background:rgba(0,0,0,0.4); z-index:110; justify-content:center; align-items:center; }
        .edit-message-modal.active { display:flex; }
        .edit-message-modal .edit-box { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:20px; width:90%; max-width:400px; box-shadow:var(--shadow-lg); }
        .edit-message-modal .edit-box h3 { font-size:15px; margin-bottom:12px; color:var(--text); }
        .edit-message-modal .edit-box textarea { width:100%; padding:10px; border:1px solid var(--border); border-radius:8px; font-size:14px; resize:none; outline:none; background:var(--surface2); color:var(--text); font-family:inherit; min-height:80px; }
        .edit-message-modal .edit-box textarea:focus { border-color:var(--accent); }
        .edit-message-modal .edit-box .btns { display:flex; gap:8px; justify-content:flex-end; margin-top:12px; }
        .edit-message-modal .edit-box .btns button { padding:8px 18px; border:none; border-radius:6px; font-size:13px; cursor:pointer; font-weight:500; }
        .msg-edited-tag { font-size:10px; color:var(--text3); margin-left:4px; font-style:italic; }
        .self .msg-edited-tag { color:rgba(255,255,255,0.6); }
        /* ---- 私聊已读/未读 ---- */
        /* 已读/未读标记：跟在时间戳左边的同一行，不再挤在气泡侧边 */
        .pm-read { font-size:11px; color:var(--text3); white-space:nowrap; flex-shrink:0; letter-spacing:.2px; }
        .pm-read.read { color:var(--accent); }

        @keyframes gnPop { from{ transform:scale(.92); opacity:0 } to{ transform:scale(1); opacity:1 } }

        /* ---- 公告中心下拉面板 ---- */
.notices-pop { position:fixed; left:10px; bottom:68px; width:300px; max-width:88vw; z-index:214;
            background:var(--surface); border:1px solid var(--border); border-radius:14px; box-shadow:0 12px 34px rgba(0,0,0,.16);
            display:none; flex-direction:column; overflow:hidden; }
.notices-pop.active { display:flex; animation:gnPop .16s ease; }
.np-head { display:flex; align-items:center; gap:8px; padding:10px 14px; border-bottom:1px solid var(--border); }
.np-title { flex:1; font-size:14px; font-weight:600; color:var(--text); }
.np-close { cursor:pointer; display:flex; padding:3px; border-radius:6px; color:var(--text3); }
.np-close:hover { background:var(--surface2); color:var(--text); }
.np-body { max-height:46vh; overflow-y:auto; padding:6px; }
.np-item { display:flex; gap:10px; padding:10px 10px; border-radius:10px; cursor:pointer; transition:background .13s; }
.np-item:hover { background:var(--surface2); }
.np-item.unread { background:rgba(250,81,81,.06); }
.np-item + .np-item { border-top:1px solid var(--border); }
.np-dot { width:8px; height:8px; border-radius:50%; background:var(--danger); flex-shrink:0; margin-top:6px; }
.np-dot.new { animation:noticeTagPulse 1.6s infinite; }
.np-dot.read { background:transparent; animation:none; }
.np-c { flex:1; min-width:0; }
.np-t { display:flex; align-items:baseline; gap:6px; }
.np-author { font-size:12.5px; font-weight:600; color:var(--text); }
.np-time { font-size:11px; color:var(--text3); margin-left:auto; flex-shrink:0; }
.np-prev { font-size:12.5px; color:var(--text2); margin-top:3px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.np-empty { text-align:center; color:var(--text3); padding:26px 0; font-size:13px; }

/* ---- 单条公告详情弹窗 ---- */
#noticeDetailModal { display:none; position:fixed; top:0;left:0;right:0;bottom:0; background:rgba(0,0,0,.45);
            z-index:216; justify-content:center; align-items:center; padding:20px; }
#noticeDetailModal.active { display:flex; }
.notice-detail-box { width:520px; max-width:95vw; background:var(--surface); border:1px solid var(--border);
            border-radius:16px; box-shadow:0 18px 60px rgba(0,0,0,.25); overflow:hidden; display:flex; flex-direction:column;
            max-height:82vh; animation:gnPop .2s ease; }
.nd-head { display:flex; align-items:center; gap:10px; padding:15px 20px; border-bottom:1px solid var(--border); }
.nd-badge { padding:3px 12px; border-radius:8px; background:var(--accent); color:#fff; font-size:11px; font-weight:700; letter-spacing:1px; }
.nd-close { margin-left:auto; cursor:pointer; display:flex; padding:4px; border-radius:7px; color:var(--text2); }
.nd-close:hover { background:var(--surface2); color:var(--text); }
.nd-meta { display:flex; align-items:center; gap:8px; padding:12px 20px 0; font-size:12px; color:var(--text3); }
.nd-body { padding:14px 20px 20px; font-size:14px; line-height:1.75; color:var(--text); white-space:pre-wrap; word-break:break-word; flex:1; overflow-y:auto; }
.nd-foot { display:flex; justify-content:flex-end; padding:6px 20px 16px; }
.gn-btn { background:var(--accent); color:#fff; border:none; border-radius:999px; padding:8px 30px; font-size:13px; font-weight:500; cursor:pointer; transition:filter .15s, transform .15s; }
.gn-btn:hover { filter:brightness(1.08); }
.gn-btn:active { transform:scale(.97); }
        /* ---- 批量文件上传 ---- */
        .batch-upload-wrap { }
        .batch-upload-item { display:flex; flex-direction:column; gap:5px; padding:9px 0; border-bottom:1px solid var(--border); }
        .batch-upload-item:last-child { border-bottom:none; }
        .batch-upload-top { display:flex; align-items:center; gap:8px; font-size:13px; color:var(--text); }
        .batch-upload-top .bu-name { flex:1; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:500; }
        .batch-upload-top .bu-size { font-size:11px; color:var(--text3); flex-shrink:0; }
        .batch-upload-top .bu-status { font-size:11px; flex-shrink:0; }
        .bu-status.done { color:#07c160; }
        .bu-status.err { color:var(--danger); }
        .batch-upload-bar { height:6px; background:var(--surface3); border-radius:3px; overflow:hidden; }
        .batch-upload-bar .bufill { height:100%; width:0; background:linear-gradient(90deg,#12b7f5,#7B85F5); border-radius:3px; transition:width .15s; }
        .batch-upload-bar .bufill.err { background:var(--danger); }
        /* ---- 文件上传弹窗：拖拽投放区 ---- */
        .file-drop-zone { margin:10px 0 12px; padding:20px 14px; border:1.5px dashed var(--border2); border-radius:10px; text-align:center; color:var(--text2); cursor:pointer; background:var(--surface2); transition:border-color .15s, background .15s, transform .15s; }
        .file-drop-zone:hover { border-color:var(--accent); background:rgba(var(--accent-rgb),0.06); }
        .file-drop-zone.dragover { border-color:var(--accent); background:rgba(var(--accent-rgb),0.14); transform:scale(1.01); }
        .file-drop-zone svg { color:var(--accent); display:block; margin:0 auto 6px; }
        .file-drop-zone .fdz-title { font-size:14px; font-weight:500; color:var(--text); }
        .file-drop-zone .fdz-sub { font-size:12px; color:var(--text3); margin-top:3px; }

        /* 拖拽发送蒙层：浅色半透明，只做"这里能放"的提示，不抢内容 */
        .drop-hint { display:none; position:fixed; inset:0; z-index:400; background:rgba(0,0,0,0.22); align-items:center; justify-content:center; pointer-events:none; }
        .drop-hint.active { display:flex; }
        .drop-hint .dh-box { padding:26px 36px; border:1.5px dashed var(--accent); border-radius:12px; background:var(--surface); color:var(--text); font-size:14px; text-align:center; box-shadow:var(--shadow-lg); }
        .drop-hint .dh-box svg { display:block; margin:0 auto 9px; color:var(--accent); }
        .drop-hint .dh-sub { font-size:12px; color:var(--text3); margin-top:5px; }
        .batch-upload-sum { font-size:12px; color:var(--text3); margin-top:4px; }
        /* ---- 引用块可点击：跳到被引用的原消息并闪一下 ---- */
        .quote-block { cursor:pointer; transition:background .15s; }
        .quote-block:hover { background:rgba(var(--accent-rgb),0.12); }
        .self .quote-block:hover { background:rgba(255,255,255,0.26); }
        @keyframes msgJumpFlash { 0%,100% { background:transparent; } 22% { background:rgba(var(--accent-rgb),0.18); } }
        .msg-row.msg-jump-flash { animation:msgJumpFlash 1.5s ease; border-radius:8px; }
        /* ---- 发送失败：气泡描红 + 时间位置变成"点击重发" ---- */
        .msg-row.send-failed .bubble { box-shadow:0 0 0 1.5px var(--danger) !important; }
        .msg-row.send-failed .msg-time { color:var(--danger); cursor:pointer; }
        .msg-row.send-failed .msg-time:hover { text-decoration:underline; }
        .msg-row.send-failed .msg-time svg { width:11px; height:11px; fill:currentColor; flex:0 0 auto; }
        .msg-row.sending-again .bubble { opacity:.55; }
        /* ---- 拖文件到页面任意位置：全屏投放提示 ---- */
        .chat-drop-mask { position:fixed; inset:0; z-index:9000; display:none; align-items:center; justify-content:center; background:rgba(var(--accent-rgb),0.12); }
        .chat-drop-mask.show { display:flex; }
        .chat-drop-mask .cdm-box { display:flex; flex-direction:column; align-items:center; gap:10px; padding:26px 42px; border:2px dashed var(--accent); border-radius:14px; background:var(--surface); color:var(--accent); font-size:14px; font-weight:500; box-shadow:0 10px 30px rgba(0,0,0,0.14); }
        .chat-drop-mask .cdm-box svg { width:32px; height:32px; fill:none; stroke:currentColor; stroke-width:1.6; stroke-linecap:round; stroke-linejoin:round; }
        /* ---- "以下是新消息"上的跳转入口 ---- */
        .unread-divider .unread-jump { margin-left:8px; font-size:11px; color:var(--accent); background:none; border:none; cursor:pointer; padding:0 2px; font-family:inherit; }
        .unread-divider .unread-jump:hover { text-decoration:underline; }
    </style>
    <style id="themeVars"><?php echo buildThemeCss(); ?></style>
    <?php echo i18nScriptBlock(); ?>
</head>
<body>
<div class="mobile-overlay" id="mobileOverlay" onclick="closeSidebar()"></div>

<div class="sidebar" id="sidebar" data-tab="group">
    <div class="sidebar-search">
        <input type="text" id="searchInput" placeholder="搜索用户 / 群号..." oninput="searchUsers()">
        <div id="searchResults" style="display:none;margin-top:4px;max-height:300px;overflow-y:auto;"></div>
    </div>
    <div class="sidebar-nav" id="sidebarNav">
        <!-- 三个分区：群聊 / 好友 / 公共聊天。点上面切换，下面只显示当前分类 -->
        <div class="sidebar-tabs">
            <button type="button" class="sidebar-tab active" data-t="group" onclick="switchSidebarTab('group')">群聊<span class="tab-badge" id="tabBadgeGroup"></span></button>
            <button type="button" class="sidebar-tab" data-t="friend" onclick="switchSidebarTab('friend')">好友<span class="tab-badge" id="tabBadgeFriend"></span></button>
            <button type="button" class="sidebar-tab" data-t="channel" onclick="switchSidebarTab('channel')">公共聊天<span class="tab-badge" id="tabBadgeChannel"></span></button>
        </div>
        <div class="nav-section" data-sec="group">
            <div class="nav-section-title">群聊 <span class="nav-count" id="groupCount">0</span></div>
            <button class="create-group-btn" onclick="openCreateGroup()">+ 创建群聊</button>
            <input type="text" class="section-search" id="groupSearchInput" placeholder="搜索群聊 / 群号..." oninput="filterGroupList()">
            <div id="groupList" class="nav-scroll"></div>
        </div>
        <div class="nav-section" data-sec="friend">
            <div class="nav-section-title">好友 <span class="nav-count" id="friendCount">0</span></div>
            <input type="text" class="section-search" id="friendSearchInput" placeholder="搜索好友..." oninput="filterFriendList()">
            <div id="friendReqList"></div>
            <div id="friendList" class="nav-scroll"></div>
        </div>
        <div class="nav-section" data-sec="channel">
            <div class="nav-section-title">公共聊天</div>
            <div id="channelList"></div>
        </div>
    </div>
    <script>
    /* 侧栏分区切换：只切换显示，不重新渲染列表（渲染逻辑保持原样） */
    function switchSidebarTab(t) {
        const sb = document.getElementById('sidebar');
        if (sb) sb.dataset.tab = t;
        document.querySelectorAll('.sidebar-tab').forEach(function (b) {
            b.classList.toggle('active', b.dataset.t === t);
        });
    }
    </script>
    <div class="sidebar-footer">
        <span class="status-text" id="sidebarStatusText" onclick="editMyStatus(event)" title="点击设置状态">在线</span>
        <div class="my-avatar" id="myAvatar" onclick="openSettings()" title="点击更换头像"></div>
        <span class="user-info"><span class="user-name" onclick="openSettings()" id="sidebarUserName"><?php echo htmlspecialchars($loggedUser); ?></span></span>
        <button class="side-bell-btn" onclick="openNoticesPop()" title="查看全部公告">
            <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3.4" y="5.6" width="17.2" height="13" rx="2.4"/><path d="M7.6 10.2h8.8M7.6 13.8h5.4"/><path d="M12 5.6V3.2M9.2 3.2h5.6"/></svg>
            <span class="side-bell-dot" id="sideBellDot" style="display:none"></span>
        </button>
    </div>
</div>

<div class="main">
    <div class="loading-bar" id="loadingBar"><div class="bar-inner"></div></div>
    <div class="chat-header">
        <div style="display:flex;align-items:center;gap:10px;">
            <button class="hamburger-btn" id="hamburgerBtn" onclick="toggleSidebar()">
                <div class="hamburger-lines"><span></span><span></span><span></span></div>
                <span class="hamburger-badge" id="hamburgerBadge">0</span>
            </button>
            <div>
                <div class="room-title" id="roomTitle">公共聊天</div>
                <div class="room-subtitle" id="roomSubtitle">0 人在线</div>
            </div>
        </div>
        <div class="header-right">
            <span id="headerOnlineCount" style="display:none;">0 在线</span>
            <button class="group-menu-btn" id="favBtn" title="我的收藏" onclick="event.stopPropagation();openFavModal()" style="position:relative;display:inline-flex;align-items:center;justify-content:center;">
                <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
                <span class="fav-badge" id="favBadge" style="display:none;">0</span>
            </button>
            <button class="group-menu-btn" id="roomMoreBtn" title="更多" onclick="event.stopPropagation();toggleRoomDropdown(event)" style="position:relative;display:inline-flex;align-items:center;justify-content:center;">
                <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><circle cx="12" cy="5" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="12" cy="19" r="1.8"/></svg>
                <div class="header-dropdown" id="roomDropdown" style="top:100%;right:0;"></div>
            </button>
        </div>
    </div>
    <div class="quote-bar" id="quoteBar">
        <span class="quote-text" id="quoteContent"></span>
        <button class="quote-close" onclick="clearQuote()">
            <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
        </button>
    </div>
    <div class="announcement-bar" id="announcementBar">
        <span class="ann-icon"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3.4" y="5.6" width="17.2" height="13" rx="2.4"/><path d="M7.6 10.2h8.8M7.6 13.8h5.4"/><path d="M12 5.6V3.2M9.2 3.2h5.6"/></svg></span>
        <span class="ann-content" id="annContent"></span>
        <span class="ann-meta" id="annMeta"></span>
        <button class="ann-close" id="annClose" onclick="closeAnnouncement()">
            <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
        </button>
    </div>
    <div class="room-ban-bar" id="roomBanBar" style="display:none;">
        <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-1.06 15.54L6.4 12.1l1.41-1.41 3.13 3.13 6.13-6.13 1.41 1.41-7.54 7.44z"/></svg>
        <span id="roomBanText"></span>
    </div>
    <div class="room-ban-bar" id="roomMuteBar" style="display:none;">
        <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z"/></svg>
        <span id="roomMuteText"></span>
    </div>
    <div class="room-pin-bar" id="groupPinBar">
        <span class="pin-tag"><svg viewBox="0 0 24 24" width="10" height="10" fill="#fff"><path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"/></svg></span>
        <span class="pin-content" id="groupPinContent" onclick="pinContentClick()"></span>
        <button class="bar-close" id="groupPinClose" onclick="removeGroupPin()" title="取消置顶">
            <svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
        </button>
    </div>
    <div class="room-ann-bar" id="groupAnnBar">
        <span class="ann-tag"><svg viewBox="0 0 24 24" width="10" height="10" fill="#fff"><path d="M5 3h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-4v2h1v2H8v-2h1v-2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm1 2v10h12V5H6zM8 6.4h8V8H8V6.4zM8 9.6h5V11.2H8V9.6z"/></svg></span>
        <span class="ann-content" id="groupAnnContent" onclick="openAnnouncementFromMenu(getCurrentGroupName())"></span>
        <button class="bar-close" id="groupAnnClose" onclick="dismissGroupAnn()" title="收起">
            <svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
        </button>
    </div>
    <!-- 公告中心下拉面板（点铃铛弹出） -->
    <div class="notices-pop" id="noticesPop">
        <div class="np-head">
            <span class="np-title"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;"><rect x="3.4" y="5.6" width="17.2" height="13" rx="2.4"/><path d="M7.6 10.2h8.8M7.6 13.8h5.4"/><path d="M12 5.6V3.2M9.2 3.2h5.6"/></svg>全部公告</span>
            <span class="np-close" onclick="closeNoticesPop()">
                <svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
            </span>
        </div>
        <div class="np-body" id="noticesPopList"></div>
    </div>

    <!-- 单条公告详情弹窗 -->
    <div class="modal-overlay" id="noticeDetailModal" onclick="if(event.target===this)closeNoticeDetail()">
        <div class="notice-detail-box">
            <div class="nd-head">
                <span class="nd-badge">公 告</span>
                <span class="nd-close" onclick="closeNoticeDetail()" title="关闭">
                    <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
                </span>
            </div>
            <div class="nd-meta" id="ndMeta"></div>
            <div class="nd-body" id="ndBody"></div>
            <div class="nd-foot"><button class="gn-btn" onclick="closeNoticeDetail()">知道了</button></div>
        </div>
    </div>
    <div class="msg-search-bar" id="msgSearchBar">
        <input type="text" id="msgSearchInput" placeholder="搜索聊天记录..." oninput="searchMessages()" onkeydown="searchKeyNav(event)">
        <span class="search-count" id="searchCount"></span>
        <div class="search-nav">
            <button onclick="navigateSearch(-1)" title="上一个">
                <svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>
            </button>
            <button onclick="navigateSearch(1)" title="下一个">
                <svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/></svg>
            </button>
        </div>
        <button class="search-close" onclick="closeMsgSearch()">
            <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
        </button>
        <div class="sr-panel" id="searchResultList"></div>
    </div>
    <div class="messages" id="messagesBox"></div>
    <div class="select-bar" id="selectBar">
        <span class="sel-count">已选 <b id="selCount">0</b> 条</span>
        <span class="sel-spacer"></span>
        <button id="selAllBtn" onclick="selectAllVisible()">全选</button>
        <button id="selExportBtn" onclick="exportSelectedMessages()" disabled>导出</button>
        <button id="selDownloadBtn" onclick="downloadSelectedMessages()" disabled>下载</button>
        <button id="selFwdBtn" onclick="forwardSelectedMessages()" disabled>转发</button>
        <button id="selRecallBtn" class="sel-danger" onclick="recallSelectedMessages()" disabled>撤回</button>
        <button onclick="exitSelectMode()">取消</button>
    </div>
    <div class="input-area">
        <input type="file" id="imageFileInput" accept="image/*" style="display:none;">
        <input type="file" id="stickerFileInput" accept="image/*,.svg" style="display:none;">
        <!-- 输入行：结构照参考系统——语音按钮 + 胶囊（输入框 + 表情 + 加号 + 发送）全部在同一个胶囊内 -->
        <div class="input-row">
            <div class="input-group">
                <!-- 语音/键盘切换：点一下在「打字」和「按住说话」之间切换 -->
                <button class="tool-btn mic-btn" id="voiceBtn" title="切换语音输入" type="button">
                    <svg class="ic-mic" viewBox="0 0 24 24" fill="none"><rect x="9" y="2" width="6" height="12" rx="3" stroke="currentColor" stroke-width="1.5"/><path d="M5 11a7 7 0 0 0 14 0" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="12" y1="18" x2="12" y2="22" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
                    <svg class="ic-kbd" viewBox="0 0 24 24" fill="none"><rect x="2.5" y="6" width="19" height="12" rx="2.5" stroke="currentColor" stroke-width="1.5"/><path d="M6 9.6h.01M9.5 9.6h.01M13 9.6h.01M16.5 9.6h.01M6 12.6h.01M9.5 12.6h.01M13 12.6h.01M16.5 12.6h.01M8.6 15.6h6.8" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
                </button>
                <input type="text" id="messageInput" placeholder="输入消息..." autocomplete="off">
                <span class="char-counter" id="charCounter" style="display:none"></span>
                <!-- 语音模式下取代输入框的「按住说话」条：长按录音，上滑取消，松手即发 -->
                <button class="voice-hold-btn" id="voiceHoldBtn" type="button">按住说话</button>
                <button class="send-btn" id="sendBtn" title="发送">
                    <svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
                </button>
                <div class="input-tools">
                    <button class="tool-btn" id="emojiBtn" title="表情">
                        <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.5"/><circle cx="9" cy="10" r="1.2" fill="currentColor"/><circle cx="15" cy="10" r="1.2" fill="currentColor"/><path d="M8 14c1.5 2 4 2 5.5 0" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
                    </button>
                    <button class="tool-btn plus-btn" id="plusBtn" title="更多功能：图片 / 文件 / 通话" aria-expanded="false">
                        <svg viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
                    </button>
                </div>
            </div>
        </div>
        <!-- 录音浮层（微信式）：实时声纹随说话跳动；手指上滑进取消区后整体变红 -->
        <div class="rec-overlay" id="recOverlay" role="status" aria-live="polite">
            <div class="rec-panel" id="recPanel">
                <div class="rec-icon">
                    <svg class="rec-ic-mic" viewBox="0 0 24 24" fill="none"><rect x="9" y="2" width="6" height="12" rx="3" stroke="currentColor" stroke-width="1.6"/><path d="M5 11a7 7 0 0 0 14 0" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><line x1="12" y1="18" x2="12" y2="22" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
                    <svg class="rec-ic-cancel" viewBox="0 0 24 24" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>
                </div>
                <div class="rec-wave" id="recWave"></div>
                <div class="rec-timer" id="recTimer">0"</div>
            </div>
            <div class="rec-hint" id="recHint"><span id="recHintText">手指上滑，取消发送</span></div>
        </div>
        <div class="mention-dropdown" id="mentionDropdown"></div>
        <!-- ===== 表情 / 表情包面板（重做版）=====
             页签在内容之上，一次只显示一个分类；打开面板默认落在"默认表情包" -->
        <div class="picker-panel" id="pickerPanel">
            <div class="picker-tabs" id="pickerTabs">
                <button type="button" class="picker-tab active" data-tab="builtin" onclick="pickerSwitchTab('builtin')">默认表情包</button>
                <button type="button" class="picker-tab" data-tab="user" onclick="pickerSwitchTab('user')">自定义表情包</button>
                <button type="button" class="picker-tab" data-tab="common" onclick="pickerSwitchTab('common')">常用</button>
            </div>
            <div class="picker-body" id="pickerBody"></div>
        </div>
        <!-- 旧版面板保留为隐藏占位：仍被若干处既有逻辑引用（如通话中禁用按钮、删除后刷新），
             隐藏后不影响新面板，也不会因为移除元素而让脚本报错 -->
        <div class="emoji-panel" id="emojiPanel" style="display:none !important"></div>
        <button class="tool-btn" id="stickerBtn" title="表情包" style="display:none !important;">
            <svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="16" rx="3" stroke="currentColor" stroke-width="1.5"/><circle cx="9" cy="11" r="1.2" fill="currentColor"/><circle cx="15" cy="11" r="1.2" fill="currentColor"/><path d="M8 15c1.5 2 4 2 5.5 0" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
        </button>
        <div class="sticker-panel" id="stickerPanel" style="display:none !important"></div>
        <!-- 加号面板：照参考系统做法——在输入行下方向下撑开的 3 列网格，每项为"图标 + 文字"。
             表情包已并入表情抽屉，这里只放"发消息之外"的动作 -->
        <div class="plus-panel" id="morePanel">
            <button type="button" class="plus-item t-image" id="imageBtn" title="图片">
                <svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" stroke="currentColor" stroke-width="1.5"/><circle cx="8.5" cy="8.5" r="1.5" fill="currentColor"/><path d="M3 16l5-5 4 4 3-3 6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
                <span>图片</span>
            </button>
            <button type="button" class="plus-item t-file" id="fileBtn" title="文件">
                <svg viewBox="0 0 24 24" fill="none"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="1.5"/><polyline points="14 2 14 8 20 8" stroke="currentColor" stroke-width="1.5"/></svg>
                <span>文件</span>
            </button>
            <button type="button" class="plus-item t-call" id="callBtn" title="语音通话" style="display:none;" onclick="handleToolbarCall()">
                <svg viewBox="0 0 24 24" fill="none"><path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>
                <span>语音通话</span>
            </button>
            <button type="button" class="plus-item t-vcall" id="videoCallBtn" title="视频通话" style="display:none;" onclick="handleToolbarVideoCall()">
                <svg viewBox="0 0 24 24" fill="none"><rect x="2" y="6" width="13" height="12" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M15 10.5l5-3v9l-5-3" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>
                <span>视频通话</span>
            </button>
            <button type="button" class="plus-item t-at" id="atAllBtn" title="@全体成员" style="display:none;" onclick="insertAtAll()">
                <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="3.5" stroke="currentColor" stroke-width="1.5"/><path d="M4.5 19.5c.8-4 3.4-6 7.5-6s6.7 2 7.5 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M16.5 3.5l2.2 2.2M18.7 3.5l-2.2 2.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
                <span>@全体成员</span>
            </button>
        </div>
    </div>
</div>

<button class="back-to-bottom" id="backToBottom" onclick="scrollDown()" title="回到底部">
    <svg viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>
    <span class="count-badge" id="newMsgBadge">0</span>
</button>

<div class="image-preview-modal" id="imagePreviewModal">
    <div class="preview-box">
        <div class="preview-title">图片预览</div>
        <img id="previewImage" src="" alt="预览">
        <div class="preview-info" id="previewInfo"></div>
        <div class="preview-btns">
            <button class="btn-cancel-preview" onclick="closeImagePreview()">取消</button>
            <button class="btn-send" onclick="confirmSendImage()">发送</button>
        </div>
    </div>
</div>

<div class="image-preview-modal" id="filePreviewModal">
    <div class="preview-box file-preview-box">
        <div class="preview-title">发送文件</div>
        <div class="file-drop-zone" id="fileDropZone">
            <svg viewBox="0 0 24 24" width="34" height="34" fill="none"><path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 15v3a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
            <div class="fdz-title">点击这里选择文件</div>
            <div class="fdz-sub">也可以把文件从桌面直接拖进来（拖到聊天区松手即可发送）</div>
        </div>
        <input type="file" id="fileInput" multiple style="display:none;">
        <div id="fileBatchList" class="batch-upload-wrap" style="max-height:46vh;overflow-y:auto;"></div>
        <div class="batch-upload-sum" id="fileBatchSum">还没有选择文件</div>
        <div class="preview-btns">
            <button class="btn-cancel-preview" onclick="closeFilePreview()">取消</button>
            <button class="btn-send" id="batchSendBtn" onclick="confirmSendFiles()" disabled>发送</button>
        </div>
    </div>
</div>

<div class="friend-context-menu" id="friendContextMenu"></div>

<div class="bubble-popup" id="bubblePopup"></div>

<div class="reaction-picker" id="reactionPicker"></div>

<div class="bubble-popup" id="statusPopup"></div>

<!-- 拖拽发送时的全屏提示：拖到聊天界面任意位置都能发文件 -->
<div class="drop-hint" id="dropHint">
    <div class="dh-box">
        <svg viewBox="0 0 24 24" width="30" height="30" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>
        <div>松开即可发送文件</div>
        <div class="dh-sub">支持多选文件与整个文件夹</div>
    </div>
</div>

<div class="modal-overlay" id="noteModal">
    <div class="modal-box">
        <h3>设置好友备注</h3>
        <input type="text" id="noteInput" placeholder="输入备注（留空则清除）" maxlength="20">
        <div class="btns"><button class="btn-cancel" onclick="closeNoteModal()">取消</button><button class="btn-primary" onclick="saveNote()">保存</button></div>
    </div>
</div>

<div class="modal-overlay" id="settingsModal">
    <div class="modal-box">
        <h3>个人设置</h3>
        <p style="font-size:13px;color:var(--text3);margin-bottom:8px;">更换头像</p>
        <button class="btn-cancel" onclick="document.getElementById('avatarFile').click()" style="width:100%;">选择图片上传</button>
        <input type="file" id="avatarFile" accept="image/*" style="display:none;" onchange="uploadAvatar()">
        <p style="font-size:13px;color:var(--text3);margin:12px 0 6px;">或选默认头像</p>
        <div class="default-avatars" id="defaultAvatars"></div>
        <button class="btn-cancel" onclick="resetAvatar()" style="width:100%;font-size:12px;margin-bottom:2px;">恢复默认头像（清空自定义）</button>
        <hr style="margin:16px 0;border:none;border-top:1px solid var(--border);">
        <button class="btn-cancel" onclick="openPasswordModal()" style="width:100%;margin-bottom:8px;">修改密码</button>
        <hr style="margin:16px 0;border:none;border-top:1px solid var(--border);">
        <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
            <span style="font-size:13px;color:var(--text2);">消息提示音</span>
            <button id="soundToggle" class="btn-cancel" style="width:auto;padding:6px 16px;border-radius:20px;font-size:12px;transition:all .2s;display:flex;align-items:center;gap:4px;" onclick="toggleSound()">
                <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
                <span id="soundLabel">已开启</span>
            </button>
        </div>
        <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
            <span style="font-size:13px;color:var(--text2);">暗色模式</span>
            <button id="darkModeToggle" class="btn-cancel" style="width:auto;padding:6px 16px;border-radius:20px;font-size:12px;transition:all .2s;display:flex;align-items:center;gap:4px;" onclick="toggleDarkMode()">
                <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.82.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z"/></svg>
                <span id="darkModeLabel">暗色模式</span>
            </button>
        </div>
        <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
            <span style="font-size:13px;color:var(--text2);">语言</span>
            <select id="langSelect" onchange="setLang(this.value)" style="width:auto;padding:6px 12px;border:1px solid var(--border);border-radius:20px;font-size:12px;background:var(--surface2);color:var(--text);outline:none;cursor:pointer;font-family:inherit;">
                <option value="zh">简体中文</option>
                <option value="en">English</option>
                <option value="ja">日本語</option>
            </select>
        </div>
        <hr style="margin:16px 0;border:none;border-top:1px solid var(--border);">
        <div style="display:flex;gap:8px;margin-bottom:8px;">
            <button class="btn-cancel" onclick="openProfileEdit()" style="flex:1;font-size:13px;">编辑资料</button>
            <button class="btn-cancel" onclick="openBlacklist()" style="flex:1;font-size:13px;">黑名单管理</button>
        </div>
        <hr style="margin:16px 0;border:none;border-top:1px solid var(--border);">
        <button class="btn-cancel" onclick="logout()" style="width:100%;margin-bottom:8px;color:var(--danger);">退出登录</button>
        <button class="btn-cancel" onclick="closeSettings()" style="width:100%;">关闭</button>
    </div>
</div>

<div class="modal-overlay" id="userCardModal" onclick="if(event.target===this)closeUserCard()" style="z-index:110;">
    <div id="userCardContent"></div>
</div>

<div class="modal-overlay" id="profileEditModal" onclick="if(event.target===this)closeProfileEdit()">
    <div class="modal-box">
        <h3 style="display:flex;align-items:center;justify-content:space-between;gap:10px;"><span>编辑资料</span><button class="modal-x" onclick="closeProfileEdit()" title="关闭"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button></h3>
        <p style="font-size:13px;color:var(--text3);margin:2px 0 6px;">个性签名</p>
        <input type="text" id="decoSignature" placeholder="写点什么…" maxlength="60">
        <p style="font-size:13px;color:var(--text3);margin:12px 0 4px;">头像框</p>
        <div class="deco-row" id="decoFrames"></div>
        <p style="font-size:13px;color:var(--text3);margin:12px 0 4px;">主页背景</p>
        <div class="deco-row" id="decoBgs"></div>
        <div style="margin-top:8px;">
            <button class="btn-cancel" onclick="document.getElementById('decoBgFile').click()" style="width:100%;font-size:13px;">上传自定义背景</button>
            <input type="file" id="decoBgFile" accept="image/*" style="display:none;" onchange="uploadProfileBg()">
        </div>
        <div class="btns"><button class="btn-cancel" onclick="closeProfileEdit()">取消</button><button class="btn-primary" onclick="saveProfile()">保存</button></div>
    </div>
</div>

<div class="modal-overlay" id="blacklistModal" onclick="if(event.target===this)closeBlacklist()">
    <div class="modal-box">
        <h3 style="display:flex;align-items:center;justify-content:space-between;gap:10px;"><span>黑名单</span><button class="modal-x" onclick="closeBlacklist()" title="关闭"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button></h3>
        <p style="font-size:12px;color:var(--text3);margin-bottom:8px;">拉黑后双方将无法互发私聊消息</p>
        <div id="blacklistList" style="max-height:46vh;overflow-y:auto;"></div>
        <div class="btns"><button class="btn-cancel" onclick="closeBlacklist()">关闭</button></div>
    </div>
</div>

<div class="modal-overlay" id="chatBgModal" onclick="if(event.target===this)closeChatBg()">
    <div class="modal-box">
        <h3 style="display:flex;align-items:center;justify-content:space-between;gap:10px;"><span>聊天背景</span><button class="modal-x" onclick="closeChatBg()" title="关闭"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button></h3>
        <p style="font-size:12px;color:var(--text3);margin-bottom:8px;">为「<span id="chatBgRoomName"></span>」单独设置背景，仅自己可见</p>
        <div class="deco-row" id="chatBgList"></div>
        <div style="margin-top:10px;">
            <button class="btn-cancel" onclick="document.getElementById('chatBgFile').click()" style="width:100%;font-size:13px;">上传自定义背景图片</button>
            <input type="file" id="chatBgFile" accept="image/*" style="display:none;" onchange="uploadChatBg()">
        </div>
        <div class="btns"><button class="btn-cancel" onclick="closeChatBg()">关闭</button><button class="btn-primary" onclick="resetChatBg()">恢复默认</button></div>
    </div>
</div>

<div class="modal-overlay" id="callModal" style="z-index:300;"></div>

<!-- 来电横幅：不在与来电方聊天的界面时，从顶栏滑下。左边头像+名字，右边挂断/接通 -->
<div class="call-banner" id="callBanner">
    <div class="cb-avatar" id="cbAvatar"></div>
    <div class="cb-info">
        <div class="cb-name" id="cbName"></div>
        <div class="cb-sub" id="cbSub"></div>
    </div>
    <div class="cb-actions">
        <button class="cb-btn reject" id="cbReject" type="button" title="挂断"><svg viewBox="0 0 24 24"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.7l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z"/></svg></button>
        <button class="cb-btn accept" id="cbAccept" type="button" title="接通"><svg viewBox="0 0 24 24"><path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/></svg></button>
    </div>
</div>
<!-- 通话悬浮窗：最小化后显示。音频是圆圈（图标+计时），视频直接显示对方画面 -->
<div class="call-ball" id="callBall" title="点开恢复通话界面">
    <video id="callBallVideo" autoplay playsinline muted></video>
    <svg class="call-ball-ico" viewBox="0 0 24 24"><path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/></svg>
    <span class="call-ball-time" id="callBallTime">00:00</span>
    <button class="call-ball-size" id="callBallSize" title="放大/缩小小窗" onclick="event.stopPropagation();toggleCallBallSize()"><svg viewBox="0 0 24 24"><path d="M4 9V4h5V6H6v3H4zm16 0h-2V6h-3V4h5v5zM4 15h2v3h3v2H4v-5zm14 3v-3h2v5h-5v-2h3z"/></svg></button>
</div>
<audio id="callRemoteAudio" autoplay style="display:none;"></audio>

<div class="modal-overlay" id="callPickModal" onclick="if(event.target===this)closeCallPick()">
    <div class="modal-box">
        <h3 style="display:flex;align-items:center;justify-content:space-between;gap:10px;"><span id="callPickTitle">选择通话对象</span><button class="modal-x" onclick="closeCallPick()" title="关闭"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button></h3>
        <p style="font-size:12px;color:var(--text3);margin-bottom:8px;">选择群内一位成员进行通话（不含自己）</p>
        <div id="callPickList" style="max-height:46vh;overflow-y:auto;"></div>
        <div class="btns"><button class="btn-cancel" onclick="closeCallPick()">取消</button></div>
    </div>
</div>

<div class="modal-overlay" id="confirmCallModal" onclick="if(event.target===this)closeConfirmCall()">
    <div class="modal-box" style="width:300px;text-align:center;padding:28px 22px 22px;">
        <div class="confirm-call-avatar" id="confirmCallAvatar" style="background:#07c160;">?</div>
        <div class="confirm-call-name" id="confirmCallName"></div>
        <div class="confirm-call-hint" id="confirmCallHint"></div>
        <div class="btns" style="justify-content:center;"><button class="btn-cancel" onclick="closeConfirmCall()" style="min-width:88px;">取消</button><button class="btn-primary" onclick="doConfirmCall()" style="min-width:88px;">确定</button></div>
    </div>
</div>

<div class="modal-overlay" id="passwordModal">
    <div class="modal-box">
        <h3>修改密码</h3>
        <input type="password" id="oldPassword" placeholder="原密码">
        <input type="password" id="newPassword" placeholder="新密码（至少6位）">
        <input type="password" id="confirmPassword" placeholder="确认新密码">
        <div class="btns"><button class="btn-cancel" onclick="closePasswordModal()">取消</button><button class="btn-primary" onclick="changePassword()">修改</button></div>
    </div>
</div>

<div class="modal-overlay" id="createGroupModal">
    <div class="modal-box">
        <h3>创建群聊</h3>
        <div class="group-avatar-upload" style="text-align:center;margin-bottom:12px;">
            <div class="group-avatar-preview" id="groupAvatarPreview" style="width:72px;height:72px;border-radius:50%;margin:0 auto 8px;background:var(--surface2);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;border:2px dashed var(--border2);transition:all .2s;" onclick="document.getElementById('groupAvatarFile').click()" title="点击上传群头像">
                <svg viewBox="0 0 24 24" width="28" height="28" fill="var(--text3)"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
            </div>
            <span style="font-size:11px;color:var(--text3);">点击设置群头像</span>
            <input type="file" id="groupAvatarFile" accept="image/*" style="display:none;" onchange="previewGroupAvatar()">
        </div>
        <input type="text" id="groupName" placeholder="群聊名称">
        <p style="font-size:13px;color:var(--text3);margin:10px 0 4px;">选择成员（仅好友）</p>
        <div class="user-check-list" id="userCheckList"></div>
        <div class="btns"><button class="btn-cancel" onclick="closeCreateGroup()">取消</button><button class="btn-primary" id="createGroupBtn" onclick="createGroup()">创建</button></div>
    </div>
</div>

<div class="modal-overlay" id="friendReqModal">
    <div class="modal-box">
        <h3>好友请求</h3>
        <div id="friendReqModalList"></div>
        <div class="btns"><button class="btn-cancel" onclick="closeFriendReqModal()">关闭</button></div>
    </div>
</div>

<div class="modal-overlay" id="groupManageModal">
    <div class="modal-box">
        <h3 style="display:flex;align-items:center;justify-content:space-between;gap:10px;"><span id="groupManageTitle">群管理</span><button class="modal-x" onclick="closeGroupManage()" title="关闭"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button></h3>
        <div id="groupManageContent"></div>
        <div class="btns" id="groupManageBtns">
            <button class="btn-cancel" onclick="closeGroupManage()">关闭</button>
        </div>
    </div>
</div>

<div class="modal-overlay" id="groupRequestsModal">
    <div class="modal-box">
        <h3 style="display:flex;align-items:center;justify-content:space-between;gap:10px;"><span id="groupRequestsTitle">入群申请</span><button class="modal-x" onclick="closeGroupRequests()" title="关闭"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button></h3>
        <div id="groupRequestsContent"></div>
        <div class="btns">
            <button class="btn-cancel" onclick="closeGroupRequests()">关闭</button>
        </div>
    </div>
</div>

<div class="modal-overlay" id="muteModal" onclick="if(event.target===this)closeMuteModal()">
    <div class="modal-box">
        <h3 style="display:flex;align-items:center;justify-content:space-between;gap:10px;"><span id="muteModalTitle">禁言时长</span><button class="modal-x" onclick="closeMuteModal()" title="关闭"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button></h3>
        <div id="muteModalContent"></div>
        <div class="btns">
            <button class="btn-cancel" onclick="closeMuteModal()">取消</button>
        </div>
    </div>
</div>

<div class="modal-overlay" id="titleModal" onclick="if(event.target===this)closeTitleModal()">
    <div class="modal-box">
        <h3 style="display:flex;align-items:center;justify-content:space-between;gap:10px;"><span id="titleModalTitle">设置群头衔</span><button class="modal-x" onclick="closeTitleModal()" title="关闭"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button></h3>
        <input type="text" id="titleInput" placeholder="输入群头衔（留空清除）" maxlength="20" style="width:100%;padding:10px;border:1px solid var(--border);border-radius:8px;font-size:14px;outline:none;background:var(--surface2);color:var(--text);box-sizing:border-box;margin-top:4px;">
        <div class="btns" style="margin-top:14px;">
            <button class="btn-cancel" onclick="closeTitleModal()">取消</button>
            <button class="btn-send" onclick="confirmSetTitle()">保存</button>
        </div>
    </div>
</div>

<div class="modal-overlay" id="inviteMembersModal" onclick="if(event.target===this)closeInviteMembers()">
    <div class="modal-box">
        <h3 id="inviteMembersTitle">邀请成员</h3>
        <input type="text" id="inviteSearchInput" placeholder="搜索用户名..." oninput="filterInviteMembers()" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:13px;outline:none;background:var(--surface2);color:var(--text);margin-bottom:10px;">
        <div class="user-check-list" id="inviteUserList" style="max-height:40vh;overflow-y:auto;"></div>
        <div class="btns">
            <button class="btn-cancel" onclick="closeInviteMembers()">取消</button>
            <button class="btn-primary" onclick="confirmInviteMembers()">邀请</button>
        </div>
    </div>
</div>

<div class="modal-overlay" id="inviteToGroupModal" onclick="if(event.target===this)closeInviteToGroup()">
    <div class="modal-box">
        <h3 id="inviteToGroupTitle">拉入群聊</h3>
        <div class="user-check-list" id="inviteGroupList" style="max-height:40vh;overflow-y:auto;"></div>
        <p style="font-size:12px;color:var(--text3);margin:8px 0 0;">将好友拉入你所在/创建的群聊，每次拉取所选群聊。</p>
        <div class="btns">
            <button class="btn-cancel" onclick="closeInviteToGroup()">取消</button>
            <button class="btn-primary" onclick="confirmInviteToGroup()">拉入</button>
        </div>
    </div>
</div>

<div class="modal-overlay" id="channelMembersModal" onclick="if(event.target===this)closeChannelMembers()">
    <div class="modal-box">
        <h3>全部成员</h3>
        <div id="channelMembersContent" style="max-height:50vh;overflow-y:auto;"></div>
        <div class="btns">
            <button class="btn-cancel" onclick="closeChannelMembers()">关闭</button>
        </div>
    </div>
</div>

<div class="modal-overlay" id="groupFilesModal" onclick="if(event.target===this)closeGroupFiles()">
    <div class="modal-box gf-box">
        <h3 style="display:flex;align-items:center;justify-content:space-between;gap:10px;"><span id="groupFilesTitle">群文件</span><button class="modal-x" onclick="closeGroupFiles()" title="关闭"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button></h3>
        <div class="gf-tabs">
            <button class="gf-tab active" id="gfTabAlbum" onclick="switchGroupFilesTab('album')">相册</button>
            <button class="gf-tab" id="gfTabFiles" onclick="switchGroupFilesTab('files')">文件</button>
            <button class="gf-sel-toggle" id="gfSelToggle" onclick="toggleGfSelect()">批量管理</button>
        </div>
        <div class="gf-selbar" id="gfSelBar" style="display:none;">
            <span class="gf-check" id="gfSelAllBox" onclick="toggleGfSelectAll()" title="全选/取消全选"><svg viewBox="0 0 24 24"><path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z"/></svg></span>
            <span class="gf-selcount" id="gfSelCount">已选 0 项</span>
            <button onclick="batchDownloadGroupFiles()">下载选中</button>
            <button class="gf-sel-danger" onclick="batchDeleteGroupFiles()">删除选中</button>
            <button onclick="toggleGfSelect()">退出管理</button>
        </div>
        <div class="gf-body" id="groupFilesBody"></div>
        <div class="gf-foot">
            <button class="btn-primary gf-upload" id="gfUploadBtn" onclick="triggerGroupFileUpload()">
                <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg> 上传
            </button>
            <button class="btn-cancel" onclick="closeGroupFiles()">关闭</button>
        </div>
    </div>
    <input type="file" id="groupFileInput" style="display:none;" multiple onchange="handleGroupFileUpload()">
</div>

<div class="gf-preview" id="groupFilesPreview">
    <button class="gf-preview-close" onclick="closeGroupFilePreview()"><svg viewBox="0 0 24 24" width="26" height="26" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
    <img src="" alt="预览">
</div>

<div class="modal-overlay" id="forwardModal" onclick="if(event.target===this)closeForwardModal()">
    <div class="modal-box">
        <h3 style="display:flex;align-items:center;justify-content:space-between;gap:10px;"><span id="forwardTitle">转发消息</span><button class="modal-x" onclick="closeForwardModal()" title="关闭"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button></h3>
        <input type="text" id="forwardSearchInput" placeholder="搜索好友或群聊..." oninput="filterForwardTargets()" style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:13px;outline:none;background:var(--surface2);color:var(--text);margin-bottom:10px;">
        <div id="forwardTargetList" style="max-height:44vh;overflow-y:auto;"></div>
        <div class="btns">
            <button class="btn-cancel" onclick="closeForwardModal()">取消</button>
        </div>
    </div>
</div>

<div class="modal-overlay" id="favModal" onclick="if(event.target===this)closeFavModal()">
    <div class="modal-box">
        <h3 style="display:flex;align-items:center;justify-content:space-between;gap:10px;"><span>我的收藏</span><button class="modal-x" onclick="closeFavModal()" title="关闭"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button></h3>
        <div id="favList" style="max-height:55vh;overflow-y:auto;"></div>
    </div>
</div>

<div class="edit-message-modal" id="editMessageModal">
    <div class="edit-box">
        <h3>编辑消息</h3>
        <textarea id="editMessageInput" maxlength="500"></textarea>
        <div class="btns">
            <button class="btn-cancel" onclick="closeEditMessage()">取消</button>
            <button class="btn-primary" onclick="saveEditMessage()">保存</button>
        </div>
    </div>
</div>

<div class="file-download-modal" id="fileDownloadModal" onclick="if(event.target===this)closeFileDownload()">
    <div class="dl-box">
        <div class="dl-icon"><svg viewBox="0 0 24 24" width="60" height="60"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 7V3.5L18.5 9H13zM6 20V4h5v7h7v9H6z"/></svg></div>
        <div class="dl-name" id="dlFileName"></div>
        <div class="dl-size" id="dlFileSize"></div>
        <div class="dl-btns">
            <button class="btn-cancel-dl" onclick="closeFileDownload()">取消</button>
            <button class="btn-download" id="dlDownloadBtn" onclick="downloadFile()">下载文件</button>
        </div>
    </div>
</div>

<div class="modal-overlay" id="announcementModal">
    <div class="modal-box">
        <h3>群公告</h3>
        <textarea id="announcementInput" placeholder="输入群公告内容..." maxlength="500" style="width:100%;padding:10px;border:1px solid var(--border);border-radius:8px;font-size:14px;resize:none;outline:none;background:var(--surface2);color:var(--text);font-family:inherit;min-height:80px;"></textarea>
        <div class="btns">
            <button class="btn-cancel" onclick="closeAnnouncementModal()">取消</button>
            <button class="btn-danger" onclick="clearAnnouncement()" style="margin-right:auto;">清除公告</button>
            <button class="btn-primary" onclick="saveAnnouncement()">保存</button>
        </div>
    </div>
</div>

<div class="modal-overlay" id="confirmModal">
    <div class="modal-box">
        <div id="confirmMsg" style="margin-bottom:12px;font-size:15px;color:var(--text);"></div>
        <div class="btns"><button class="btn-cancel" id="confirmCancel">取消</button><button class="btn-primary" id="confirmOk">确认</button></div>
    </div>
</div>

<script>
const currentUser = <?php echo json_encode($loggedUser); ?>;
const RECALL_LIMIT = <?php echo RECALL_TIME_LIMIT; ?>;
let currentRoom = '公共聊天';
let currentRoomType = 'channel';
let lastId = 0, currentQuote = null, isLoading = false, isSending = false;

updateFavBadge();   // 收藏角标：本地收藏数（函数定义在其后，运行时已提升）

/* ===== 客户端错误上报 =====
 * 目的：线上出问题时不再只能靠用户截图。把报错静默发给服务端（服务端有去重与限流），
 * 后台可查看。上报自身绝不抛错、绝不递归，也绝不影响主流程。 */
(function initErrorReporting() {
    const seen = {};        // 同一条错误一次会话内只报一次
    let sent = 0;
    function send(kind, msg, extra) {
        try {
            let m = String(msg || '').slice(0, 300);
            // 资源类错误按"标签 + 路径"去重，先把 ?t=时间戳 这类查询串去掉。
            // 否则同一张图每次带的参数都不同，会被当成一条新错误反复上报，把配额刷光。
            if (kind === 'resource') m = m.replace(/[?#]\S*/g, '');
            const key = kind + '|' + m;
            if (seen[key]) return;
            seen[key] = 1;
            // 单次会话上限：资源类收紧到 6 条，脚本错误给 20 条。
            // 报错接口不该变成页面加载时的一串请求。
            if (sent > (kind === 'resource' ? 6 : 20)) return;
            sent++;
            const body = JSON.stringify(Object.assign({ kind: kind, msg: m, url: location.href }, extra || {}));
            // 用 sendBeacon：页面可能正在卸载，普通 fetch 会被浏览器中断
            if (navigator.sendBeacon) {
                navigator.sendBeacon('?action=report_error', new Blob([body], { type: 'application/json' }));
            } else {
                fetch('?action=report_error', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body, keepalive: true }).catch(() => {});
            }
        } catch (e) { /* 上报本身绝不能再抛错 */ }
    }
    window.reportClientError = send;
    window.addEventListener('error', e => {
        // 资源加载失败（图片/脚本）走单独分支：这类事件没有 message
        if (e && e.target && e.target !== window && e.target.tagName &&
            (e.target.tagName === 'IMG' || e.target.tagName === 'SCRIPT' || e.target.tagName === 'LINK')) {
            send('resource', e.target.tagName + ' 加载失败 ' + (e.target.src || e.target.href || ''));
            return;
        }
        send('error', (e && e.message) || 'unknown', {
            src: (e && e.filename) || '', line: (e && e.lineno) || 0,
            stack: (e && e.error && e.error.stack) || ''
        });
    }, true);
    window.addEventListener('unhandledrejection', e => {
        const r = e && e.reason;
        send('promise', (r && (r.message || r)) || 'unhandledrejection', { stack: (r && r.stack) || '' });
    });
})();

/* ===== 统一加载指示 ===== */
function ldHTML(text) {
    return '<div class="ld-box"><div class="ld-ring"></div><span>' + escapeHtml(text || '加载中') + '</span></div>';
}
// 切换会话时如果本地没有缓存、必须走服务端拉取，就在消息区先放一个转圈，
// 避免用户对着空白等待（也没有机会看到上一个会话的残留内容）。
let _msgLoadingShown = false;
function showMsgLoading(text) {
    const box = document.getElementById('messagesBox');
    if (!box) return;
    _msgLoadingShown = true;
    box.innerHTML = '<div class="ld-box ld-fill"><div class="ld-ring"></div><span>' + escapeHtml(text || '正在加载聊天记录') + '</span></div>';
}
function hideMsgLoading() {
    if (!_msgLoadingShown) return;
    _msgLoadingShown = false;
    const box = document.getElementById('messagesBox');
    // 只在"占位还在"时清空：如果这期间已经渲染出消息，就不要动它
    if (box && box.querySelector('.ld-fill')) box.innerHTML = '';
}

/* ===== 未读分隔线 =====
 * 进房间时在"第一条未读"上方插一条提示并滚过去，解决"知道有未读但不知道从哪条开始"。
 * 故意做成渲染后的后处理、只读 DOM，不改动消息渲染主流程，风险最小。
 * 计数来自未读表，服务端统计里可能含系统/通话气泡，所以位置允许有极小偏差。 */
function insertUnreadDivider(count) {
    const box = document.getElementById('messagesBox');
    if (!box) return false;
    const n = parseInt(count, 10) || 0;
    if (n <= 0) return false;
    const old = box.querySelector('.unread-divider');
    if (old) old.remove();
    const rows = Array.prototype.filter.call(box.querySelectorAll('.msg-row'), function (r) {
        return !r.classList.contains('system') && !r.classList.contains('call');
    });
    if (!rows.length) return false;
    const target = rows[Math.max(0, rows.length - n)];
    if (!target || !target.parentNode) return false;
    const div = document.createElement('div');
    div.className = 'unread-divider';
    // 分隔线只是"从这开始是新的"，滚到底还要再拖一把；顺手给一个直达最新的按钮
    div.innerHTML = '<span>以下是新消息</span><button class="unread-jump" type="button" onclick="scrollDown()">跳到最新</button>';
    target.parentNode.insertBefore(div, target);
    return true;
}

/* ===== 语音播放速度 =====
 * 长语音刚需：1× → 1.5× → 2× 循环。只改 playbackRate、不重新解码，切换是瞬时的。
 * 必须声明在消息渲染之前——语音气泡模板里会读 voiceRate，用 let 声明晚了会命中暂时性死区。 */
let voiceRate = 1;
const VOICE_RATES = [1, 1.5, 2];
function voiceRateLabel(r) { return (r === 1 ? '1' : String(r)) + '×'; }
function applyVoiceRateLabels() {
    document.querySelectorAll('.voice-attach .voice-rate').forEach(function (el) {
        el.textContent = voiceRateLabel(voiceRate);
        el.classList.toggle('fast', voiceRate !== 1);
    });
}
window.cycleVoiceRate = function (el, ev) {
    if (ev && ev.stopPropagation) ev.stopPropagation();
    const i = VOICE_RATES.indexOf(voiceRate);
    voiceRate = VOICE_RATES[(i + 1) % VOICE_RATES.length];
    // 正在播的那条立刻换速，其余靠标签同步
    if (voiceAudio) { try { voiceAudio.playbackRate = voiceRate; } catch (e) {} }
    applyVoiceRateLabels();
    showToast('播放速度 ' + voiceRateLabel(voiceRate));
};

/* ===== 按房间保存输入草稿 =====
 * 切到别的会话再切回来，之前写了一半的内容还在。
 * 只放内存、不落 localStorage：聊天内容不留在本地，也不会像浏览器那样刷新后回填。 */
const roomDrafts = {};
function saveRoomDraft() {
    if (!currentRoom) return;
    const inp = document.getElementById('messageInput');
    if (!inp) return;
    const v = inp.value;
    if (v && v.trim()) roomDrafts[currentRoom] = v; else delete roomDrafts[currentRoom];
}
function restoreRoomDraft() {
    const inp = document.getElementById('messageInput');
    if (!inp) return;
    inp.value = (currentRoom && roomDrafts[currentRoom]) || '';
    // 派发 input 事件，让字数统计和发送按钮可用状态跟着走
    inp.dispatchEvent(new Event('input', { bubbles: true }));
}
const CLEAR_KEY = 'chatroom_clear_watermark';
function getClearWatermark(room){ try { return parseInt((JSON.parse(localStorage.getItem(CLEAR_KEY)||'{}'))[room]||0,10)||0; } catch(e){ return 0; } }
function setClearWatermark(room, id){ try { const o = JSON.parse(localStorage.getItem(CLEAR_KEY)||'{}'); o[room]=id; localStorage.setItem(CLEAR_KEY, JSON.stringify(o)); } catch(e){} }
let roomInitialized = false;
const addedIds = new Set();
// 渲染失败的消息登记表：id -> 已失败次数。只有渲染成功才写入 addedIds，
// 失败项下一轮会重新拉取重试；连续失败达到上限才放弃，避免一条坏消息永久堵住整个房间。
const MSG_FAIL = new Map();
// loadMessages 本轮开始时间，配合下面的看门狗使用
let _loadStartedAt = 0;
let onlineUsers = [];
let lastDateStr = '';
const avatarCache = {};
let totalUnread = 0;
let avatarTs = 0;
let contextMenuTarget = null;
let noteTarget = null;
let recalledSince = 0;
let editedSince = 0;
let reactionsSince = 0;
let roomVisibleFromTs = 0;   // 服务端下发的"可见起点"（0 = 不限，>0 = 早于该时刻的消息不显示）

const roomCache = {};

function saveRoomCache() {
    const box = document.getElementById('messagesBox');
    roomCache[currentRoom] = {
        html: box.innerHTML,
        scrollTop: box.scrollTop,
        lastDateStr: lastDateStr,
        addedIdsArr: Array.from(addedIds),
        lastId: lastId,
        recalledSince: recalledSince,
        editedSince: editedSince
    };
}

function restoreRoomCache(room) {
    const box = document.getElementById('messagesBox');
    if (roomCache[room]) {
        const c = roomCache[room];
        box.innerHTML = c.html;
        box.scrollTop = c.scrollTop;
        lastDateStr = c.lastDateStr;
        addedIds.clear();
        c.addedIdsArr.forEach(id => addedIds.add(id));
        lastId = c.lastId;
        recalledSince = c.recalledSince || 0;
        editedSince = c.editedSince || 0;
        return true;
    }
    return false;
}

function showToast(msg, d=2000) { const t=document.createElement('div'); t.className='toast'; t.textContent=msg; document.body.appendChild(t); setTimeout(()=>t.remove(), d); }
function escapeHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// 内联 onclick/oncontextmenu 属性里的 JS 字符串安全引用：用 \' 转义单引号（HTML 实体 &#39; 会在属性里被解码成引号，不能用）
function jsq(s) { return "'" + String(s).replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/[\r\n]+/g,' ') + "'"; }
function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
/* 未设置头像时的统一占位：灰白人像剪影，配色由 CSS 变量 --avatar-bg / --avatar-img 按主题自动切换。
 * 首字母用 color:transparent 隐藏，但仍留在 DOM 中供读屏软件朗读。
 * 说明：函数名沿用历史命名 stringToColor，全站 20 余处头像兜底都调用它，返回的是内联样式片段。 */
const AV_FALLBACK_CSS = 'background:var(--avatar-bg);background-image:var(--avatar-img);background-size:cover;background-position:center;background-repeat:no-repeat;color:transparent';
function stringToColor() { return AV_FALLBACK_CSS; }
// 通过 DOM 赋值设置头像兜底（不能直接给 style.background 赋一段声明列表）
function applyDefaultAvatar(el) {
    if (!el) return;
    el.style.background = '';
    el.style.backgroundColor = 'var(--avatar-bg)';
    el.style.backgroundImage = 'var(--avatar-img)';
    el.style.backgroundSize = 'cover';
    el.style.backgroundPosition = 'center';
    el.style.backgroundRepeat = 'no-repeat';
    el.style.color = 'transparent';
}
function scrollDown() { const b=document.getElementById('messagesBox'); b.scrollTop=b.scrollHeight; }

async function fetchWithLoading(url, options = {}) {
    const timer = setTimeout(() => {
        const bar = document.getElementById('loadingBar');
        if (bar) bar.classList.add('active');
    }, 800);
    try {
        const res = await fetch(url, options);
        clearTimeout(timer);
        const bar = document.getElementById('loadingBar');
        if (bar) bar.classList.remove('active');
        return res;
    } catch (e) {
        clearTimeout(timer);
        const bar = document.getElementById('loadingBar');
        if (bar) bar.classList.remove('active');
        throw e;
    }
}
function formatDate(ts) { const d=new Date(ts*1000), n=new Date(); if(d.toDateString()===n.toDateString()) return '今天'; const y=new Date(n); y.setDate(n.getDate()-1); if(d.toDateString()===y.toDateString()) return '昨天'; return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; }
function formatFileSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB'; return (bytes/1048576).toFixed(1) + ' MB'; }
function dismissSystemMsg(id) { const el = document.getElementById('msg-'+id); if (el) { el.style.transition='opacity .3s'; el.style.opacity='0'; setTimeout(()=>{ if(el) el.remove(); addedIds.delete(id); },300); } }
function refreshAllAvatars() {
    document.querySelectorAll('.msg-avatar').forEach(el => {
        // Only update other users' avatars (not self)
        const row = el.closest('.msg-row');
        if (!row || row.classList.contains('self')) return;
        // Find the username from the message body
        const nameEl = row.querySelector('.msg-name');
        if (!nameEl) return;
        const uname = nameEl.textContent;
        const url = avatarCache[uname];
        if (url !== undefined) {
            if (url) { el.style.backgroundImage = `url(${url}?t=${Date.now()})`; el.textContent = ''; }
            else { el.textContent = uname.charAt(0).toUpperCase(); applyDefaultAvatar(el); }
        }
    });
}

function customConfirm(msg, cb) {
    document.getElementById('confirmMsg').textContent = msg;
    document.getElementById('confirmModal').classList.add('active');
    document.getElementById('confirmOk').onclick = () => { closeConfirm(); cb(); };
    document.getElementById('confirmCancel').onclick = closeConfirm;
}
function closeConfirm() { document.getElementById('confirmModal').classList.remove('active'); }

function openSettings() { document.getElementById('settingsModal').classList.add('active'); generateDefaultAvatars(); closeSidebar(); const ls=document.getElementById('langSelect'); if(ls) ls.value = (window.LANG || 'zh'); }
function closeSettings() { document.getElementById('settingsModal').classList.remove('active'); }

// 等级 / 经验 / 积分 / 签到 / 排行榜 已整体下架（保留后端数据不做删除，便于日后需要时恢复）

// ===== 资料卡装扮 + 黑名单 =====
let userCardTarget = null;
let _myBlacklist = [];
async function refreshBlacklist() {
    try { const r = await fetch('?action=get_blacklist'); const d = await r.json(); _myBlacklist = d.list || []; } catch(e) { _myBlacklist = []; }
}
async function openUserCard(name) {
    userCardTarget = name;
    const modal = document.getElementById('userCardModal');
    modal.classList.add('active');
    document.getElementById('userCardContent').innerHTML = '<div class="profile-card" style="margin:60px auto;">' + ldHTML('加载中') + '</div>';
    await refreshBlacklist();
    try {
        const res = await fetch('?action=get_profile&user=' + encodeURIComponent(name));
        const d = await res.json();
        renderUserCard(d);
    } catch(e) {
        document.getElementById('userCardContent').innerHTML = '<div class="profile-card" style="margin:60px auto;"><div style="padding:50px;text-align:center;color:var(--text3);font-size:13px;">加载失败</div></div>';
    }
}
function closeUserCard() { document.getElementById('userCardModal').classList.remove('active'); userCardTarget = null; }
function renderUserCard(d) {
    const isSelf = d.user === currentUser;
    const hasImg = d.bg && d.bg.indexOf('uploads/') === 0;
    const bgCls = d.bg && d.bg !== 'none' ? (hasImg ? 'bg-img' : 'bg-' + d.bg) : 'bg-none';
    const bgStyle = hasImg ? `background-image:url(${d.bg});` : '';
    const frameCls = d.frame && d.frame !== 'none' ? ' avf-' + d.frame : '';
    const av = d.avatar ? `background-image:url(${d.avatar}?t=${Date.now()})` : `background:${stringToColor(d.user)}`;
    const isBlk = _myBlacklist.indexOf(d.user) !== -1;
    let btns = '';
    if (!isSelf) {
        const pmRoom = currentUser + '_' + d.user;
        btns += `<button style="background:linear-gradient(135deg,#07c160,#43e97b);color:#fff;" onclick="openConfirmCall(${jsq(d.user)},${jsq(getPMRoomName(currentUser, d.user))})">语音</button>`;
        btns += `<button style="background:linear-gradient(135deg,#5b8def,#8a63d2);color:#fff;" onclick="openConfirmCall(${jsq(d.user)},${jsq(getPMRoomName(currentUser, d.user))},'video')">视频</button>`;
        btns += `<button style="background:linear-gradient(135deg,var(--accent),#7B85F5);color:#fff;" onclick="closeUserCard();switchRoom(${jsq(pmRoom)},'friend',${jsq(d.user)})">发消息</button>`;
        btns += `<button style="background:var(--surface3);color:${isBlk ? 'var(--danger)' : 'var(--text2)'};" onclick="toggleBlacklist(${jsq(d.user)})">${isBlk ? '解除拉黑' : '拉黑'}</button>`;
    } else {
        btns += `<button style="background:linear-gradient(135deg,var(--accent),#7B85F5);color:#fff;" onclick="openProfileEdit()">编辑资料</button>`;
    }
    btns += `<button class="btn-cancel" onclick="closeUserCard()">关闭</button>`;
    document.getElementById('userCardContent').innerHTML = `
        <div class="profile-card" style="margin:60px auto;">
            <div class="pc-bg ${bgCls}" style="${bgStyle}">
                <button class="pc-x modal-x" onclick="closeUserCard()" title="关闭"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
            </div>
            <div class="pc-avatar-wrap${frameCls}">
                <div class="pc-avatar" style="${av}">${d.avatar ? '' : escapeHtml(d.user.charAt(0).toUpperCase())}</div>
            </div>
            <div class="pc-name">${escapeHtml(d.user)}${isSelf ? '（我）' : ''}</div>
            <div class="pc-sig">${escapeHtml(d.signature || '这个人很懒，什么都没写')}</div>
            <div class="pc-btns">${btns}</div>
        </div>`;
}

const FRAME_OPTS = [
    {v:'none',  label:'无', style:'background:var(--surface3);border:1px solid var(--border2);'},
    {v:'gold',  label:'金', style:'background:var(--surface3);box-shadow:inset 0 0 0 3px #f5c518;'},
    {v:'blue',  label:'蓝', style:'background:var(--surface3);box-shadow:inset 0 0 0 3px #12b7f5;'},
    {v:'green', label:'绿', style:'background:var(--surface3);box-shadow:inset 0 0 0 3px #07c160;'},
    {v:'red',   label:'红', style:'background:var(--surface3);box-shadow:inset 0 0 0 3px #e74c3c;'},
    {v:'purple',label:'紫', style:'background:var(--surface3);box-shadow:inset 0 0 0 3px #8e44ad;'},
    {v:'rainbow',label:'彩',style:'background:conic-gradient(#f00,#ff8000,#ff0,#0c0,#00f,#90f);'}
];
const BG_OPTS = [
    {v:'none',  style:'background:linear-gradient(135deg,#eef6fd,#f7fbfe);border:1px solid var(--border2);'},
    {v:'light', style:'background:linear-gradient(135deg,#eef6fd,#f7fbfe);'},
    {v:'dark',  style:'background:linear-gradient(135deg,#2c3e50,#4ca1af);'},
    {v:'blue',  style:'background:linear-gradient(135deg,#12b7f5,#0ea5e0);'},
    {v:'grad1', style:'background:linear-gradient(135deg,#f093fb,#f5576c);'},
    {v:'grad2', style:'background:linear-gradient(135deg,#4facfe,#00f2fe);'},
    {v:'grad3', style:'background:linear-gradient(135deg,#43e97b,#38f9d7);'}
];
let _decoFrame = 'none', _decoBg = 'none';
async function openProfileEdit() {
    document.getElementById('profileEditModal').classList.add('active');
    try {
        const res = await fetch('?action=get_profile&user=' + encodeURIComponent(currentUser));
        const d = await res.json();
        document.getElementById('decoSignature').value = d.signature || '';
        _decoFrame = d.frame || 'none';
        _decoBg = d.bg || 'none';
        renderDeco();
    } catch(e) {}
}
function closeProfileEdit() { document.getElementById('profileEditModal').classList.remove('active'); }
function renderDeco() {
    const fEl = document.getElementById('decoFrames');
    fEl.innerHTML = FRAME_OPTS.map(f => `<div class="deco-swatch ${_decoFrame === f.v ? 'sel' : ''}" style="${f.style}" onclick="setDecoFrame('${f.v}')" title="${f.label}"></div>`).join('');
    const bEl = document.getElementById('decoBgs');
    bEl.innerHTML = BG_OPTS.map(b => `<div class="deco-swatch ${_decoBg === b.v ? 'sel' : ''}" style="${b.style}" onclick="setDecoBg('${b.v}')"></div>`).join('');
}
function setDecoFrame(v) { _decoFrame = v; renderDeco(); }
function setDecoBg(v) { _decoBg = v; renderDeco(); }
async function uploadProfileBg() {
    const file = document.getElementById('decoBgFile').files[0];
    if (!file) return;
    const fd = new FormData(); fd.append('bg', file);
    const res = await fetch('?action=upload_profile_bg', { method:'POST', body: fd });
    const d = await res.json();
    if (d.success) { _decoBg = d.url; renderDeco(); showToast('背景已上传，点击保存生效'); }
    else showToast(d.error || '上传失败');
}
async function saveProfile() {
    const signature = document.getElementById('decoSignature').value.trim();
    const fd = new URLSearchParams();
    fd.append('signature', signature);
    fd.append('frame', _decoFrame);
    fd.append('bg', _decoBg);
    const res = await fetch('?action=save_profile', { method:'POST', body: fd });
    const d = await res.json();
    if (d.success) { showToast('资料已保存'); closeProfileEdit(); if (userCardTarget === currentUser) openUserCard(currentUser); }
    else showToast(d.error || '保存失败');
}

async function openBlacklist() {
    document.getElementById('blacklistModal').classList.add('active');
    document.getElementById('blacklistList').innerHTML = ldHTML('加载中');
    try {
        const res = await fetch('?action=get_blacklist');
        const d = await res.json();
        _myBlacklist = d.list || [];
        const list = document.getElementById('blacklistList');
        if (!_myBlacklist.length) { list.innerHTML = '<div style="font-size:12px;color:var(--text3);text-align:center;padding:20px 0;">暂无拉黑用户</div>'; return; }
        list.innerHTML = _myBlacklist.map(u => `
            <div class="blk-item">
                <div class="nav-avatar" data-blk="${escapeHtml(u)}" style="background:${stringToColor(u)};">${escapeHtml(u.charAt(0).toUpperCase())}</div>
                <div class="blk-name">${escapeHtml(u)}</div>
                <button class="btn-cancel btn-sm" onclick="removeBlacklist(${jsq(u)})">解除</button>
            </div>`).join('');
        _myBlacklist.forEach(u => {
            getAvatar(u).then(url => {
                document.querySelectorAll('[data-blk="' + CSS.escape(u) + '"]').forEach(el => {
                    if (url) { el.style.backgroundImage = `url(${url}?t=${Date.now()})`; el.textContent = ''; }
                });
            });
        });
    } catch(e) {
        document.getElementById('blacklistList').innerHTML = '<div style="font-size:12px;color:var(--text3);text-align:center;padding:20px 0;">加载失败</div>';
    }
}
function closeBlacklist() { document.getElementById('blacklistModal').classList.remove('active'); }
async function removeBlacklist(u) {
    const fd = new URLSearchParams();
    fd.append('target', u); fd.append('action', 'remove');
    const res = await fetch('?action=set_blacklist', { method:'POST', body: fd });
    const d = await res.json();
    if (d.success) { _myBlacklist = d.list || []; showToast('已解除拉黑'); openBlacklist(); if (userCardTarget) openUserCard(userCardTarget); }
    else showToast(d.error || '操作失败');
}
async function toggleBlacklist(u) {
    const isBlk = _myBlacklist.indexOf(u) !== -1;
    const fd = new URLSearchParams();
    fd.append('target', u); fd.append('action', isBlk ? 'remove' : 'add');
    const res = await fetch('?action=set_blacklist', { method:'POST', body: fd });
    const d = await res.json();
    if (d.success) { _myBlacklist = d.list || []; showToast(isBlk ? '已解除拉黑' : '已拉黑，双方无法互发私聊'); openUserCard(u); }
    else showToast(d.error || '操作失败');
}

// ===== 聊天背景自定义（每聊天单独设置，仅自己可见）=====
const CHAT_BG_OPTS = [
    {v:'none',  label:'无'},
    {v:'light', label:''},
    {v:'blue',  label:''},
    {v:'green', label:''},
    {v:'dark',  label:''},
    {v:'grad1', label:''},
    {v:'grad2', label:''},
    {v:'grad3', label:''}
];
let _chatBg = 'none';
function currentRoomLabel() {
    if (currentRoomType === 'friend') return getPMRoomUser(currentRoom);
    if (currentRoomType === 'group') return getCurrentGroupName();
    return '公共聊天';
}
async function openChatBg() {
    document.getElementById('chatBgModal').classList.add('active');
    document.getElementById('chatBgRoomName').textContent = currentRoomLabel();
    try {
        const res = await fetch('?action=get_chat_bg&room=' + encodeURIComponent(currentRoom));
        const d = await res.json();
        _chatBg = d.bg || 'none';
        renderChatBg();
    } catch(e) {}
}
function closeChatBg() { document.getElementById('chatBgModal').classList.remove('active'); }
function renderChatBg() {
    let html = CHAT_BG_OPTS.map(o =>
        `<div class="cb-swatch ${o.v}${_chatBg === o.v ? ' sel' : ''}" data-v="${o.v}"><span class="cb-label">${o.label}</span></div>`
    ).join('');
    if (_chatBg.indexOf('uploads/') === 0) {
        html += `<div class="cb-swatch img sel" data-v="${escapeHtml(_chatBg)}" style="background-image:url(${_chatBg}?t=${Date.now()})"></div>`;
    }
    const list = document.getElementById('chatBgList');
    list.innerHTML = html;
    list.querySelectorAll('.cb-swatch').forEach(el => {
        el.addEventListener('click', () => selectChatBg(el.getAttribute('data-v')));
    });
}
async function selectChatBg(bg) {
    _chatBg = bg;
    renderChatBg();
    const fd = new URLSearchParams();
    fd.append('room', currentRoom);
    fd.append('bg', bg);
    try {
        const res = await fetch('?action=save_chat_bg', { method:'POST', body: fd });
        const d = await res.json();
        if (d.success) { applyChatBg(bg); showToast('背景已更新'); }
        else showToast(d.error || '设置失败');
    } catch(e) { showToast('设置失败'); }
}
async function resetChatBg() { await selectChatBg('none'); }
async function uploadChatBg() {
    const file = document.getElementById('chatBgFile').files[0];
    if (!file) return;
    const fd = new FormData();
    fd.append('bg', file);
    try {
        const res = await fetch('?action=upload_chat_bg', { method:'POST', body: fd });
        const d = await res.json();
        if (d.success) { await selectChatBg(d.url); }
        else showToast(d.error || '上传失败');
    } catch(e) { showToast('上传失败'); }
}
function applyChatBg(bg) {
    const box = document.getElementById('messagesBox');
    box.className = box.className.replace(/\s*msg-bg-[a-z0-9]+/g, '');
    if (bg && bg !== 'none') {
        if (bg.indexOf('uploads/') === 0) {
            box.classList.add('msg-bg-img');
            box.style.backgroundImage = 'url(' + bg + '?t=' + Date.now() + ')';
        } else {
            box.classList.add('msg-bg-' + bg);
            box.style.backgroundImage = '';
        }
    } else {
        box.style.backgroundImage = '';
    }
}
async function loadChatBg(room) {
    try {
        const res = await fetch('?action=get_chat_bg&room=' + encodeURIComponent(room));
        const d = await res.json();
        applyChatBg(d.bg || 'none');
    } catch(e) {}
}

// ===== 输入栏通话按钮（公共聊天隐藏 / 私聊确认呼出 / 群聊选人确认）=====
function updateCallToolbar() {
    const show = currentRoomType === 'friend' || currentRoomType === 'group';
    const cb = document.getElementById('callBtn');
    if (cb) cb.style.display = show ? '' : 'none';
    const vcb = document.getElementById('videoCallBtn');
    if (vcb) vcb.style.display = show ? '' : 'none';
}
function handleToolbarCall() {
    if (currentRoomType === 'friend') {
        openConfirmCall(getPMRoomUser(currentRoom), currentRoom, 'voice');
    } else if (currentRoomType === 'group') {
        openCallPick('voice');
    }
}
function handleToolbarVideoCall() {
    if (currentRoomType === 'friend') {
        openConfirmCall(getPMRoomUser(currentRoom), currentRoom, 'video');
    } else if (currentRoomType === 'group') {
        openCallPick('video');
    }
}

// 群聊选人（语音/视频）
async function openCallPick(type) {
    type = type || 'voice';
    document.getElementById('callPickModal').classList.add('active');
    document.getElementById('callPickTitle').textContent = type === 'video' ? '选择要视频通话的成员' : '选择要语音通话的成员';
    document.getElementById('callPickList').innerHTML = ldHTML('加载中');
    try {
        const res = await fetch(`?action=get_group_info&group=${encodeURIComponent(getCurrentGroupName())}`);
        const d = await res.json();
        const members = (d.members || []).filter(m => m !== currentUser);
        const list = document.getElementById('callPickList');
        if (!members.length) {
            list.innerHTML = '<div style="font-size:12px;color:var(--text3);text-align:center;padding:20px 0;">群内暂无其他成员</div>';
            return;
        }
        const icon = type === 'video'
            ? '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>'
            : '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/></svg>';
        list.innerHTML = members.map(m => `
            <div class="cp-item" onclick="pickGroupCallMember(${jsq(m)},'${type}')">
                <div class="cp-avatar" data-cp="${CSS.escape(m)}" style="background:${stringToColor(m)};">${escapeHtml(String(m).charAt(0).toUpperCase())}</div>
                <span class="cp-name">${escapeHtml(m)}</span>
                <span class="cp-icon">${icon}</span>
            </div>`).join('');
        members.forEach(m => {
            getAvatar(m).then(url => {
                const els = document.querySelectorAll(`[data-cp="${CSS.escape(m)}"]`);
                els.forEach(el => { if (url) { el.style.backgroundImage = `url(${url}?t=${Date.now()})`; el.textContent = ''; } });
            });
        });
    } catch(e) {
        document.getElementById('callPickList').innerHTML = '<div style="font-size:12px;color:var(--text3);text-align:center;padding:20px 0;">加载失败</div>';
    }
}
function closeCallPick() { document.getElementById('callPickModal').classList.remove('active'); }
function pickGroupCallMember(m, type) { closeCallPick(); openConfirmCall(m, currentRoom, type || 'voice'); }

// ===== 发起前确认弹窗（点击"确定"后才真正发起通话）=====
let confirmCallTarget = null;
function openConfirmCall(peer, room, type) {
    if (CALL.active) { showToast('当前有通话进行中'); return; }
    type = type || 'voice';
    confirmCallTarget = { peer: peer, room: room, type: type };
    const modal = document.getElementById('confirmCallModal');
    modal.classList.add('active');
    const av = document.getElementById('confirmCallAvatar');
    av.textContent = peer.charAt(0).toUpperCase(); applyDefaultAvatar(av);
    getAvatar(peer).then(url => { if (url) { av.style.backgroundImage = `url(${url}?t=${Date.now()})`; av.textContent = ''; } });
    document.getElementById('confirmCallName').textContent = peer;
    document.getElementById('confirmCallHint').textContent = type === 'video' ? '确定要发起视频通话吗？' : '确定要发起语音通话吗？';
}
function closeConfirmCall() {
    document.getElementById('confirmCallModal').classList.remove('active');
    confirmCallTarget = null;
}
async function doConfirmCall() {
    if (!confirmCallTarget) return;
    const t = confirmCallTarget;
    closeConfirmCall();
    CALL.type = t.type || 'voice';
    CALL._videoDegraded = false;
    CALL._lastStreamErr = '';
    // 先把媒体流拿到手，再拨出去。
    // 原来这里是"不等待地预取"：拿不到流也照常拨号，等对方接听后才在建连阶段失败并
    // 自动挂断——用户看到的就是"允许权限后通话自己关掉了"，中途还会被再问一次权限。
    // 这里的 await 仍处于点击手势的调用链中，不会影响权限弹窗与自动播放策略。
    const s = await ensureLocalStream();
    if (!s) { callLog('未拨出：本地媒体不可用', CALL._lastStreamErr || ''); return; }
    await startCall(t.peer, t.room, t.type);
}

// ===== 语音/视频通话（WebRTC + 服务器信令）=====
// ICE 配置由服务端下发（后台"安全设置 → 通话中继"可增删 TURN），无需改代码。
// 只有 STUN 时，双方处于对称 NAT / 运营商级 NAT / 公司防火墙会打洞失败，
// 表现为"一直卡在连接中"；配置 TURN 中继后即可跨网络接通。
const CHAT_SETTINGS = <?php echo json_encode(getChatSettingsForClient(), JSON_UNESCAPED_UNICODE); ?>;
// 文本字数限制：0 = 无限制（输入框与技术上限 5000）；>0 时超限拦截发送并保留输入内容
const MAX_CHARS = Math.max(0, parseInt(CHAT_SETTINGS.max_chars, 10) || 0);
function countChars(s){ return [...(s || '')].length; }
function updateCharCounter(){
    const el = document.getElementById('charCounter');
    if (!el) return;
    if (MAX_CHARS <= 0) { el.style.display = 'none'; return; }
    const input = document.getElementById('messageInput');
    const len = countChars(input && input.value);
    el.style.display = 'inline';
    el.textContent = len + ' / ' + MAX_CHARS;
    el.classList.toggle('over', len > MAX_CHARS);
}
(function(){
    const input = document.getElementById('messageInput');
    if (!input) return;
    if (MAX_CHARS > 0) { input.removeAttribute('maxlength'); } else { input.setAttribute('maxlength', '5000'); }
    input.addEventListener('input', updateCharCounter);
    updateCharCounter();
})();
const CALL_STUN = <?php echo json_encode(getIceConfigForClient(), JSON_UNESCAPED_UNICODE); ?>;
CALL_STUN.bundlePolicy = 'max-bundle';
CALL_STUN.rtcpMuxPolicy = 'require';
const CALL_HAS_TURN = (CALL_STUN.iceServers || []).some(s => {
    const u = Array.isArray(s.urls) ? s.urls : [s.urls];
    return u.some(x => typeof x === 'string' && /^turns?:/i.test(x));
});
// 连接建立超时（毫秒）：有 TURN 时 ICE 需要更多往返，给足时间，避免误报失败
const CALL_CONNECT_TIMEOUT = CALL_HAS_TURN ? 45000 : 30000;
// 单次轮询请求的超时：轮询是"对端挂断后本端能否收尾"的唯一通道，
// 一旦某个请求卡住不返回，_polling 会一直是 true，之后每轮都在第一行被挡掉，
// 界面就会永远停在"通话中"（对端其实早就挂了）。所以每个请求都必须能超时。
const CALL_POLL_TIMEOUT = 8000;
// 看门狗阈值：通话期间超过这么久没有一次成功的轮询，就认为与服务端的联系已断，
// 就地收尾，绝不把用户永久留在通话界面里。
const CALL_POLL_STALL_MS = 15000;
const CALL = {
    active: false, role: null, callId: null, type: 'voice', peer: null, room: null,
    pc: null, localStream: null, remoteStream: null,
    currentStatus: null, pendingCandidates: [], remoteDescSet: false,
    durationTimer: null, startTs: 0, endedByMe: false,
    cameraOn: true, micOn: true, remoteVideoTrack: null,
    // 视频通话里主画面显示谁：false=对方（默认），true=自己。由"切换画面"按钮或双击小窗翻转
    swapped: false,
    // 扬声器（=对方声音开关）：网页端切不了音频输出设备，做成对方的音量开关
    speakerOn: true,
    _iceRestarted: false, _iceFailTimer: null,
    _connectTimer: null, _cameraCount: 1, _flipInProgress: false,
    _lastOfferSdp: '', _lastAnswerSdp: '', _remoteHadVideo: false, _remoteMuteTimer: null,
    // 已接通但迟迟收不到对方画面时的观察定时器；_videoRenegotiated 保证只重协商一次
    _remoteVideoTimer: null, _videoRenegotiated: false,
    _candSince: 0, _retryTimer: null, _retryCount: 0, _signalOk: true, _peerSeenWarned: false, _viaRelay: false,
    // _missCount：连续"读不到通话记录"的轮数，用于把"未知"与"已结束"区分开；
    // _acceptedLocally：本地是否已接听，一旦接听就绝不再因记录读不到而判定对方取消
    _missCount: 0, _acceptedLocally: false,
    // 握手凭证（全自动交换，不出现任何手工复制粘贴）：
    //   inviteCode  邀请码 —— 主叫发起时生成；被叫来电界面自动录入
    //   receiptCode 回执码 —— 被叫接听时生成；自动回传给主叫
    // handshake 仅用于展示，权威状态始终以服务端记录为准
    inviteCode: null, receiptCode: null, handshake: 'idle',
    // 媒体获取失败原因（用于给出"为什么打不通"的确切提示，而不是笼统的"建立失败"）
    _lastStreamErr: '',
    // 视频通话退化为语音时置位（摄像头被占用/被拒绝时不再让整通电话失败）
    _videoDegraded: false,
    // 最近一次成功轮询的时间戳 + 看门狗句柄，用于兜住"界面卡死在通话中"
    _lastPollOkAt: 0, _watchdog: null,
    // 对端上报的摄像头/麦克风开关（带外状态，来自服务端 callPeerMedia）：
    // 判断"对方是否关了摄像头"只能靠它，不能靠 WebRTC 的 track 状态
    peerMedia: null,
    // 是否处于"最小化"形态：只影响界面（横幅/悬浮窗），不影响通话本身
    minimized: false
};
const CALL_ICONS = {
    cam: '<svg viewBox="0 0 24 24"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>',
    camOff: '<svg viewBox="0 0 24 24"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/><path d="M4 20L20 4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
    mic: '<svg viewBox="0 0 24 24"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"/></svg>',
    micOff: '<svg viewBox="0 0 24 24"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"/><path d="M4 20L20 4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
    // 扬声器：开=音量图标，关=静音图标。网页端没法真的切换音频输出设备，
    // 这里做成"对方声音开/关"，语义和使用感受与手机上的免提键接近
    spk: '<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>',
    spkOff: '<svg viewBox="0 0 24 24"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zM19 12c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>'
};
/* ---------------- 通话诊断日志 ----------------
 * 通话是"两个浏览器 + 服务端"的分布式握手，单看代码无法判断卡在哪一步。
 * 这里把每次状态迁移、信令收发、轮询结果都记进一个环形缓冲，并同步打到控制台：
 * 复现一次后在控制台执行 dumpCallLog() 即可拿到带时间戳的完整轨迹，直接贴回给我定位。 */
window.CALL_LOG = [];
/* 通话日志默认"只进内存、不打控制台"。
 * 原因：通话中每 800ms 一轮、空闲时也有 2s 一轮的后台轮询，全打到控制台会把控制台刷满；
 * 而且服务端为能通知终态，会把刚结束的记录也返回一段时间，于是挂断后仍会继续输出。
 * 需要现场跟随时在控制台执行 CALL_DEBUG_ON()，用完 CALL_DEBUG_OFF()；
 * 任何时候都可以用 dumpCallLog() 取回最近 300 条轨迹。 */
window.CALL_DEBUG = false;
window.CALL_DEBUG_ON = function () { window.CALL_DEBUG = true; return '通话日志已打开（会持续输出到控制台）'; };
window.CALL_DEBUG_OFF = function () { window.CALL_DEBUG = false; return '通话日志已关闭'; };
function callLog(tag, detail) {
    const t = new Date().toISOString().substr(11, 12);
    const line = '[' + t + '] ' + tag + (detail !== undefined && detail !== '' ? ' :: ' + (typeof detail === 'object' ? JSON.stringify(detail) : detail) : '');
    window.CALL_LOG.push(line);
    if (window.CALL_LOG.length > 300) window.CALL_LOG.shift();
    if (window.CALL_DEBUG) { try { console.log('[通话] ' + line); } catch (e) {} }
}
window.dumpCallLog = function () {
    const txt = '=== 通话日志（' + window.CALL_LOG.length + ' 条） ===\n' + window.CALL_LOG.join('\n');
    try { console.log(txt); } catch (e) {}
    return txt;
};
// 把 getUserMedia 的错误翻译成用户能照着做的提示，并记录到 CALL._lastStreamErr，
// 这样上层可以给出"为什么打不通"，而不是笼统的"通话建立失败"。
function describeMediaError(e) {
    const name = (e && e.name) || '';
    if (name === 'NotAllowedError' || name === 'PermissionDeniedError') return '麦克风/摄像头权限被拒绝，请在浏览器地址栏点击权限图标允许后重试';
    if (name === 'NotFoundError' || name === 'DevicesNotFoundError') return '未检测到麦克风/摄像头设备';
    if (name === 'NotReadableError' || name === 'TrackStartError') return '麦克风/摄像头被其他程序占用，请关闭占用它的程序后重试';
    if (name === 'OverconstrainedError') return '当前设备不支持所需的音视频参数';
    if (name === 'SecurityError') return '当前页面不是安全环境（需 HTTPS），无法调用麦克风/摄像头';
    return '无法访问麦克风/摄像头，请检查设备与权限';
}
async function ensureLocalStream() {
    if (CALL.localStream) return CALL.localStream;
    if (CALL._streamPromise) return CALL._streamPromise;
    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
        CALL._lastStreamErr = '当前环境不支持通话（需 HTTPS 或 localhost）';
        showToast(CALL._lastStreamErr);
        return null;
    }
    CALL._streamPromise = (async () => {
        const wantVideo = CALL.type === 'video';
        try {
            CALL.localStream = await navigator.mediaDevices.getUserMedia({ video: wantVideo, audio: true });
            CALL._lastStreamErr = '';
            return CALL.localStream;
        } catch(e) {
            // 视频通话拿不到摄像头时退化为语音，而不是让整通电话失败。
            // 否则会出现"允许权限后通话立刻自动关闭"这种最让人困惑的结果——
            // 摄像头被别的程序占用、笔记本没摄像头，都很常见。
            if (wantVideo) {
                try {
                    CALL.localStream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
                    CALL._videoDegraded = true;
                    CALL._lastStreamErr = '';
                    callLog('摄像头不可用，已降级为语音通话', describeMediaError(e));
                    showToast('摄像头不可用，已切换为语音通话');
                    return CALL.localStream;
                } catch(e2) { e = e2; }
            }
            CALL._lastStreamErr = describeMediaError(e);
            callLog('本地媒体获取失败', CALL._lastStreamErr);
            showToast(CALL._lastStreamErr);
            return null;
        }
    })();
    const result = await CALL._streamPromise;
    CALL._streamPromise = null;
    return result;
}
function formatDuration(s) {
    const m = String(Math.floor(s / 60)).padStart(2, '0');
    const ss = String(s % 60).padStart(2, '0');
    return m + ':' + ss;
}
async function getCall() {
    // 带上候选游标：服务端只回"本轮新增"的 ICE 候选，避免每 800ms 重放全量。
    // 必须能超时中止：请求卡死会让轮询永久停摆，通话界面就会一直挂在那里。
    const ctrl = new AbortController();
    const timer = setTimeout(() => { try { ctrl.abort(); } catch(e) {} }, CALL_POLL_TIMEOUT);
    try {
        const res = await fetch('?action=get_call&cand_since=' + (CALL._candSince || 0), { signal: ctrl.signal });
        const data = await res.json();
        CALL._lastPollOkAt = Date.now();   // 供看门狗判断"轮询是否还活着"
        return data;
    } finally {
        clearTimeout(timer);
    }
}
async function postCallAction(action, extra) {
    const fd = new URLSearchParams();
    fd.append('call_id', CALL.callId || '');
    fd.append('action', action);
    if (extra) Object.keys(extra).forEach(k => fd.append(k, extra[k]));
    const url = '?action=respond_call';
    try {
        const res = await fetch(url, { method:'POST', body: fd });
        return await res.json();
    } catch(e) {
        // 网络异常时用 beacon 兜底，保证服务端仍能回收通话记录（避免永久占线）
        try {
            const body = new Blob([fd.toString()], { type:'application/x-www-form-urlencoded' });
            if (navigator.sendBeacon) navigator.sendBeacon(url, body);
        } catch(_) {}
        return {};
    }
}
// 信令写入必须能感知成败：短连接 + 轮询下，静默失败会让通话永远停在"连接中"
async function postSignal(kind, payload) {
    if (!CALL.callId) return false;
    const fd = new URLSearchParams();
    fd.append('call_id', CALL.callId);
    fd.append('kind', kind);
    if (kind === 'state') {
        // 摄像头/麦克风开关走独立字段，不和 SDP 混在同一个参数里
        const st = (payload && typeof payload === 'object') ? payload : {};
        fd.append('cam', st.cam ? '1' : '0');
        fd.append('mic', st.mic ? '1' : '0');
    } else {
        fd.append(kind === 'candidate' ? 'candidate' : 'sdp', payload);
    }
    try {
        const res = await fetch('?action=call_signal', { method:'POST', body: fd });
        const d = await res.json().catch(() => ({}));
        if (!res.ok) {
            if (d && d.code === 'gone') CALL._signalOk = false;
            callLog('信令发送失败', kind + ' HTTP' + res.status + (d && d.code ? (' code=' + d.code) : ''));
            return false;
        }
        CALL._signalOk = true;
        callLog('信令已发送', kind === 'state'
            ? ('state cam=' + (payload && payload.cam ? 1 : 0) + ' mic=' + (payload && payload.mic ? 1 : 0))
            : (kind + ' 长度=' + (payload ? String(payload).length : 0)));
        return true;
    } catch(e) { CALL._signalOk = false; callLog('信令发送异常', kind + ' ' + (e && e.message)); return false; }
}
// 上报本端摄像头/麦克风开关。对端据此显示"对方摄像头已关闭"——
// 只靠 WebRTC 是判断不出来的：关了摄像头对端收到的是黑帧，track 依然 live 且未静音。
function postMediaState() {
    if (!CALL.active || !CALL.callId) return;
    postSignal('state', { cam: !!CALL.cameraOn, mic: !!CALL.micOn }).catch(() => {});
}
// offer/answer 采用退避 + 抖动重发：一次丢包不至于让通话卡死，
// 抖动避免双方锁步重发；服务端按 SDP 去重，重复下发不会打断已建立的媒体流。
function scheduleSignalRetry() {
    clearSignalRetry();
    if (!CALL.active || !CALL.pc) return;
    const delay = 2500 + CALL._retryCount * 1500 + Math.floor(Math.random() * 600);
    CALL._retryTimer = setTimeout(async () => {
        CALL._retryTimer = null;
        if (!CALL.active || !CALL.pc || CALL.currentStatus === 'connected') return;
        if (CALL._retryCount >= 5 || !CALL._signalOk) return;
        CALL._retryCount++;
        try {
            const desc = CALL.pc.localDescription;
            if (desc && desc.sdp) {
                const kind = CALL.role === 'caller' ? 'offer' : 'answer';
                await postSignal(kind, JSON.stringify(desc));
            }
        } catch(e) {}
        scheduleSignalRetry();
    }, delay);
}
function clearSignalRetry() {
    if (CALL._retryTimer) { clearTimeout(CALL._retryTimer); CALL._retryTimer = null; }
}
// 增量应用 ICE 候选：entry 形如 {seq, c}
function applyCandidateList(list, next) {
    if (Array.isArray(list)) {
        list.forEach(entry => {
            const cand = entry && entry.c ? entry.c : entry;
            if (!cand || !cand.candidate) return;
            if (!CALL.pc || !CALL.remoteDescSet) { CALL.pendingCandidates.push(cand); return; }
            CALL.pc.addIceCandidate(cand).catch(() => {});
        });
    }
    const n = parseInt(next, 10);
    if (!isNaN(n) && n > (CALL._candSince || 0)) CALL._candSince = n;
}
function renderCallUI(state) {
    const modal = document.getElementById('callModal');
    // 最小化期间不动全屏界面：状态照常推进，只刷新"最小化形态"（横幅/悬浮窗）。
    // 通话的媒体与信令完全不受影响；恢复时再按当前状态重建全屏界面。
    if (CALL.minimized) { renderCallMini(); return; }
    modal.classList.add('active');
    const peer = CALL.peer || '';
    const isVideo = CALL.type === 'video';
    const kind = isVideo ? '视频通话' : '语音通话';
    const avHtml = `<div class="call-avatar" id="callAvatar" style="background:${stringToColor(peer)};">${escapeHtml(peer.charAt(0).toUpperCase())}</div>`;
    const phoneIco = '<svg viewBox="0 0 24 24"><path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/></svg>';
    const camIco = CALL_ICONS.cam;
    const camOffIco = CALL_ICONS.camOff;
    const micIco = CALL_ICONS.mic;
    const micOffIco = CALL_ICONS.micOff;
    const spkIco = CALL_ICONS.spk;
    const spkOffIco = CALL_ICONS.spkOff;
    const rejectBtn = `<button class="call-btn reject" onclick="rejectCall()">${phoneIco}拒绝</button>`;
    const acceptBtn = `<button class="call-btn accept" onclick="acceptCall()">${isVideo ? camIco : phoneIco}接听</button>`;
    const hangupBtn = `<button class="call-btn hangup" onclick="hangupCall()">${phoneIco}挂断</button>`;
    const cancelBtn = `<button class="call-btn hangup" onclick="cancelCall()">取消</button>`;
    // 按钮文案统一成"状态描述"（麦克风已开 / 摄像头已关 …）：QQ、微信都是这样标的，
    // 一眼能看出当前是开还是关，不用靠颜色去猜
    const camToggleBtn = `<button class="call-btn toggle ${CALL.cameraOn ? 'on' : 'off'}" id="callCamBtn" onclick="toggleCamera()">${CALL.cameraOn ? camIco : camOffIco}${CALL.cameraOn ? '摄像头已开' : '摄像头已关'}</button>`;
    const micToggleBtn = `<button class="call-btn toggle ${CALL.micOn ? 'on' : 'off'}" id="callMicBtn" onclick="toggleMic()">${CALL.micOn ? micIco : micOffIco}${CALL.micOn ? '麦克风已开' : '麦克风已关'}</button>`;
    const spkToggleBtn = `<button class="call-btn toggle ${CALL.speakerOn ? 'on' : 'off'}" id="callSpkBtn" onclick="toggleSpeaker()">${CALL.speakerOn ? spkIco : spkOffIco}${CALL.speakerOn ? '扬声器已开' : '扬声器已关'}</button>`;
    const flipIco = '<svg viewBox="0 0 24 24"><path d="M20 4h-3.17L15 2H9L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-5 11.5V13H9v2.5L5.5 12 9 8.5V11h6V8.5l3.5 3.5-3.5 3.5z"/></svg>';
    const flipBtn = `<button class="call-btn toggle" id="callFlipBtn" onclick="flipCamera()" style="display:${CALL._cameraCount > 1 ? '' : 'none'};">${flipIco}翻转</button>`;
    // 切换画面：主画面在"对方 / 自己"之间对调，小窗跟着换主角，方便看清想看的那一方
    const swapIco = '<svg viewBox="0 0 24 24"><path d="M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></svg>';
    const swapBtn = `<button class="call-btn toggle" id="callSwapBtn" onclick="toggleCallSwap()">${swapIco}切换画面</button>`;

    // 首次进入或通话类型变化时构建屏幕结构；视频元素持久保留，状态切换不再重建，避免画面闪黑/流丢失
    let screen = document.getElementById('callScreen');
    const rebuilt = !screen || (screen.dataset.video || '') !== String(isVideo);
    if (rebuilt) {
        let html;
        if (isVideo) {
            html = `<div class="call-screen video ${state}" id="callScreen" data-video="true">
                <button class="call-min-btn" type="button" onclick="minimizeCall()" title="最小化"><svg viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg></button>
                <div class="call-pane call-pane-remote full" id="callRemotePane">
                    <div class="call-video-placeholder" id="callVideoPlaceholder">${avHtml}<div class="call-wait-hint" id="callWaitHint" style="display:none;">正在等待对方画面…</div></div>
                    <video id="callRemoteVideo" autoplay playsinline></video>
                    <div class="call-remote-off" id="callRemoteOff" style="display:none;">${camOffIco}<span>对方摄像头已关闭</span></div>
                </div>
                <div class="call-pane call-pane-local pip" id="callLocalBox">
                    <video id="callLocalVideo" autoplay playsinline muted></video>
                    <div class="call-local-off" id="callLocalOff" style="display:none;">${camOffIco}<span>摄像头已关闭</span></div>
                </div>
                <div class="call-video-top"><div class="call-name" id="callName">${escapeHtml(peer)}</div><div class="call-status" id="callStatusText"></div></div>
                <div class="call-actions" id="callActions"></div>
            </div>`;
        } else {
            html = `<div class="call-screen" id="callScreen" data-video="false"><button class="call-min-btn" type="button" onclick="minimizeCall()" title="最小化"><svg viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg></button>${avHtml}<div class="call-name" id="callName">${escapeHtml(peer)}</div><div class="call-status" id="callStatusText"></div><div class="call-actions" id="callActions"></div></div>`;
        }
        modal.innerHTML = `<div class="call-overlay-inner">${html}</div>`;
        screen = document.getElementById('callScreen');
    }

    screen.className = 'call-screen' + (isVideo ? ' video ' + state : '');
    // 画面归属（谁铺满、谁进小窗）按当前状态摆好；小窗位置也在这里按记忆恢复。
    // 必须每次都执行：通话弹窗的 DOM 在多通电话之间是保留复用的，
    // 若上一通结束在"切换画面"状态，下一通不重排就会出现两块画面归属错乱。
    if (window.applyCallLayout) window.applyCallLayout();

    const nameEl = document.getElementById('callName');
    if (nameEl) nameEl.textContent = peer;
    const av = document.getElementById('callAvatar');
    if (av) { av.textContent = peer.charAt(0).toUpperCase(); applyDefaultAvatar(av); }

    const statusEl = document.getElementById('callStatusText');
    let statusText = '';
    if (state === 'incoming') statusText = '邀请你进行' + kind;
    else if (state === 'calling') statusText = '正在呼叫，等待对方接听…';
    else if (state === 'connecting') statusText = '正在连接，请稍候…';
    else if (state === 'connected') statusText = '通话中 <span id="callDuration">00:00</span>';
    if (statusEl) statusEl.innerHTML = statusText;

    const actionsEl = document.getElementById('callActions');
    let actionsHtml = '';
    // 排布照 QQ/微信：功能键一排，挂断单独一排居中且更大 —— 这样误触不到挂断
    if (state === 'incoming') {
        actionsHtml = `<div class="call-fn-row">${rejectBtn}${acceptBtn}</div>`;
    } else if (state === 'calling') {
        actionsHtml = `<div class="call-end-row">${cancelBtn}</div>`;
    } else if (state === 'connecting' || state === 'connected') {
        actionsHtml = `<div class="call-fn-row">${micToggleBtn}${spkToggleBtn}${isVideo ? camToggleBtn : ''}${isVideo ? swapBtn : ''}${isVideo ? flipBtn : ''}</div>`
                    + `<div class="call-end-row">${hangupBtn}</div>`;
    }
    if (actionsEl) actionsEl.innerHTML = actionsHtml;

    if (isVideo) {
        const placeholder = document.getElementById('callVideoPlaceholder');
        const remoteVideo = document.getElementById('callRemoteVideo');
        const localBox = document.getElementById('callLocalBox');
        if (state === 'incoming' || state === 'calling') {
            if (placeholder) placeholder.style.display = 'flex';
            if (remoteVideo) remoteVideo.style.display = 'none';
            // 主叫在拨号前就已经拿到了本地流（doConfirmCall 里先取流再拨号），
            // 所以这里照 QQ/微信把自己的画面放进小窗：用户能立刻确认"我的画面正常"，
            // 而不是盯着一屏头像怀疑是设备坏了。被叫此时还没取流，自动不显示。
            if (localBox) localBox.style.display = CALL.localStream ? 'flex' : 'none';
        } else {
            if (placeholder) placeholder.style.display = 'flex';
            if (localBox) localBox.style.display = 'flex';
            updateRemoteVideoState();
        }
    }

    updateCallToggles();
    attachCallStreams();
    if (state === 'connected') { startCallTimer(); clearCallConnectTimer(); }
    else if (state === 'connecting') startCallConnectTimer();
    getAvatar(peer).then(url => {
        const a = document.getElementById('callAvatar');
        if (a && url) { a.style.backgroundImage = `url(${url}?t=${Date.now()})`; a.textContent = ''; }
    });
}
// 通话界面不再显示邀请码/回执码面板。
// 握手本身仍然全自动进行（inviteCode / receiptCode 照常交换，只进通话日志不上面），
// 这样界面上不会暴露通话是用什么方式建立起来的。
/* ===== 通话最小化形态：来电横幅 / 悬浮窗 =====
 * 目的：来电时若用户不在与对方聊天的界面，不要用全屏界面打断他，而是从顶栏滑出横幅；
 * 接通后可以缩成右下角悬浮窗（显示通话时长；视频通话直接显示对方画面），
 * 点悬浮窗随时回到全屏。整个过程通话本身照常进行，媒体与信令都不受影响。 */
function isInPeerConversation() {
    // 来电方发起通话时所在的房间，就是我当前正在看的房间 → 视为"正在和他聊天"
    return !!(CALL.room && currentRoom === CALL.room);
}
/* ===== 本地画面小窗可拖动 =====
 * 默认停在右上角，用户拖到哪儿就记在哪儿（按屏幕比例存，窗口尺寸变了也不会跑到屏幕外）。
 * 用 Pointer Events 统一鼠标与触屏；拖动期间不让事件冒泡，避免顺带触发其它组件。 */
(function initLocalBoxDrag() {
    const modal = document.getElementById('callModal');
    if (!modal) return;
    const POS_KEY = 'chat_call_local_pos';
    let drag = null;

    const screenEl = () => document.getElementById('callScreen');
    // 当前的小窗元素：切换画面后小窗的主角会换人（自己 ⇄ 对方），定位和拖动都以它为准
    const pipEl = () => document.querySelector('#callScreen .call-pane.pip');
    function readSaved() {
        try {
            const o = JSON.parse(localStorage.getItem(POS_KEY) || 'null');
            return (o && typeof o.x === 'number' && typeof o.y === 'number') ? o : null;
        } catch (e) { return null; }
    }
    function place(box, left, top) {
        const sc = screenEl();
        if (!sc || !box) return;
        const maxL = Math.max(0, sc.clientWidth - box.offsetWidth);
        const maxT = Math.max(0, sc.clientHeight - box.offsetHeight);
        box.style.right = 'auto';
        box.style.left = Math.min(Math.max(0, left), maxL) + 'px';
        box.style.top = Math.min(Math.max(0, top), maxT) + 'px';
    }
    // 应用画面归属：默认"对方铺满 + 自己进小窗"，CALL.swapped 为真时对调。
    // 只切类名，不移动 video 节点 —— 移动 DOM 会让播放流重建、画面闪黑。
    window.applyCallLayout = function () {
        const sc = screenEl();
        if (!sc || !sc.classList.contains('video')) return;
        const rp = document.getElementById('callRemotePane');
        const lp = document.getElementById('callLocalBox');
        if (!rp || !lp) return;
        const sw = !!CALL.swapped;
        rp.classList.toggle('full', !sw);
        rp.classList.toggle('pip', sw);
        lp.classList.toggle('full', sw);
        lp.classList.toggle('pip', !sw);
        // 小窗被拖过会留下内联 left/top，轮到它铺满时必须清掉：
        // 内联值优先级高于 .call-pane.full 的 inset:0，不清就会让画面整体错位
        const fullEl = sw ? lp : rp;
        fullEl.style.left = ''; fullEl.style.top = ''; fullEl.style.right = '';
        window.restoreCallLocalPos();
    };
    window.toggleCallSwap = function () {
        CALL.swapped = !CALL.swapped;
        window.applyCallLayout();
        showToast(CALL.swapped ? '已切换：主画面显示自己' : '已切换：主画面显示对方');
    };
    // 界面重建后按记忆位置摆回去；窗口尺寸变化也走这里，保证小窗始终在可视范围内
    window.restoreCallLocalPos = function () {
        const box = pipEl();
        const sc = screenEl();
        if (!box || !sc) return;
        const saved = readSaved();
        if (!saved) return;
        place(box, saved.x * sc.clientWidth, saved.y * sc.clientHeight);
    };
    function persist(box) {
        const sc = screenEl();
        if (!sc || !sc.clientWidth || !sc.clientHeight) return;
        const left = parseFloat(box.style.left) || 0;
        const top = parseFloat(box.style.top) || 0;
        try { localStorage.setItem(POS_KEY, JSON.stringify({ x: left / sc.clientWidth, y: top / sc.clientHeight })); } catch (e) {}
    }
    modal.addEventListener('pointerdown', e => {
        const box = e.target.closest && e.target.closest('.call-pane.pip');
        if (!box || (e.button !== undefined && e.button !== 0)) return;
        if (!screenEl()) return;
        e.preventDefault();
        const r = box.getBoundingClientRect();
        drag = { box, pid: e.pointerId, sx: e.clientX, sy: e.clientY, ox: e.clientX - r.left, oy: e.clientY - r.top, moved: false };
        box.classList.add('dragging');
        try { box.setPointerCapture(e.pointerId); } catch (err) {}
    });
    modal.addEventListener('pointermove', e => {
        if (!drag) return;
        const sc = screenEl();
        if (!sc) return;
        // 位移超过 2px 才算"拖动过"：轻点一下不该写回位置
        if (Math.abs(e.clientX - drag.sx) > 2 || Math.abs(e.clientY - drag.sy) > 2) drag.moved = true;
        e.preventDefault();
        const sr = sc.getBoundingClientRect();
        place(drag.box, e.clientX - sr.left - drag.ox, e.clientY - sr.top - drag.oy);
    });
    const endDrag = () => {
        if (!drag) return;
        const box = drag.box;
        if (drag.moved) persist(box);
        box.classList.remove('dragging');
        try { box.releasePointerCapture(drag.pid); } catch (err) {}
        drag = null;
    };
    modal.addEventListener('pointerup', endDrag);
    modal.addEventListener('pointercancel', endDrag);
    // 双击小窗放大/还原一档：想看清某一方时不用去够底部的按钮
    modal.addEventListener('dblclick', e => {
        const box = e.target.closest && e.target.closest('.call-pane.pip');
        if (!box) return;
        box.classList.toggle('big');
        window.restoreCallLocalPos();
    });
    window.addEventListener('resize', () => { window.restoreCallLocalPos(); });
})();
// 悬浮小窗的大小档位：视频通话看对方画面时，大档明显更好用
window.toggleCallBallSize = function () {
    const ball = document.getElementById('callBall');
    if (!ball) return;
    const big = ball.classList.toggle('big');
    showToast(big ? '通话小窗已放大' : '通话小窗已缩小');
};
window.minimizeCall = function () {
    if (!CALL.active) return;
    CALL.minimized = true;
    const modal = document.getElementById('callModal');
    if (modal) modal.classList.remove('active');
    renderCallMini();
    callLog('通话界面已最小化');
};
function restoreCallUI() {
    if (!CALL.active) return;
    CALL.minimized = false;
    hideCallMini();
    renderCallUI(CALL.currentStatus || 'connecting');
    callLog('通话界面已恢复');
}
function hideCallMini() {
    const banner = document.getElementById('callBanner');
    if (banner) banner.classList.remove('show');
    const ball = document.getElementById('callBall');
    if (ball) ball.classList.remove('show', 'video', 'ringing', 'big');
    const bv = document.getElementById('callBallVideo');
    if (bv) bv.srcObject = null;
}
// 悬浮窗上的文字：接通后是通话时长，未接通是"来电中/呼叫中"
function updateBallTime() {
    const el = document.getElementById('callBallTime');
    if (!el) return;
    if (CALL.currentStatus === 'connected' && CALL.startTs) {
        el.textContent = formatDuration(Math.floor((Date.now() - CALL.startTs) / 1000));
    } else {
        el.textContent = (CALL.role === 'callee') ? '来电中' : '呼叫中';
    }
}
function renderCallMini() {
    if (!CALL.active || !CALL.minimized) { hideCallMini(); return; }
    const connected = (CALL.currentStatus === 'connected');
    const isVideo = CALL.type === 'video';
    const name = CALL.peer || '';
    const banner = document.getElementById('callBanner');
    const ball = document.getElementById('callBall');
    // 未接通 → 顶部横幅（可直接接通/挂断）；已接通 → 右下角悬浮窗
    if (!connected && banner) {
        const av = document.getElementById('cbAvatar');
        if (av) {
            av.textContent = name.charAt(0).toUpperCase();
            applyDefaultAvatar(av);
            getAvatar(name).then(u => { if (u) { av.style.backgroundImage = `url(${u}?t=${Date.now()})`; av.textContent = ''; } });
        }
        const nm = document.getElementById('cbName'); if (nm) nm.textContent = name;
        const kind = isVideo ? '视频通话' : '语音通话';
        const linking = (CALL.currentStatus === 'accepted' || CALL.currentStatus === 'connecting');
        const sub = document.getElementById('cbSub');
        if (sub) {
            sub.textContent = linking
                ? (CALL.role === 'callee' ? '已接通，正在连接…' : '对方已接听，正在连接…')
                : ((CALL.role === 'callee' ? '邀请你' : '正在呼叫') + kind);
        }
        // "接通"只在对方还在响铃时才有意义；已接通/正在连接时收起来，避免误点
        const acc = document.getElementById('cbAccept');
        if (acc) acc.style.display = (CALL.role === 'callee' && CALL.currentStatus === 'ringing') ? '' : 'none';
        banner.classList.add('show');
    } else if (banner) {
        banner.classList.remove('show');
    }
    if (connected && ball) {
        // 视频通话且已收到对方画面 → 悬浮窗直接显示对方画面；否则退回图标+计时
        const showVideo = isVideo && !!CALL.remoteStream && !(CALL.peerMedia && CALL.peerMedia.cam === false);
        ball.classList.toggle('video', showVideo);
        ball.classList.remove('ringing');
        const bv = document.getElementById('callBallVideo');
        if (bv) {
            // 只在需要变化时重新绑定，避免每次刷新都打断悬浮窗的播放
            const want = showVideo ? CALL.remoteStream : null;
            if (bv.srcObject !== want) { bv.srcObject = want; if (want) bv.play().catch(() => {}); }
        }
        ball.classList.add('show');
        updateBallTime();
    } else if (ball) {
        ball.classList.remove('show');
    }
}
// 横幅与悬浮窗的交互（点横幅/悬浮窗回到全屏；横幅上的按钮直接接通或挂断）
(function initCallMini() {
    const banner = document.getElementById('callBanner');
    if (banner) banner.addEventListener('click', (e) => { if (!e.target.closest('.cb-btn')) restoreCallUI(); });
    const rej = document.getElementById('cbReject');
    if (rej) rej.addEventListener('click', (e) => {
        e.stopPropagation();
        if (CALL.role === 'callee') rejectCall(); else cancelCall();
    });
    const acc = document.getElementById('cbAccept');
    if (acc) acc.addEventListener('click', async (e) => {
        e.stopPropagation();
        if (CALL.role !== 'callee') return;
        // 从横幅接通：直接以悬浮窗形态开始通话；想看全屏就点悬浮窗
        await acceptCall();
        CALL.minimized = true;
        renderCallMini();
    });
    const ball = document.getElementById('callBall');
    if (ball) ball.addEventListener('click', () => restoreCallUI());
})();
function attachCallStreams() {
    if (CALL.localStream) {
        const lv = document.getElementById('callLocalVideo');
        if (lv) {
            if (lv.srcObject !== CALL.localStream) lv.srcObject = CALL.localStream;
            lv.play().catch(()=>{});
        }
    }
    if (CALL.remoteStream) {
        const rv = document.getElementById('callRemoteVideo');
        if (rv) {
            if (rv.srcObject !== CALL.remoteStream) rv.srcObject = CALL.remoteStream;
            rv.muted = !CALL.speakerOn;
            rv.play().catch(()=>{});
        }
        if (CALL.type !== 'video') {
            const ra = document.getElementById('callRemoteAudio');
            if (ra) {
                if (ra.srcObject !== CALL.remoteStream) ra.srcObject = CALL.remoteStream;
                ra.muted = !CALL.speakerOn;
                ra.play().catch(()=>{});
            }
        }
    }
}
function toggleCamera() {
    if (!CALL.localStream) return;
    const track = CALL.localStream.getVideoTracks()[0];
    if (!track) { showToast('当前通话没有视频'); return; }
    track.enabled = !track.enabled;
    CALL.cameraOn = track.enabled;
    postMediaState();   // 必须主动告诉对端，否则对端只是看到黑屏而不知道摄像头已关
    updateCallToggles();
}
// 扬声器：网页端无法直接切换音频输出设备，这里实现为“远端声音开关”
function toggleSpeaker() {
    CALL.speakerOn = !CALL.speakerOn;
    if (CALL.remoteStream) {
        CALL.remoteStream.getAudioTracks().forEach(t => { t.enabled = CALL.speakerOn; });
    }
    const ra = document.getElementById('callRemoteAudio');
    if (ra) ra.muted = !CALL.speakerOn;
    const rv = document.getElementById('callRemoteVideo');
    if (rv) rv.muted = !CALL.speakerOn;
    updateCallToggles();
    showToast(CALL.speakerOn ? '已开启扬声器' : '已关闭扬声器');
}
function toggleMic() {
    if (!CALL.localStream) return;
    const track = CALL.localStream.getAudioTracks()[0];
    if (!track) return;
    track.enabled = !track.enabled;
    CALL.micOn = track.enabled;
    postMediaState();
    updateCallToggles();
}
// 检测可用摄像头数量，决定是否显示"翻转"按钮（仅视频通话使用）
async function updateCameraCount() {
    try {
        const devices = await navigator.mediaDevices.enumerateDevices();
        CALL._cameraCount = devices.filter(d => d.kind === 'videoinput').length;
    } catch(e) { CALL._cameraCount = 1; }
    updateFlipBtnVisibility();
}
function updateFlipBtnVisibility() {
    const btn = document.getElementById('callFlipBtn');
    if (btn) btn.style.display = (CALL._cameraCount > 1) ? '' : 'none';
}
// 翻转摄像头：前后摄像头切换（手机前摄/后摄，或电脑多个摄像头）
async function flipCamera() {
    if (!CALL.localStream || CALL.type !== 'video') return;
    if (CALL._flipInProgress) return;
    CALL._flipInProgress = true;
    const oldTrack = CALL.localStream.getVideoTracks()[0];
    const oldId = oldTrack ? (oldTrack.getSettings().deviceId || '') : '';
    try {
        const devices = await navigator.mediaDevices.enumerateDevices();
        const cams = devices.filter(d => d.kind === 'videoinput');
        if (cams.length < 2) { showToast('当前设备没有可切换的摄像头'); return; }
        const next = cams.find(c => c.deviceId !== oldId) || cams[0];
        // 先释放旧摄像头再切换：移动端同一时间只能有一个视频轨道占用摄像头，
        // 不先 stop 会导致 getUserMedia 报 NotReadableError（摄像头被占用）
        if (oldTrack) { CALL.localStream.removeTrack(oldTrack); oldTrack.stop(); }
        let newStream;
        try {
            // 优先用 facingMode 切换前后摄（移动端最可靠），桌面多摄像头退回 deviceId
            const curFacing = oldTrack ? (oldTrack.getSettings().facingMode || '') : '';
            let videoConstraint;
            if (curFacing === 'user') videoConstraint = { facingMode: 'environment' };
            else if (curFacing === 'environment') videoConstraint = { facingMode: 'user' };
            else videoConstraint = { deviceId: next.deviceId };
            // 用非 exact 约束，避免 OverconstrainedError；仅取视频轨道，保留原音频
            newStream = await navigator.mediaDevices.getUserMedia({ video: videoConstraint, audio: false });
        } catch(e) {
            // 切换失败时尝试恢复原摄像头，避免画面丢失
            newStream = await navigator.mediaDevices.getUserMedia({ video: oldId ? { deviceId: oldId } : true, audio: false });
        }
        const newVideoTrack = newStream.getVideoTracks()[0];
        if (!newVideoTrack) { showToast('摄像头切换失败'); return; }
        CALL.localStream.addTrack(newVideoTrack);
        // 保持摄像头开关状态
        newVideoTrack.enabled = CALL.cameraOn;
        // 更新 RTCPeerConnection 的视频发送轨道
        const sender = CALL.pc ? CALL.pc.getSenders().find(s => s.track && s.track.kind === 'video') : null;
        if (sender) await sender.replaceTrack(newVideoTrack);
        // 更新本地预览
        const lv = document.getElementById('callLocalVideo');
        if (lv) { lv.srcObject = CALL.localStream; lv.play().catch(()=>{}); }
    } catch(e) {
        const name = (e && e.name) || '';
        let msg = '摄像头切换失败';
        if (name === 'NotReadableError' || name === 'TrackStartError') msg = '摄像头被占用，请稍后重试';
        else if (name === 'NotAllowedError') msg = '摄像头权限被拒绝';
        else if (name === 'NotFoundError') msg = '未找到可切换的摄像头';
        showToast(msg);
    } finally {
        CALL._flipInProgress = false;
    }
}
function updateCallToggles() {
    // 文案统一成"状态描述"（摄像头已开/已关、麦克风已开/已关），与 QQ/微信一致：
    // 一眼看出当前是开还是关，不必靠颜色去猜。三个按钮必须同一种风格，
    // 否则会出现"扬声器已开"和"关闭摄像头"混排的割裂感。
    const camBtn = document.getElementById('callCamBtn');
    if (camBtn) {
        // 本地流里没有视频轨时（摄像头被占用/无摄像头，已降级为语音），按钮必须说实话：
        // 还显示"摄像头已开"会让用户以为"看不到人"是对方的问题，排查方向直接跑偏
        const noCamDevice = !!(CALL.localStream && !CALL.localStream.getVideoTracks().length);
        const on = CALL.cameraOn && !noCamDevice;
        camBtn.className = 'call-btn toggle ' + (on ? 'on' : 'off');
        camBtn.innerHTML = (on ? CALL_ICONS.cam : CALL_ICONS.camOff)
                         + (noCamDevice ? '摄像头不可用' : (on ? '摄像头已开' : '摄像头已关'));
    }
    const micBtn = document.getElementById('callMicBtn');
    if (micBtn) {
        micBtn.className = 'call-btn toggle ' + (CALL.micOn ? 'on' : 'off');
        micBtn.innerHTML = (CALL.micOn ? CALL_ICONS.mic : CALL_ICONS.micOff) + (CALL.micOn ? '麦克风已开' : '麦克风已关');
    }
    // 本地预览：除了"自己关了摄像头"，还要覆盖"本地流里根本没有视频轨"的情况
    // （摄像头被占用/没摄像头，已降级为语音）。那时若还照常显示 <video>，
    // 用户看到的就是一片黑，会以为是自己这边坏了或对方看不到自己 —— 必须给出说明文字。
    const hasStream = !!CALL.localStream;
    const hasLocalVideo = !!(hasStream && CALL.localStream.getVideoTracks().length);
    const localVideoOn = CALL.cameraOn && hasLocalVideo;
    const lv = document.getElementById('callLocalVideo');
    const lo = document.getElementById('callLocalOff');
    if (lv) lv.style.display = localVideoOn ? '' : 'none';
    if (lo) {
        lo.style.display = localVideoOn ? 'none' : 'flex';
        const txt = lo.querySelector('span');
        if (txt) {
            // 三种情况说清楚，尤其"还没拿到流"不能报成"摄像头不可用"，否则是误报
            txt.textContent = hasLocalVideo ? '摄像头已关闭'
                            : (hasStream ? '摄像头不可用' : '正在开启摄像头…');
        }
    }
    // 被叫是在接听后（initCalleePC）才拿到本地流的，而界面那时可能已经画成"连接中"，
    // 本地小窗还停在隐藏状态。这里补一次显示，保证接听方也能看到自己的画面 ——
    // 双方都看得到人，才是 QQ/微信那种通话体验。
    const lb = document.getElementById('callLocalBox');
    if (lb && CALL.localStream && CALL.currentStatus &&
        CALL.currentStatus !== 'incoming' && CALL.currentStatus !== 'ringing') {
        lb.style.display = 'flex';
    }
    const spkBtn = document.getElementById('callSpkBtn');
    if (spkBtn) {
        spkBtn.className = 'call-btn toggle ' + (CALL.speakerOn ? 'on' : 'off');
        spkBtn.innerHTML = (CALL.speakerOn ? CALL_ICONS.spk : CALL_ICONS.spkOff) + (CALL.speakerOn ? '扬声器已开' : '扬声器已关');
    }
}
function updateRemoteVideoState(sustainedMute) {
    const rv = document.getElementById('callRemoteVideo');
    const ro = document.getElementById('callRemoteOff');
    if (!rv) return;
    // 首选"对端主动上报的摄像头开关"。对端关摄像头时，本端收到的只是黑帧，
    // track 既不会 ended 也不会 muted，靠 track 状态永远判断不出来（这是原来的 bug）。
    // 只有拿不到带外状态时（例如对端是旧版客户端），才退回 track.muted 的判断。
    const explicitOff = CALL.peerMedia ? (CALL.peerMedia.cam === false) : null;
    const muted = CALL.remoteVideoTrack ? CALL.remoteVideoTrack.muted : false;
    // 兜底判断必须要求"持续静音"（由 handleRemoteMute 观察 3 秒后才传 true）。
    // 原来只要 800ms 看不到画面就认定对方关了摄像头并隐藏画面，
    // 一次网络抖动、编码器瞬时暂停都会让对方的画面凭空消失 —— 用户会以为对方掉线了。
    const camOff = (explicitOff !== null) ? explicitOff : (!!sustainedMute && muted && CALL._remoteHadVideo);
    const hasStream = !!CALL.remoteStream;
    const hasRemoteVideo = !!(CALL.remoteStream && CALL.remoteStream.getVideoTracks().length);
    const showOff = hasStream && camOff && CALL.currentStatus === 'connected';
    rv.style.display = hasStream ? '' : 'none';
    if (ro) ro.style.display = showOff ? 'flex' : 'none';
    rv.style.visibility = (hasStream && !camOff) ? 'visible' : 'hidden';
    // 已接通、对方也没关摄像头，但画面就是没到：必须说清楚在等什么，
    // 否则界面上只剩一个头像，用户会以为通话坏了（"看不到对方"的反馈多来自这里）
    const hint = document.getElementById('callWaitHint');
    if (hint) {
        const waiting = CALL.type === 'video' && CALL.currentStatus === 'connected' && !camOff && !hasRemoteVideo;
        hint.style.display = waiting ? '' : 'none';
    }
    // 最小化期间：悬浮窗要在"对方画面"和"图标+计时"之间切换，这里跟着刷新一下
    if (CALL.minimized) renderCallMini();
}
function handleRemoteMute() {
    // track.muted 只表示"暂时收不到画面"，可能来自网络抖动或编码器暂停，
    // 并不等于"对方关了摄像头"。所以拉长观察窗到 3 秒，且只在拿不到对端
    // 上报状态时才用它兜底；正常情况下对端的开关由 postMediaState 直接告诉我们。
    clearTimeout(CALL._remoteMuteTimer);
    CALL._remoteMuteTimer = setTimeout(() => {
        if (CALL.peerMedia) { updateRemoteVideoState(); return; }
        if (CALL.remoteVideoTrack && CALL.remoteVideoTrack.muted) updateRemoteVideoState(true);
    }, 3000);
}
function handleRemoteUnmute() {
    clearTimeout(CALL._remoteMuteTimer);
    CALL._remoteHadVideo = true;   // 收到过画面后才允许后续判定"对方已关闭摄像头"
    updateRemoteVideoState();
}
/**
 * "已接通但对方画面迟迟不来"的兜底。
 * WebRTC 连接建立（pc.connected）不等于视频帧已经送达：对端只发了音频、
 * 协商里少了视频轨、或媒体收流被卡住，都会表现成"接通了却看不到人"。
 * 这里观察 6 秒，仍拿不到对方的视频轨就给出提示并重新协商一次；
 * 只由主叫发起 —— 双方同时重协商会让服务器 offer 槽互相覆盖，反而更糟。
 */
function startRemoteVideoWatchdog() {
    clearTimeout(CALL._remoteVideoTimer);
    CALL._remoteVideoTimer = null;
    if (CALL.type !== 'video') return;
    CALL._remoteVideoTimer = setTimeout(() => {
        CALL._remoteVideoTimer = null;
        if (!CALL.active || CALL.currentStatus !== 'connected') return;
        if (CALL.remoteStream && CALL.remoteStream.getVideoTracks().length) return;  // 画面已到，无需干预
        if (CALL.peerMedia && CALL.peerMedia.cam === false) return;                  // 对方主动关摄像头，属正常
        callLog('已接通但未收到对方画面，尝试重新协商');
        updateRemoteVideoState();   // 立刻把"正在等待对方画面…"显示出来
        if (CALL.role === 'caller' && !CALL._videoRenegotiated && CALL.pc) {
            CALL._videoRenegotiated = true;
            try {
                CALL.pc.restartIce();
                CALL.pc.createOffer()
                    .then(o => CALL.pc.setLocalDescription(o))
                    .then(() => postSignal('offer', JSON.stringify(CALL.pc.localDescription)))
                    .catch(() => {});
            } catch (e) {}
        }
    }, 6000);
}
function startCallTimer() {
    stopCallTimer();
    // 已经计过时就不重置起点：否则从悬浮窗恢复全屏界面时会重新调用 renderCallUI('connected')，
    // 把通话时长归零。cleanupCall 会把 startTs 清 0，所以下一通电话仍是全新计时。
    if (!CALL.startTs) CALL.startTs = Date.now();
    CALL.durationTimer = setInterval(() => {
        const el = document.getElementById('callDuration');
        if (el) el.textContent = formatDuration(Math.floor((Date.now() - CALL.startTs) / 1000));
        if (CALL.minimized) updateBallTime();   // 悬浮窗上的计时也要跟着走
    }, 1000);
}
function stopCallTimer() { if (CALL.durationTimer) { clearInterval(CALL.durationTimer); CALL.durationTimer = null; } }
// 连接建立超时：进入 connecting 状态后若长时间未连通则提示失败，避免一直卡在"正在连接"
function startCallConnectTimer() {
    clearCallConnectTimer();
    CALL._connectTimer = setTimeout(() => {
        if (CALL.currentStatus === 'connecting' || CALL.currentStatus === 'accepted') {
            if (CALL.pc && CALL.pc.connectionState !== 'connected') {
                // 区分"网络无法直连"与"一般网络抖动"，给出可操作的下一步
                const why = (CALL.pc.iceConnectionState === 'failed' || CALL.pc.iceConnectionState === 'disconnected')
                    ? (CALL_HAS_TURN ? '双方网络受限，建议切换网络（如手机热点）后重试' : '双方网络无法直连，需在后台配置 TURN 中继后才能跨网络通话')
                    : '对方可能已离线或网络不稳定，请稍后重试';
                showToast('连接超时：' + why);
                hangupCall();
            }
        }
    }, CALL_CONNECT_TIMEOUT);
}
function clearCallConnectTimer() {
    if (CALL._connectTimer) { clearTimeout(CALL._connectTimer); CALL._connectTimer = null; }
}
function startRing() {
    stopRing();
    try {
        const ctx = new (window.AudioContext || window.webkitAudioContext)();
        CALL._ringCtx = ctx;
        if (ctx.state === 'suspended') ctx.resume().catch(()=>{});
        const play = () => {
            if (!ctx) return;
            const t = ctx.currentTime;
            for (let i = 0; i < 3; i++) {
                const o = ctx.createOscillator(), g = ctx.createGain();
                o.type = 'sine'; o.frequency.value = i % 2 ? 800 : 620;
                const off = t + i * 0.4;
                g.gain.setValueAtTime(0, off);
                g.gain.linearRampToValueAtTime(0.22, off + 0.03);
                g.gain.linearRampToValueAtTime(0, off + 0.22);
                o.connect(g); g.connect(ctx.destination);
                o.start(off); o.stop(off + 0.25);
            }
        };
        play();
        CALL._ringTimer = setInterval(play, 1700);
    } catch(e) {}
}
function stopRing() {
    if (CALL._ringTimer) { clearInterval(CALL._ringTimer); CALL._ringTimer = null; }
    if (CALL._ringCtx) { CALL._ringCtx.close().catch(()=>{}); CALL._ringCtx = null; }
}
// 主叫回铃音：等待对方接听时播放
function startRingback() {
    stopRing();
    try {
        const ctx = new (window.AudioContext || window.webkitAudioContext)();
        CALL._ringCtx = ctx;
        if (ctx.state === 'suspended') ctx.resume().catch(()=>{});
        const play = () => {
            if (!ctx) return;
            const t = ctx.currentTime;
            for (let i = 0; i < 4; i++) {
                const o = ctx.createOscillator(), g = ctx.createGain();
                o.type = 'sine'; o.frequency.value = 440;
                const off = t + i * 0.5;
                g.gain.setValueAtTime(0, off);
                g.gain.linearRampToValueAtTime(0.18, off + 0.03);
                g.gain.linearRampToValueAtTime(0, off + 0.4);
                o.connect(g); g.connect(ctx.destination);
                o.start(off); o.stop(off + 0.42);
            }
        };
        play();
        CALL._ringTimer = setInterval(play, 2200);
    } catch(e) {}
}
// 离开/刷新/断网时也要让服务端回收通话记录。
// 旧版本只在"点挂断"时通知服务端，刷新页面或直接关标签会留下 accepted 记录永久占线，
// 之后双方再呼叫任何人都会提示"对方正在通话中"——这是本次修复的核心。
function notifyCallEnd() {
    if (CALL.endedByMe) return;   // 已经显式挂断/取消过，无需重复通知
    if (!CALL.callId) return;
    const id = CALL.callId, st = CALL.currentStatus, role = CALL.role;
    CALL.callId = null;   // 防止重复发送
    let action = null;
    if (st === 'connecting' || st === 'connected' || st === 'accepted') action = 'end';
    else if (st === 'ringing' && role === 'caller') action = 'cancel';
    else return;          // 被叫响铃中离开：交给服务端响铃超时兜底，不误报"挂断"
    try {
        const fd = new URLSearchParams();
        fd.append('call_id', id);
        fd.append('action', action);
        const url = '?action=respond_call';
        // sendBeacon 必须是表单类型，否则 PHP 的 $_POST 取不到参数
        const body = new Blob([fd.toString()], { type: 'application/x-www-form-urlencoded' });
        if (navigator.sendBeacon && navigator.sendBeacon(url, body)) return;
        fetch(url, { method:'POST', body: fd, keepalive: true }).catch(() => {});
    } catch(e) {}
}
function cleanupCall() {
    notifyCallEnd();
    stopRing(); stopCallTimer(); stopFastCallPoll(); clearCallConnectTimer(); clearSignalRetry(); stopCallWatchdog();
    CALL.minimized = false; hideCallMini();   // 通话结束，横幅/悬浮窗一起收掉
    CALL.active = false; CALL.role = null; CALL.callId = null; CALL.peer = null; CALL.room = null;
    CALL.currentStatus = null; CALL.pendingCandidates = []; CALL.remoteDescSet = false; CALL.endedByMe = false;
    CALL.startTs = 0;   // 重置通话计时，防止下一通电话挂断时误用旧时长
    CALL.cameraOn = true; CALL.micOn = true; CALL.remoteVideoTrack = null; CALL.swapped = false; CALL.speakerOn = true;
    CALL._streamPromise = null;
    CALL._iceRestarted = false;
    if (CALL._iceFailTimer) { clearTimeout(CALL._iceFailTimer); CALL._iceFailTimer = null; }
    CALL._connectTimer = null;
    CALL._cameraCount = 1; CALL._flipInProgress = false;
    CALL._lastOfferSdp = ''; CALL._lastAnswerSdp = '';
    CALL._remoteHadVideo = false;
    CALL._videoRenegotiated = false;   // 允许下一通电话再自动重协商一次
    CALL._candSince = 0; CALL._retryCount = 0; CALL._signalOk = true; CALL._peerSeenWarned = false;
    // 握手凭证随通话一起清空，避免下一通电话复用上一通的码
    CALL.inviteCode = null; CALL.receiptCode = null; CALL.handshake = 'idle';
    // 媒体相关状态复位：否则上一通留下的失败原因/降级标记会影响下一通
    CALL._lastStreamErr = ''; CALL._videoDegraded = false;
    CALL._lastPollOkAt = 0;
    CALL.peerMedia = null;   // 对端开关状态不得跨通话残留
    CALL._viaRelay = false;
    if (CALL._remoteMuteTimer) { clearTimeout(CALL._remoteMuteTimer); CALL._remoteMuteTimer = null; }
    if (CALL._remoteVideoTimer) { clearTimeout(CALL._remoteVideoTimer); CALL._remoteVideoTimer = null; }
    if (CALL.pc) { try { CALL.pc.close(); } catch(e) {} CALL.pc = null; }
    if (CALL.localStream) { CALL.localStream.getTracks().forEach(t => t.stop()); CALL.localStream = null; }
    CALL.remoteStream = null;
    const ra = document.getElementById('callRemoteAudio'); if (ra) ra.srcObject = null;
    const rv = document.getElementById('callRemoteVideo'); if (rv) rv.srcObject = null;
    const lv = document.getElementById('callLocalVideo'); if (lv) lv.srcObject = null;
    const modal = document.getElementById('callModal');
    modal.classList.remove('active');
}
async function startCall(peer, room, type) {
    if (CALL.active) { showToast('当前有通话进行中'); return; }
    type = type || 'voice';
    const fd = new URLSearchParams();
    fd.append('to', peer); fd.append('room', room || ''); fd.append('type', type);
    try {
        const res = await fetch('?action=start_call', { method:'POST', body: fd });
        const d = await res.json();
        if (!d.success) {
            showToast(d.error || '无法发起通话');
            if (CALL.localStream) { CALL.localStream.getTracks().forEach(t => t.stop()); CALL.localStream = null; }
            return;
        }
        CALL.active = true; CALL.role = 'caller'; CALL.callId = d.call_id;
        CALL.type = type; CALL.peer = peer; CALL.room = room || null; CALL.currentStatus = 'ringing';
        // 邀请码由服务端在"发起"这一刻生成并随响应返回，直接落到界面上，
        // 对方接通时自动出现在他的来电界面里，中间不需要任何手工传递
        CALL.inviteCode = d.invite_code || null;
        CALL.receiptCode = null;
        CALL.handshake = 'inviting';
        callLog('邀请码已生成', CALL.inviteCode);
        startFastCallPoll();
        renderCallUI('calling');
        startRingback();
    } catch(e) {
        showToast('发起失败');
        if (CALL.localStream) { CALL.localStream.getTracks().forEach(t => t.stop()); CALL.localStream = null; }
    }
}
async function acceptCall() {
    if (CALL.role !== 'callee' || CALL.currentStatus !== 'ringing') return;
    // 先同步在本地进入"已接听"，再去请求服务端。
    // 原因：轮询间隔很短，若等 POST 返回才改状态，这期间服务端已不再是 ringing，
    // 那一轮轮询会把"读不到 incoming"误判成"对方已取消"，直接把通话拆掉。
    CALL._acceptedLocally = true;
    CALL._missCount = 0;
    CALL.currentStatus = 'accepted';
    stopRing();
    renderCallUI('connecting');
    callLog('已点接听（本地先行，随后上报服务端）');
    // 先确保本地媒体就绪再上报接听：拿不到流就直接拒接并说明原因，
    // 避免"接听后立刻自动挂断"这种看起来像 bug 的结果。
    // 权限弹窗仍在这次点击的手势调用链内，不会被浏览器拦截。
    const s = await ensureLocalStream();
    if (!s) {
        CALL._acceptedLocally = false;
        callLog('接听中止：本地媒体不可用', CALL._lastStreamErr || '');
        await postCallAction('reject');
        cleanupCall();
        return;
    }
    const d = await postCallAction('accept');
    if (d && d.code === 'gone') {
        // 服务端明确答复"这通电话已经不存在了" → 可以安全收尾
        CALL._acceptedLocally = false;
        callLog('接听上报失败', d.error || d.code);
        showToast(d.error || '接听失败，对方可能已挂断');
        cleanupCall();
        return;
    }
    if (!d || (d.success === undefined && !d.error)) {
        // 结果未知：请求超时/断网（beacon 兜底可能已送达服务端）。
        // 未知不等于失败——服务端很可能已经接听成功，此时拆掉界面反而误杀。
        // 交给轮询确认：真没送达的话，对方的响铃超时会结束通话并从这里收尾。
        callLog('接听上报结果未知，交由轮询确认');
        return;
    }
    if (!d.success) {
        CALL._acceptedLocally = false;
        callLog('接听上报被拒', d.error || d.code || '');
        showToast(d.error || '接听失败，对方可能已挂断');
        cleanupCall();
        return;
    }
    callLog('接听已上报成功');
    // 回执码由服务端生成，随这次接听响应一起回来；同一轮里主叫端也会从通话记录中读到它。
    // 这里做兜底合并（d.invite_code 缺省时保留原值），保证两端看到的凭证一致。
    CALL.inviteCode = d.invite_code || CALL.inviteCode;
    CALL.receiptCode = d.receipt_code || null;
    CALL.handshake = 'accepted';
    callLog('回执码已生成并自动回传', CALL.receiptCode);
    renderCallUI('connecting');   // 重绘一次，把回执码显示出来
}
async function rejectCall() {
    CALL.endedByMe = true;
    if (CALL.role === 'callee') await postCallAction('reject');
    cleanupCall();
}
async function cancelCall() {
    CALL.endedByMe = true;
    await postCallAction('cancel');
    cleanupCall();
}
async function hangupCall() {
    CALL.endedByMe = true;
    const dur = CALL.startTs ? Math.floor((Date.now() - CALL.startTs) / 1000) : 0;
    await postCallAction('end', { duration: dur });
    cleanupCall();
}
async function initCallerPC() {
    CALL.pc = new RTCPeerConnection(CALL_STUN);
    // 注意：不能清空 pendingCandidates——pc 尚未创建时收到的候选已被缓存，
    // 清空会造成候选永久丢失（游标已前进，服务端不会重发），表现为"一直连接中"
    CALL.remoteDescSet = false;
    if (!CALL.localStream) {
        const s = await ensureLocalStream();
        if (!s) { hangupCall(); return; }
    }
    updateCameraCount();   // 检测可用摄像头数量，决定是否显示"翻转"按钮
    CALL.localStream.getTracks().forEach(t => CALL.pc.addTrack(t, CALL.localStream));
    const lv = document.getElementById('callLocalVideo');
    if (lv) { lv.srcObject = CALL.localStream; lv.play().catch(()=>{}); }
    // 本地流是在这一步才拿到的，而界面可能更早就画好了：
    // 必须立刻同步一次预览状态，否则"正在开启摄像头…"的浮层会一直盖住自己的画面
    updateCallToggles();
    CALL.pc.ontrack = e => { if (e.streams && e.streams[0]) attachRemoteStream(e.streams[0]); };
    CALL.pc.onicecandidate = e => { if (e.candidate) postSignal('candidate', JSON.stringify(e.candidate)); };
    CALL.pc.onconnectionstatechange = onCallConnected;
    CALL.pc.oniceconnectionstatechange = onCallConnected;
    const offer = await CALL.pc.createOffer();
    await CALL.pc.setLocalDescription(offer);
    await postSignal('offer', JSON.stringify(offer));
    scheduleSignalRetry();   // offer 丢包时按退避重发，避免一直卡在"连接中"
}
async function initCalleePC(offerSdp) {
    CALL.pc = new RTCPeerConnection(CALL_STUN);
    // 同上：保留 pc 创建前已缓存的候选，避免丢候选导致连接卡住
    CALL.remoteDescSet = false;
    if (!CALL.localStream) {
        const s = await ensureLocalStream();
        if (!s) { hangupCall(); return; }
    }
    updateCameraCount();   // 检测可用摄像头数量，决定是否显示"翻转"按钮
    CALL.localStream.getTracks().forEach(t => CALL.pc.addTrack(t, CALL.localStream));
    const lv = document.getElementById('callLocalVideo');
    if (lv) { lv.srcObject = CALL.localStream; lv.play().catch(()=>{}); }
    // 同上：本地流到位后立刻同步预览状态，避免说明浮层残留盖住自己的画面
    updateCallToggles();
    CALL.pc.ontrack = e => { if (e.streams && e.streams[0]) attachRemoteStream(e.streams[0]); };
    CALL.pc.onicecandidate = e => { if (e.candidate) postSignal('candidate', JSON.stringify(e.candidate)); };
    CALL.pc.onconnectionstatechange = onCallConnected;
    CALL.pc.oniceconnectionstatechange = onCallConnected;
    await CALL.pc.setRemoteDescription(offerSdp);
    CALL._lastOfferSdp = (offerSdp && offerSdp.sdp) || '';
    CALL.remoteDescSet = true;
    flushPendingCandidates();
    const answer = await CALL.pc.createAnswer();
    await CALL.pc.setLocalDescription(answer);
    await postSignal('answer', JSON.stringify(answer));
    scheduleSignalRetry();   // answer 丢包时按退避重发，避免主叫一直卡在"连接中"
}
function attachRemoteStream(stream) {
    CALL.remoteStream = stream;
    const rv = document.getElementById('callRemoteVideo');
    if (rv) { rv.srcObject = stream; rv.play().catch(()=>{}); }
    if (CALL.type !== 'video') {
        const ra = document.getElementById('callRemoteAudio');
        if (ra) { ra.srcObject = stream; ra.play().catch(()=>{}); }
    }
    CALL.remoteVideoTrack = stream.getVideoTracks()[0] || null;
    if (CALL.remoteVideoTrack) {
        // 对方的画面已经到了，撤掉"等待对方画面"的观察，避免多余的重新协商
        clearTimeout(CALL._remoteVideoTimer);
        CALL._remoteVideoTimer = null;
        CALL.remoteVideoTrack.onmute = handleRemoteMute;
        CALL.remoteVideoTrack.onunmute = handleRemoteUnmute;
    }
    updateRemoteVideoState();
    attachCallStreams();
}
function onCallConnected() {
    if (!CALL.pc) return;
    const cs = CALL.pc.connectionState;
    const ics = CALL.pc.iceConnectionState;
    if (cs === 'connected' || ics === 'connected') {
        if (CALL.currentStatus !== 'connected') {
            CALL.currentStatus = 'connected';
            stopRing();
            clearCallConnectTimer();
            clearSignalRetry();
            CALL._retryCount = 0;
            if (CALL._iceFailTimer) { clearTimeout(CALL._iceFailTimer); CALL._iceFailTimer = null; }
            renderCallUI('connected');
            callLog('已接通', 'pc=' + (CALL.pc ? CALL.pc.connectionState : 'none'));
            postMediaState();   // 接通即上报一次本端摄像头/麦克风开关，对端才能正确显示状态
            detectRelay();
            startRemoteVideoWatchdog();   // 视频通话：盯一下"接通了但对方画面没到"
            startFastCallPoll();   // 接通后把轮询放慢到 800ms，信令此时只需保活
        }
        CALL._iceRestarted = false;   // 连接成功后允许通话中途掉线时再次尝试重启
        return;
    }
    if (cs !== 'failed' && ics !== 'failed') return;
    if (CALL.currentStatus === 'connected') return;
    // 只有主叫负责 ICE 重启：被叫被动等待新 offer 并回应答。
    // 若双方同时重启，服务器 offer 槽会被互相覆盖，导致协商错乱、重启失败
    if (CALL.role !== 'caller') return;
    if (CALL._iceRestarted) return;
    CALL._iceRestarted = true;
    try {
        CALL.pc.restartIce();
        CALL.pc.createOffer().then(offer => CALL.pc.setLocalDescription(offer))
            .then(() => {
                postSignal('offer', JSON.stringify(CALL.pc.localDescription));
                // 重启协商需多轮信令往返（offer→answer→candidate），给足时间再确认失败，避免误报
                clearTimeout(CALL._iceFailTimer);
                CALL._iceFailTimer = setTimeout(() => {
                    if (CALL.currentStatus !== 'connected' && CALL.pc &&
                        (CALL.pc.connectionState === 'failed' || CALL.pc.iceConnectionState === 'failed')) {
                        showToast('通话建立失败'); hangupCall();
                    }
                }, 8000);
            })
            .catch(() => { showToast('通话建立失败'); hangupCall(); });
        startCallConnectTimer();   // 重启后重新计时，避免被旧超时误杀
    } catch(e) { showToast('通话建立失败'); hangupCall(); }
}
// 诊断：判断本次通话是否走了 TURN 中继。走中继说明双方无法直连，
// 同时也证明后台配置的中继确实生效了（便于判断"该不该配 TURN"）。
function detectRelay() {
    if (!CALL.pc || !CALL.pc.getStats) return;
    CALL.pc.getStats(null).then(stats => {
        const byId = {};
        stats.forEach(r => { byId[r.id] = r; });
        let pairId = null;
        stats.forEach(r => { if (r.type === 'transport' && r.selectedCandidatePairId) pairId = r.selectedCandidatePairId; });
        let relay = false;
        stats.forEach(r => {
            if (r.type === 'candidate-pair' && (r.selected || (pairId && r.id === pairId))) {
                const lc = byId[r.localCandidateId];
                if (lc && lc.candidateType === 'relay') relay = true;
            }
        });
        CALL._viaRelay = relay;
        // 传输路径只进通话日志，不再上界面。
        // 原来会在状态行后面挂一个"（直连）/（中继）"角标，普通用户看到这种技术名词
        // 只会觉得莫名其妙甚至担心被监听；需要排查时看日志就够了。
        callLog('媒体传输路径', relay ? '中继(TURN)' : '直连(P2P)');
    }).catch(() => {});
}
function startFastCallPoll() {
    stopFastCallPoll();
    // 回到 800ms：450ms 在自建 PHP 上请求过密，反而让信令变慢、竞态窗口变多。
    // 等诊断日志确认瓶颈后再决定是否加频。
    const iv = (CALL.currentStatus === 'connected') ? 800 : 800;
    CALL._lastPollOkAt = Date.now();
    CALL._fastPoll = setInterval(callPoll, iv);
    startCallWatchdog();
}
function stopFastCallPoll() {
    if (CALL._fastPoll) { clearInterval(CALL._fastPoll); CALL._fastPoll = null; }
}
/* 通话看门狗：兜住"对端已挂断、本端界面却一直停在通话中"的僵尸态。
 * 轮询是唯一能发现"对端挂断"的通道，所以这里只盯一件事：
 * 通话期间是否还能成功轮询到服务端。长时间一次都成功不了，就地收尾——
 * 宁可本地结束（会给出明确提示），也不把用户永远留在通话界面上。
 * 服务端的僵尸记录由 sweepCalls 的心跳超时兜底回收。 */
function startCallWatchdog() {
    stopCallWatchdog();
    CALL._watchdog = setInterval(() => {
        if (!CALL.active) return;
        // 关键：后台标签页里浏览器会把定时器节流到约 1 次/分钟，此时"很久没有成功轮询"
        // 是正常现象而不是断线。不跳过的话，切到后台待一会儿就会被误判为断线并挂断电话。
        if (document.hidden) return;
        const gap = Date.now() - (CALL._lastPollOkAt || 0);
        if (gap < CALL_POLL_STALL_MS) return;
        callLog('看门狗：轮询已停滞 ' + Math.round(gap / 1000) + 's，就地结束通话');
        showToast('与服务器的连接已中断，通话已结束');
        CALL.endedByMe = true;
        try { postCallAction('end', { duration: CALL.startTs ? Math.floor((Date.now() - CALL.startTs) / 1000) : 0 }); } catch(e) {}
        cleanupCall();
    }, 3000);
}
function stopCallWatchdog() {
    if (CALL._watchdog) { clearInterval(CALL._watchdog); CALL._watchdog = null; }
}
// 从后台切回前台：重置看门狗基准并立刻补一次轮询，
// 把"被节流的那段静默"和被真正断线区分开。
document.addEventListener('visibilitychange', () => {
    if (document.hidden || !CALL.active) return;
    CALL._lastPollOkAt = Date.now();
    callPoll();
});
function flushPendingCandidates() {
    if (!CALL.pc || !CALL.remoteDescSet) return;
    const list = CALL.pendingCandidates; CALL.pendingCandidates = [];
    list.forEach(c => { if (c && c.candidate) CALL.pc.addIceCandidate(c).catch(()=>{}); });
}
function handleSignals(d) {
    if (!CALL.pc) {
        if (CALL.role === 'callee' && CALL.currentStatus === 'accepted' && d.offer) {
            initCalleePC(d.offer).catch(() => { showToast('通话建立失败'); hangupCall(); });
        }
        applyCandidateList(d.candidates, d.cand_next);
        return;
    }
    // 被叫收到新 offer（ICE 重启重新协商）。注意：服务器每次轮询都会返回同一个 offer，
    // 必须比对 SDP，否则会每 800ms 重复协商一次，把已建立的媒体流打断。
    // 处理完 offer 后继续向下处理候选，避免 ICE 重启的新候选被跳过
    if (CALL.role === 'callee' && d.offer) {
        const sdp = (d.offer && d.offer.sdp) || '';
        if (sdp && sdp !== CALL._lastOfferSdp) {
            CALL._lastOfferSdp = sdp;
            CALL.pc.setRemoteDescription(d.offer).then(() => {
                return CALL.pc.createAnswer();
            }).then(answer => {
                return CALL.pc.setLocalDescription(answer);
            }).then(() => {
                postSignal('answer', JSON.stringify(CALL.pc.localDescription));
                startCallConnectTimer();   // 收到新 offer 重新协商，重置连接超时
            }).catch(() => {});
        }
    }
    // 主叫收到 answer（含 ICE 重启后的新 answer），同样比对 SDP 避免重复 setRemoteDescription
    if (CALL.role === 'caller' && d.answer) {
        const sdp = (d.answer && d.answer.sdp) || '';
        if (sdp && sdp !== CALL._lastAnswerSdp) {
            CALL._lastAnswerSdp = sdp;
            CALL.pc.setRemoteDescription(d.answer).then(() => {
                CALL.remoteDescSet = true;
                flushPendingCandidates();
            }).catch(() => {});
        }
    }
    applyCandidateList(d.candidates, d.cand_next);
}
async function callPoll() {
    if (CALL._polling) return;           // 快速轮询与后台轮询可能重叠，避免并发请求
    CALL._polling = true;
    // 整个轮询体放进 try/finally：任何分支、任何异常都必须把 _polling 放掉。
    // 只要漏放一次，之后每轮都会在第一行被挡掉，界面就永远停在"通话中"。
    try {
        await callPollInner();
    } finally {
        CALL._polling = false;
    }
}
// 轮询主体（只由 callPoll 调用；_polling 的释放统一由 callPoll 负责）
async function callPollInner() {
    let d;
    try { d = await getCall(); } catch(e) { return; }
    // 服务端暂时读不到通话文件：跳过本轮，绝不能当成"通话已结束"把通话误杀
    if (d && d.error === 'busy') { CALL._polling = false; return; }
    // 只在"确实处于通话中"或"正在来电响铃"时记录。
    // 不能只判断"存在通话记录"：服务端为了能通知终态，会把刚结束的记录也返回一段时间，
    // 那样挂断之后仍会每 2 秒记一条，看起来就像一直在刷屏。
    if (CALL.active || (d && d.incoming && d.incoming.status === 'ringing')) {
        callLog('轮询', 'role=' + (CALL.role || 'none') + ' 本地=' + (CALL.currentStatus || 'idle')
            + ' mine=' + (d && d.mine ? d.mine.status : 'none')
            + ' incoming=' + (d && d.incoming ? d.incoming.status : 'none')
            + ' offer=' + ((d && d.offer) ? 'Y' : 'N')
            + ' answer=' + ((d && d.answer) ? 'Y' : 'N')
            + ' cand=' + ((d && d.candidates) ? d.candidates.length : 0)
            + ' pc=' + (CALL.pc ? CALL.pc.connectionState : 'none'));
    }
    if (!CALL.active) {
        CALL._polling = false;
        if (d.incoming && d.incoming.status === 'ringing') {
            CALL.active = true; CALL.role = 'callee'; CALL.callId = d.incoming.call_id;
            CALL.type = d.incoming.type || 'voice'; CALL.peer = d.incoming.peer; CALL.currentStatus = 'ringing';
            CALL.room = d.incoming.room || null;
            // 来电界面上邀请码自动录入：就是对方发起时生成的那个码，无需对方另行告知
            CALL.inviteCode = d.incoming.invite_code || null;
            CALL.receiptCode = null;
            CALL.handshake = 'invited';
            callLog('来电：邀请码已自动录入', CALL.inviteCode);
            startFastCallPoll();
            // 正在和来电方聊天 → 用原来的全屏来电界面（用户就在这个会话里，不打断他）；
            // 否则从顶栏滑出横幅，他可以继续手上的操作，想接再点接通。
            if (isInPeerConversation()) { CALL.minimized = false; renderCallUI('incoming'); }
            else { CALL.minimized = true; renderCallMini(); }
            startRing();
        }
        return;
    }
    // 对端上报的摄像头/麦克风开关（带外状态）：立刻反映到画面上。
    // 判断"对方是否关了摄像头"只能靠它——对端关摄像头时本端只会收到黑帧。
    const peerMedia = (CALL.role === 'caller' ? (d.mine && d.mine.peer_media) : (d.incoming && d.incoming.peer_media)) || null;
    if (peerMedia) {
        const camChanged = !CALL.peerMedia || CALL.peerMedia.cam !== !!peerMedia.cam;
        CALL.peerMedia = { cam: !!peerMedia.cam, mic: !!peerMedia.mic };
        if (camChanged) { callLog('对端摄像头状态', CALL.peerMedia.cam ? '已开启' : '已关闭'); updateRemoteVideoState(); }
    }
    // 仅在建连阶段对"对端心跳变慢"给出可见提示（已接通后无需打扰）
    const peerSeen = (CALL.role === 'caller' ? (d.mine && d.mine.peer_seen) : (d.incoming && d.incoming.peer_seen));
    if (!CALL._peerSeenWarned && CALL.currentStatus === 'connecting'
        && typeof peerSeen === 'number' && peerSeen > 20) {
        CALL._peerSeenWarned = true;
        showToast('对方网络似乎不稳定，正在尝试重连…');
    }
    if (CALL.role === 'caller') {
        if (d.mine) {
            CALL._missCount = 0;
            const st = d.mine.status;
            if (st === 'accepted') {
                if (CALL.currentStatus === 'ringing') {
                    CALL.currentStatus = 'accepted';
                    stopRing();
                    renderCallUI('connecting');
                    callLog('对方已接听，主叫开始建连');
                    initCallerPC().catch(() => { showToast('通话建立失败'); hangupCall(); });
                }
            } else if (st === 'ended') {
                const reason = d.mine.end_reason;
                if (CALL.currentStatus === 'ringing') {
                    if (reason === 'reject') showToast('对方拒绝了通话');
                    else if (reason === 'timeout') showToast('对方未接听');
                    else showToast('对方已取消通话');
                } else if (reason === 'disconnect') showToast('对方已断开连接');
                else showToast('通话已结束');
                cleanupCall(); CALL._polling = false; return;
            }
            // 服务器状态一直是 accepted，不能覆盖已连接的 connected 状态
            if (CALL.currentStatus !== 'connected') CALL.currentStatus = st;
            // 回执码一旦出现在记录里就自动回填到主叫界面。
            // 只在"值发生变化"时重绘：否则 800ms 一轮的轮询会把界面反复重建。
            if (d.mine.receipt_code && CALL.receiptCode !== d.mine.receipt_code) {
                CALL.receiptCode = d.mine.receipt_code;
                CALL.handshake = 'linked';
                callLog('回执码已自动回传', CALL.receiptCode);
                if (CALL.currentStatus !== 'connected') renderCallUI('connecting');
            }
        } else {
            // "读不到通话记录"只是本轮未知，不能当成通话已结束：
            // 服务端一次瞬时读失败或时序抖动就会把正在进行的通话误杀。连续确认后才判定。
            CALL._missCount = (CALL._missCount || 0) + 1;
            if (CALL._missCount >= 3) {
                if (CALL.currentStatus === 'ringing') showToast('对方未接听');
                else showToast('通话已结束');
                cleanupCall(); CALL._polling = false; return;
            }
            CALL._polling = false; return;
        }
        CALL._missCount = 0;
        handleSignals(d);
    } else {
        // status==='ended' 是服务端给出的明确终态，可以直接判定；
        // 而"记录不存在"只代表本轮读不到（时序抖动 / 瞬时失败），必须连续确认，
        // 并且一旦本地已接听，就绝不因此判定"对方已取消"。
        if (d.incoming && d.incoming.status === 'ended') {
            if (CALL.currentStatus === 'ringing') showToast('对方已取消');
            else if (!CALL.endedByMe) showToast('通话已结束');
            cleanupCall(); CALL._polling = false; return;
        }
        if (!d.incoming) {
            CALL._missCount = (CALL._missCount || 0) + 1;
            // 响铃阶段：连续读不到记录 = 对方取消了（阈值保守，避免时序抖动误判）
            if (!CALL._acceptedLocally && CALL.currentStatus === 'ringing' && CALL._missCount >= 3) {
                showToast('对方已取消');
                cleanupCall(); CALL._polling = false; return;
            }
            // 已接听之后：连续读不到记录 = 这通电话在服务端已经不存在了（对端挂断后被回收）。
            // 以前这里什么都不做，导致"对端已挂断、本端界面却永远停在通话中"——
            // 因为服务端不会为已结束的记录返回 incoming，被叫侧没有任何别的收尾路径。
            // 阈值取得比响铃阶段大，避开记录刚被回收时的时序抖动。
            if (CALL._acceptedLocally && CALL._missCount >= 5) {
                callLog('连续读不到通话记录，判定通话已结束');
                if (!CALL.endedByMe) showToast('通话已结束');
                cleanupCall(); CALL._polling = false; return;
            }
        } else {
            CALL._missCount = 0;
        }
        handleSignals(d);
    }
    CALL._polling = false;
}


/* 自设计的 8 款默认头像：统一的"人物剪影"造型，靠配色和一个小装饰区分。
 * 全部按几何参数确定性绘制（不再用随机噪点），因此每次打开看到的都是同一套。 */
const AVATAR_PRESETS = [
    { name: '静蓝', bg: '#E8EEFC', body: '#9DB2E8', accent: '#5B7FD4', motif: 'none' },
    { name: '智紫', bg: '#F2EAFD', body: '#B79BE0', accent: '#8A63D2', motif: 'glasses' },
    { name: '声青', bg: '#E2F6F4', body: '#8FD3CC', accent: '#27B0A4', motif: 'headphones' },
    { name: '暖橙', bg: '#FFF1E4', body: '#F5BE8C', accent: '#EE8B3C', motif: 'cat' },
    { name: '青绿', bg: '#E9F8EA', body: '#A3D6A3', accent: '#4CAF50', motif: 'leaf' },
    { name: '柔粉', bg: '#FDE9F2', body: '#EFA7C4', accent: '#E0578E', motif: 'star' },
    { name: '暖黄', bg: '#FFF8E2', body: '#F0D48A', accent: '#D9A521', motif: 'moon' },
    { name: '素灰', bg: '#EEF0F4', body: '#B4BCC8', accent: '#7A8595', motif: 'none' }
];

function paintAvatar(cv, p) {
    const S = cv.width, ctx = cv.getContext('2d');
    ctx.clearRect(0, 0, S, S);
    ctx.fillStyle = p.bg; ctx.fillRect(0, 0, S, S);
    // 头
    ctx.fillStyle = p.body;
    ctx.beginPath(); ctx.arc(S * 0.5, S * 0.38, S * 0.165, 0, Math.PI * 2); ctx.fill();
    // 肩（底部留出空隙，避免圆形裁切后显得太满）
    ctx.beginPath();
    ctx.moveTo(S * 0.17, S * 0.985);
    ctx.quadraticCurveTo(S * 0.5, S * 0.57, S * 0.83, S * 0.985);
    ctx.closePath(); ctx.fill();
    // 装饰
    ctx.fillStyle = p.accent; ctx.strokeStyle = p.accent;
    ctx.lineWidth = S * 0.042; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
    if (p.motif === 'glasses') {
        ctx.beginPath(); ctx.arc(S * 0.437, S * 0.383, S * 0.056, 0, Math.PI * 2); ctx.stroke();
        ctx.beginPath(); ctx.arc(S * 0.563, S * 0.383, S * 0.056, 0, Math.PI * 2); ctx.stroke();
        ctx.beginPath(); ctx.moveTo(S * 0.493, S * 0.383); ctx.lineTo(S * 0.507, S * 0.383); ctx.stroke();
    } else if (p.motif === 'headphones') {
        ctx.beginPath(); ctx.arc(S * 0.5, S * 0.378, S * 0.207, Math.PI * 1.13, Math.PI * 1.87); ctx.stroke();
        ctx.beginPath(); ctx.arc(S * 0.303, S * 0.404, S * 0.052, 0, Math.PI * 2); ctx.fill();
        ctx.beginPath(); ctx.arc(S * 0.697, S * 0.404, S * 0.052, 0, Math.PI * 2); ctx.fill();
    } else if (p.motif === 'cat') {
        ctx.beginPath(); ctx.moveTo(S * 0.360, S * 0.272); ctx.lineTo(S * 0.310, S * 0.150); ctx.lineTo(S * 0.442, S * 0.222); ctx.closePath(); ctx.fill();
        ctx.beginPath(); ctx.moveTo(S * 0.640, S * 0.272); ctx.lineTo(S * 0.690, S * 0.150); ctx.lineTo(S * 0.558, S * 0.222); ctx.closePath(); ctx.fill();
    } else if (p.motif === 'leaf') {
        ctx.beginPath(); ctx.ellipse(S * 0.712, S * 0.195, S * 0.082, S * 0.048, -0.72, 0, Math.PI * 2); ctx.fill();
        ctx.beginPath(); ctx.moveTo(S * 0.706, S * 0.252); ctx.lineTo(S * 0.636, S * 0.296); ctx.stroke();
    } else if (p.motif === 'star') {
        const cx = S * 0.795, cy = S * 0.198, R = S * 0.098, r = R * 0.44;
        ctx.beginPath();
        for (let i = 0; i < 10; i++) {
            const rad = (i % 2 === 0) ? R : r;
            const a = -Math.PI / 2 + i * Math.PI / 5;
            const x = cx + Math.cos(a) * rad, y = cy + Math.sin(a) * rad;
            if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
        }
        ctx.closePath(); ctx.fill();
    } else if (p.motif === 'moon') {
        ctx.beginPath(); ctx.arc(S * 0.795, S * 0.198, S * 0.095, 0, Math.PI * 2); ctx.fill();
        ctx.fillStyle = p.bg;
        ctx.beginPath(); ctx.arc(S * 0.845, S * 0.160, S * 0.088, 0, Math.PI * 2); ctx.fill();
    }
}

function generateDefaultAvatars() {
    const c = document.getElementById('defaultAvatars');
    if (!c) return;
    c.innerHTML = '';
    AVATAR_PRESETS.forEach(p => {
        const cv = document.createElement('canvas'); cv.width = 100; cv.height = 100;
        paintAvatar(cv, p);
        const img = document.createElement('img');
        img.className = 'default-avatar';
        img.src = cv.toDataURL('image/png');
        img.title = p.name;
        img.addEventListener('click', () => selectDefaultAvatar(cv));
        c.appendChild(img);
    });
}
async function selectDefaultAvatar(cv) {
    cv.toBlob(async b => {
        const fd = new FormData(); fd.append('avatar', b, 'avatar.png');
        const res = await fetch('?action=upload_avatar', { method:'POST', body:fd });
        const d = await res.json();
        if (d.success) { showToast('头像更换成功'); avatarCache[currentUser] = d.avatar_url; updateAllOwnAvatars(); closeSettings(); }
        else showToast(d.error || '更换失败');
    }, 'image/png');
}
async function resetAvatar() {
    const res = await fetch('?action=reset_avatar', { method: 'POST' });
    const d = await res.json();
    if (d.success) {
        avatarCache[currentUser] = null;
        updateAllOwnAvatars();
        showToast('已恢复默认头像');
        closeSettings();
    } else showToast(d.error || '操作失败');
}
async function uploadAvatar() {
    const f = document.getElementById('avatarFile').files[0]; if (!f) return;
    const fd = new FormData(); fd.append('avatar', f);
    const res = await fetch('?action=upload_avatar', { method:'POST', body:fd });
    const d = await res.json();
    if (d.success) { showToast('头像更新成功'); closeSettings(); avatarCache[currentUser] = d.avatar_url; updateAllOwnAvatars(); }
    else showToast(d.error || '上传失败');
    document.getElementById('avatarFile').value = '';
}
function updateAllOwnAvatars() {
    const url = avatarCache[currentUser];
    const ts = Date.now();
    document.querySelectorAll('.msg-row.self .msg-avatar').forEach(el => {
        if (url) { el.style.backgroundImage = `url(${url}?t=${ts})`; el.textContent = ''; }
        else { el.textContent = currentUser.charAt(0).toUpperCase(); applyDefaultAvatar(el); }
    });
    const myAv = document.getElementById('myAvatar');
    if (myAv) {
        if (url) { myAv.style.backgroundImage = `url(${url}?t=${ts})`; myAv.textContent = ''; }
        else { myAv.textContent = currentUser.charAt(0).toUpperCase(); applyDefaultAvatar(myAv); }
    }
}

function openPasswordModal() { closeSettings(); document.getElementById('passwordModal').classList.add('active'); }
function closePasswordModal() { document.getElementById('passwordModal').classList.remove('active'); }
async function changePassword() {
    const o = document.getElementById('oldPassword').value.trim();
    const n = document.getElementById('newPassword').value.trim();
    const c = document.getElementById('confirmPassword').value.trim();
    if (!o || !n || !c) { showToast('请填写完整'); return; }
    if (n !== c) { showToast('两次密码不一致'); return; }
    const res = await fetch('?action=change_password', { method:'POST', body:new URLSearchParams({old:o, new:n, confirm:c}) });
    const d = await res.json();
    if (d.success) { showToast('密码修改成功'); closePasswordModal(); }
    else showToast(d.error || '修改失败');
}
function logout() { fetch('?action=logout').then(()=>location.reload()); }

async function getAvatar(u) {
    if (avatarCache[u] !== undefined) return avatarCache[u];
    try {
        const res = await fetch(`?action=get_avatar&user=${encodeURIComponent(u)}`);
        const d = await res.json();
        avatarCache[u] = d.avatar || null;
    } catch(e) { avatarCache[u] = null; }
    return avatarCache[u];
}

async function preloadAvatars(users, timeoutMs = 5000) {
    const toFetch = users.filter(u => avatarCache[u] === undefined);
    if (toFetch.length === 0) return;
    await Promise.all(toFetch.map(async u => {
        // 单个头像请求加超时：以前没有上限，一旦某个请求悬住，await 它的调用方
        // （尤其是消息渲染）就会一直等下去，表现为"消息迟迟不出现"。
        const ctrl = new AbortController();
        const tid = setTimeout(() => ctrl.abort(), timeoutMs);
        try {
            const res = await fetch(`?action=get_avatar&user=${encodeURIComponent(u)}`, { signal: ctrl.signal });
            const d = await res.json();
            avatarCache[u] = d.avatar || null;
        } catch(e) {
            avatarCache[u] = null;   // 超时/失败按"无头像"处理，回落到默认剪影，不阻塞后续流程
            console.warn('[preloadAvatars] 头像获取失败：' + u, e && e.name);
        } finally { clearTimeout(tid); }
    }));
}

function toggleSidebar() {
    const sb = document.getElementById('sidebar');
    const opening = !sb.classList.contains('open');
    sb.classList.toggle('open');
    document.getElementById('mobileOverlay').classList.toggle('active');
    if (opening && document.getElementById('searchInput').value.trim() !== '') {
        searchUsers();
    }
}
function closeSidebar() {
    document.getElementById('sidebar').classList.remove('open');
    document.getElementById('mobileOverlay').classList.remove('active');
}

async function loadRooms() {
    let d;
    try {
        const res = await fetchWithLoading(`?action=get_rooms&room=${encodeURIComponent(currentRoom)}`);
        d = await res.json();
    } catch (e) {
        // 拉取失败时保留上一次的列表，绝不清空界面（清空会让人以为群聊"消失"了）
        console.warn('[loadRooms] 拉取失败，保留现有列表', e);
        return;
    }
    if (!d || d.error) { console.warn('[loadRooms] 返回异常', d && d.error); return; }
    // 字段兜底：以前直接使用 d.friends / d.groups，一旦某段数据缺失就在中途抛错，
    // 导致它后面的列表（尤其是群聊）整片不渲染。统一补成空数组后各段互不影响。
    const channels = Array.isArray(d.channels) ? d.channels : [];
    const friends = Array.isArray(d.friends) ? d.friends : [];
    const groups = Array.isArray(d.groups) ? d.groups : [];
    try {
    onlineUsers = d.online_users || [];
    const _hocEl = document.getElementById('headerOnlineCount');
    if (_hocEl) _hocEl.textContent = `${d.online_count} 在线`;
    if (currentRoomType === 'channel') {
        setRoomSubtitle(`${d.online_count} 人在线`);
    }

    let ch = ''; channels.forEach(r => {
        const unreadBadge = r.unread > 0 ? `<span class="nav-badge">${r.unread}</span>` : '';
        const onlineCount = (d.channel_online && d.channel_online[r.name]) ? `<span class="nav-online">${d.channel_online[r.name]}</span>` : '';
        ch += `<div class="nav-item ${currentRoom===r.name?'active':''}" onclick="switchRoom(${jsq(r.name)},'channel')">
            <div class="nav-channel-icon">${r.name==='公共聊天'?'<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 12.75c1.63 0 3.07.39 4.24.9 1.08.48 1.76 1.56 1.76 2.73V18H6v-1.61c0-1.18.68-2.26 1.76-2.73 1.17-.52 2.61-.91 4.24-.91zM4 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm1.13 1.1c-.37-.06-.74-.1-1.13-.1-.99 0-1.93.21-2.78.58C.48 14.9 0 15.62 0 16.43V18h4.5v-1.61c0-.83.23-1.61.63-2.29zM20 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4 3.43c0-.81-.48-1.53-1.22-1.85-.85-.37-1.79-.58-2.78-.58-.39 0-.76.04-1.13.1.4.68.63 1.46.63 2.29V18H24v-1.57zM12 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3z"/></svg>':'<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M3 3h7v7H3V3zm11 0h7v7h-7V3zm0 11h7v7h-7v-7zM3 14h7v7H3v-7z"/></svg>'}</div><div class="nav-info"><div class="nav-name">${escapeHtml(r.name)}</div></div>${onlineCount}${unreadBadge}</div>`;
    });
    document.getElementById('channelList').innerHTML = ch;

    let pendingReqs = d.pending_requests || [];
    let reqHtml = '';
    if (pendingReqs.length > 0) {
        pendingReqs.forEach(r => {
            reqHtml += `<div class="nav-item" style="background:rgba(255,200,50,0.12);" onclick="openFriendReqModal()">
                <div class="nav-avatar" style="background:${stringToColor(r.from)}">${r.from.charAt(0).toUpperCase()}</div>
                <div class="nav-info"><div class="nav-name">${escapeHtml(r.from)}</div><div class="nav-preview">请求添加好友</div></div></div>`;
        });
    } else {
    }
    document.getElementById('friendReqList').innerHTML = reqHtml;

    window._friendData = [...friends].sort((a, b) => {
        if (a.pinned && !b.pinned) return -1;
        if (!a.pinned && b.pinned) return 1;
        return 0;
    });
    renderFriendList(window._friendData);
    const _fcEl = document.getElementById('friendCount');
    if (_fcEl) _fcEl.textContent = friends.length;

    window._groupData = groups;
    window._bannedRooms = {};
    groups.forEach(g => { if (g.banned) window._bannedRooms['group:' + g.name] = g.ban_reason || ''; });
    renderGroupList(window._groupData);
    const _gcEl = document.getElementById('groupCount');
    if (_gcEl) _gcEl.textContent = groups.length;

    updateUnreadBadge();
    updateRoomBanUI();
    } catch (e) {
        console.warn('[loadRooms] 渲染中断（已渲染的部分保留，其余等下一轮）', e);
    }
}

function updateRoomBanUI() {
    const ban = window._bannedRooms && window._bannedRooms[currentRoom];
    const bar = document.getElementById('roomBanBar');
    const input = document.getElementById('messageInput');
    const sendBtn = document.getElementById('sendBtn');
    const emojiBtn = document.getElementById('emojiBtn');
    const stickerBtn = document.getElementById('stickerBtn');
    const voiceBtn = document.getElementById('voiceBtn');
    const imageBtn = document.getElementById('imageBtn');
    const fileBtn = document.getElementById('fileBtn');
    if (ban) {
        if (bar) { bar.style.display = 'flex'; document.getElementById('roomBanText').textContent = '该群已被封禁，暂时无法发送消息' + (ban ? '：' + ban : ''); }
        if (input) input.disabled = true;
        if (sendBtn) sendBtn.disabled = true;
        if (emojiBtn) emojiBtn.disabled = true;
        if (stickerBtn) stickerBtn.disabled = true;
        if (voiceBtn) voiceBtn.disabled = true;
        if (imageBtn) imageBtn.disabled = true;
        if (fileBtn) fileBtn.disabled = true;
    } else {
        if (bar) bar.style.display = 'none';
        if (input) { input.disabled = false; }
        if (sendBtn) sendBtn.disabled = false;
        if (emojiBtn) emojiBtn.disabled = false;
        if (stickerBtn) stickerBtn.disabled = false;
        if (voiceBtn) voiceBtn.disabled = false;
        if (imageBtn) imageBtn.disabled = false;
        if (fileBtn) fileBtn.disabled = false;
    }
    updateRoomGroupBars();
}

function updateRoomMuteUI() {
    const bar = document.getElementById('roomMuteBar');
    const muteUntil = window._myGroupMute;
    if (currentRoomType === 'group' && muteUntil !== undefined && muteUntil !== null) {
        const remain = muteUntil > 0 ? muteUntil - Math.floor(Date.now()/1000) : -1;
        const txt = remain < 0 ? '你已被永久禁言，暂时无法在群内发言' : `你已被禁言，剩余约 ${Math.ceil(remain/60)} 分钟，暂时无法发言`;
        document.getElementById('roomMuteText').textContent = txt;
        bar.style.display = 'flex';
    } else {
        bar.style.display = 'none';
    }
}
async function fetchGroupMuteStatus(room) {
    try {
        const gname = room.replace(/^group:/,'');
        const res = await fetch(`?action=get_group_info&group=${encodeURIComponent(gname)}`);
        const d = await res.json();
        window._myGroupMute = (d.my_mute !== undefined && d.my_mute !== null && d.my_mute !== 0) ? d.my_mute : null;
    } catch(e) { window._myGroupMute = null; }
    updateRoomMuteUI();
}

function getCurrentGroupName() {
    return currentRoomType === 'group' ? currentRoom.replace(/^group:/,'') : null;
}

function insertAtAll() {
    const input = document.getElementById('messageInput');
    if (!input) return;
    const txt = '@全体成员 ';
    const s = input.selectionStart || input.value.length;
    const e = input.selectionEnd || s;
    input.value = input.value.slice(0, s) + txt + input.value.slice(e);
    const pos = s + txt.length;
    input.focus();
    input.setSelectionRange(pos, pos);
}

function updateRoomGroupBars() {
    const pinBar = document.getElementById('groupPinBar');
    const annBar = document.getElementById('groupAnnBar');
    const atAllBtn = document.getElementById('atAllBtn');
    if (currentRoomType !== 'group') {
        pinBar.classList.remove('active');
        annBar.classList.remove('active');
        if (atAllBtn) atAllBtn.style.display = 'none';
        return;
    }
    if (atAllBtn) atAllBtn.style.display = 'inline-flex';
    const g = (window._groupData || []).find(x => x.name === getCurrentGroupName());
    const canPin = !!(g && (g.created_by === currentUser || listHasUser(g.admins, currentUser)));
    const pin = g ? g.pinned_msg : null;
    if (pin && (pin.content || pin.username)) {
        const pinName = escapeHtml(pin.username || '');
        let pinText = escapeHtml((pin.content || '').replace(/\n/g, ' '));
        pinText = linkify(pinText);
        document.getElementById('groupPinContent').innerHTML = `<span class="pin-name">${pinName}</span><span class="pin-text">${pinText}</span>`;
        pinBar.classList.add('active');
        document.getElementById('groupPinClose').style.display = canPin ? 'inline-flex' : 'none';
    } else {
        pinBar.classList.remove('active');
    }
    const ann = g ? g.announcement : null;
    if (ann && ann.content) {
        document.getElementById('groupAnnContent').textContent = ann.content.replace(/\n/g, ' ');
        annBar.classList.add('active');
    } else {
        annBar.classList.remove('active');
    }
}

function pinContentClick() {
    const g = (window._groupData || []).find(x => x.name === getCurrentGroupName());
    if (!g || !g.pinned_msg) return;
    const el = document.getElementById('msg-' + g.pinned_msg.id);
    if (el) {
        el.scrollIntoView({behavior:'smooth', block:'center'});
        const prev = el.style.background;
        el.style.transition = 'background .6s';
        el.style.background = 'rgba(255,200,0,0.15)';
        setTimeout(() => { el.style.background = prev; }, 1500);
    } else {
        showToast('置顶消息较早，不在当前已加载记录中');
    }
}

async function removeGroupPin() {
    const gname = getCurrentGroupName();
    if (!gname) return;
    const res = await fetch('?action=remove_pin_msg', { method:'POST', body:new URLSearchParams({group:gname}) });
    const d = await res.json();
    if (d.success) { showToast('已取消置顶'); pollRooms(); }
    else showToast(d.error || '操作失败');
}

function dismissGroupAnn() {
    document.getElementById('groupAnnBar').classList.remove('active');
}

async function pinMessage(msg) {
    if (!msg || currentRoomType !== 'group') return;
    const gname = getCurrentGroupName();
    const text = msg.message || (msg.image && '图片') || (msg.file && '文件') || (msg.voice && '语音') || '';
    const res = await fetch('?action=set_pin_msg', { method:'POST', body:new URLSearchParams({group:gname, id:msg.id, username:msg.username, content:text}) });
    const d = await res.json();
    if (d.success) { showToast('已置顶消息'); pollRooms(); }
    else showToast(d.error || '操作失败');
}

function currentUserCanPin() {
    if (currentRoomType !== 'group') return false;
    const g = (window._groupData || []).find(x => x.name === getCurrentGroupName());
    return !!(g && (g.created_by === currentUser || listHasUser(g.admins, currentUser)));
}

function pinFromPopup() {
    if (!bubblePopupTarget) return;
    const msg = bubblePopupTarget.msg;
    const g = (window._groupData || []).find(x => x.name === getCurrentGroupName());
    const isPinned = g && g.pinned_msg && g.pinned_msg.id === msg.id;
    closeBubblePopup();
    if (isPinned) removeGroupPin();
    else pinMessage(msg);
}

function renderFriendList(list) {
    let fr = '';
    list.forEach(f => {
        const av = f.avatar ? `background-image:url(${f.avatar})` : `background:${stringToColor(f.name)}`;
        const avTxt = f.avatar ? '' : f.name.charAt(0).toUpperCase();
        const pmRoom = getPMRoomName(currentUser, f.name);
        const unreadBadge = f.unread > 0 ? `<span class="nav-badge">${f.unread}</span>` : '';
        const pinIcon = f.pinned ? '<span class="pin-icon"><svg viewBox="0 0 24 24" width="10" height="10" fill="var(--text3)"><path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"/></svg></span>' : '';
        const displayName = f.note || f.name;
        const noteHtml = f.note ? `<div class="nav-note">${escapeHtml(f.name)}</div>` : '';
        fr += `<div class="nav-item ${currentRoom===pmRoom?'active':''}" onclick="switchRoom(${jsq(pmRoom)},'friend', ${jsq(f.name)})" oncontextmenu="event.preventDefault();showFriendContextMenu(event,${jsq(f.name)},${jsq(f.note||'')})">
            <div class="nav-avatar" style="${av}">${avTxt}</div>
            <div class="nav-info"><div class="nav-name">${escapeHtml(displayName)}${pinIcon}</div>${noteHtml}</div>
            <div class="nav-item-actions">
                ${unreadBadge}
                <button class="friend-more-btn" onclick="event.stopPropagation();showFriendMoreMenu(event,${jsq(f.name)},${jsq(f.note||'')})" title="更多">
                    <svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/><circle cx="12" cy="19" r="1.5" fill="currentColor"/></svg>
                </button>
            </div></div>`;
    });
    if (!list.length) fr = '<div style="font-size:12px;color:var(--text3);text-align:center;padding:10px 0;">暂无好友</div>';
    document.getElementById('friendList').innerHTML = fr;
}
function filterFriendList() {
    const q = document.getElementById('friendSearchInput').value.trim().toLowerCase();
    const list = (window._friendData || []).filter(f => !q || f.name.toLowerCase().includes(q) || (f.note||'').toLowerCase().includes(q));
    renderFriendList(list);
}
function renderGroupList(list) {
    let gr = '';
    list.forEach(g => {
        const grRoom = 'group:' + g.name;
        const unreadBadge = g.unread > 0 ? `<span class="nav-badge">${g.unread}</span>` : '';
        const banBadge = g.banned ? `<span class="banned-tag">已封禁</span>` : '';
        const infoHtml = `<div class="nav-preview" style="font-size:11px;">群号 ${escapeHtml(g.group_id || '—')} · ${g.member_count||0}人 · ${g.online_count||0}在线</div>`;
        const grAv = g.avatar ? `<div class="nav-avatar" style="background-image:url(${g.avatar})"></div>` : `<div class="nav-channel-icon"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg></div>`;
        gr += `<div class="nav-item ${currentRoom===grRoom?'active':''}" onclick="switchRoom(${jsq(grRoom)},'group', ${jsq(g.name)})" oncontextmenu="event.preventDefault();showGroupContextMenu(event,${jsq(g.name)})">
            ${grAv}<div class="nav-info"><div class="nav-name">${escapeHtml(g.name)}${banBadge}</div>${infoHtml}</div>
            <div class="nav-item-actions">${unreadBadge}<button class="group-more-btn" onclick="event.stopPropagation();showGroupCardMenu(event,${jsq(g.name)})" title="更多"><svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/><circle cx="12" cy="19" r="1.5" fill="currentColor"/></svg></button></div></div>`;
    });
    if (!list.length) gr = '<div style="font-size:12px;color:var(--text3);text-align:center;padding:10px 0;">暂无群聊</div>';
    document.getElementById('groupList').innerHTML = gr;
}
function filterGroupList() {
    const q = document.getElementById('groupSearchInput').value.trim().toLowerCase();
    const list = (window._groupData || []).filter(g => !q || g.name.toLowerCase().includes(q) || (g.group_id||'').includes(q));
    renderGroupList(list);
}

let prevUnreadMap = {};
let unreadInitialized = false;

// 分类未读角标：按房间名前缀归类——pm: 是好友、group: 是群聊、其余是公共聊天频道。
// 直接从未读表归类，而不是把三个列表的 unread 相加：列表可能还没加载完，
// 也可能正被搜索框过滤，那样算出来的总数会偏小。这与服务端的归类口径完全一致。
function setTabBadge(id, n) {
    const el = document.getElementById(id);
    if (!el) return;
    if (n > 0) { el.textContent = n > 99 ? '99+' : String(n); el.classList.add('show'); }
    else { el.textContent = ''; el.classList.remove('show'); }
}
function updateTabBadges(unread) {
    let g = 0, f = 0, c = 0;
    Object.keys(unread || {}).forEach(room => {
        const n = parseInt(unread[room], 10) || 0;
        if (n <= 0) return;
        if (room.indexOf('group:') === 0) g += n;
        else if (room.indexOf('pm:') === 0) f += n;
        else c += n;
    });
    setTabBadge('tabBadgeGroup', g);
    setTabBadge('tabBadgeFriend', f);
    setTabBadge('tabBadgeChannel', c);
}

async function updateUnreadBadge() {
    try {
        const res = await fetch('?action=get_unread');
        const d = await res.json();
        const newUnread = d.unread || {};
        totalUnread = d.total || 0;
        updateTabBadges(newUnread);   // 三个分类标签上的未读总数
        const badge = document.getElementById('hamburgerBadge');
        if (totalUnread > 0) {
            badge.textContent = totalUnread > 99 ? '99+' : totalUnread;
            badge.classList.add('show');
        } else {
            badge.classList.remove('show');
        }
        if (unreadInitialized) {
            // 其他房间（私信/群聊等）有新消息未读时，即使不在该界面也响提示音（该房间开了免打扰则不响）
            for (const [room, count] of Object.entries(newUnread)) {
                const prev = prevUnreadMap[room] || 0;
                if (count > prev && room !== currentRoom && !isRoomMuted(room)) {
                    notifySoundThrottled();
                    break;
                }
            }
        }
        prevUnreadMap = newUnread;
        unreadInitialized = true;
    } catch(e) {}
}

async function markRoomRead(room) {
    try {
        await fetch('?action=mark_read', { method:'POST', body:new URLSearchParams({room}) });
        updateUnreadBadge();
    } catch(e) {}
}

function getPMRoomName(u1, u2) { return 'pm:' + [u1, u2].sort().join(':'); }
function getPMRoomUser(roomName) {
    const parts = roomName.replace('pm:', '').split(':');
    return parts.filter(p => p !== currentUser)[0] || parts[0];
}

// 全局兜底：把被静默吞掉的脚本错误、未处理的异步错误打到控制台，
// 便于定位"某处渲染不出来"。此前这类异常没有任何输出，只能靠猜。
window.addEventListener('error', e => {
    console.error('[脚本错误]', e.message, e.filename ? (e.filename + ':' + e.lineno) : '');
});
window.addEventListener('unhandledrejection', e => {
    console.error('[未处理的异步错误]', e.reason);
});

async function switchRoom(room, type, displayName = null, force = false) {
    // 同一房间重复点击：若消息区已经有内容就忽略；若上次渲染失败（空白的），则强制重绘一次。
    // 不能只是 return —— 否则一旦渲染失败，用户再怎么点都恢复不了。
    if (room === currentRoom && !force) {
        const box = document.getElementById('messagesBox');
        if (box && box.querySelector('.msg-row')) return;
        force = true;
    }
    saveRoomCache();
    saveRoomDraft();   // 离开前把当前输入存成草稿，切回来还在
    // 换房间必须退出多选：勾选集合是按消息 id 存的，跨房间留着会指向另一个会话的消息
    if (selectMode) exitSelectMode();
    // 进入时的未读数：用于在消息流里插一条"以下是新消息"。必须在标记已读之前取。
    window._enterUnread = (prevUnreadMap && prevUnreadMap[room]) ? (parseInt(prevUnreadMap[room], 10) || 0) : 0;
    // "标记已读"属于次要动作，失败绝不能挡住后面的渲染流程
    try { await markRoomRead(currentRoom); } catch (e) { console.warn('[switchRoom] 标记已读失败', e); }
    currentRoom = room;
    currentRoomType = type;
    restoreRoomDraft();   // 恢复这个房间上次没发完的内容
    const title = displayName || room;
    document.getElementById('roomTitle').textContent = title;
    if (type === 'friend') {
        const friend = getPMRoomUser(room);
        document.getElementById('roomTitle').textContent = friend;
        setRoomSubtitle('私聊');
    } else if (type === 'group') {
        setRoomSubtitle('群聊');
    } else {
        setRoomSubtitle(`${onlineUsers.length} 人在线`);
    }

    // 缓存只有在"确实渲染出了消息行"时才算数，否则回落到服务端加载，
    // 避免拿到一份空的/不完整的缓存后一直停在空白页
    let rendered = false;
    if (!force && restoreRoomCache(room)) {
        const box = document.getElementById('messagesBox');
        rendered = !!(box && box.querySelector('.msg-row'));
        if (rendered) { roomInitialized = true; scrollDown(); }
    }
    if (!rendered) {
        roomInitialized = false;
        document.getElementById('messagesBox').innerHTML = '';
        addedIds.clear();
        lastDateStr = '';
        lastId = 0;
        recalledSince = 0;
        editedSince = 0;
        reactionsSince = 0;
        roomVisibleFromTs = 0;
        showMsgLoading();   // 没有本地缓存、要走服务端拉取：先给个转圈，不要留着空白等
        loadMessages();
    }

    try { await markRoomRead(room); } catch (e) { console.warn('[switchRoom] 标记已读失败', e); }
    // 收尾这些调用互不依赖：任何一个失败都不应连带中断其余渲染
    const tail = (fn, label) => {
        try { const r = fn(); if (r && r.catch) r.catch(e => console.warn('[switchRoom] ' + label, e)); }
        catch (e) { console.warn('[switchRoom] ' + label, e); }
    };
    tail(() => loadRooms(), 'loadRooms');
    tail(closeSidebar, 'closeSidebar');
    tail(updateUnreadBadge, 'updateUnreadBadge');
    tail(updateGroupMenuVisibility, 'updateGroupMenuVisibility');
    tail(updateRoomBanUI, 'updateRoomBanUI');
    tail(() => loadChatBg(room), 'loadChatBg');
    tail(updateCallToolbar, 'updateCallToolbar');
    if (type === 'group') { tail(() => fetchGroupMuteStatus(room), 'fetchGroupMuteStatus'); }
    else { window._myGroupMute = null; tail(updateRoomMuteUI, 'updateRoomMuteUI'); }
}

function showFriendContextMenu(e, friendName, currentNote) {
    contextMenuTarget = friendName;
    const menu = document.getElementById('friendContextMenu');
    menu.innerHTML = `
        <div onclick="openUserCard(${jsq(friendName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>查看资料</div>
        <div onclick="openConfirmCall(${jsq(friendName)},${jsq(getPMRoomName(currentUser, friendName))})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/></svg>语音通话</div>
        <div onclick="openConfirmCall(${jsq(friendName)},${jsq(getPMRoomName(currentUser, friendName))},'video')"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>视频通话</div>
        <div class="menu-sep"></div>
        <div onclick="openNoteModal(${jsq(friendName)},${jsq(currentNote)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>设置备注</div>
        <div onclick="togglePin(${jsq(friendName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"/></svg>切换置顶</div>
        <div onclick="inviteToGroup(${jsq(friendName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm0 16c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7zm1-11h-2v3H8v2h3v3h2v-3h3v-2h-3V8z"/></svg>拉入群聊</div>
        <div class="menu-sep"></div>
        <div class="menu-danger" onclick="deleteFriend(${jsq(friendName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>删除好友</div>
        <div class="menu-danger" onclick="closeContextMenu()"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>关闭</div>`;
    menu.style.left = e.clientX + 'px';
    menu.style.top = Math.min(e.clientY, window.innerHeight - 180) + 'px';
    menu.classList.add('active');
    setTimeout(() => document.addEventListener('click', closeContextMenu, { once: true }), 100);
}
function closeContextMenu() {
    document.getElementById('friendContextMenu').classList.remove('active');
    contextMenuTarget = null;
}
function showFriendMoreMenu(e, friendName, currentNote) {
    closeContextMenu();
    closeBubblePopup();
    const menu = document.getElementById('friendContextMenu');
    menu.innerHTML = `
        <div onclick="openUserCard(${jsq(friendName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>查看资料</div>
        <div onclick="openConfirmCall(${jsq(friendName)},${jsq(getPMRoomName(currentUser, friendName))})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/></svg>语音通话</div>
        <div onclick="openConfirmCall(${jsq(friendName)},${jsq(getPMRoomName(currentUser, friendName))},'video')"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>视频通话</div>
        <div class="menu-sep"></div>
        <div onclick="openNoteModal(${jsq(friendName)},${jsq(currentNote)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>设置备注</div>
        <div onclick="togglePin(${jsq(friendName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"/></svg>切换置顶</div>
        <div onclick="inviteToGroup(${jsq(friendName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm0 16c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7zm1-11h-2v3H8v2h3v3h2v-3h3v-2h-3V8z"/></svg>拉入群聊</div>
        <div class="menu-sep"></div>
        <div class="menu-danger" onclick="deleteFriend(${jsq(friendName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>删除好友</div>
        <div class="menu-danger" onclick="closeContextMenu()"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>关闭</div>`;
    const btn = e.target.closest('.friend-more-btn');
    const rect = btn ? btn.getBoundingClientRect() : { left: e.clientX, top: e.clientY };
    menu.style.left = Math.min(rect.left - 120, window.innerWidth - 150) + 'px';
    menu.style.top = Math.min(rect.bottom + 4, window.innerHeight - 200) + 'px';
    menu.classList.add('active');
    setTimeout(() => document.addEventListener('click', closeContextMenu, { once: true }), 100);
}
function openAnnouncementFromMenu(groupName) {
    closeContextMenu();
    groupManageTarget = groupName;
    document.getElementById('announcementModal').dataset.group = groupName;
    openAnnouncementModal();
}

let bubblePopupTarget = null;
function showBubblePopup(e, msg, row) {
    // 多选状态下不弹单条菜单：此时点消息的含义是"勾选/取消勾选"，
    // 再弹一个作用域是单条的菜单会让人以为选中的那批要一起执行。
    if (selectMode) return;
    closeBubblePopup();
    closeContextMenu();
    const popup = document.getElementById('bubblePopup');
    const isSelf = msg.username === currentUser;
    const elapsed = Math.floor(Date.now()/1000) - (msg.timestamp || 0);
    const canEdit = isSelf && elapsed <= RECALL_LIMIT;   // 编辑仍限 2 分钟
    const canRecall = isSelf;                            // 撤回不限时间
    
    let html = `<div onclick="copyBubbleText()"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>复制</div>`;
    html += `<div onclick="setQuoteFromPopup()"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M6 17h3l2-4V7H5v6h3l-2 4zm8 0h3l2-4V7h-6v6h3l-2 4z"/></svg>引用</div>`;
    html += `<div onclick="openForwardModal()"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M14 4l-4 4h3v7h2V8h3l-4-4zm-8 4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-2h-2v2H6v-9h4V8H6z"/></svg>转发</div>`;
    html += `<div onclick="openReactionPickerFromPopup()"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-5.6 8.6h3.2L12 8.1l2.4 2.5h3.2l-2.7 3.7 1 3.3L12 16.2l-3.9 1.4 1-3.3-2.7-3.7z"/></svg>表情回应</div>`;
    const favOn = isFavMsg(msg.id, currentRoom);
    html += `<div${favOn ? ' class="status-option active"' : ''} onclick="toggleFavoriteFromPopup()"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>${favOn ? '取消收藏' : '收藏'}</div>`;
    if (currentUserCanPin()) {
        const grp = (window._groupData || []).find(x => x.name === getCurrentGroupName());
        const isPinnedMsg = !!(grp && grp.pinned_msg && grp.pinned_msg.id === msg.id);
        const pinCls = isPinnedMsg ? ' class="status-option active"' : '';
        html += `<div${pinCls} onclick="pinFromPopup()"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"/></svg>${isPinnedMsg ? '取消置顶' : '置顶消息'}</div>`;
    }
    if (canEdit) {
        html += `<div onclick="editMessageFromPopup()"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>编辑</div>`;
    }
    if (canRecall) {
        if (canEdit) html += `<div class="menu-sep"></div>`;
        html += `<div class="menu-danger" onclick="deleteMessageFromPopup()"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z"/></svg>撤回</div>`;
    }
    html += `<div class="menu-sep"></div>`;
    html += `<div onclick="enterSelectMode(${msg.id})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M3 5h2v2H3V5zm0 6h2v2H3v-2zm0 6h2v2H3v-2zM7 5h14v2H7V5zm0 6h14v2H7v-2zm0 6h14v2H7v-2z"/></svg>多选</div>`;
    popup.innerHTML = html;
    
    bubblePopupTarget = { msg, row };
    
    // Get position from event
    let x, y;
    if (e.touches && e.touches.length > 0) {
        x = e.touches[0].clientX;
        y = e.touches[0].clientY;
    } else {
        x = e.clientX;
        y = e.clientY;
    }
    popup.style.left = Math.min(x, window.innerWidth - 140) + 'px';
    popup.style.top = Math.min(y, window.innerHeight - 120) + 'px';
    popup.classList.add('active');
    setTimeout(() => document.addEventListener('click', closeBubblePopup, { once: true }), 100);
}
function closeBubblePopup() {
    document.getElementById('bubblePopup').classList.remove('active');
    bubblePopupTarget = null;
}
function copyBubbleText() {
    if (bubblePopupTarget) {
        const t = bubblePopupTarget.msg.message || '';
        if (t) navigator.clipboard.writeText(t).then(() => showToast('已复制'));
    }
    closeBubblePopup();
}
function setQuoteFromPopup() {
    if (bubblePopupTarget) {
        const msg = bubblePopupTarget.msg;
        const msgText = escapeHtml((msg.message || (msg.image && '图片') || (msg.file && '文件') || '').replace(/'/g,"\\'"));
        const uname = escapeHtml(msg.username);
        setQuote(msg.id, uname, msgText);
    }
    closeBubblePopup();
}
function deleteMessageFromPopup() {
    if (bubblePopupTarget) {
        const id = bubblePopupTarget.msg.id;
        closeBubblePopup();
        deleteMessage(id);
    }
}
function editMessageFromPopup() {
    if (bubblePopupTarget) {
        const msg = bubblePopupTarget.msg;
        closeBubblePopup();
        openEditMessage(msg.id, msg.message || '');
    }
}

// ===== 表情回应 =====
const REACTION_EMOJIS = ['👍','❤️','😂','😮','😢','🔥','👏','🎉','🙏','💯','🤔','😡'];
function openReactionPickerFromPopup() {
    const t = bubblePopupTarget;
    if (!t) return;
    openReactionPicker(t.msg, t.row);
}
function openReactionPicker(msg, row) {
    if (!msg || msg.id <= 0) { showToast('消息尚未发送成功，无法回应'); return; }
    closeReactionPicker();
    const picker = document.getElementById('reactionPicker');
    picker.innerHTML = REACTION_EMOJIS.map(em =>
        `<span data-emoji="${em}" title="回应 ${em}" onclick="event.stopPropagation();pickReaction(${msg.id},'${em}')">${em}</span>`).join('');
    const r = row.getBoundingClientRect();
    const pw = 300;
    let x = r.left + (r.width - pw) / 2;
    x = Math.max(8, Math.min(x, window.innerWidth - pw - 8));
    let y = r.top - 52;
    if (y < 8) y = r.bottom + 8;
    picker.style.left = x + 'px';
    picker.style.top = y + 'px';
    picker.classList.add('active');
    setTimeout(() => document.addEventListener('click', closeReactionPicker, { once: true }), 100);
}
function closeReactionPicker() {
    const p = document.getElementById('reactionPicker');
    if (p) p.classList.remove('active');
}
async function pickReaction(msgId, emoji) {
    closeReactionPicker();
    await toggleReaction(msgId, emoji);
}
async function toggleReaction(msgId, emoji) {
    const res = await fetch('?action=toggle_reaction', { method:'POST', body:new URLSearchParams({ id: msgId, room: currentRoom, emoji }) });
    const d = await res.json();
    if (d.success) {
        updateRowReactions(document.getElementById('msg-' + msgId), d.reactions || {});
    } else {
        showToast(d.error || '操作失败');
    }
}
function reactionsHtmlFor(msg, mid) {
    const rx = msg.reactions;
    if (!rx || typeof rx !== 'object') return '';
    const entries = Object.entries(rx).filter(([, list]) => Array.isArray(list) && list.length > 0);
    if (!entries.length) return '';
    return '<div class="msg-reactions">' + entries.map(([em, list]) => {
        const active = list.includes(currentUser);
        const emSafe = escapeHtml(em).replace(/'/g, "\\'");
        const title = escapeHtml(list.join('、'));
        return `<span class="rx-chip${active ? ' active' : ''}" onclick="event.stopPropagation();toggleReaction(${mid},'${emSafe}')" title="${title}">${escapeHtml(em)}<em>${list.length}</em></span>`;
    }).join('') + '</div>';
}
function updateRowReactions(row, reactions) {
    if (!row) return;
    let bar = row.querySelector('.msg-reactions');
    const has = reactions && typeof reactions === 'object' && Object.values(reactions).some(l => Array.isArray(l) && l.length);
    if (!has) { if (bar) bar.remove(); return; }
    const mid = parseInt(row.dataset.mid, 10) || 0;
    const html = reactionsHtmlFor({ reactions }, mid);
    if (bar) { bar.outerHTML = html; return; }
    const bodyEl = row.querySelector('.msg-body');
    if (!bodyEl) return;
    const wrap = document.createElement('div');
    wrap.innerHTML = html;
    const nbar = wrap.firstChild;
    if (!nbar) return;
    const timeEl = row.querySelector('.msg-time');
    const footerEl = row.querySelector('.msg-footer');
    if (timeEl && timeEl.parentNode === bodyEl) bodyEl.insertBefore(nbar, timeEl);
    else if (footerEl && footerEl.parentNode === bodyEl) bodyEl.insertBefore(nbar, footerEl);
    else bodyEl.appendChild(nbar);
}

// ===== 消息收藏（本机保存） =====
function favKey() { return 'chat_favs_' + (currentUser || 'guest'); }
function getFavs() {
    try { const a = JSON.parse(localStorage.getItem(favKey()) || '[]'); return Array.isArray(a) ? a : []; }
    catch (e) { return []; }
}
function saveFavs(list) {
    try { localStorage.setItem(favKey(), JSON.stringify(list.slice(0, 200))); } catch (e) {}
}
function isFavMsg(id, room) {
    return getFavs().some(f => f.id === id && f.room === room);
}
function updateFavBadge() {
    const n = getFavs().length;
    const b = document.getElementById('favBadge');
    if (b) { b.style.display = n ? 'inline-flex' : 'none'; b.textContent = n > 99 ? '99+' : n; }
}
function toggleFavoriteFromPopup() {
    const t = bubblePopupTarget;
    if (!t) return;
    const msg = t.msg;
    closeBubblePopup();
    if (msg.id <= 0) { showToast('消息尚未发送成功，无法收藏'); return; }
    const favs = getFavs();
    const idx = favs.findIndex(f => f.id === msg.id && f.room === currentRoom);
    if (idx >= 0) {
        favs.splice(idx, 1);
        saveFavs(favs);
        showToast('已取消收藏');
    } else {
        favs.unshift({
            id: msg.id, room: currentRoom,
            username: msg.username || '', message: msg.message || '',
            time: msg.time || '', ts: msg.timestamp || 0, type: msg.type || 'chat',
            image: msg.image || '', file: msg.file || '', file_name: msg.file_name || '',
            voice: msg.voice ? { duration: msg.voice.duration } : null,
            quote: msg.quote ? { username: msg.quote.username, message: msg.quote.message } : null
        });
        saveFavs(favs);
        showToast('已收藏');
    }
    updateFavBadge();
}
function openFavModal() {
    document.getElementById('favModal').classList.add('active');
    renderFavList();
}
function closeFavModal() {
    document.getElementById('favModal').classList.remove('active');
}
function renderFavList() {
    const box = document.getElementById('favList');
    const favs = getFavs();
    if (!favs.length) {
        box.innerHTML = '<div style="padding:32px 16px;text-align:center;color:var(--text3);font-size:13px;line-height:1.8;">还没有收藏的消息<br>在消息上右键/长按选择「收藏」即可添加</div>';
        return;
    }
    box.innerHTML = favs.map((f, i) => {
        const preview = f.message || (f.image ? '[图片]' : (f.file ? '[文件] ' + (f.file_name || '') : (f.voice ? '[语音]' : '')));
        const roomLabel = f.room || '公共聊天';
        return `<div class="fav-item">
            <div class="fav-main" onclick="jumpToFav(${i})">
                <div class="fav-top"><span class="fav-user">${escapeHtml(f.username)}</span><span class="fav-time">${escapeHtml(f.time || '')}</span></div>
                <div class="fav-text">${escapeHtml(preview)}</div>
                <div class="fav-room">${escapeHtml(roomLabel)}</div>
            </div>
            <button class="fav-del" onclick="event.stopPropagation();removeFav(${i})" title="取消收藏"><svg viewBox="0 0 24 24" width="14" height="14"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
        </div>`;
    }).join('');
}
function removeFav(i) {
    const favs = getFavs();
    favs.splice(i, 1);
    saveFavs(favs);
    updateFavBadge();
    renderFavList();
}
async function jumpToFav(i) {
    const f = getFavs()[i];
    if (!f) return;
    closeFavModal();
    if (f.room !== currentRoom) {
        if (f.room === '公共聊天') {
            await switchRoom('公共聊天', 'channel');
        } else if (String(f.room).indexOf('pm:') === 0) {
            const parts = f.room.split(':');
            const other = parts[1] === currentUser ? (parts[2] || '') : (parts[1] || '');
            await switchRoom(f.room, 'friend', other);
        } else if (String(f.room).indexOf('group:') === 0) {
            await switchRoom(f.room, 'group', f.room.slice(6));
        } else {
            await switchRoom(f.room, 'channel');
        }
    }
    setTimeout(() => {
        // 消息可能还没拉取完：轮询等一小段时间再跳，仍找不到才提示
        let tries = 0;
        const tryJump = () => {
            const el = document.getElementById('msg-' + f.id);
            if (el) {
                el.scrollIntoView({ behavior:'smooth', block:'center' });
                el.classList.remove('msg-jump-flash');
                void el.offsetWidth;
                el.classList.add('msg-jump-flash');
                setTimeout(() => el.classList.remove('msg-jump-flash'), 1600);
                return;
            }
            if (++tries < 10) setTimeout(tryJump, 250);
            else showToast('原消息不在当前加载范围内');
        };
        tryJump();
    }, 300);
}

function openNoteModal(friendName, note) {
    closeContextMenu();
    noteTarget = friendName;
    document.getElementById('noteInput').value = note || '';
    document.getElementById('noteModal').classList.add('active');
    setTimeout(() => document.getElementById('noteInput').focus(), 150);
}
function closeNoteModal() {
    document.getElementById('noteModal').classList.remove('active');
    noteTarget = null;
}
async function saveNote() {
    const note = document.getElementById('noteInput').value.trim();
    if (!noteTarget) return;
    const res = await fetch('?action=set_note', { method:'POST', body:new URLSearchParams({target:noteTarget, note}) });
    const d = await res.json();
    if (d.success) { showToast('备注已保存'); closeNoteModal(); loadRooms(); }
    else showToast(d.error || '操作失败');
}
async function togglePin(friendName) {
    closeContextMenu();
    const res = await fetch('?action=toggle_pin', { method:'POST', body:new URLSearchParams({target:friendName}) });
    const d = await res.json();
    if (d.success) { showToast(d.pinned ? '已置顶' : '已取消置顶'); loadRooms(); }
    else showToast(d.error || '操作失败');
}
async function deleteFriend(friendName) {
    closeContextMenu();
    customConfirm(`确定删除好友 "${friendName}" 吗？此操作不可撤销！`, async () => {
        const res = await fetch('?action=delete_friend', { method:'POST', body:new URLSearchParams({target:friendName}) });
        const d = await res.json();
        if (d.success) {
            showToast('好友已删除');
            const pmRoom = getPMRoomName(currentUser, friendName);
            if (currentRoom === pmRoom) switchRoom('公共聊天', 'channel');
            loadRooms();
        } else showToast(d.error || '操作失败');
    });
}

let groupManageTarget = null;
function showGroupContextMenu(e, groupName) {
    groupManageTarget = groupName;
    const g = (window._groupData || []).find(x => x.name === groupName) || {};
    const isOwner = g.created_by === currentUser;
    const reqCount = g.request_count || 0;
    const historyVisible = !!g.history_visible;
    const joinMode = g.join_mode || 'apply';
    const menu = document.getElementById('friendContextMenu');
    let html = '';
    if (isOwner) {
        html += `<div onclick="openGroupRequests(${jsq(groupName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm0 16c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7zm-1-11h2v3h3v2h-3v3h-2v-3H8v-2h3V8z"/></svg>入群申请${reqCount ? ` <span style="background:var(--danger);color:#fff;border-radius:8px;padding:0 6px;font-size:10px;line-height:14px;margin-left:4px;">${reqCount}</span>` : ''}</div>`;
        html += `<div onclick="setGroupJoinMode(${jsq(groupName)},'${joinMode==='free'?'apply':'free'}')"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>${joinMode==='free'?'改为需申请':'改为免申请'}</div>`;
        html += `<div onclick="setGroupHistoryVisible(${jsq(groupName)},${historyVisible?0:1})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/></svg>${historyVisible?'禁止新成员看历史':'允许新成员看历史'}</div>`;
        html += `<div class="menu-sep"></div>`;
    }
    html += `<div onclick="openGroupManage(${jsq(groupName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>查看成员</div>`;
    html += `<div onclick="openAnnouncementFromMenu(${jsq(groupName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M5 3h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-4v2h1v2H8v-2h1v-2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm1 2v10h12V5H6zM8 6.4h8V8H8V6.4zM8 9.6h5V11.2H8V9.6z"/></svg>群公告</div>`;
    html += `<div class="menu-sep"></div>`;
    html += `<div class="menu-danger" onclick="leaveGroup(${jsq(groupName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>退出群聊</div>`;
    html += `<div class="menu-danger" onclick="closeContextMenu()"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>关闭</div>`;
    menu.innerHTML = html;
    menu.style.left = e.clientX + 'px';
    menu.style.top = Math.min(e.clientY, window.innerHeight - 160) + 'px';
    menu.classList.add('active');
    setTimeout(() => document.addEventListener('click', closeContextMenu, { once: true }), 100);
}

function showGroupCardMenu(e, groupName) {
    closeContextMenu();
    closeRoomDropdown();
    const g = (window._groupData || []).find(x => x.name === groupName) || {};
    const isOwner = g.created_by === currentUser;
    const reqCount = g.request_count || 0;
    const historyVisible = !!g.history_visible;
    const joinMode = g.join_mode || 'apply';
    const menu = document.getElementById('friendContextMenu');
    let html = '';
    if (isOwner) {
        html += `<div onclick="openGroupRequests(${jsq(groupName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm0 16c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7zm-1-11h2v3h3v2h-3v3h-2v-3H8v-2h3V8z"/></svg>入群申请${reqCount ? ` <span style="background:var(--danger);color:#fff;border-radius:8px;padding:0 6px;font-size:10px;line-height:14px;margin-left:4px;">${reqCount}</span>` : ''}</div>`;
        html += `<div onclick="setGroupJoinMode(${jsq(groupName)},'${joinMode==='free'?'apply':'free'}')"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>${joinMode==='free'?'改为需申请':'改为免申请'}</div>`;
        html += `<div onclick="setGroupHistoryVisible(${jsq(groupName)},${historyVisible?0:1})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/></svg>${historyVisible?'禁止新成员看历史':'允许新成员看历史'}</div>`;
        html += `<div class="menu-sep"></div>`;
    }
    html += `<div onclick="openGroupManage(${jsq(groupName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>查看成员</div>`;
    html += `<div onclick="openAnnouncementFromMenu(${jsq(groupName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M5 3h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-4v2h1v2H8v-2h1v-2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm1 2v10h12V5H6zM8 6.4h8V8H8V6.4zM8 9.6h5V11.2H8V9.6z"/></svg>群公告</div>`;
    html += `<div class="menu-sep"></div>`;
    html += `<div class="menu-danger" onclick="leaveGroup(${jsq(groupName)})"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>退出群聊</div>`;
    html += `<div class="menu-danger" onclick="closeContextMenu()"><svg class="menu-icon" viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>关闭</div>`;
    menu.innerHTML = html;
    const btn = e.target.closest('.group-more-btn');
    const rect = btn ? btn.getBoundingClientRect() : { left: e.clientX, top: e.clientY };
    menu.style.left = Math.min(rect.left - 130, window.innerWidth - 170) + 'px';
    menu.style.top = Math.min(rect.bottom + 4, window.innerHeight - 230) + 'px';
    menu.classList.add('active');
    setTimeout(() => document.addEventListener('click', closeContextMenu, { once: true }), 100);
}

function updateGroupMenuVisibility() {
    const btn = document.getElementById('roomMoreBtn');
    if (btn) btn.style.display = 'inline-flex';
}
async function openChannelMembers(event) {
    event.stopPropagation();
    const modal = document.getElementById('channelMembersModal');
    const content = document.getElementById('channelMembersContent');
    content.innerHTML = ldHTML('加载中');
    modal.classList.add('active');
    try {
        const res = await fetch('?action=get_all_users&_t=' + Date.now());
        if (!res.ok) {
            const text = await res.text();
            throw new Error('HTTP ' + res.status + ': ' + text.substring(0, 100));
        }
        const text = await res.text();
        let d;
        try { d = JSON.parse(text); } catch (pe) { throw new Error('响应非JSON: ' + text.substring(0, 200)); }
        const allUsers = (d.users || []).map(u => {
            return { ...u, name: String(u.name != null ? u.name : '') };
        });
        const myself = String(d.myself != null ? d.myself : currentUser);
        let html = `<p style="font-size:12px;color:var(--text3);margin-bottom:12px;">全部成员（按随机排序，共 ${allUsers.length + 1} 人）</p>`;
        const myAvBg = stringToColor(myself);
        const myInitial = myself.charAt(0).toUpperCase();
        html += `<div class="group-member-item">
            <div class="member-avatar" style="background:${myAvBg}">${escapeHtml(myInitial)}</div>
            <span class="member-name">${escapeHtml(myself)}</span>
            <span style="font-size:11px;color:var(--text3);margin-left:4px;">我</span></div>`;
        if (allUsers.length === 0) {
            html += '<div style="text-align:center;color:var(--text3);padding:10px;">暂无其他成员</div>';
        } else {
            allUsers.forEach(u => {
                const name = String(u.name || '');
                const initial = name.charAt(0).toUpperCase();
                const av = u.avatar ? `background-image:url(${escapeHtml(u.avatar)});background-size:cover;background-position:center` : `background:${stringToColor(name)}`;
                const avTxt = u.avatar ? '' : escapeHtml(initial);
                const onlineStatus = u.online ? '<span style="font-size:10px;color:#07c160;margin-left:4px;">在线</span>' : '<span style="font-size:10px;color:var(--text3);margin-left:4px;">离线</span>';
                let actionHtml = '';
                if (u.is_friend) {
                    actionHtml = '<span style="font-size:11px;color:var(--text3);">好友</span>';
                } else if (u.request_sent) {
                    actionHtml = '<span style="font-size:11px;color:var(--text3);">已请求</span>';
                } else if (u.request_received) {
                    actionHtml = `<button class="member-kick" style="color:var(--accent);" onclick="event.stopPropagation();acceptFriendRequest(${jsq(name)})">接受</button>`;
                } else {
                    actionHtml = `<button class="member-kick" style="color:var(--accent);" onclick="event.stopPropagation();sendFriendRequest(${jsq(name)})">添加好友</button>`;
                }
                html += `<div class="group-member-item">
                    <div class="member-avatar" style="${av}">${avTxt}</div>
                    <span class="member-name">${escapeHtml(name)}</span>
                    ${onlineStatus}
                    ${actionHtml}</div>`;
            });
        }
        content.innerHTML = html;
    } catch (e) {
        console.error('获取成员列表失败:', e);
        content.innerHTML = '<div style="text-align:center;padding:20px;color:var(--danger);">加载失败</div><div style="text-align:center;font-size:12px;color:var(--text3);margin-top:8px;word-break:break-all;">' + escapeHtml(String(e.message || e)) + '</div><div style="text-align:center;margin-top:12px;"><button onclick="openChannelMembers(event)" style="background:var(--accent);color:#fff;border:none;padding:6px 16px;border-radius:6px;cursor:pointer;font-size:13px;">重试</button></div>';
    }
}
function closeChannelMembers() {
    document.getElementById('channelMembersModal').classList.remove('active');
}

// ---------- 右上角"更多"菜单（QQ 风格）+ 每房间独立免打扰 ----------
function isRoomMuted(room) {
    try { return localStorage.getItem('chatroom_mute_' + room) === '1'; } catch (e) { return false; }
}
function setRoomMuted(room, muted) {
    try {
        if (muted) localStorage.setItem('chatroom_mute_' + room, '1');
        else localStorage.removeItem('chatroom_mute_' + room);
    } catch (e) {}
}
function toggleRoomMute() {
    const muted = !isRoomMuted(currentRoom);
    setRoomMuted(currentRoom, muted);
    showToast(muted ? '已开启免打扰，该聊天的新消息不再响提示音' : '已关闭免打扰');
    renderRoomDropdown();
}
function renderRoomDropdown() {
    const dd = document.getElementById('roomDropdown');
    if (!dd) return;
    const muted = isRoomMuted(currentRoom);
    let html = '';
    const groupName = currentRoomType === 'group' ? currentRoom.replace('group:', '') : null;
    if (currentRoomType === 'channel') {
        html += `<div onclick="event.stopPropagation();closeRoomDropdown();openChannelMembers(event)"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>查看成员</div>`;
    } else if (currentRoomType === 'group') {
        html += `<div onclick="event.stopPropagation();closeRoomDropdown();openGroupManage(${jsq(groupName)})"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>查看成员</div>`;
        html += `<div onclick="event.stopPropagation();closeRoomDropdown();openAnnouncementFromMenu(${jsq(groupName)})"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M5 3h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-4v2h1v2H8v-2h1v-2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm1 2v10h12V5H6zM8 6.4h8V8H8V6.4zM8 9.6h5V11.2H8V9.6z"/></svg>群公告</div>`;
        html += `<div onclick="event.stopPropagation();closeRoomDropdown();openGroupFiles(${jsq(groupName)})"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>群文件</div>`;
        const gInfo = (window._groupData || []).find(g => g.name === groupName);
        const isOwner = gInfo && gInfo.created_by === currentUser;
        if (isOwner) {
            const reqCount = gInfo.request_count || 0;
            html += `<div onclick="event.stopPropagation();closeRoomDropdown();openGroupRequests(${jsq(groupName)})"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm0 16c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7zm-1-11h2v3h3v2h-3v3h-2v-3H8v-2h3V8z"/></svg>入群申请${reqCount ? ` <span style="background:var(--danger);color:#fff;border-radius:8px;padding:0 6px;font-size:10px;line-height:14px;margin-left:4px;">${reqCount}</span>` : ''}</div>`;
            html += `<div onclick="event.stopPropagation();closeRoomDropdown();openInviteMembers(${jsq(groupName)})"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>邀请好友</div>`;
        }
    }
    html += `<div onclick="event.stopPropagation();closeRoomDropdown();toggleMsgSearch()"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>搜索消息<span style="margin-left:8px;font-size:11px;color:var(--text3)">Ctrl+K</span></div>`;
    html += `<div onclick="event.stopPropagation();closeRoomDropdown();openChatBg()"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>聊天背景</div>`;
    html += `<div onclick="event.stopPropagation();closeRoomDropdown();enterSelectMode()"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M3 5h2v2H3V5zm0 6h2v2H3v-2zm0 6h2v2H3v-2zM7 5h14v2H7V5zm0 6h14v2H7v-2zm0 6h14v2H7v-2z"/></svg>多选消息</div>`;
    html += `<div onclick="event.stopPropagation();closeRoomDropdown();exportRoomChat()"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>导出聊天记录</div>`;
    html += `<div class="menu-sep"></div>`;
    html += `<div onclick="event.stopPropagation();toggleRoomMute()" style="justify-content:space-between;"><span style="display:flex;align-items:center;gap:6px;"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 4v12l-2-2H8V8h2l2-2zM4 10v4h3l4 4V6L7 10H4z" transform="rotate(0)"/><path d="M7.5 4.5a8 8 0 0 1 0 11M10 2a10 10 0 0 1 0 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>免打扰</span>${muted ? '<span style="color:var(--accent);font-size:13px;font-weight:700;">开</span>' : '<span style="color:var(--text3);font-size:13px;">关</span>'}</div>`;
    html += `<div class="menu-sep"></div>`;
    html += `<div class="menu-danger" onclick="event.stopPropagation();closeRoomDropdown();clearChatHistory()"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>清空聊天记录</div>`;
    if (currentRoomType === 'group') {
        const gInfo2 = (window._groupData || []).find(g => g.name === groupName);
        if (gInfo2 && gInfo2.created_by === currentUser) {
            html += `<div class="menu-danger" onclick="event.stopPropagation();closeRoomDropdown();deleteGroup(${jsq(groupName)})"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>解散群聊</div>`;
            html += `<div class="menu-sep"></div>`;
        }
        html += `<div class="menu-danger" onclick="event.stopPropagation();closeRoomDropdown();leaveGroup(${jsq(groupName)})"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>退出群聊</div>`;
    }
    dd.innerHTML = html;
}
function toggleRoomDropdown(event) {
    event.stopPropagation();
    renderRoomDropdown();
    const dd = document.getElementById('roomDropdown');
    dd.classList.toggle('active');
    closeBubblePopup();
    closeContextMenu();
}
function closeRoomDropdown() {
    const dd = document.getElementById('roomDropdown');
    if (dd) dd.classList.remove('active');
}
let currentDownloadUrl = '';
function showFileDownload(url, name, size) {
    currentDownloadUrl = url;
    document.getElementById('dlFileName').textContent = name || '文件';
    document.getElementById('dlFileSize').textContent = size ? '大小: ' + size : '';
    document.getElementById('fileDownloadModal').classList.add('active');
}
function closeFileDownload() {
    document.getElementById('fileDownloadModal').classList.remove('active');
    currentDownloadUrl = '';
}
function downloadFile() {
    if (!currentDownloadUrl) return;
    const a = document.createElement('a');
    a.href = currentDownloadUrl;
    a.download = document.getElementById('dlFileName').textContent || 'file';
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    closeFileDownload();
}

// ---------- 群文件 / 群相册 ----------
let groupFilesTarget = null;
let groupFilesTab = 'album';
let groupFilesCache = [];                       // 当前群的文件清单（相册/文件两个 tab 共用）
let gfPerm = { isOwner: false, isAdmin: false, isMember: false };
let gfSelectMode = false;                       // 批量管理开关
const gfSelected = new Set();                   // 已勾选的 url 集合
let gfAutoTried = false;                        // 本次打开弹窗是否已尝试过"自动跳到有内容的标签"
// 在"成员/管理员"名单里查人：统一按字符串比。
// 纯数字用户名（如"123"）经过 JSON 往返后可能是数字，用 includes 严格比较会把同一个人
// 判成"不在名单里"——表现是明明在群里却看不到群文件、传了文件列表里没有，
// 而后台用的是同一套口径，两边判断必须一致。
function listHasUser(list, user) {
    if (!Array.isArray(list)) return false;
    const u = String(user == null ? '' : user);
    return list.some(m => m != null && String(m) === u);
}
function gfVisibleList() {
    return groupFilesCache.filter(f => groupFilesTab === 'album' ? f.type === 'image' : f.type !== 'image');
}
function resetGfSelect() {
    gfSelectMode = false;
    gfSelected.clear();
    const btn = document.getElementById('gfSelToggle');
    if (btn) { btn.classList.remove('on'); btn.textContent = '批量管理'; }
    const bar = document.getElementById('gfSelBar');
    if (bar) bar.style.display = 'none';
}
function openGroupFiles(groupName) {
    groupFilesTarget = groupName;
    groupFilesTab = 'album';
    gfAutoTried = false;        // 只在打开后的第一次加载里做自动跳转
    resetGfSelect();
    document.getElementById('groupFilesTitle').textContent = `群文件 · ${groupName}`;
    document.getElementById('groupFilesModal').classList.add('active');
    switchGroupFilesTab('album');
    startGroupFilesPoll();
}
// 弹窗开着的时候定时静默刷新：别人在群里发的文件/照片也会归档进来，
// 不刷新的话要关掉重开才看得到。批量管理中不刷新，免得打断勾选。
let gfPollTimer = null;
function startGroupFilesPoll() {
    stopGroupFilesPoll();
    gfPollTimer = setInterval(() => {
        const modal = document.getElementById('groupFilesModal');
        if (!modal || !modal.classList.contains('active')) { stopGroupFilesPoll(); return; }
        if (gfSelectMode) return;
        loadGroupFiles(true);
    }, 8000);
}
function stopGroupFilesPoll() {
    if (gfPollTimer) { clearInterval(gfPollTimer); gfPollTimer = null; }
}
function closeGroupFiles() {
    stopGroupFilesPoll();
    document.getElementById('groupFilesModal').classList.remove('active');
    document.getElementById('groupFilesPreview').classList.remove('active');
    groupFilesTarget = null;
    groupFilesCache = [];
    resetGfSelect();
}
function switchGroupFilesTab(tab) {
    groupFilesTab = tab;
    const ab = document.getElementById('gfTabAlbum');
    const fl = document.getElementById('gfTabFiles');
    if (ab) ab.classList.toggle('active', tab === 'album');
    if (fl) fl.classList.toggle('active', tab === 'files');
    gfSelected.clear();          // 切换 tab 时清空勾选：勾选是按 url 存的，跨 tab 留着会指向看不见的项
    loadGroupFiles();
}
function toggleGfSelect() {
    gfSelectMode = !gfSelectMode;
    gfSelected.clear();
    const btn = document.getElementById('gfSelToggle');
    if (btn) { btn.classList.toggle('on', gfSelectMode); btn.textContent = gfSelectMode ? '管理进行中' : '批量管理'; }
    const bar = document.getElementById('gfSelBar');
    if (bar) bar.style.display = gfSelectMode ? '' : 'none';
    renderCurrentGfTab();
}
function toggleGfItem(url) {
    if (gfSelected.has(url)) gfSelected.delete(url); else gfSelected.add(url);
    const on = gfSelected.has(url);
    // 只改被点的那一项，不整表重绘——重绘会把列表滚动位置带回顶部，翻长列表时很难受
    document.querySelectorAll('#groupFilesBody [data-gfurl]').forEach(el => {
        if (el.dataset.gfurl === url) {
            el.classList.toggle('selected', on);
            const c = el.querySelector('.gf-check');
            if (c) c.classList.toggle('on', on);
        }
    });
    updateGfSelCount();
}
function toggleGfSelectAll() {
    const list = gfVisibleList();
    const allOn = list.length > 0 && list.every(f => gfSelected.has(f.url));
    gfSelected.clear();
    if (!allOn) list.forEach(f => gfSelected.add(f.url));
    renderCurrentGfTab();
}
function updateGfSelCount() {
    const el = document.getElementById('gfSelCount');
    if (el) el.textContent = '已选 ' + gfSelected.size + ' 项';
    const box = document.getElementById('gfSelAllBox');
    if (box) {
        const list = gfVisibleList();
        box.classList.toggle('on', list.length > 0 && list.every(f => gfSelected.has(f.url)));
    }
}
function renderCurrentGfTab() {
    const body = document.getElementById('groupFilesBody');
    if (!body) return;
    if (groupFilesTab === 'album') renderGroupAlbum(body, gfVisibleList(), gfPerm.isOwner, gfPerm.isAdmin);
    else renderGroupFileList(body, gfVisibleList(), gfPerm.isOwner, gfPerm.isAdmin);
    updateGfSelCount();
}
async function loadGroupFiles(silent) {
    const body = document.getElementById('groupFilesBody');
    if (!body) return;
    const keepScroll = silent ? body.scrollTop : 0;
    if (!silent) body.innerHTML = ldHTML('加载中');
    if (!groupFilesTarget) return;
    try {
        const res = await fetch('?action=get_group_files&group=' + encodeURIComponent(groupFilesTarget));
        const d = await res.json();
        // 服务端拒绝时（例如"你不在该群中"）必须把原因显示出来。
        // 以前这里直接把 d.files 当空数组渲染，界面显示"还没有文件"，
        // 看起来就像"刚上传的东西没上去"，把真正的权限/网络问题藏起来了。
        if (!res.ok || !Array.isArray(d.files)) {
            groupFilesCache = [];
            body.innerHTML = '<div class="gf-empty">' + escapeHtml(d.error || '群文件加载失败，请重试') + '</div>';
            return;
        }
        groupFilesCache = d.files;
        const gInfo = (window._groupData || []).find(g => g.name === groupFilesTarget);
        gfPerm = {
            isOwner: !!(gInfo && gInfo.created_by === currentUser),
            isAdmin: !!(gInfo && listHasUser(gInfo.admins, currentUser)),
            isMember: !!(gInfo && listHasUser(gInfo.members, currentUser))
        };
        const upBtn = document.getElementById('gfUploadBtn');
        if (upBtn) upBtn.style.display = gfPerm.isMember ? '' : 'none';
        // 打开弹窗后第一次加载：如果默认的"图片"标签是空的、而"文件"里有东西，自动切过去，
        // 免得群里明明有文件、打开却是一片空。只自动跳一次，之后用户手动切的标签不再被改。
        if (!gfAutoTried) {
            gfAutoTried = true;
            const isAlbum = groupFilesTab === 'album';
            const hasCur = groupFilesCache.some(f => isAlbum ? f.type === 'image' : f.type !== 'image');
            if (!hasCur && groupFilesCache.length > 0) { switchGroupFilesTab(isAlbum ? 'files' : 'album'); return; }
        }
        renderCurrentGfTab();
        if (silent) body.scrollTop = keepScroll;
    } catch (e) {
        // 静默刷新失败就保持原样，别把已经显示的列表换成"加载失败"吓人一跳
        if (!silent) body.innerHTML = '<div class="gf-empty">加载失败</div>';
    }
}
function renderGroupAlbum(body, imgs, isOwner, isAdmin) {
    if (!imgs.length) { body.innerHTML = '<div class="gf-empty">还没有图片，点右上角上传吧</div>'; return; }
    let html = '<div class="gf-album">';
    imgs.forEach(f => {
        const canDel = isOwner || isAdmin || f.user === currentUser;
        const on = gfSelected.has(f.url);
        const urlJs = jsq(f.url);
        const click = gfSelectMode ? `toggleGfItem(${urlJs})` : `openGroupFilePreview(${urlJs})`;
        html += `<div class="gf-photo${gfSelectMode ? ' selectable' : ''}${on ? ' selected' : ''}" data-gfurl="${escapeHtml(f.url)}" onclick="${click}">
            <img src="${escapeHtml(f.url)}?t=${f.time || 0}" loading="lazy" alt="">
            ${gfSelectMode ? `<span class="gf-check${on ? ' on' : ''}" onclick="event.stopPropagation();toggleGfItem(${urlJs})" title="选择"><svg viewBox="0 0 24 24"><path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z"/></svg></span>` : ''}
            ${(!gfSelectMode && canDel) ? `<button class="gf-del" onclick="event.stopPropagation();deleteGroupFile(${urlJs}, ${jsq(f.name || '图片')})" title="删除"><svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg></button>` : ''}
        </div>`;
    });
    html += '</div>';
    body.innerHTML = html;
}
function renderGroupFileList(body, files, isOwner, isAdmin) {
    if (!files.length) { body.innerHTML = '<div class="gf-empty">还没有文件，点右上角上传吧</div>'; return; }
    let html = '';
    files.forEach(f => {
        const canDel = isOwner || isAdmin || f.user === currentUser;
        const on = gfSelected.has(f.url);
        const urlJs = jsq(f.url);
        const rowClick = gfSelectMode ? ` onclick="toggleGfItem(${urlJs})"` : '';
        html += `<div class="gf-file${gfSelectMode ? ' selectable' : ''}${on ? ' selected' : ''}" data-gfurl="${escapeHtml(f.url)}"${rowClick}>
            ${gfSelectMode ? `<span class="gf-check${on ? ' on' : ''}" onclick="event.stopPropagation();toggleGfItem(${urlJs})" title="选择"><svg viewBox="0 0 24 24"><path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z"/></svg></span>` : ''}
            <div class="gf-file-ico"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" fill="none" stroke="currentColor" stroke-width="1.5"/><polyline points="14 2 14 8 20 8" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></div>
            <div class="gf-file-info">
                <div class="gf-file-name" title="${escapeHtml(f.name || '')}">${escapeHtml(f.name || '未命名')}</div>
                <div class="gf-file-meta">${escapeHtml(f.user || '')} · ${formatSize(f.size || 0)} · ${formatTime(f.time || 0)}</div>
            </div>
            <div class="gf-file-op">
                ${gfSelectMode ? '' : `<button class="gf-op-btn" onclick="downloadGroupFile(${urlJs}, ${jsq(f.name || 'file')})" title="下载"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg></button>${canDel ? `<button class="gf-op-btn danger" onclick="deleteGroupFile(${urlJs}, ${jsq(f.name || 'file')})" title="删除"><svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg></button>` : ''}`}
            </div>
        </div>`;
    });
    body.innerHTML = html;
}
function triggerGroupFileUpload() { document.getElementById('groupFileInput').click(); }
async function handleGroupFileUpload() {
    const input = document.getElementById('groupFileInput');
    const files = [...(input.files || [])];
    if (!files.length || !groupFilesTarget) return;
    input.value = '';
    let ok = 0, fail = 0, lastWasImg = null, failMsg = '', lastCount = null;
    for (const file of files) {
        const isImg = /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(file.name);
        lastWasImg = isImg;
        const fd = new FormData();
        fd.append('group', groupFilesTarget);
        fd.append('type', isImg ? 'image' : 'file');
        fd.append('file', file);
        try {
            const res = await fetch('?action=upload_group_file', { method: 'POST', body: fd });
            const d = await res.json();
            if (d.success) { ok++; if (typeof d.count === 'number') lastCount = d.count; }
            else { fail++; if (!failMsg) failMsg = d.error || '上传失败'; }
        } catch (e) { fail++; if (!failMsg) failMsg = '网络异常'; }
    }
    if (fail) showToast(`${ok} 个成功、${fail} 个失败：${failMsg}`);
    else if (files.length > 1) showToast(`${ok} 个文件已上传`);
    else if (ok) showToast('已上传 ' + files[0].name + (lastCount !== null ? '（该群文件共 ' + lastCount + ' 个）' : ''));
    // 传完自动切到该文件所在的标签页：弹窗默认停在"图片"，
    // 传普通文件时列表里看不到刚传的东西，很容易被当成"上传没成功"。
    const wantTab = lastWasImg ? 'album' : 'files';
    if (ok && groupFilesTab !== wantTab) switchGroupFilesTab(wantTab);
    else loadGroupFiles();
}
function downloadGroupFile(url, name) {
    // 走服务端下载接口，而不是直接指向 files/ 静态路径：
    // 静态目录一旦被服务器禁止直接访问，直链就会静默失败（"能上传不能下载"多为这种）
    const a = document.createElement('a');
    a.href = '?action=download_group_file&group=' + encodeURIComponent(groupFilesTarget || '') + '&url=' + encodeURIComponent(url);
    a.download = name || 'file';
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
}
function deleteGroupFile(url, name) {
    customConfirm('确定删除「' + (name || '该文件') + '」吗？', () => {
        const fd = new FormData();
        fd.append('group', groupFilesTarget || '');
        fd.append('url', url);
        fetch('?action=delete_group_file', { method: 'POST', body: fd })
            .then(r => r.json())
            .then(d => { showToast(d.success ? '已删除' : (d.error || '删除失败')); loadGroupFiles(); })
            .catch(() => showToast('删除失败'));
    });
}
// 批量下载：交给通用下载器处理（zip 流走 fetch 再落盘，失败时能拿到服务端的提示）
function batchDownloadGroupFiles() {
    if (!gfSelected.size) { showToast('请先选择要下载的文件'); return; }
    if (!groupFilesTarget) return;
    const urls = [...gfSelected];
    showToast(urls.length > 1 ? ('正在打包下载 ' + urls.length + ' 个文件…') : '开始下载');
    downloadViaPost('download_group_files', { group: groupFilesTarget, urls }, '群文件');
}
function batchDeleteGroupFiles() {
    if (!gfSelected.size) { showToast('请先选择要删除的文件'); return; }
    const urls = [...gfSelected];
    const n = urls.length;
    customConfirm('确定删除选中的 ' + n + ' 个文件吗？此操作不可恢复！', async () => {
        const fd = new FormData();
        fd.append('group', groupFilesTarget || '');
        urls.forEach(u => fd.append('urls[]', u));
        try {
            const res = await fetch('?action=batch_delete_group_file', { method: 'POST', body: fd });
            const d = await res.json();
            if (d.success) {
                let msg = '已删除 ' + (d.deleted || 0) + ' 个文件';
                // 普通成员删不了别人上传的，明确说清楚有几个被跳过，而不是笼统报"成功"
                if (d.denied) msg += '，' + d.denied + ' 个无权限（只能删自己上传的）';
                showToast(msg);
                gfSelected.clear();
                loadGroupFiles();
            } else {
                showToast(d.error || '删除失败');
            }
        } catch (e) { showToast('删除失败'); }
    });
}
function openGroupFilePreview(url) {
    const pv = document.getElementById('groupFilesPreview');
    pv.querySelector('img').src = url;
    pv.classList.add('active');
}
function closeGroupFilePreview() { document.getElementById('groupFilesPreview').classList.remove('active'); }
function formatSize(bytes) {
    if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
    if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
    return bytes + ' B';
}
function formatTime(ts) {
    const d = new Date(ts * 1000);
    const p = n => (n < 10 ? '0' : '') + n;
    return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}

// ---------- 消息转发 ----------
let forwardCache = [];
let forwardMsg = null;      // 单条转发
let forwardBatch = null;    // 多选批量转发：[{id, room}, ...]
async function openForwardModal(batch) {
    if (batch) {
        if (!forwardBatch || !forwardBatch.length) return;
        forwardMsg = null;
        document.getElementById('forwardTitle').textContent = `转发 ${forwardBatch.length} 条消息到...`;
    } else {
        if (!bubblePopupTarget) return;
        forwardMsg = bubblePopupTarget.msg;
        forwardBatch = null;
        document.getElementById('forwardTitle').textContent = '转发到...';
    }
    document.getElementById('forwardSearchInput').value = '';
    document.getElementById('forwardTargetList').innerHTML = ldHTML('加载中');
    document.getElementById('forwardModal').classList.add('active');
    closeBubblePopup();
    try {
        const res = await fetch('?action=get_rooms');
        const d = await res.json();
        forwardCache = [];
        forwardCache.push({ name:'公共聊天', type:'channel', label:'公共聊天' });
        (d.friends || []).forEach(f => forwardCache.push({ name:f.name, type:'friend', label:'好友 · ' + f.name }));
        (d.groups || []).forEach(g => {
            if (!g.banned) forwardCache.push({ name:g.name, type:'group', label:'群聊 · ' + g.name + (g.group_id ? '（' + g.group_id + '）' : '') });
        });
        renderForwardTargets();
    } catch (e) {
        document.getElementById('forwardTargetList').innerHTML = '<div style="padding:16px;color:var(--text3);font-size:13px;text-align:center;">加载失败</div>';
    }
}
function filterForwardTargets() { renderForwardTargets(); }
function renderForwardTargets() {
    const q = (document.getElementById('forwardSearchInput').value || '').trim().toLowerCase();
    const list = document.getElementById('forwardTargetList');
    if (!list) return;
    let html = '';
    forwardCache.filter(t => !q || t.name.toLowerCase().includes(q) || t.label.toLowerCase().includes(q)).forEach(t => {
        const icon = t.type === 'friend'
            ? '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>'
            : (t.type === 'group'
                ? '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>'
                : '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 6l2.29 2.29-4.88 4.88-4-4L2 16.59 3.41 18l6-6 4 4 6.3-6.29L22 12V6z"/></svg>');
        html += `<div class="fwd-item" onclick="doForward('${t.type}', ${jsq(t.name)}, ${jsq(t.label)})"><div class="fwd-ico">${icon}</div><span>${escapeHtml(t.label)}</span></div>`;
    });
    if (!html) html = '<div style="padding:16px;color:var(--text3);font-size:13px;text-align:center;">没有可转发对象</div>';
    list.innerHTML = html;
}
function closeForwardModal() {
    document.getElementById('forwardModal').classList.remove('active');
    forwardBatch = null;
}
function forwardTargetRoom(type, name) {
    if (type === 'friend') return getPMRoomName(currentUser, name);
    if (type === 'group') return 'group:' + name;
    return '公共聊天';
}
async function doForward(type, name, label) {
    const batch = forwardBatch;
    const single = forwardMsg;
    if (!batch && !single) { closeForwardModal(); return; }
    const targetRoom = forwardTargetRoom(type, name);
    const fd = new URLSearchParams();
    fd.append('target_room', targetRoom);
    if (batch) {
        fd.append('src_room', batch[0].room || currentRoom);
        fd.append('msg_ids', batch.map(x => x.id).join(','));
    } else {
        fd.append('msg_id', single.id);
        fd.append('src_room', single.room || currentRoom);
    }
    try {
        const res = await fetch('?action=forward_message', { method:'POST', body:fd });
        const d = await res.json();
        if (d.success) {
            const n = d.count || 1;
            showToast(n > 1 ? `已转发 ${n} 条到 ${label}` : '已转发到 ' + label);
            forwardMsg = null;
            closeForwardModal();
            if (targetRoom === currentRoom) loadMessages();
            if (selectMode) exitSelectMode();
        } else {
            showToast(d.error || '转发失败');
        }
    } catch (e) { showToast('转发失败'); }
}

let inviteMembersTarget = null;      // 群管理 -> 邀请好友 的目标群
let inviteMembersCache = [];          // 好友列表缓存
let inviteToGroupTarget = null;       // 拉入群聊 的目标好友

async function openInviteMembers(groupName) {
    inviteMembersTarget = groupName;
    const res = await fetch('?action=get_rooms');
    const d = await res.json();
    inviteMembersCache = (d.friends || []).map(f => f.name).filter(n => {
        const g = (d.groups || []).find(x => x.name === groupName);
        return g ? !listHasUser(g.members, n) : true;
    });
    document.getElementById('inviteMembersTitle').textContent = `邀请好友加入「${groupName}」`;
    document.getElementById('inviteSearchInput').value = '';
    renderInviteMembers();
    document.getElementById('inviteMembersModal').classList.add('active');
}
function renderInviteMembers() {
    const q = (document.getElementById('inviteSearchInput').value || '').trim().toLowerCase();
    const list = document.getElementById('inviteUserList');
    let html = '';
    inviteMembersCache.filter(n => !q || n.toLowerCase().includes(q)).forEach(n => {
        html += `<label class="user-check-item" style="display:flex;align-items:center;gap:8px;padding:8px 4px;color:var(--text);font-size:13px;cursor:pointer;">
            <input type="checkbox" value="${escapeHtml(n)}"> <span>${escapeHtml(n)}</span></label>`;
    });
    if (!html) html = '<div style="padding:16px;color:var(--text3);font-size:13px;text-align:center;">没有可邀请的好友（好友均已在该群或暂无好友）。</div>';
    list.innerHTML = html;
}
function filterInviteMembers() { renderInviteMembers(); }
function closeInviteMembers() {
    document.getElementById('inviteMembersModal').classList.remove('active');
    inviteMembersTarget = null;
}
async function confirmInviteMembers() {
    const checks = [...document.querySelectorAll('#inviteUserList input:checked')].map(c => c.value);
    const g = inviteMembersTarget;
    if (!g || !checks.length) { showToast('请勾选要邀请的好友'); return; }
    const res = await fetch('?action=add_group_members', { method:'POST', body:new URLSearchParams({group:g, members: JSON.stringify(checks)}) });
    const d = await res.json();
    if (d.success) { showToast('已邀请'); closeInviteMembers(); openGroupManage(g); loadRooms(); }
    else showToast(d.error || '操作失败');
}

async function inviteToGroup(friendName) {
    inviteToGroupTarget = friendName;
    closeContextMenu();
    const res = await fetch('?action=get_rooms');
    const d = await res.json();
    document.getElementById('inviteToGroupTitle').textContent = `拉入群聊 - ${friendName}`;
    const list = document.getElementById('inviteGroupList');
    let html = '';
    (d.groups || []).forEach(g => {
        html += `<label class="user-check-item" style="display:flex;align-items:center;gap:8px;padding:8px 4px;color:var(--text);font-size:13px;cursor:pointer;">
            <input type="checkbox" value="${escapeHtml(g.name)}"> <span>${escapeHtml(g.name)}（${(g.members||[]).length}人）</span></label>`;
    });
    if (!html) html = '<div style="padding:16px;color:var(--text3);font-size:13px;text-align:center;">你还没有加入任何群聊。</div>';
    list.innerHTML = html;
    document.getElementById('inviteToGroupModal').classList.add('active');
}
function closeInviteToGroup() {
    document.getElementById('inviteToGroupModal').classList.remove('active');
    inviteToGroupTarget = null;
}
async function confirmInviteToGroup() {
    const checks = [...document.querySelectorAll('#inviteGroupList input:checked')].map(c => c.value);
    const target = inviteToGroupTarget;
    if (!target) return;
    if (!checks.length) { showToast('请至少选择一个群聊'); return; }
    let ok = 0, fail = 0;
    for (const g of checks) {
        try {
            const res = await fetch('?action=add_group_members', { method:'POST', body:new URLSearchParams({group:g, members: JSON.stringify([target])}) });
            const d = await res.json();
            if (d.success) ok++; else fail++;
        } catch(e) { fail++; }
    }
    closeInviteToGroup();
    loadRooms();
    showToast(ok ? `已拉入 ${ok} 个群聊` : (fail ? '操作失败' : '')
       );
}

async function openGroupManage(groupName) {
    closeContextMenu();
    closeRoomDropdown();
    groupManageTarget = groupName;
    const res = await fetch(`?action=get_group_info&group=${encodeURIComponent(groupName)}`);
    const d = await res.json();
    if (!d.name) { showToast('获取群信息失败'); return; }
    document.getElementById('groupManageTitle').textContent = `群成员 (${d.members.length})`;
    const isCreator = d.created_by === currentUser;
    const isAdmin = !!d.is_admin;
    const admins = d.admins || [];
    const titles = d.titles || {};
    const mutes = d.mutes || {};
    let html = '';
    d.members.forEach(m => {
        const avBg = stringToColor(m);
        let badges = '';
        if (m === d.created_by) badges += '<span class="member-badge">群主</span>';
        else if (admins.includes(m)) badges += '<span class="member-badge" style="background:#f5a623;">管理员</span>';
        const customTitle = titles[m];
        if (customTitle) badges += `<span class="member-title">${escapeHtml(customTitle)}</span>`;
        const muted = mutes[m] !== undefined;
        if (muted) {
            const until = parseInt(mutes[m]) || 0;
            const remain = until > 0 ? Math.ceil((until - Math.floor(Date.now()/1000)) / 60) : -1;
            badges += `<span class="member-badge" style="background:var(--danger);">禁言${remain < 0 ? '中' : ' ' + remain + '分'}</span>`;
        }
        let actions = '';
        if (isCreator && m !== currentUser) {
            const isAd = admins.includes(m);
            actions += `<button class="member-act" style="color:var(--accent);" onclick="setGroupAdmin(${jsq(groupName)},${jsq(m)},${isAd ? 0 : 1})">${isAd ? '取消管理员' : '设为管理员'}</button>`;
            actions += `<button class="member-act" onclick="openMuteModal(${jsq(groupName)},${jsq(m)},${muted ? 'true' : 'false'})">${muted ? '取消禁言' : '禁言'}</button>`;
            actions += `<button class="member-act" style="color:#f5a623;" onclick="openTitleModal(${jsq(groupName)},${jsq(m)},${jsq(customTitle || '')})">头衔</button>`;
            actions += `<button class="member-kick" onclick="kickMember(${jsq(groupName)},${jsq(m)})">踢出</button>`;
        } else if (isAdmin && m !== currentUser && m !== d.created_by) {
            actions += `<button class="member-act" onclick="openMuteModal(${jsq(groupName)},${jsq(m)},${muted ? 'true' : 'false'})">${muted ? '取消禁言' : '禁言'}</button>`;
        }
        html += `<div class="group-member-item">
            <div class="member-avatar" style="background:${avBg}">${m.charAt(0).toUpperCase()}</div>
            <span class="member-name">${escapeHtml(m)}</span>${badges}${actions}</div>`;
    });
    if (!d.members.length) html = '<div style="padding:20px;text-align:center;color:var(--text3);font-size:13px;">暂无成员</div>';
    document.getElementById('groupManageContent').innerHTML = html;
    document.getElementById('groupManageBtns').innerHTML = `<button class="btn-cancel" onclick="closeGroupManage()">关闭</button>`;
    document.getElementById('groupManageModal').classList.add('active');
}

// ---- 群管理员 / 禁言 / 头衔 ----
async function setGroupAdmin(groupName, target, make) {
    const res = await fetch('?action=set_group_admin', { method:'POST', body:new URLSearchParams({group:groupName, target, make: make ? '1' : '0'}) });
    const d = await res.json();
    if (d.success) { showToast(make ? `已将 ${target} 设为管理员` : `已取消 ${target} 管理员`); openGroupManage(groupName); }
    else showToast(d.error || '操作失败');
}
let muteTarget = null;
let muteGroup = null;
function openMuteModal(groupName, target, alreadyMuted) {
    muteGroup = groupName; muteTarget = target;
    document.getElementById('muteModalTitle').textContent = `禁言 ${target}`;
    if (alreadyMuted) {
        document.getElementById('muteModalContent').innerHTML = '<p style="font-size:13px;color:var(--text3);margin-bottom:12px;">该成员当前已被禁言</p><button class="btn btn-danger btn-sm" onclick="confirmUnmute()">解除禁言</button>';
    } else {
        const presets = [[5,'5分钟'],[30,'30分钟'],[60,'1小时'],[720,'12小时'],[1440,'24小时'],[0,'永久']];
        document.getElementById('muteModalContent').innerHTML = `<p style="font-size:13px;color:var(--text3);margin-bottom:10px;">选择禁言时长（禁言期间对方无法在群内发言）</p><div style="display:flex;flex-wrap:wrap;gap:8px;">${presets.map(p => `<button class="btn btn-plain btn-sm" onclick="confirmMute(${p[0]})">${p[1]}</button>`).join('')}</div>`;
    }
    document.getElementById('muteModal').classList.add('active');
}
async function confirmMute(minutes) {
    const res = await fetch('?action=mute_group_member', { method:'POST', body:new URLSearchParams({group:muteGroup, target:muteTarget, act:'mute', minutes:String(minutes)}) });
    const d = await res.json();
    if (d.success) { showToast(d.message); closeMuteModal(); openGroupManage(muteGroup); }
    else showToast(d.error || '操作失败');
}
async function confirmUnmute() {
    const res = await fetch('?action=mute_group_member', { method:'POST', body:new URLSearchParams({group:muteGroup, target:muteTarget, act:'unmute'}) });
    const d = await res.json();
    if (d.success) { showToast(d.message); closeMuteModal(); openGroupManage(muteGroup); }
    else showToast(d.error || '操作失败');
}
function closeMuteModal() { document.getElementById('muteModal').classList.remove('active'); muteGroup = null; muteTarget = null; }
let titleGroup = null, titleTarget = null;
function openTitleModal(groupName, target, currentTitle) {
    titleGroup = groupName; titleTarget = target;
    document.getElementById('titleModalTitle').textContent = `设置 ${target} 的群头衔`;
    document.getElementById('titleInput').value = currentTitle || '';
    document.getElementById('titleModal').classList.add('active');
    setTimeout(() => document.getElementById('titleInput').focus(), 50);
}
async function confirmSetTitle() {
    const title = document.getElementById('titleInput').value.trim();
    const res = await fetch('?action=set_group_title', { method:'POST', body:new URLSearchParams({group:titleGroup, target:titleTarget, title}) });
    const d = await res.json();
    if (d.success) { showToast(title ? '头衔已设置' : '已清除头衔'); closeTitleModal(); openGroupManage(titleGroup); }
    else showToast(d.error || '操作失败');
}
function closeTitleModal() { document.getElementById('titleModal').classList.remove('active'); titleGroup = null; titleTarget = null; }
async function setGroupJoinMode(groupName, mode) {
    const res = await fetch('?action=set_group_join_mode', { method:'POST', body:new URLSearchParams({group:groupName, mode}) });
    const d = await res.json();
    if (d.success) { showToast(mode === 'free' ? '已设为免申请，任何人可直接入群' : '已设为需申请，入群需群主审批'); loadRooms(); }
    else showToast(d.error || '操作失败');
}
// 新成员能否看到入群前的聊天记录（默认禁止，和 QQ 群一致）
async function setGroupHistoryVisible(groupName, visible) {
    const res = await fetch('?action=set_group_history_visible', { method:'POST', body:new URLSearchParams({group:groupName, visible: visible ? '1' : '0'}) });
    const d = await res.json();
    if (d.success) {
        showToast(visible ? '新成员可以看到入群前的聊天记录' : '新成员只能看到入群后的消息');
        loadRooms();
    } else showToast(d.error || '操作失败');
}
async function openGroupRequests(groupName) {
    closeContextMenu();
    groupManageTarget = groupName;
    const res = await fetch(`?action=get_group_info&group=${encodeURIComponent(groupName)}`);
    const d = await res.json();
    if (!d.name) { showToast('获取群信息失败'); return; }
    const reqs = d.requests || [];
    document.getElementById('groupRequestsTitle').textContent = `入群申请 (${reqs.length})`;
    let html = '';
    if (!reqs.length) {
        html = '<div style="padding:20px;text-align:center;color:var(--text3);font-size:13px;">暂无入群申请</div>';
    } else {
        reqs.forEach(r => {
            const uname = String(r.user || '');   // 兼容旧数据 user 可能为数字索引
            const avBg = stringToColor(uname);
            html += `<div class="group-member-item" style="background:rgba(230,162,60,0.08);">
                <div class="member-avatar" style="background:${avBg}">${uname.charAt(0).toUpperCase()}</div>
                <span class="member-name">${escapeHtml(uname)}</span>
                <button class="member-kick" style="background:var(--accent);color:#fff;border:none;" onclick="handleGroupRequest(${jsq(groupName)},${jsq(uname)},'approve')">同意</button>
                <button class="member-kick" style="background:var(--surface3);color:var(--text2);" onclick="handleGroupRequest(${jsq(groupName)},${jsq(uname)},'reject')">拒绝</button>
            </div>`;
        });
    }
    document.getElementById('groupRequestsContent').innerHTML = html;
    document.getElementById('groupRequestsModal').classList.add('active');
}
function closeGroupRequests() {
    document.getElementById('groupRequestsModal').classList.remove('active');
}
async function handleGroupRequest(groupName, target, action) {
    const res = await fetch('?action=handle_group_request', { method:'POST', body:new URLSearchParams({group:groupName, target, action}) });
    const d = await res.json();
    if (d.success) {
        showToast(action === 'approve' ? `已同意 ${target} 入群` : `已拒绝 ${target} 的申请`);
        openGroupRequests(groupName);
        loadRooms();
    }
    else showToast(d.error || '操作失败');
}
function closeGroupManage() {
    document.getElementById('groupManageModal').classList.remove('active');
    groupManageTarget = null;
}
async function kickMember(groupName, target) {
    customConfirm(`确定将 ${target} 踢出群聊？`, async () => {
        const res = await fetch('?action=kick_member', { method:'POST', body:new URLSearchParams({group:groupName, target}) });
        const d = await res.json();
        if (d.success) { showToast('已踢出'); openGroupManage(groupName); loadRooms(); }
        else showToast(d.error || '操作失败');
    });
}
async function leaveGroup(groupName) {
    closeContextMenu();
    customConfirm(`确定退出群聊 "${groupName}"？`, async () => {
        const res = await fetch('?action=leave_group', { method:'POST', body:new URLSearchParams({group:groupName}) });
        const d = await res.json();
        if (d.success) {
            showToast('已退出群聊');
            if (currentRoom === 'group:' + groupName) switchRoom('公共聊天', 'channel');
            loadRooms();
        }
        else showToast(d.error || '操作失败');
    });
}
async function deleteGroup(groupName) {
    customConfirm(`确定解散群聊 "${groupName}"？此操作不可撤销！`, async () => {
        const res = await fetch('?action=delete_group', { method:'POST', body:new URLSearchParams({group:groupName}) });
        const d = await res.json();
        if (d.success) {
            showToast('群聊已解散');
            closeGroupManage();
            if (currentRoom === 'group:' + groupName) switchRoom('公共聊天', 'channel');
            loadRooms();
        }
        else showToast(d.error || '操作失败');
    });
}
async function searchUsers() {
    const q = document.getElementById('searchInput').value.trim();
    const resultsDiv = document.getElementById('searchResults');
    if (q === '') {
        resultsDiv.style.display = 'none';
        loadRooms();
        return;
    }
    const res = await fetchWithLoading(`?action=search_users&q=${encodeURIComponent(q)}`);
    const d = await res.json();
    const users = d.users || [];
    const groups = d.groups || [];
    if (users.length === 0 && groups.length === 0) {
        resultsDiv.style.display = 'block';
        resultsDiv.innerHTML = '<div style="padding:16px;color:var(--text3);font-size:13px;text-align:center;">未找到用户或群</div>';
        return;
    }
    resultsDiv.style.display = 'block';
    let html = '';
    if (users.length > 0) {
        html += '<div style="font-size:11px;color:var(--text3);padding:6px 10px 2px;">用户</div>';
        users.forEach(u => {
            const av = u.avatar ? `background-image:url(${u.avatar})` : `background:${stringToColor(u.name)}`;
            const avTxt = u.avatar ? '' : u.name.charAt(0).toUpperCase();
            const onlineHtml = u.online ? '<span style="color:#07c160;font-size:10px;margin-left:4px;">在线</span>' : '<span style="color:var(--text3);font-size:10px;margin-left:4px;">离线</span>';
            let actionBtn = '';
            if (u.is_friend) {
                actionBtn = `<span class="added-label">已是好友</span>`;
            } else if (u.request_sent) {
                actionBtn = `<button class="add-btn" style="background:var(--surface2);color:var(--text2);" onclick="event.stopPropagation();cancelFriendRequest(${jsq(u.name)})">取消请求</button>`;
            } else if (u.request_received) {
                actionBtn = `<button class="add-btn" onclick="event.stopPropagation();acceptFriendRequest(${jsq(u.name)})"><svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>接受</button>`;
            } else {
                actionBtn = `<button class="add-btn" onclick="event.stopPropagation();sendFriendRequest(${jsq(u.name)})"><svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>添加</button>`;
            }
            const pmRoom = getPMRoomName(currentUser, u.name);
            html += `<div class="search-result-item ${currentRoom===pmRoom?'active':''}" onclick="${u.is_friend ? `switchRoom(${jsq(pmRoom)},'friend',${jsq(u.name)})` : ''}" style="${u.is_friend?'cursor:pointer;':''}">
                <div class="nav-avatar" style="${av}">${avTxt}</div>
                <div class="nav-info"><div class="nav-name">${escapeHtml(u.name)}${onlineHtml}</div></div>
                ${actionBtn}</div>`;
        });
    }
    if (groups.length > 0) {
        html += '<div style="font-size:11px;color:var(--text3);padding:6px 10px 2px;">群聊</div>';
        groups.forEach(g => {
            const banTag = g.banned ? `<span class="banned-tag">已封禁</span>` : '';
            const infoHtml = `<div class="nav-preview" style="font-size:11px;">群号 ${escapeHtml(g.group_id || '—')} · 群主 ${escapeHtml(g.created_by)} · ${g.member_count||0}人 · ${g.online_count||0}在线</div>`;
            const btn = g.is_member
                ? `<button class="add-btn" onclick="event.stopPropagation();document.getElementById('searchInput').value='';searchUsers();switchRoom('group:'+${jsq(g.name)},'group',${jsq(g.name)})">进入</button>`
                : (g.banned
                    ? `<span class="added-label" style="color:var(--text3);">已封禁</span>`
                    : `<button class="add-btn" onclick="event.stopPropagation();joinGroup(${jsq(g.name)})">加入</button>`);
            const grAv = g.avatar ? `<div class="nav-avatar" style="background-image:url(${g.avatar});width:36px;height:36px;"></div>` : `<div class="nav-channel-icon" style="width:36px;height:36px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:var(--surface3);"><svg viewBox="0 0 24 24" width="18" height="18" fill="var(--accent)"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg></div>`;
            html += `<div class="search-result-item">
                ${grAv}
                <div class="nav-info"><div class="nav-name" style="font-size:13px;">${escapeHtml(g.name)}${banTag}</div>${infoHtml}</div>
                ${btn}</div>`;
        });
    }
    resultsDiv.innerHTML = html;
}

async function joinGroup(groupName) {
    const res = await fetch('?action=join_group', { method:'POST', body:new URLSearchParams({group:groupName}) });
    const d = await res.json();
    if (d.success && d.joined) {
        showToast('已加入群聊');
        document.getElementById('searchInput').value = '';
        loadRooms();
        switchRoom('group:'+groupName, 'group', groupName);
    } else if (d.success && d.applied) {
        showToast('已发送入群申请，等待群主同意');
        document.getElementById('searchInput').value = '';
        loadRooms();
    } else {
        showToast(d.error || '操作失败');
    }
}

async function cancelFriendRequest(target) {
    const res = await fetch('?action=cancel_friend_request', { method:'POST', body:new URLSearchParams({target}) });
    const d = await res.json();
    if (d.success) { showToast('已取消好友请求'); searchUsers(); loadRooms(); }
    else showToast(d.error || '操作失败');
}

async function sendFriendRequest(target) {
    const res = await fetch('?action=send_friend_request', { method:'POST', body:new URLSearchParams({target}) });
    const d = await res.json();
    if (d.success) {
        if (d.auto_accepted) showToast('已自动成为好友！');
        else showToast('好友请求已发送');
        searchUsers();
        loadRooms();
    } else showToast(d.error || '操作失败');
}

async function acceptFriendRequest(from) {
    const res = await fetch('?action=accept_friend_request', { method:'POST', body:new URLSearchParams({from}) });
    const d = await res.json();
    if (d.success) { showToast('已接受好友请求'); searchUsers(); loadRooms(); }
    else showToast(d.error || '操作失败');
}

async function openFriendReqModal() {
    const res = await fetch('?action=get_friend_requests');
    const d = await res.json();
    let html = '';
    if (d.requests.length === 0) {
        html = '<div style="text-align:center;color:var(--text3);padding:20px;">暂无好友请求</div>';
    } else {
        d.requests.forEach(r => {
            const t = new Date(r.time * 1000);
            const timeStr = `${t.getMonth()+1}/${t.getDate()} ${String(t.getHours()).padStart(2,'0')}:${String(t.getMinutes()).padStart(2,'0')}`;
            html += `<div class="friend-req-item">
                <div class="nav-avatar" style="background:${stringToColor(r.from)}">${r.from.charAt(0).toUpperCase()}</div>
                <div class="req-info"><div class="req-name">${escapeHtml(r.from)}</div><div class="req-time">${timeStr}</div></div>
                <div class="req-actions">
                    <button style="background:linear-gradient(135deg, var(--accent), #7B85F5);color:#fff;" onclick="acceptFriendRequest(${jsq(r.from)});closeFriendReqModal();">接受</button>
                    <button style="background:var(--surface3);color:var(--text2);" onclick="rejectFriendRequest(${jsq(r.from)})">拒绝</button>
                </div></div>`;
        });
    }
    document.getElementById('friendReqModalList').innerHTML = html;
    document.getElementById('friendReqModal').classList.add('active');
}
function closeFriendReqModal() { document.getElementById('friendReqModal').classList.remove('active'); loadRooms(); }
async function rejectFriendRequest(from) {
    const res = await fetch('?action=reject_friend_request', { method:'POST', body:new URLSearchParams({from}) });
    const d = await res.json();
    if (d.success) { showToast('已拒绝'); openFriendReqModal(); }
    else showToast(d.error || '操作失败');
}

let groupAvatarUrl = '';
function previewGroupAvatar() {
    const file = document.getElementById('groupAvatarFile').files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = function(e) {
        document.getElementById('groupAvatarPreview').innerHTML = `<img src="${e.target.result}" style="width:100%;height:100%;object-fit:cover;">`;
    };
    reader.readAsDataURL(file);
}
async function openCreateGroup() {
    document.getElementById('createGroupModal').classList.add('active');
    document.getElementById('groupName').value = '';
    groupAvatarUrl = '';
    document.getElementById('groupAvatarFile').value = '';
    document.getElementById('groupAvatarPreview').innerHTML = '<svg viewBox="0 0 24 24" width="28" height="28" fill="var(--text3)"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>';
    const res = await fetch('?action=get_rooms');
    const d = await res.json();
    let html = '';
    d.friends.forEach(f => {
        html += `<label class="user-check-item"><input type="checkbox" value="${escapeHtml(f.name)}"> ${escapeHtml(f.name)}</label>`;
    });
    if (!html) html = '<div style="color:var(--text3);text-align:center;padding:10px;">请先添加好友</div>';
    document.getElementById('userCheckList').innerHTML = html;
}
function closeCreateGroup() {
    document.getElementById('createGroupModal').classList.remove('active');
    groupAvatarUrl = '';
    document.getElementById('groupAvatarFile').value = '';
    document.getElementById('groupAvatarPreview').innerHTML = '<svg viewBox="0 0 24 24" width="28" height="28" fill="var(--text3)"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>';
}
async function createGroup() {
    const name = document.getElementById('groupName').value.trim();
    if (!name) { showToast('请输入群名称'); return; }
    const btn = document.getElementById('createGroupBtn');
    btn.disabled = true; btn.textContent = '创建中...';
    const members = [];
    document.querySelectorAll('#userCheckList input:checked').forEach(cb => members.push(cb.value));
    let avatar = '';
    const avatarFile = document.getElementById('groupAvatarFile').files[0];
    if (avatarFile) {
        const fd = new FormData(); fd.append('avatar', avatarFile);
        try {
            const avRes = await fetch('?action=upload_group_avatar', { method:'POST', body:fd });
            const avD = await avRes.json();
            if (avD.success) { avatar = avD.avatar_url; }
        } catch(e) { }
    }
    const res = await fetch('?action=create_group', { method:'POST', body:new URLSearchParams({name, members:JSON.stringify(members), avatar}) });
    const d = await res.json();
    btn.disabled = false; btn.textContent = '创建';
    if (d.success) { showToast('群聊创建成功'); closeCreateGroup(); loadRooms(); switchRoom('group:'+name, 'group', name); }
    else showToast(d.error || '创建失败');
}

// 当前房间来了 @ 我的新消息时单独提示一句：气泡里的黄色高亮只有在"这条已经滚进视野"时
// 才看得见，而 @ 恰恰最需要在没看到的时候被提醒。边界规则与气泡高亮保持一致。
function notifyMention(msg) {
    const text = String(msg.message || '');
    if (!text) return;
    if (!new RegExp('@' + escapeRegex(currentUser) + '\\b', 'i').test(text)) return;
    showToast((msg.username || '有人') + ' @了你', 3000);
}

// 拉取消息失败时的可见提示。原来只有一句 console.warn，用户看到的就是一片空白，
// 分不清是"这个房间还没消息"还是"连接断了"。轮询本身会自动重试，这里只负责说清楚状态。
function showConnLostHint(show) {
    const box = document.getElementById('messagesBox');
    if (!box) return;
    const el = box.querySelector('.msg-conn-lost');
    if (!show) { if (el) el.remove(); return; }
    if (el) return;
    if (box.querySelector('.msg-row')) return;   // 已经有消息在显示，就不要打断阅读
    const empty = box.querySelector('.room-empty-hint');
    if (empty) empty.remove();
    const div = document.createElement('div');
    div.className = 'msg-conn-lost';
    div.textContent = '连接中断，正在自动重试…';
    box.appendChild(div);
}

// 房间里一条可见消息都没有时给一句说明。
// 新注册 / 新入群的人按规则看不到"进来之前"的记录，进来就是一片空白，
// 不解释一句会被当成加载失败或掉线。
function updateRoomEmptyState() {
    const box = document.getElementById('messagesBox');
    if (!box) return;
    let el = box.querySelector('.room-empty-hint');
    if (box.querySelector('.msg-row')) { if (el) el.remove(); return; }
    if (box.querySelector('.msg-conn-lost')) return;   // 断线提示优先，别和"没有消息"挤在一起
    if (el) return;
    el = document.createElement('div');
    el.className = 'room-empty-hint';
    // 服务端下发了可见起点说明就是"受限可见"：这时要把原因讲清楚，
    // 否则用户会以为记录丢了或者加载失败
    if (roomVisibleFromTs > 0) {
        el.textContent = (currentRoomType === 'group')
            ? '这里还没有你可见的消息。你入群之前的聊天记录不会显示'
            : '这里还没有你可见的消息。你注册之前的聊天记录不会显示';
    } else {
        el.textContent = (currentRoomType === 'group') ? '这里还没有消息，等大家开口吧' : '这里还没有消息，说点什么吧';
    }
    box.appendChild(el);
}

async function loadMessages() {
    if (isLoading) return;
    isLoading = true;
    _loadStartedAt = Date.now();
    let _abortTimer = null;
    try {
        const wm = getClearWatermark(currentRoom);
        const fetchLastId = Math.max(lastId, wm);
        // 请求超时兜底（关键）：如果 fetch 长时间挂起，isLoading 会一直是 true，
        // 之后每一轮轮询都会被开头的 return 挡掉 —— 表现就是"消息再也出不来、只能刷新"。
        // 这里给单次请求设上限，超时就中止并释放锁，让下一轮重新拉取。
        const _ctrl = new AbortController();
        _abortTimer = setTimeout(() => _ctrl.abort(), 10000);
        const res = await fetch(`?action=get_messages&last_id=${fetchLastId}&room=${encodeURIComponent(currentRoom)}&avatar_since=${avatarTs}&recalled_since=${recalledSince}&edited_since=${editedSince}&reactions_since=${reactionsSince}`, { signal: _ctrl.signal });
        clearTimeout(_abortTimer);
        _abortTimer = null;
        const d = await res.json();
        // 账号态异常（封禁／被管理员强制下线／IP 封禁／被其他设备挤下线）统一处理：
        // 提示原因 → 退回登录页，并把原因写进 sessionStorage，让登录页把它显示出来。
        // 否则用户只看到自己莫名其妙回到了登录页，会以为是系统坏了。
        if (d.code === 'banned' || d.code === 'kicked' || d.code === 'ip_banned' || d.code === 'session_replaced') {
            const notice = d.error || (d.code === 'session_replaced' ? '该账号已在其他设备登录，你已被下线' : '您已被强制下线');
            try { sessionStorage.setItem('chat_auth_notice', notice); } catch (e) {}
            showToast(notice);
            setTimeout(() => { try { fetch('?action=logout'); } catch (e) {} location.reload(); }, 1800);
            return;
        }
        if (d.deleted_ids && d.deleted_ids.length > 0) {
            d.deleted_ids.forEach(id => {
                const el = document.getElementById('msg-'+id);
                if (el) { el.remove(); addedIds.delete(id); }
            });
        }
        if (d.recalled_messages && d.recalled_messages.length > 0) {
            d.recalled_messages.forEach(rm => {
                const el = document.getElementById('msg-'+rm.id);
                if (el) {
                    const bubble = el.querySelector('.bubble');
                    if (bubble && !bubble.classList.contains('recalled')) {
                        bubble.innerHTML = `<span style="font-size:12px;font-style:italic;">${escapeHtml(rm.message)}</span>`;
                        bubble.classList.remove('img-only');
                        bubble.classList.remove('voice-only');   // 撤回后按普通气泡渲染，恢复内边距与虚线框
                        bubble.classList.add('recalled');
                    }
                    const footer = el.querySelector('.msg-footer');
                    if (footer) footer.remove();
                    // 时间行现在承载"已读/未读"标记：只有不含标记时才整块删掉，
                    // 否则会把私聊的已读状态一起抹掉
                    const timeEl = el.querySelector('.msg-time');
                    if (timeEl && !timeEl.querySelector('.pm-read')) timeEl.remove();
                    const img = el.querySelector('.bubble img');
                    if (img) img.remove();
                    const fileEl = el.querySelector('.file-attach');
                    if (fileEl) fileEl.remove();
                    const rxBar = el.querySelector('.msg-reactions');
                    if (rxBar) rxBar.remove();
                }
            });
            recalledSince = Math.floor(Date.now() / 1000);
        }
        if (d.edited_messages && d.edited_messages.length > 0) {
            d.edited_messages.forEach(em => {
                const el = document.getElementById('msg-'+em.id);
                if (el) {
                    const bubble = el.querySelector('.bubble');
                    if (bubble) {
                        const quote = bubble.querySelector('.quote-block');
                        const img = bubble.querySelector('img');
                        const file = bubble.querySelector('.file-attach');
                        const editedTag = bubble.querySelector('.msg-edited-tag');
                        
                        let newHtml = '';
                        if (quote) newHtml += quote.outerHTML;
                        if (img) newHtml += img.outerHTML;
                        if (file) newHtml += file.outerHTML;
                        newHtml += linkify(escapeHtml(em.message)) + '<span class="msg-edited-tag">（已编辑）</span>';
                        bubble.innerHTML = newHtml;
                        // 编辑后重新判断：仅当只剩图片且无文字/引用/文件时才保持无气泡外观
                        bubble.classList.toggle('img-only', !!img && !(em.message && em.message.trim()) && !quote && !file);
                    }
                }
            });
            editedSince = Math.floor(Date.now() / 1000);
        }
        // 表情回应增量：别的用户点了回应，把徽章同步到已渲染的气泡上
        if (d.reaction_updates && d.reaction_updates.length > 0) {
            d.reaction_updates.forEach(ru => {
                const el = document.getElementById('msg-' + ru.id);
                if (el) updateRowReactions(el, ru.reactions || {});
            });
        }
        if (d.reactions_since) reactionsSince = d.reactions_since;
        if (d.avatar_updates && Object.keys(d.avatar_updates).length > 0) {
            for (const [user, avatarUrl] of Object.entries(d.avatar_updates)) {
                avatarCache[user] = avatarUrl || null;
            }
            refreshAllAvatars();
            loadRooms();
        }
        if (d.avatar_ts) avatarTs = d.avatar_ts;
        if (typeof d.visible_from === 'number') roomVisibleFromTs = d.visible_from;
        if (d.messages && d.messages.length > 0) {
            const authors = [...new Set(d.messages.map(m => m.username).filter(Boolean))];
            // 头像预取改为不阻塞：以前是 await，每个作者一次请求，网络一慢就把整批消息卡在后面，
            // 表现就是"消息过很久才突然一起冒出来"。现在先渲染文字，头像到了再补上。
            preloadAvatars(authors).then(() => refreshAllAvatars()).catch(() => {});
            let renderFailed = false;
            for (let msg of d.messages) {
                if (addedIds.has(msg.id)) continue;
                // 只在"房间里已经有内容"时提示被 @：首次进房间的整屏历史里翻出旧的 @ 不该弹提示
                if (roomInitialized && msg.username !== currentUser) notifyMention(msg);
                try {
                    await addMessage(msg, roomInitialized);
                    addedIds.add(msg.id);      // 只有渲染成功才登记，失败项下一轮可以重试
                    MSG_FAIL.delete(msg.id);
                } catch (e) {
                    renderFailed = true;
                    const n = (MSG_FAIL.get(msg.id) || 0) + 1;
                    MSG_FAIL.set(msg.id, n);
                    console.error('[消息渲染失败] id=' + msg.id + '（第 ' + n + ' 次）', e);
                    if (n >= 3) { addedIds.add(msg.id); MSG_FAIL.delete(msg.id); }   // 上限兜底
                }
            }
            // 有渲染失败时不推进游标，下一轮会把失败项重新取回来重试，避免消息永久丢失
            if (!renderFailed) lastId = d.last_id;
            scrollDown();
            showConnLostHint(false);
            updateRoomEmptyState();
            saveRoomCache();
            // 当前房间消息直接可见，不响提示音（其他房间的新消息由 updateUnreadBadge 触发）
        }
        if (d.read_ids && d.read_ids.length > 0) {
            d.read_ids.forEach(id => {
                const el = document.getElementById('pmread-'+id);
                if (el) { el.textContent = '已读'; el.classList.add('read'); }
            });
        }
        roomInitialized = true;
        // 进房间的第一次渲染：插未读分隔线并定位过去。
        // 放到下一 tick，等本次渲染自带的自动滚底先执行完，否则会被它覆盖。
        if (window._enterUnread > 0) {
            const _n = window._enterUnread; window._enterUnread = 0;
            setTimeout(function () {
                if (!insertUnreadDivider(_n)) return;
                const dv = document.querySelector('#messagesBox .unread-divider');
                if (dv && dv.scrollIntoView) dv.scrollIntoView({ block: 'center' });
            }, 0);
        }
        // "发送失败"的气泡不属于服务端消息，轮询不会带回来；切房间/缓存恢复时行会被重建，
        // 所以这里每轮兜底重画一次（幂等，正常情况下 Map 是空的，开销可忽略）
        if (window.restoreFailedRows) { try { window.restoreFailedRows(); } catch (e) {} }
        document.getElementById('headerOnlineCount').textContent = `${d.online} 在线`;
        onlineUsers = d.users || [];
        if (currentRoomType === 'channel') {
            setRoomSubtitle(`${d.online} 人在线`);
        }
    } catch(e) {
        // 不能再静默吞掉：否则"消息不显示"完全没有任何可查线索
        if (e && e.name === 'AbortError') console.warn('[loadMessages] 请求超时已中止，等待下一轮重试');
        else console.warn('[loadMessages] 拉取或渲染失败', e);
        // 请求失败（含被服务器切断连接）时，别把消息区留成一片空白让人以为界面坏了
        showConnLostHint(true);
    }
    finally { if (_abortTimer) clearTimeout(_abortTimer); isLoading = false; hideMsgLoading(); }
}
loadMessages();
loadChatBg('公共聊天');
updateCallToolbar();

// 判断是否为"纯图片消息"：只有图片，没有文字/引用/转发/文件/语音，这类消息不套聊天气泡
function isImgOnlyMsg(msg) {
    if (!msg || !msg.image) return false;
    if (msg.message && msg.message.trim()) return false;
    if (msg.quote || msg.forward || msg.file || msg.voice) return false;
    return true;
}
// 判断是否为"纯语音消息"：只有语音，没有文字/引用/转发/文件/图片，这类消息同样不套聊天气泡
function isVoiceOnlyMsg(msg) {
    if (!msg || !msg.voice) return false;
    if (msg.message && msg.message.trim()) return false;
    if (msg.quote || msg.forward || msg.file || msg.image) return false;
    return true;
}
// 把服务端存的声纹包络（0-9 数字串）还原成柱子高度；缺失或非法时回退到固定花纹。
// 这是纯展示字段，解析失败绝不影响消息本身渲染。
const VOICE_FALLBACK_BARS = [8,13,18,11,16,21,10,15,19,12,17,9,14,20,13,7,16,11];
function voiceBarsFromWave(wave) {
    if (typeof wave === 'string' && wave.length >= 8 && /^[0-9]+$/.test(wave)) {
        const n = 20;                                   // 采样点多时压到 20 根，视觉疏密更接近微信
        const out = [];
        for (let i = 0; i < n; i++) {
            const v = parseInt(wave[Math.round(i * (wave.length - 1) / (n - 1))], 10) || 0;
            out.push(5 + v * 2);                        // 0-9 → 5..23px
        }
        return out;
    }
    return VOICE_FALLBACK_BARS;
}
function linkify(text) {
    const urlRegex = /(https?:\/\/[^\s<]+)/gi;
    return text.replace(urlRegex, url => `<a href="${url}" target="_blank" rel="noopener" class="msg-link">${url}</a>`);
}

// 导出 / 多选都按"行"取数据，而不是去解析气泡 DOM（气泡里有图片、语音条、引用块，
// 从渲染结果反推原文会丢信息）。这里在渲染时把关键字段挂到行上，一处维护。
function stampRowMeta(row, msg) {
    row.dataset.mid = msg.id;
    row.dataset.user = msg.username || '';
    row.dataset.ts = msg.timestamp || 0;
    row.dataset.kind = msg.type || 'chat';
    row.dataset.text = String(msg.message || '').slice(0, 4000);
    row.dataset.att = msg.image ? '[图片]' : (msg.file ? ('[文件] ' + (msg.file_name || '')) : (msg.voice ? ('[语音] ' + (parseInt(msg.voice.duration) || 1) + '″') : ''));
    if (msg.quote) {
        row.dataset.quoteUser = msg.quote.username || '';
        row.dataset.quoteText = String(msg.quote.message || '').slice(0, 200);
    }
}
// 勾选圈要贴在外侧：自己发的消息靠右、别人的靠左，所以插入位置按 isSelf 决定
function appendRowParts(row, isSelf, avatar, body) {
    const chk = document.createElement('div'); chk.className = 'msg-check';
    if (isSelf) { row.appendChild(avatar); row.appendChild(body); row.appendChild(chk); }
    else { row.appendChild(chk); row.appendChild(avatar); row.appendChild(body); }
}

async function addMessage(msg, isNew = true) {
    if (msg.type === 'recalled') {
        const existing = document.getElementById('msg-'+msg.id);
        if (existing) {
            const bubble = existing.querySelector('.bubble');
            if (bubble) {
                bubble.innerHTML = `<span style="font-size:12px;font-style:italic;">${escapeHtml(msg.message)}</span>`;
                bubble.classList.remove('img-only');
                bubble.classList.remove('voice-only');   // 撤回后按普通气泡渲染，恢复内边距与虚线框
                bubble.classList.add('recalled');
            }
            // 同上：带"已读/未读"标记的时间行要保留，否则撤回会把已读状态一起抹掉
            const timeEl = existing.querySelector('.msg-time');
            if (timeEl && !timeEl.querySelector('.pm-read')) timeEl.remove();
            const footer = existing.querySelector('.msg-footer');
            if (footer) footer.remove();
            const img = existing.querySelector('img');
            if (img) img.remove();
            const fileEl = existing.querySelector('.file-attach');
            if (fileEl) fileEl.remove();
            const rxBar = existing.querySelector('.msg-reactions');
            if (rxBar) rxBar.remove();
            // 行上的导出/多选元数据也要跟着变成"已撤回"，否则导出出来的还是原文
            existing.dataset.kind = 'recalled';
            existing.dataset.text = String(msg.message || '');
            existing.dataset.att = '';
            delete existing.dataset.quoteUser;
            delete existing.dataset.quoteText;
            return;
        }
    }
    
    if (document.getElementById('msg-'+msg.id)) return;
    const box = document.getElementById('messagesBox');
    const row = document.createElement('div');
    row.className = 'msg-row'; row.id = 'msg-'+msg.id;
    stampRowMeta(row, msg);

    if (msg.timestamp) {
        const ds = formatDate(msg.timestamp);
        if (ds !== lastDateStr) {
            lastDateStr = ds;
            const div = document.createElement('div'); div.className = 'date-divider';
            div.innerHTML = `<span>${ds}</span>`; box.appendChild(div);
        }
    }

    if (msg.type === 'system') {
        row.className += ' system';
        row.innerHTML = `<div class="sys-notice"><span>${escapeHtml(msg.message)}</span><span class="sys-close" onclick="event.stopPropagation();dismissSystemMsg(${msg.id})" title="关闭"><svg viewBox="0 0 24 24" width="10" height="10" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></div>`;
        box.appendChild(row);
        const sysMsgId = msg.id;
        setTimeout(() => { const el = document.getElementById('msg-'+sysMsgId); if (el) { el.style.transition='opacity .3s'; el.style.opacity='0'; setTimeout(()=>{ if(el) el.remove(); addedIds.delete(sysMsgId); },300); } }, 10000);
        return;
    }

    // 通话记录气泡：居中显示（模仿 QQ 通话记录样式），作为聊天记录保留不自动消失
    if (msg.type === 'call') {
        row.className += ' system call';
        const isVideoCall = msg.call_type === 'video';
        const callIco = isVideoCall
            ? '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>'
            : '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/></svg>';
        row.innerHTML = `<div class="sys-notice"><span class="call-ico">${callIco}</span><span class="call-txt">${escapeHtml(msg.message)}</span></div>`;
        box.appendChild(row);
        return;
    }

    if (msg.type === 'recalled') {
        const isSelf = msg.username === currentUser;
        row.className += isSelf ? ' self' : '';
        const avatarUrl = await getAvatar(msg.username);
        const avStyle = avatarUrl ? `background-image:url(${avatarUrl}?t=${Date.now()})` : `background:${stringToColor(msg.username)}`;
        const avContent = avatarUrl ? '' : msg.username.charAt(0).toUpperCase();
        const avatar = document.createElement('div'); avatar.className = 'msg-avatar' + ((msg.frame && msg.frame !== 'none') ? ' avf-' + msg.frame : '');
        avatar.style.cssText = avStyle; avatar.textContent = avContent;
        // 同上：撤回消息的头像也不响应点击
        avatar.title = msg.username;
        const body = document.createElement('div'); body.className = 'msg-body';
        // 撤回后的消息同样保留已读/未读标记，位置与普通消息一致（跟时间戳同排）
        const recalledRead = (isSelf && currentRoomType === 'friend')
            ? `<span class="pm-read${msg.read ? ' read' : ''}" id="pmread-${msg.id}">${msg.read ? '已读' : '未读'}</span>` : '';
        const recalledBubble = `<div class="bubble recalled"><span style="font-size:12px;font-style:italic;">${escapeHtml(msg.message)}</span></div>`;
        const recalledTime = recalledRead ? `<div class="msg-time">${recalledRead}${msg.time || ''}</div>` : '';
        body.innerHTML = `<div class="msg-name">${escapeHtml(msg.username)}</div>${recalledBubble}${recalledTime}`;
        appendRowParts(row, isSelf, avatar, body);
        box.appendChild(row);
        return;
    }

    const isSelf = msg.username === currentUser;
    row.className += isSelf ? ' self' : '';
    const avatarUrl = await getAvatar(msg.username);
    const avStyle = avatarUrl ? `background-image:url(${avatarUrl}?t=${Date.now()})` : `background:${stringToColor(msg.username)}`;
    const avContent = avatarUrl ? '' : msg.username.charAt(0).toUpperCase();

    const avatar = document.createElement('div'); avatar.className = 'msg-avatar' + ((msg.frame && msg.frame !== 'none') ? ' avf-' + msg.frame : '');
    avatar.style.cssText = avStyle; avatar.textContent = avContent;
    // 点击消息头像不再弹出资料卡（语音/视频/发消息/拉黑那一排按钮）：误触概率太高。
    // 只保留悬停显示名字；需要看资料时走好友列表里的「查看资料」入口。
    avatar.title = msg.username;

    const body = document.createElement('div'); body.className = 'msg-body';

    let bubbleContent = '';
    if (msg.quote) {
        // 引用块可点击：跳到被引用的那条消息并闪一下（原消息没加载出来时给个提示，不做静默失败）
        const qid = parseInt(msg.quote.id, 10) || 0;
        const qAttr = qid ? ` data-quote-id="${qid}" onclick="event.stopPropagation();jumpToMessage(${qid})" title="点击查看原消息"` : '';
        bubbleContent += `<div class="quote-block"${qAttr}><b>${escapeHtml(msg.quote.username)}</b>: ${escapeHtml(msg.quote.message)}</div>`;
    }
    if (msg.forward) bubbleContent += `<div class="fwd-tag">转发自 ${escapeHtml(msg.forward.from_user || '')}</div>`;
    if (msg.image) bubbleContent += `<img src="${escapeHtml(msg.image)}" alt="图片" onclick="event.stopPropagation();window.open(this.src)" loading="lazy">`;
    if (msg.file) {
        const fname = escapeHtml(msg.file_name || '文件');
        const fsize = msg.file_size ? formatFileSize(msg.file_size) : '';
        bubbleContent += `<div class="file-attach" data-file-url="${escapeHtml(msg.file)}" data-file-name="${fname}" data-file-size="${fsize}" onclick="event.stopPropagation();showFileDownload(this.dataset.fileUrl,this.dataset.fileName,this.dataset.fileSize)"><div class="file-icon"><svg viewBox="0 0 24 24" width="16" height="16"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 7V3.5L18.5 9H13zM6 20V4h5v7h7v9H6z"/></svg></div><div class="file-info"><div class="file-name">${fname}</div><div class="file-size">${fsize}</div></div></div>`;
    }
    if (msg.voice) {
        const dur = parseInt(msg.voice.duration) || 1;
        // 声纹：优先用发送时采集的真实音量包络；老消息没有该字段就回退到固定花纹，
        // 保证任何情况下都画得出波形，不会出现空白语音条。
        const barsHtml = voiceBarsFromWave(msg.voice.wave).map((h, i) =>
            `<i style="height:${h}px;animation-delay:-${((i % 7) * 0.12).toFixed(2)}s"></i>`).join('');
        bubbleContent += `<div class="voice-attach" data-url="${escapeHtml(msg.voice.url)}" data-duration="${dur}" onclick="event.stopPropagation();toggleVoicePlay(this)"><span class="voice-play"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></span><span class="voice-body"><span class="voice-wave">${barsHtml}</span><span class="voice-track"><i></i></span></span><span class="voice-dur">${dur}"</span><span class="voice-rate${voiceRate !== 1 ? ' fast' : ''}" onclick="event.stopPropagation();cycleVoiceRate(null,event)" title="点击切换播放速度">${voiceRateLabel(voiceRate)}</span></div>`;
    }
    if (msg.message) {
        let text = escapeHtml(msg.message);
        text = linkify(text);
        if (!isSelf) {
            const regex = new RegExp(`@${escapeRegex(currentUser)}\\b`, 'gi');
            text = text.replace(regex, '<span class="highlight-mention">$&</span>');
        }
        text = text.replace(/@全体成员/g, '<span class="highlight-atall">@全体成员</span>');
        if (msg.edited) text += '<span class="msg-edited-tag">（已编辑）</span>';
        bubbleContent += text;
    }

    const msgTextSafe = escapeHtml((msg.message || (msg.image && '图片') || (msg.file && '文件') || (msg.voice && '语音') || '').replace(/'/g,"\\'"));
    const unameSafe = escapeHtml(msg.username).replace(/'/g,"\\'");
    let footerHtml = '';
    if (isSelf) {
        const elapsed = Math.floor(Date.now()/1000) - (msg.timestamp || 0);
        const canEdit = elapsed <= RECALL_LIMIT;   // 编辑仍限 2 分钟，撤回不限时间
        footerHtml = `<div class="msg-footer">${canEdit ? `<button onclick="event.stopPropagation();openEditMessage(${msg.id},'${msgTextSafe}')">编辑</button>` : ''}<button onclick="event.stopPropagation();deleteMessage(${msg.id})">撤回</button><button onclick="event.stopPropagation();setQuote(${msg.id},'${unameSafe}','${msgTextSafe}')">引用</button></div>`;
    } else {
        footerHtml = `<div class="msg-footer"><button onclick="event.stopPropagation();setQuote(${msg.id},'${unameSafe}','${msgTextSafe}')">引用</button></div>`;
    }
    // 私聊专属的已读/未读标记：跟在时间戳左边、同一行显示
    let readHtml = '';
    if (isSelf && currentRoomType === 'friend') {
        const isRead = !!msg.read;
        readHtml = `<span class="pm-read${isRead ? ' read' : ''}" id="pmread-${msg.id}">${isRead ? '已读' : '未读'}</span>`;
    }

    const voiceOnly = isVoiceOnlyMsg(msg);
    const bubbleCls = voiceOnly ? ' voice-only' : (isImgOnlyMsg(msg) ? ' img-only' : '');
    const bubbleHtml = `<div class="bubble${bubbleCls}">${bubbleContent}</div>`;
    // 表情回应徽章：跟在气泡下方、时间戳上方
    const reactionsHtml = reactionsHtmlFor(msg, msg.id);
    // 纯语音消息本来不显示时间；私聊里"已读/未读"要有落脚处，这时把时间行一并带上
    const timeHtml = (!voiceOnly || readHtml) ? `<div class="msg-time">${readHtml}${msg.time}</div>` : '';
    if (voiceOnly) {
        // 纯语音消息：不显示昵称，只留语音条本身；
        // 但保留底部操作条（编辑/撤回/引用）——鼠标移上去即可直接点，不必先右键。
        body.innerHTML = `${bubbleHtml}${reactionsHtml}${timeHtml}${footerHtml}`;
    } else {
        body.innerHTML = `<div class="msg-name">${escapeHtml(msg.username)}</div>${bubbleHtml}${reactionsHtml}${timeHtml}${footerHtml}`;
    }

    appendRowParts(row, isSelf, avatar, body);

    let longPressTimer = null;
    let longPressTriggered = false;
    const startLongPress = (e) => {
        longPressTriggered = false;
        longPressTimer = setTimeout(() => {
            longPressTriggered = true;
            showBubblePopup(e, msg, row);
        }, 500);
    };
    const cancelLongPress = () => {
        clearTimeout(longPressTimer);
        longPressTimer = null;
    };
    row.addEventListener('touchstart', startLongPress, {passive:true});
    row.addEventListener('touchend', cancelLongPress);
    row.addEventListener('touchmove', cancelLongPress);
    row.addEventListener('contextmenu', (e) => {
        e.preventDefault();
        showBubblePopup(e, msg, row);
    });
    row.addEventListener('dblclick', () => { if (selectMode) return; const t = msg.message||''; if(t) navigator.clipboard.writeText(t).then(()=>showToast('已复制')); });
    row.addEventListener('click', (e) => {
        if (!longPressTriggered) closeBubblePopup();
    });

    box.appendChild(row);
}

window.setQuote = (id, username, message) => {
    currentQuote = { id, username, message };
    document.getElementById('quoteContent').textContent = `回复 ${username}：${message}`;
    document.getElementById('quoteBar').classList.add('active');
};
function clearQuote() { currentQuote = null; document.getElementById('quoteBar').classList.remove('active'); }

// 点击引用块 → 滚到原消息并闪一下。原消息不在当前已加载范围内时明确提示，
// 不做静默失败（用户会以为是点了没反应）。
window.jumpToMessage = function (id) {
    const el = document.getElementById('msg-' + id);
    if (!el) { showToast('原消息不在当前加载范围内，可往上翻找'); return; }
    el.scrollIntoView({ behavior:'smooth', block:'center' });
    // 先移除再强制重排，保证连续点击同一条也能重新播放动画
    el.classList.remove('msg-jump-flash');
    void el.offsetWidth;
    el.classList.add('msg-jump-flash');
    setTimeout(() => el.classList.remove('msg-jump-flash'), 1600);
};

const emojis = ['😀','😂','😍','😎','😢','😡','👍','👎','🎉','❤️','🔥','😊','🤔','🙄','😴','😇','🤗','🫡','💪','👏','🙏','💔','✨','🌟','💯','🎶','🍕','☕','📷','💻','😜','😋','🥳'];
document.getElementById('emojiBtn').addEventListener('click', e => {
    e.stopPropagation();
    const panel = document.getElementById('emojiPanel');
    document.getElementById('stickerPanel').classList.remove('active');
    panel.classList.toggle('active');
    if (panel.classList.contains('active') && panel.children.length === 0) {
        emojis.forEach(emoji => {
            const s = document.createElement('span'); s.textContent = emoji;
            s.addEventListener('click', () => { document.getElementById('messageInput').value += emoji; });
            panel.appendChild(s);
        });
    }
});

// ===== 表情包：内置 SVG + 用户上传 =====
const stickerPanel = document.getElementById('stickerPanel');
document.getElementById('stickerBtn').addEventListener('click', e => {
    e.stopPropagation();
    const emojiPanel = document.getElementById('emojiPanel');
    emojiPanel.classList.remove('active');
    stickerPanel.classList.toggle('active');
    if (stickerPanel.classList.contains('active') && !stickerPanel.dataset.loaded) loadStickers();
});
async function loadStickers() {
    stickerPanel.innerHTML = ldHTML('加载中');
    try {
        const res = await fetch('?action=get_stickers');
        const d = await res.json();
        if (!d.success) { stickerPanel.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text3);font-size:13px;">加载失败</div>'; return; }
        const all = d.stickers || [];
        const builtin = all.filter(s => s.type === 'builtin');
        const users = all.filter(s => s.type === 'user');
        let html = '';
        if (builtin.length) {
            html += '<div class="stk-section">内置表情</div><div class="stk-grid">';
            builtin.forEach(s => {
                const u = escapeHtml(s.url).replace(/'/g,"\\'");
                html += `<div class="stk-item" onclick="sendSticker('${u}')"><img src="${escapeHtml(s.url)}" alt=""></div>`;
            });
            html += '</div>';
        }
        if (users.length) {
            html += '<div class="stk-section">大家的表情</div><div class="stk-grid">';
            users.forEach(s => {
                const u = escapeHtml(s.url).replace(/'/g,"\\'");
                const idSafe = escapeHtml(s.id || '').replace(/'/g,"\\'");
                const isMine = s.user === currentUser;
                html += `<div class="stk-item" onclick="sendSticker('${u}')">${isMine ? `<span class="stk-del" title="删除" onclick="event.stopPropagation();deleteSticker('${idSafe}')">×</span>` : ''}<img src="${escapeHtml(s.url)}" alt=""></div>`;
            });
            html += '</div>';
        }
        html += '<div class="stk-section">上传我的表情</div><div class="stk-grid"><div class="stk-add" title="上传图片表情" onclick="document.getElementById(\'stickerFileInput\').click()">＋</div></div>';
        stickerPanel.innerHTML = html;
        stickerPanel.dataset.loaded = '1';
    } catch(e) { stickerPanel.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text3);font-size:13px;">加载失败</div>'; }
}
window.sendSticker = function(url) { sendMessage(url); };

/* ---------------- 表情面板：分类页签（默认表情包 / 自定义表情包） ----------------
 * 复用服务端已有的 get_stickers（type 为 builtin / user）与 sendSticker，不新增后端逻辑。
 * 自定义分类的第一个格子是上传入口（＋），上传后清空缓存，下次查看即刷新。 */
window._stkCache = null;
window.switchStickerTab = function(tab) {
    const tabs = document.querySelectorAll('#stkTabs .stk-tab');
    tabs.forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
    const list = document.getElementById('stkList');
    if (!list) return;
    const render = (all) => {
        const items = all.filter(s => (s.type || 'builtin') === tab);
        let html = '';
        if (tab === 'user') {
            // 上传入口放在自定义列表的左上角第一个
            html += '<div class="stk-cell stk-plus" title="上传表情包" onclick="window._stkCache=null;document.getElementById(\'stickerFileInput\').click()">＋</div>';
        }
        items.forEach(s => {
            const u = escapeHtml(s.url).replace(/'/g, "\\'");
            html += `<div class="stk-cell" title="点击发送" onclick="sendSticker('${u}')"><img src="${escapeHtml(s.url)}" alt="表情包"></div>`;
        });
        if (!items.length) {
            html += '<div class="stk-hint">' + (tab === 'user' ? '还没有自定义表情包，点左上角 ＋ 上传' : '暂无默认表情包') + '</div>';
        }
        list.innerHTML = html;
    };
    if (window._stkCache) { render(window._stkCache); return; }
    list.innerHTML = '<div class="stk-hint">加载中…</div>';
    fetch('?action=get_stickers').then(r => r.json()).then(d => {
        window._stkCache = (d && d.stickers) || [];
        render(window._stkCache);
    }).catch(() => { list.innerHTML = '<div class="stk-hint">加载失败</div>'; });
};
/* 让侧栏底栏与聊天输入区等高：按"输入行"的实际高度（不含下方展开的表情面板）算出输入区高度，
   写入 --composer-h。这样侧栏那条分隔线与输入框上方那条线就会落在同一水平线上。 */
(function () {
    const area = document.querySelector('.input-area');
    const row = document.querySelector('.input-row');
    if (!area || !row) return;
    const sync = () => {
        const cs = getComputedStyle(area);
        const h = row.offsetHeight
            + parseFloat(cs.paddingTop || 0) + parseFloat(cs.paddingBottom || 0)
            + parseFloat(cs.borderTopWidth || 0) + parseFloat(cs.borderBottomWidth || 0);
        if (h > 0) document.documentElement.style.setProperty('--composer-h', Math.round(h) + 'px');
    };
    sync();
    window.addEventListener('resize', sync);
    // 字体/头像加载完后再校准一次
    setTimeout(sync, 300);
})();

/* 面板的收起规则（与参考系统一致）：
 * 只有"点击面板以外的区域"或"再次点击表情按钮"才收起；点击面板内部的任何内容都不收起。
 * 关闭逻辑在下方的外部点击判断中按"内部范围"排除。 */
window.deleteSticker = async function(id) {
    const res = await fetch('?action=delete_sticker', { method:'POST', body:new URLSearchParams({ id }) });
    const d = await res.json();
    if (d.success) { stickerPanel.dataset.loaded = ''; loadStickers(); showToast('已删除'); }
    else showToast(d.error || '删除失败');
};
document.getElementById('stickerFileInput').addEventListener('change', async function() {
    const f = this.files[0]; if (!f) return;
    const fd = new FormData(); fd.append('sticker', f);
    const res = await fetch('?action=upload_sticker', { method:'POST', body:fd });
    const d = await res.json();
    if (d.success) { stickerPanel.dataset.loaded = ''; loadStickers(); showToast('表情已上传'); }
    else showToast(d.error || '上传失败');
    this.value = '';
});
document.addEventListener('click', e => {
    const panel = document.getElementById('emojiPanel');
    // 面板本身、以及它下方的分类页签与表情包列表，都算"内部"：点这些地方都不收起面板
    const _stkTabs = document.getElementById('stkTabs');
    const _stkList = document.getElementById('stkList');
    const _insideEmoji = panel.contains(e.target)
        || (_stkTabs && _stkTabs.contains(e.target))
        || (_stkList && _stkList.contains(e.target));
    if (!_insideEmoji && e.target !== document.getElementById('emojiBtn')) panel.classList.remove('active');
    const sp = document.getElementById('stickerPanel');
    if (sp && !sp.contains(e.target) && e.target !== document.getElementById('stickerBtn')) sp.classList.remove('active');
    const sr = document.getElementById('searchResults');
    const si = document.getElementById('searchInput');
    if (sr && !sr.contains(e.target) && e.target !== si) sr.style.display = 'none';
    const bp = document.getElementById('bubblePopup');
    if (bp && bp.classList.contains('active') && !bp.contains(e.target)) closeBubblePopup();
    const rd = document.getElementById('roomDropdown');
    const rmBtn = document.getElementById('roomMoreBtn');
    if (rd && rd.classList.contains('active') && rmBtn && !rmBtn.contains(e.target)) rd.classList.remove('active');
});

// ===== 语音消息：微信式「按住说话」 =====
// 交互：点麦克风切到语音模式 → 长按「按住说话」开始录（实时声纹随音量跳动）→
//       手指上滑进取消区整体变红、松手取消；未上滑就松手 = 立即发送。
const VOICE_WAVE_BARS = 26;           // 录音浮层声纹柱数量（纯视觉）
const VOICE_WAVE_SAMPLES = 28;        // 存进消息的声纹采样点数
const REC_CANCEL_PX = 72;             // 上滑超过多少像素算进入"取消"区
const REC_MIN_SECONDS = 1;            // 低于该秒数视为误触，不发送

let mediaRecorder = null;
let voiceChunks = [];
let voiceTimer = null;
let voiceSeconds = 0;
let voiceStream = null;
let isRecordingVoice = false;
let voiceCanceled = false;
let voiceAudioCtx = null;
let voiceAnalyser = null;
let voiceRafId = null;
let voiceHoldStartY = 0;
let voiceCancelArmed = false;
let voiceHoldActive = false;          // 手指是否还按着（用于化解 getUserMedia 的异步竞态）
let voiceLiveLevels = [];             // 浮层声纹的滚动窗口，越靠后越新
let voiceEnvelope = [];               // 采样进消息的声纹包络（0-9）
let voiceEnvAcc = [];                 // 包络采样累加器

const voiceBtn = document.getElementById('voiceBtn');
const voiceHoldBtn = document.getElementById('voiceHoldBtn');
const inputGroup = voiceBtn ? voiceBtn.closest('.input-group') : null;
const recTimer = document.getElementById('recTimer');
const recOverlay = document.getElementById('recOverlay');
const recWave = document.getElementById('recWave');
const recHintText = document.getElementById('recHintText');

function recSetTimer() { if (recTimer) recTimer.textContent = voiceSeconds + '"'; }
function recIsBanned() { return !!(window._bannedRooms && window._bannedRooms[currentRoom]); }

// 浮层声纹柱只建一次，之后每帧只改高度，避免频繁创建 DOM
function buildRecWaveBars() {
    if (!recWave) return;
    if (recWave.childElementCount !== VOICE_WAVE_BARS) {
        recWave.innerHTML = '';
        for (let i = 0; i < VOICE_WAVE_BARS; i++) {
            const b = document.createElement('i');
            b.style.height = '8%';
            recWave.appendChild(b);
        }
    }
    voiceLiveLevels = new Array(VOICE_WAVE_BARS).fill(0);
}

// ===== 模式切换：打字 ⇄ 按住说话 =====
if (voiceBtn) voiceBtn.addEventListener('click', toggleVoiceMode);
function toggleVoiceMode() {
    if (!inputGroup) return;
    const on = inputGroup.classList.toggle('voice-mode');
    voiceBtn.title = on ? '切换回键盘输入' : '切换语音输入';
    // 输入框里的文字始终留在 input 上：切到语音模式不会清空，也不会被一起发出去
    if (on) closeVoicePanels();
    else { const input = document.getElementById('messageInput'); if (input) input.focus(); }
}
// 进语音模式时收起表情/更多面板，避免多层浮层叠在一起
function closeVoicePanels() {
    if (window.closeBottomPanels) window.closeBottomPanels();
}

// ===== 按住说话：按下开录，上滑取消，松手发送 =====
if (voiceHoldBtn) voiceHoldBtn.addEventListener('pointerdown', onHoldDown);
function onHoldDown(e) {
    if (e.pointerType === 'mouse' && e.button !== 0) return;      // 只响应鼠标左键
    if (e.cancelable) e.preventDefault();
    if (recIsBanned()) { showToast('该群已被封禁，无法发送消息'); return; }
    if (isRecordingVoice) return;
    voiceHoldStartY = e.clientY;
    voiceCancelArmed = false;
    voiceHoldActive = true;
    voiceHoldBtn.classList.add('holding');
    if (recOverlay) recOverlay.classList.remove('cancel');
    if (recHintText) recHintText.textContent = '手指上滑，取消发送';
    window.addEventListener('pointermove', onHoldMove);
    window.addEventListener('pointerup', onHoldUp, { once: true });
    window.addEventListener('pointercancel', onHoldCancel, { once: true });
    beginVoiceRecording();
}
function onHoldMove(e) {
    if (!isRecordingVoice) return;
    const armed = (voiceHoldStartY - e.clientY) >= REC_CANCEL_PX;
    if (armed === voiceCancelArmed) return;
    voiceCancelArmed = armed;
    if (recOverlay) recOverlay.classList.toggle('cancel', armed);
    if (recHintText) recHintText.textContent = armed ? '松开手指，取消发送' : '手指上滑，取消发送';
}
function onHoldUp() { endVoiceHold(!voiceCancelArmed); }
function onHoldCancel() { endVoiceHold(false); }
function endVoiceHold(shouldSend) {
    voiceHoldActive = false;
    window.removeEventListener('pointermove', onHoldMove);
    if (voiceHoldBtn) voiceHoldBtn.classList.remove('holding');
    if (!isRecordingVoice) { cleanupVoiceUI(); return; }
    if (shouldSend) stopVoiceRecord(); else cancelVoiceRecord();
}

async function beginVoiceRecording() {
    try {
        voiceStream = await navigator.mediaDevices.getUserMedia({ audio: true });
    } catch (err) { showToast('无法访问麦克风，请检查浏览器权限'); cleanupVoiceUI(); return; }
    // 竞态保护：首次授权会弹权限框，这期间用户可能已经松手了。
    // 此时不能再开录，否则录完没人叫停，会一直录到 5 分钟上限。
    if (!voiceHoldActive) {
        voiceStream.getTracks().forEach(t => t.stop()); voiceStream = null;
        cleanupVoiceUI();
        return;
    }
    voiceChunks = [];
    voiceSeconds = 0;
    voiceCanceled = false;
    voiceEnvelope = [];
    voiceEnvAcc = [];
    try {
        mediaRecorder = new MediaRecorder(voiceStream);
    } catch (err) {
        voiceStream.getTracks().forEach(t => t.stop()); voiceStream = null;
        showToast('当前浏览器不支持录音'); cleanupVoiceUI(); return;
    }
    mediaRecorder.ondataavailable = e => { if (e.data && e.data.size > 0) voiceChunks.push(e.data); };
    mediaRecorder.onstop = handleRecStop;
    mediaRecorder.start();
    isRecordingVoice = true;
    if (voiceBtn) voiceBtn.classList.add('recording');
    recSetTimer();
    buildRecWaveBars();
    if (recOverlay) recOverlay.classList.add('active');
    startVoiceMeter();
    voiceTimer = setInterval(() => {
        voiceSeconds++;
        if (voiceSeconds >= 300) { stopVoiceRecord(); return; }
        recSetTimer();
    }, 1000);
}

// 实时音量分析：每帧驱动浮层声纹，同时按固定节奏把峰值采进包络（供发出去的语音复用）
function startVoiceMeter() {
    let buf = null;
    let lastEnvAt = 0;
    try {
        const AC = window.AudioContext || window.webkitAudioContext;
        if (!AC) return;
        voiceAudioCtx = new AC();
        const src = voiceAudioCtx.createMediaStreamSource(voiceStream);
        voiceAnalyser = voiceAudioCtx.createAnalyser();
        voiceAnalyser.fftSize = 512;
        voiceAnalyser.smoothingTimeConstant = 0.72;
        src.connect(voiceAnalyser);
        buf = new Uint8Array(voiceAnalyser.fftSize);
    } catch (err) { voiceAnalyser = null; return; }
    const tick = () => {
        if (!isRecordingVoice || !voiceAnalyser) return;
        voiceAnalyser.getByteTimeDomainData(buf);
        let sum = 0;
        for (let i = 0; i < buf.length; i++) { const v = (buf[i] - 128) / 128; sum += v * v; }
        // 人声 RMS 偏小，这里放大后再映射，柱子才会明显跟着说话跳动
        const lv = Math.min(1, Math.sqrt(sum / buf.length) * 4.2);
        voiceLiveLevels.push(lv);
        if (voiceLiveLevels.length > VOICE_WAVE_BARS) voiceLiveLevels.shift();
        renderRecWave();
        const now = Date.now();
        if (now - lastEnvAt >= 100) {
            lastEnvAt = now;
            voiceEnvAcc.push(lv);
            if (voiceEnvAcc.length >= 2) {          // 取一小段里的峰值，避免采样点落在停顿处
                voiceEnvelope.push(Math.round(Math.min(1, Math.max.apply(null, voiceEnvAcc)) * 9));
                voiceEnvAcc = [];
                if (voiceEnvelope.length > VOICE_WAVE_SAMPLES * 3) voiceEnvelope.shift();
            }
        }
        voiceRafId = requestAnimationFrame(tick);
    };
    voiceRafId = requestAnimationFrame(tick);
}

function renderRecWave() {
    if (!recWave) return;
    const kids = recWave.children;
    for (let i = 0; i < kids.length; i++) {
        const lv = voiceLiveLevels[voiceLiveLevels.length - kids.length + i] || 0;
        kids[i].style.height = (8 + lv * 92) + '%';
    }
}

// 包络重采样成固定长度的 0-9 数字串：越短越省，服务端只校验长度与字符集
function buildVoiceWaveString() {
    const n = VOICE_WAVE_SAMPLES;
    if (!voiceEnvelope.length) return '';
    const out = [];
    for (let i = 0; i < n; i++) {
        const idx = Math.round(i * (voiceEnvelope.length - 1) / (n - 1));
        out.push(String(voiceEnvelope[Math.min(idx, voiceEnvelope.length - 1)] || 0));
    }
    return out.join('');
}

function handleRecStop() {
    clearInterval(voiceTimer);
    stopVoiceMeter();
    if (voiceStream) { voiceStream.getTracks().forEach(t => t.stop()); voiceStream = null; }
    cleanupVoiceUI();
    const canceled = voiceCanceled;
    voiceCanceled = false;
    isRecordingVoice = false;
    if (canceled) return;                                   // 上滑取消：什么都不发
    if (voiceSeconds < REC_MIN_SECONDS) { showToast('说话时间太短'); return; }
    const wave = buildVoiceWaveString();
    const blob = new Blob(voiceChunks, { type: (mediaRecorder && mediaRecorder.mimeType) || 'audio/webm' });
    uploadAndSendVoice(blob, voiceSeconds, wave);
}

function stopVoiceMeter() {
    if (voiceRafId) { cancelAnimationFrame(voiceRafId); voiceRafId = null; }
    if (voiceAudioCtx) { try { voiceAudioCtx.close(); } catch(e) {} voiceAudioCtx = null; }
    voiceAnalyser = null;
}
function cleanupVoiceUI() {
    if (recOverlay) { recOverlay.classList.remove('active'); recOverlay.classList.remove('cancel'); }
    if (voiceBtn) voiceBtn.classList.remove('recording');
    if (voiceLiveLevels.length) { voiceLiveLevels = new Array(VOICE_WAVE_BARS).fill(0); renderRecWave(); }
}

function stopVoiceRecord() {
    if (!isRecordingVoice || !mediaRecorder) return;
    try { if (mediaRecorder.state !== 'inactive') mediaRecorder.stop(); } catch(e) {}
}

function cancelVoiceRecord() {
    if (!isRecordingVoice) { cleanupVoiceUI(); return; }
    voiceCanceled = true;
    clearInterval(voiceTimer);
    if (mediaRecorder) { try { if (mediaRecorder.state !== 'inactive') mediaRecorder.stop(); } catch(e){} }
}
window.cancelVoiceRecord = cancelVoiceRecord;

// 松手即发：先传音频拿到地址，再带上声纹一起发出去（不再有二次确认）
function uploadAndSendVoice(blob, secs, wave) {
    const fd = new FormData();
    fd.append('voice', blob, 'voice.webm');
    fd.append('duration', String(secs));
    fetch('?action=upload_voice', { method:'POST', body:fd })
        .then(r => r.json())
        .then(d => {
            if (d.success) sendMessage(null, null, { url:d.voice_url, duration:d.duration, wave:wave || '' });
            else showToast(d.error || '语音上传失败');
        })
        .catch(() => showToast('语音上传失败'));
}

let voiceAudio = null;
let voicePlayingEl = null;
// 语音播放：图标在播放/暂停之间切换，播放中波形起伏、时长显示已播秒数，便于确认是否已点开
const VOICE_ICON_PLAY = '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>';
const VOICE_ICON_PAUSE = '<svg viewBox="0 0 24 24"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>';
function setVoiceBtnIcon(el, playing) {
    const btn = el && el.querySelector('.voice-play');
    if (btn) btn.innerHTML = playing ? VOICE_ICON_PAUSE : VOICE_ICON_PLAY;
}
function setVoiceProgress(el, ratio) {
    const fill = el && el.querySelector('.voice-track i');
    if (!fill) return;
    const r = isFinite(ratio) ? Math.max(0, Math.min(1, ratio)) : 0;
    fill.style.width = (r * 100) + '%';
}
function resetVoiceDur(el) {
    const d = el && el.querySelector('.voice-dur');
    if (d && el.dataset.duration) d.textContent = parseInt(el.dataset.duration) + '"';
    setVoiceProgress(el, 0);
}
// 播放时的声纹起伏用 JS 逐帧驱动，不用 CSS 动画：
// 系统的"减少动效"设置会禁用 CSS 动画，那会导致播放时声纹一动不动。
let voicePlayRaf = null;
let voicePlayAnimEl = null;
function startVoicePlayAnim(el) {
    stopVoicePlayAnim();
    const bars = el ? el.querySelectorAll('.voice-wave i') : null;
    if (!bars || !bars.length) return;
    voicePlayAnimEl = el;
    const t0 = performance.now();
    const tick = () => {
        // 播放被暂停/结束时自行停表，避免留下空转的 rAF
        if (voicePlayAnimEl !== el || !voiceAudio || voiceAudio.paused) return;
        const t = (performance.now() - t0) / 1000;
        for (let i = 0; i < bars.length; i++) {
            // 每根柱子错开相位，形成声波流动的观感
            const k = 0.55 + 0.75 * Math.abs(Math.sin(t * 3.6 + i * 0.55));
            bars[i].style.transform = 'scaleY(' + k.toFixed(3) + ')';
        }
        voicePlayRaf = requestAnimationFrame(tick);
    };
    voicePlayRaf = requestAnimationFrame(tick);
}
function stopVoicePlayAnim() {
    if (voicePlayRaf) { cancelAnimationFrame(voicePlayRaf); voicePlayRaf = null; }
    if (voicePlayAnimEl) {
        const bars = voicePlayAnimEl.querySelectorAll('.voice-wave i');
        for (let i = 0; i < bars.length; i++) bars[i].style.transform = '';
        voicePlayAnimEl = null;
    }
}
window.toggleVoicePlay = function(el) {
    const dur = parseFloat(el.dataset.duration) || 0;
    // 再次点击正在播放的语音 → 暂停；进度与已播时长保留，方便接着听
    if (voiceAudio && voicePlayingEl === el && !voiceAudio.paused) {
        voiceAudio.pause();
        el.classList.remove('playing');
        setVoiceBtnIcon(el, false);
        stopVoicePlayAnim();
        const d = el.querySelector('.voice-dur');
        if (d) d.textContent = Math.ceil(voiceAudio.currentTime) + '"';
        return;
    }
    if (voiceAudio) {
        voiceAudio.pause();
        if (voicePlayingEl) { voicePlayingEl.classList.remove('playing'); setVoiceBtnIcon(voicePlayingEl, false); resetVoiceDur(voicePlayingEl); }
        stopVoicePlayAnim();
    }
    voicePlayingEl = el;
    el.classList.add('playing');
    setVoiceBtnIcon(el, true);
    voiceAudio = new Audio(el.dataset.url);
    try { voiceAudio.playbackRate = voiceRate; } catch (e) {}
    const stop = (msg) => {
        el.classList.remove('playing'); setVoiceBtnIcon(el, false); resetVoiceDur(el);
        stopVoicePlayAnim();
        voiceAudio = null; voicePlayingEl = null;
        if (msg) showToast(msg);
    };
    voiceAudio.onended = () => stop();
    voiceAudio.onerror = () => stop('语音播放失败');
    voiceAudio.ontimeupdate = () => {
        const d = el.querySelector('.voice-dur');
        if (d) d.textContent = Math.ceil(voiceAudio.currentTime) + '"';
        // 进度轨跟随播放位置；data-duration 缺失时用音频真实长度兜底
        const total = dur > 0 ? dur : (voiceAudio.duration || 0);
        if (total > 0) setVoiceProgress(el, voiceAudio.currentTime / total);
    };
    // 播放真正开始后再启动声纹起伏（被浏览器拦下自动播放时不空转）
    voiceAudio.play().then(() => startVoicePlayAnim(el)).catch(() => stop('语音播放失败'));
};

// 语音按钮只负责「打字 / 按住说话」模式切换，录音绑在「按住说话」条上（见上方 onHoldDown）

document.getElementById('imageBtn').addEventListener('click', () => document.getElementById('imageFileInput').click());
let pendingImageUrl = null;

// 图片上传 + 预览：选择文件、粘贴、拖拽三条入口共用同一段逻辑
async function uploadChatImage(f) {
    if (!f) return;
    const fd = new FormData(); fd.append('image', f);
    let d = null;
    try {
        const res = await fetch('?action=upload_chat_image', { method:'POST', body:fd });
        d = await res.json();
    } catch (e) { showToast('图片上传失败，请检查网络'); return; }
    if (d && d.success) {
        pendingImageUrl = d.image_url;
        document.getElementById('previewImage').src = d.image_url;
        const sizeMB = (f.size / 1024 / 1024).toFixed(1);
        document.getElementById('previewInfo').textContent = `文件大小: ${sizeMB} MB · ${f.name || '图片'}`;
        document.getElementById('imagePreviewModal').classList.add('active');
    } else {
        showToast((d && d.error) || '上传失败');
    }
}
document.getElementById('imageFileInput').addEventListener('change', async function() {
    const f = this.files[0];
    this.value = '';
    await uploadChatImage(f);
});

// 粘贴发送：截图后直接 Ctrl+V 就能发。单张图片走图片预览流程（可确认后再发），
// 其它情况（多文件/非图片）交给文件批量上传，复用已有的分块上传能力。
document.getElementById('messageInput').addEventListener('paste', async e => {
    const cd = e.clipboardData;
    if (!cd) return;
    const files = [];
    if (cd.items && cd.items.length) {
        for (const it of cd.items) {
            if (it.kind !== 'file') continue;
            const f = it.getAsFile();
            if (f) files.push(f);
        }
    }
    if (!files.length) return;          // 纯文本粘贴：交给浏览器默认行为
    e.preventDefault();
    const isImg = f => /^image\//i.test(f.type || '');
    if (files.length === 1 && isImg(files[0])) await uploadChatImage(files[0]);
    else addFilesToBatch(files);
});
function closeImagePreview() {
    document.getElementById('imagePreviewModal').classList.remove('active');
    pendingImageUrl = null;
}
async function confirmSendImage() {
    document.getElementById('imagePreviewModal').classList.remove('active');
    if (pendingImageUrl) {
        const url = pendingImageUrl;
        pendingImageUrl = null;
        await sendMessage(url);
    }
}

// 点击工具栏的文件按钮：直接打开弹窗（弹窗内可点击选择文件，也可从桌面拖入）
document.getElementById('fileBtn').addEventListener('click', openFilePicker);
let pendingFiles = [];         // {name,size,url,progress,status}
let batchSending = false;
const CHUNK_BYTES_JS = 2 * 1024 * 1024; // 2MB 分块，兼容常见 PHP 上传限制，也支持几 GB 大文件
const MAX_BATCH_FILES = 50;                    // 单批最多文件数（边界校验，避免一次性把服务器打满）
const MAX_SINGLE_FILE_BYTES = 2 * 1024 * 1024 * 1024;  // 单文件 2GB 上限（分块上传）

function openFilePicker() {
    if (batchSending) { showToast('上一批文件仍在发送中，请稍候'); return; }
    document.getElementById('filePreviewModal').classList.add('active');
    renderFileBatch();
}

document.getElementById('fileInput').addEventListener('change', function() {
    const files = Array.from(this.files);
    this.value = '';
    addFilesToBatch(files);
});

// 把新选中的文件追加进待发列表（不覆盖已选内容），并立刻开始分块上传
function addFilesToBatch(files) {
    if (!files || !files.length) return;
    document.getElementById('filePreviewModal').classList.add('active');
    if (batchSending) { showToast('上一批文件仍在发送中，请稍候'); return; }
    const accepted = [], rejected = [];
    for (const f of files) {
        if (!f.size) { rejected.push(f.name + '（空文件）'); continue; }
        if (f.size > MAX_SINGLE_FILE_BYTES) { rejected.push(f.name + '（超过 2GB）'); continue; }
        accepted.push(f);
    }
    if (rejected.length) showToast('已跳过：' + rejected.slice(0, 3).join('、') + (rejected.length > 3 ? ' 等' : ''));
    const room = MAX_BATCH_FILES - pendingFiles.length;
    if (room <= 0) { showToast('一次最多发送 ' + MAX_BATCH_FILES + ' 个文件'); renderFileBatch(); return; }
    if (accepted.length > room) { showToast('一次最多发送 ' + MAX_BATCH_FILES + ' 个文件，多余的已忽略'); accepted.length = room; }
    if (!accepted.length) { renderFileBatch(); return; }
    const startIdx = pendingFiles.length;
    accepted.forEach(f => pendingFiles.push({ name: f.name, size: f.size, url: null, progress: 0, status: 'waiting' }));
    renderFileBatch();
    return uploadBatch(accepted, startIdx);   // 返回 promise：拖拽直发需要等上传完成
}

// 拖拽落下的文件直接上传并发出去：拖动这个动作本身就已经表达了"要发这个文件"，
// 再让用户点一次"发送"是多余的一步。失败的文件会留在弹窗里，可单独重试。
async function dropAndSendFiles(files) {
    const startIdx = pendingFiles.length;
    await addFilesToBatch(files);
    const mine = pendingFiles.slice(startIdx).filter(x => x.url && x.status === 'done');
    if (!mine.length) { showToast('文件上传失败，可在弹窗中重试'); return; }
    // 只发本次拖入且上传成功的，用户之前手动挑选、尚未发送的那些原样留着
    const keep = pendingFiles.filter(x => !mine.includes(x));
    batchSending = true;
    renderFileBatch();
    for (const data of mine) { await sendMessage(null, { url: data.url, name: data.name, size: data.size }); }
    batchSending = false;
    pendingFiles = keep;
    renderFileBatch();
    if (!pendingFiles.length) document.getElementById('filePreviewModal').classList.remove('active');
    showToast(`已发送 ${mine.length} 个文件`);
}

// 读取拖入的内容：优先用 entry 接口，这样从桌面拖整个文件夹进来也能展开其中的文件
async function collectDroppedFiles(dt) {
    if (!dt) return [];
    const items = dt.items ? Array.prototype.slice.call(dt.items) : [];
    const entries = [];
    for (const it of items) {
        if (it.kind === 'file' && typeof it.webkitGetAsEntry === 'function') {
            const en = it.webkitGetAsEntry();
            if (en) entries.push(en);
        }
    }
    if (!entries.length) return Array.prototype.slice.call(dt.files || []);
    const out = [];
    const walk = async (entry, depth) => {
        if (!entry || out.length >= MAX_BATCH_FILES * 4) return;
        if (entry.isFile) {
            await new Promise(res => { entry.file(f => { out.push(f); res(); }, () => res()); });
            return;
        }
        if (entry.isDirectory && depth < 3) {
            const reader = entry.createReader();
            const readBatch = () => new Promise(res => reader.readEntries(r => res(r || []), () => res([])));
            let batch;
            while ((batch = await readBatch()).length) { for (const en of batch) await walk(en, depth + 1); }
        }
    };
    for (const en of entries) await walk(en, 0);
    return out;
}

(function initFileDropZone() {
    const zone = document.getElementById('fileDropZone');
    const modal = document.getElementById('filePreviewModal');
    if (!zone || !modal) return;
    const stop = e => { e.preventDefault(); e.stopPropagation(); };
    zone.addEventListener('click', () => document.getElementById('fileInput').click());
    ['dragenter', 'dragover'].forEach(ev => zone.addEventListener(ev, e => { stop(e); zone.classList.add('dragover'); }));
    ['dragleave', 'dragend'].forEach(ev => zone.addEventListener(ev, e => { stop(e); zone.classList.remove('dragover'); }));
    zone.addEventListener('drop', async e => {
        stop(e);
        zone.classList.remove('dragover');
        addFilesToBatch(await collectDroppedFiles(e.dataTransfer));
    });
    // 拖到窗口其它位置时不要让浏览器直接打开该文件（仅在弹窗打开时拦截）
    ['dragover', 'drop'].forEach(ev => window.addEventListener(ev, e => {
        if (modal.classList.contains('active')) e.preventDefault();
    }));
})();

/* ===== 全局拖拽发送 =====
 * 以前只有"文件弹窗里的投放区"能接拖拽，拖到聊天区任意位置都会让浏览器直接打开文件。
 * 现在整个窗口都能接：拖进来出蒙层提示，松手后走和"点文件按钮"完全一样的批量上传流程。
 * 蒙层显隐用 dragover 续期 + 超时兜底，不靠 dragenter/dragleave 计数——投放区里的
 * stopPropagation 会把成对的进出事件吞掉，计数法在那条路径上必然漏掉一次。 */
(function initGlobalDrop() {
    const hint = document.getElementById('dropHint');
    if (!hint) return;
    let hideTimer = null;
    const hasFiles = e => {
        const dt = e.dataTransfer;
        if (!dt || !dt.types) return false;
        for (let i = 0; i < dt.types.length; i++) { if (dt.types[i] === 'Files') return true; }
        return false;
    };
    const hideHint = () => { clearTimeout(hideTimer); hint.classList.remove('active'); };
    // 捕获阶段先收蒙层：投放区自己的 drop 会 stopPropagation，冒泡阶段就轮不到这里
    window.addEventListener('drop', hideHint, true);
    window.addEventListener('dragend', hideHint);
    window.addEventListener('dragover', e => {
        if (!hasFiles(e)) return;
        e.preventDefault();   // 不拦截的话浏览器会拿被拖进来的文件替换当前页面
        if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
        hint.classList.add('active');
        clearTimeout(hideTimer);
        hideTimer = setTimeout(() => hint.classList.remove('active'), 600);
    });
    window.addEventListener('drop', async e => {
        if (!hasFiles(e)) return;
        e.preventDefault();
        hideHint();
        if (batchSending) { showToast('上一批文件仍在发送中，请稍候'); return; }
        const files = await collectDroppedFiles(e.dataTransfer);
        if (!files.length) return;
        dropAndSendFiles(files);   // 拖到聊天区松手 = 直接上传并发送
    });
})();

function renderFileBatch() {
    const box = document.getElementById('fileBatchList');
    if (!box) return;
    if (!pendingFiles.length) { box.innerHTML = '<div style="color:var(--text3);text-align:center;padding:12px;">还没有选择文件</div>'; }
    let html = '';
    pendingFiles.forEach(it => {
        let statusClass = '', statusTxt = '';
        if (it.status === 'done') { statusClass = 'done'; statusTxt = '已完成'; }
        else if (it.status === 'err') { statusClass = 'err'; statusTxt = '失败'; }
        else if (it.status === 'uploading') { statusTxt = it.progress + '%'; }
        else statusTxt = '等待上传';
        const barColor = it.status === 'err' ? 'err' : '';
        html += `<div class="batch-upload-item">
            <div class="batch-upload-top"><span class="bu-name">${escapeHtml(it.name)}</span><span class="bu-size">${formatFileSize(it.size)}</span><span class="bu-status ${statusClass}">${statusTxt}</span></div>
            <div class="batch-upload-bar"><div class="bufill ${barColor}" style="width:${it.progress}%"></div></div>
        </div>`;
    });
    box.innerHTML = html;
    const done = pendingFiles.filter(x => x.url).length;
    const hasErr = pendingFiles.some(x => x.status === 'err');
    const uploading = pendingFiles.some(x => x.status === 'uploading');
    const btn = document.getElementById('batchSendBtn');
    if (btn) { btn.disabled = !(done > 0 && !hasErr) || batchSending; btn.textContent = batchSending ? '发送中...' : `发送（${done}/${pendingFiles.length}）`; }
    const sum = document.getElementById('fileBatchSum');
    if (sum) {
        if (!pendingFiles.length) sum.textContent = '还没有选择文件';
        else if (uploading) sum.textContent = '正在上传，请稍候…';
        else if (hasErr) sum.textContent = '部分文件上传失败，已成功的仍可发送';
        else if (done === pendingFiles.length) sum.textContent = '全部上传完成，点击下方「发送」即可发出。';
        else sum.textContent = '上传中…';
    }
}

function uploadChunkedFile(it, file) {
    return new Promise((resolve, reject) => {
        const totalBytes = file.size;
        const total = Math.max(1, Math.ceil(totalBytes / CHUNK_BYTES_JS));
        const uploadId = 'u' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
        let index = 0, offset = 0;
        const next = () => {
            const slice = file.slice(offset, offset + CHUNK_BYTES_JS);
            const fd = new FormData();
            fd.append('chunk', slice, it.name);
            fd.append('upload_id', uploadId);
            fd.append('index', index);
            fd.append('total', total);
            fd.append('name', it.name);
            fetch('?action=upload_chunk', { method: 'POST', body: fd })
                .then(r => r.json().then(d => ({ ok: r.ok, d })))
                .then(({ ok, d }) => {
                    offset += slice.size; index++;
                    if (d.success && d.file_url) { it.url = d.file_url; it.name = d.file_name; it.size = d.file_size; it.progress = 100; it.status = 'done'; renderFileBatch(); resolve(); return; }
                    if (!ok) { it.status = 'err'; it.progress = 0; renderFileBatch(); reject(new Error(d.error || '上传失败')); return; }
                    it.progress = Math.min(99, Math.round(offset / totalBytes * 100));
                    it.status = 'uploading';
                    renderFileBatch();
                    next();
                })
                .catch(err => { it.status = 'err'; renderFileBatch(); reject(err); });
        };
        next();
    });
}

async function uploadBatch(files, startIdx = 0) {
    for (let i = 0; i < files.length; i++) {
        const idx = startIdx + i;
        if (!pendingFiles[idx]) continue;
        renderFileBatch();
        try { pendingFiles[idx].status = 'uploading'; await uploadChunkedFile(pendingFiles[idx], files[i]); }
        catch (e) { /* 单个失败已标记，继续下一个 */ }
    }
    renderFileBatch();
}

function closeFilePreview() {
    if (batchSending) return;
    document.getElementById('filePreviewModal').classList.remove('active');
    pendingFiles = [];
}
async function confirmSendFiles() {
    const ready = pendingFiles.filter(x => x.url);
    if (!ready.length || batchSending) return;
    batchSending = true;
    renderFileBatch();
    for (const data of ready) { await sendMessage(null, { url: data.url, name: data.name, size: data.size }); }
    batchSending = false;
    pendingFiles = [];
    document.getElementById('filePreviewModal').classList.remove('active');
    showToast(`已发送 ${ready.length} 个文件`);
}

async function sendMessage(imageUrl = null, fileData = null, voiceData = null) {
    if (window._bannedRooms && window._bannedRooms[currentRoom]) { showToast('该群已被封禁，无法发送消息'); return; }
    if (isSending) return;
    const input = document.getElementById('messageInput');
    // 语音消息只发语音：输入框里已有的文字不带走、也不清空（切回键盘可继续编辑）
    const text = voiceData ? '' : input.value.trim();
    if (!imageUrl && !text && !fileData && !voiceData) return;
    // 文本字数限制：超限时提示并保留输入框内容，不发出去
    if (MAX_CHARS > 0 && countChars(text) > MAX_CHARS) {
        showToast(`消息最多 ${MAX_CHARS} 个字，当前 ${countChars(text)} 个字，无法发送`);
        updateCharCounter();
        return;
    }
    // 把这次要发的东西先冻结成 payload：失败后重发直接复用它，
    // 不依赖输入框（输入框在失败时会被清空，见 markSendFailed）
    await deliverMessage({
        room: currentRoom,
        roomType: currentRoomType,
        text: text,
        imageUrl: imageUrl || null,
        fileData: fileData || null,
        voiceData: voiceData || null,
        quote: currentQuote ? { id: currentQuote.id, username: currentQuote.username, message: currentQuote.message } : null
    });
}

// 真正发出去。失败不丢消息：在聊天流里留一条"发送失败"的气泡，点一下即可重发。
async function deliverMessage(p) {
    if (isSending) { showToast('正在发送，请稍候'); return false; }
    isSending = true;
    const fd = new URLSearchParams();
    fd.append('message', p.text); fd.append('room', p.room);
    if (p.imageUrl) fd.append('image', p.imageUrl);
    if (p.fileData) { fd.append('file', p.fileData.url); fd.append('file_name', p.fileData.name); fd.append('file_size', p.fileData.size); }
    if (p.voiceData) {
        fd.append('voice', p.voiceData.url);
        fd.append('voice_duration', p.voiceData.duration);
        if (p.voiceData.wave) fd.append('voice_wave', p.voiceData.wave);   // 声纹包络，老客户端不发则服务端忽略
    }
    if (p.quote) fd.append('quote', JSON.stringify(p.quote));
    try {
        const res = await fetch('?action=send_message', { method:'POST', body:fd });
        const d = await res.json();
        if (d.success) {
            if (p.tempId !== undefined) clearFailedSend(p.tempId);
            const input = document.getElementById('messageInput');
            if (!p.voiceData && input) input.value = '';          // 发语音不动输入框里的文字
            clearQuote();
            const newMsg = { id:d.id, username:currentUser, message:p.text, image:p.imageUrl||null, file:p.fileData?p.fileData.url:null, file_name:p.fileData?p.fileData.name:null, file_size:p.fileData?p.fileData.size:null, voice:p.voiceData?{url:p.voiceData.url,duration:p.voiceData.duration,wave:p.voiceData.wave||''}:null, quote:p.quote, time:new Date().toTimeString().slice(0,8), timestamp:Math.floor(Date.now()/1000), room:p.room };
            if (!addedIds.has(newMsg.id)) { addedIds.add(newMsg.id); await addMessage(newMsg, true); lastId = Math.max(lastId, newMsg.id); scrollDown(); saveRoomCache(); }
            return true;
        }
        // 以下是服务端明确拒绝：重发也一样会被拒，所以不进入"重发"状态，只提示
        if (d.code === 'not_friend') {
            showToast('对方还不是你的好友');
            const sysMsg = { id:Date.now(), type:'system', message:'对方还不是你的好友，无法发送消息', username:'system', time:new Date().toTimeString().slice(0,8), timestamp:Math.floor(Date.now()/1000) };
            if (!addedIds.has(sysMsg.id)) { addedIds.add(sysMsg.id); await addMessage(sysMsg, true); saveRoomCache(); }
            return false;
        }
        if (d.code === 'muted') {
            showToast(d.error || '发送失败');
            if (currentRoomType === 'group') fetchGroupMuteStatus(currentRoom);
            return false;
        }
        if (d.code === 'too_long') {
            showToast(d.error || '消息超过字数限制');
            updateCharCounter();
            return false;
        }
        // 其余失败（服务端 5xx、未知错误）都当作可重发处理
        await markSendFailed(p, d.error || '发送失败，点击重发');
        return false;
    } catch(e) {
        await markSendFailed(p, '网络异常，点击重发');
        return false;
    } finally { isSending = false; }
}

// ===== 发送失败的气泡：留在聊天流里，点一下就重发 =====
// tempId 用负数，和真实消息 id（正数）天然不冲突
const failedSends = new Map();
let _failedSeq = 0;

function buildFailedMsg(p) {
    return {
        id: p.tempId,
        username: currentUser,
        message: p.text,
        image: p.imageUrl || null,
        file: p.fileData ? p.fileData.url : null,
        file_name: p.fileData ? p.fileData.name : null,
        file_size: p.fileData ? p.fileData.size : null,
        voice: p.voiceData ? { url:p.voiceData.url, duration:p.voiceData.duration, wave:p.voiceData.wave || '' } : null,
        quote: p.quote,
        time: new Date().toTimeString().slice(0,8),
        timestamp: Math.floor(Date.now()/1000),
        room: p.room
    };
}

// 失败气泡上的重发入口做在"时间"那一行的位置：不额外占位、不破坏气泡对齐。
// 用内联 onclick 而不是 addEventListener —— 房间缓存恢复用的是 innerHTML，
// 监听器会丢，内联属性才能活下来。
function decorateFailedRow(id, reason) {
    const row = document.getElementById('msg-' + id);
    if (!row) return;
    row.classList.add('send-failed');
    row.classList.remove('sending-again');
    const footer = row.querySelector('.msg-footer');   // 失败的消息不给编辑/撤回/引用
    if (footer) footer.remove();
    const body = row.querySelector('.msg-body');
    let timeEl = row.querySelector('.msg-time');
    if (!timeEl && body) { timeEl = document.createElement('div'); timeEl.className = 'msg-time'; body.appendChild(timeEl); }
    if (!timeEl) return;
    timeEl.innerHTML = '<svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>' + escapeHtml(reason || '发送失败，点击重发');
    timeEl.setAttribute('onclick', 'event.stopPropagation();retryFailedSend(' + id + ')');
    timeEl.title = '点击重新发送';
}

async function markSendFailed(p, reason) {
    if (p.tempId === undefined) { _failedSeq += 1; p.tempId = -_failedSeq; }
    p.reason = reason || '发送失败，点击重发';
    failedSends.set(p.tempId, p);
    if (p.room !== currentRoom) return;      // 不在当前房间就只记着，切回去时再画
    // 消息已经以"失败气泡"的形式留在聊天流里了，输入框和引用条清掉，避免同一句话出现两遍
    const input = document.getElementById('messageInput');
    if (!p.voiceData && input) { input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); }
    clearQuote();
    if (!document.getElementById('msg-' + p.tempId)) {
        addedIds.add(p.tempId);
        await addMessage(buildFailedMsg(p), true);
    }
    decorateFailedRow(p.tempId, p.reason);
    scrollDown();
    saveRoomCache();
}

function clearFailedSend(id) {
    failedSends.delete(id);
    addedIds.delete(id);
    const row = document.getElementById('msg-' + id);
    if (row) row.remove();
}

window.retryFailedSend = async function (id) {
    const p = failedSends.get(id);
    if (!p) return;
    if (isSending) { showToast('正在发送，请稍候'); return; }
    const row = document.getElementById('msg-' + id);
    if (row) row.classList.add('sending-again');
    if (p.room !== currentRoom) {
        // 理论上点不到（气泡只画在它自己的房间），保底做一次跳转
        await switchRoom(p.room, p.roomType || currentRoomType);
        return;
    }
    const ok = await deliverMessage(p);
    if (!ok && failedSends.has(id)) decorateFailedRow(id, p.reason);
};

// 切房间 / 缓存恢复都会重建聊天流，可能把失败气泡弄丢；这里按需重画（幂等，代价极低）
window.restoreFailedRows = async function () {
    for (const [id, p] of Array.from(failedSends.entries())) {
        if (p.room !== currentRoom) continue;
        if (document.getElementById('msg-' + id)) { decorateFailedRow(id, p.reason); continue; }
        addedIds.add(id);
        await addMessage(buildFailedMsg(p), false);
        decorateFailedRow(id, p.reason);
    }
};

// 断网时积下的失败消息，恢复联网后自动重发一次（只重发当前房间的：换房间会打断用户正在做的事）。
// 必须串行 await：deliverMessage 内部有 isSending 互斥，并发调用会被"正在发送"挡掉。
window.addEventListener('online', async () => {
    const ids = Array.from(failedSends.keys()).filter(id => (failedSends.get(id) || {}).room === currentRoom);
    if (!ids.length) return;
    showToast(`网络已恢复，正在重发 ${ids.length} 条消息`);
    for (const id of ids) { await window.retryFailedSend(id); }
});
document.getElementById('sendBtn').addEventListener('click', () => sendMessage());
document.getElementById('messageInput').addEventListener('keypress', e => { if (e.key==='Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });

window.deleteMessage = function(id) {
    customConfirm('确定撤回这条消息吗？', async () => {
        const res = await fetch('?action=delete_message', { method:'POST', body:new URLSearchParams({id, room:currentRoom}) });
        const d = await res.json();
        if (d.success) {
            applyRecalledRow(id, d.recall_msg);
            recalledSince = Math.floor(Date.now() / 1000);
            saveRoomCache();
        }
        else showToast(d.error || '撤回失败');
    });
};

// 撤回成功后的行内更新（单条撤回与批量撤回共用），顺带把行上的导出元数据改成"已撤回"
function applyRecalledRow(id, recallMsg) {
    const el = document.getElementById('msg-'+id);
    if (!el) return;
    const bubble = el.querySelector('.bubble');
    if (bubble) {
        bubble.innerHTML = `<span style="font-size:12px;font-style:italic;">${escapeHtml(recallMsg || '')}</span>`;
        bubble.classList.remove('img-only');
        bubble.classList.remove('voice-only');   // 撤回后按普通气泡渲染，恢复内边距与虚线框
        bubble.classList.add('recalled');
    }
    const footer = el.querySelector('.msg-footer');
    if (footer) footer.remove();
    const timeEl = el.querySelector('.msg-time');
    if (timeEl) timeEl.remove();
    const img = el.querySelector('.bubble img');
    if (img) img.remove();
    const fileEl = el.querySelector('.file-attach');
    if (fileEl) fileEl.remove();
    el.dataset.kind = 'recalled';
    el.dataset.text = String(recallMsg || '');
    el.dataset.att = '';
    delete el.dataset.quoteUser;
    delete el.dataset.quoteText;
}

let newMsgCount = 0;
const backBtn = document.getElementById('backToBottom');
const msgBox = document.getElementById('messagesBox');
msgBox.addEventListener('scroll', () => {
    const distToBottom = msgBox.scrollHeight - msgBox.scrollTop - msgBox.clientHeight;
    if (distToBottom > 200) {
        backBtn.classList.add('show');
    } else {
        backBtn.classList.remove('show');
        newMsgCount = 0;
        document.getElementById('newMsgBadge').classList.remove('show');
    }
});
const origScrollDown = scrollDown;
scrollDown = function() {
    origScrollDown();
    newMsgCount = 0;
    document.getElementById('newMsgBadge').classList.remove('show');
    backBtn.classList.remove('show');
};
const origLoadMessages = loadMessages;
loadMessages = async function() {
    const beforeScroll = msgBox.scrollHeight - msgBox.scrollTop - msgBox.clientHeight;
    const beforeCount = addedIds.size;
    await origLoadMessages();
    const afterCount = addedIds.size;
    const realNewMsgs = afterCount - beforeCount;
    if (beforeScroll > 200 && realNewMsgs > 0) {
        newMsgCount += realNewMsgs;
        const badge = document.getElementById('newMsgBadge');
        badge.textContent = newMsgCount > 99 ? '99+' : newMsgCount;
        badge.classList.add('show');
    }
};

// 副标题 + 正在输入提示：正在输入时覆盖为 "XX 正在输入…"，否则恢复原副标题
let _subBase = '';
let _typingUsers = [];
function setRoomSubtitle(text) {
    _subBase = text;
    const el = document.getElementById('roomSubtitle');
    if (el) el.textContent = _typingUsers.length ? typingText() : text;
}
function typingText() {
    return _typingUsers.length > 1 ? '多人正在输入…' : _typingUsers[0] + ' 正在输入…';
}
async function loadTyping() {
    if (!currentRoom || currentRoomType === 'channel') { _typingUsers = []; return; }
    try {
        const res = await fetch('?action=get_typing&room=' + encodeURIComponent(currentRoom));
        const d = await res.json();
        _typingUsers = (d.users || []).filter(u => u !== currentUser);
    } catch (e) { _typingUsers = []; }
    const el = document.getElementById('roomSubtitle');
    if (el) el.textContent = _typingUsers.length ? typingText() : _subBase;
}
// 输入心跳：停手 1.2 秒后上报一次正在输入
let typingHeartbeat = null;
document.getElementById('messageInput').addEventListener('input', () => {
    clearTimeout(typingHeartbeat);
    typingHeartbeat = setTimeout(() => {
        if (!currentRoom || currentRoomType === 'channel') return;
        fetch('?action=set_typing', { method:'POST', body:new URLSearchParams({room: currentRoom}) }).catch(()=>{});
    }, 1200);
});

// 智能轮询：页面不可见时大幅降低频率，省电省流量
let pollTimers = [];
function startPolling(slow) {
    pollTimers.forEach(t => clearInterval(t));
    pollTimers = [];
    const s = slow ? 6 : 1; // 慢模式下所有间隔乘以 6
    pollTimers.push(setInterval(loadMessages, 2500 * s));
    pollTimers.push(setInterval(loadRooms, 5000 * s));
    pollTimers.push(setInterval(updateUnreadBadge, 3000 * s));
    pollTimers.push(setInterval(loadAnnouncement, 30000 * s));
    pollTimers.push(setInterval(loadGlobalNotice, 10000 * s));
    pollTimers.push(setInterval(loadMyStatus, 30000 * s));
    pollTimers.push(setInterval(loadTyping, 2500 * s));
    pollTimers.push(setInterval(callPoll, 2000 * s));
}
startPolling(false);
// 看门狗：万一某轮 loadMessages 走了异常路径没释放锁，消息就会从此刷不出来（只能刷新页面）。
// 超过 15 秒仍未结束就强制释放并立即重试，保证"消息最终一定会显示"。
setInterval(() => {
    if (isLoading && _loadStartedAt && Date.now() - _loadStartedAt > 15000) {
        console.warn('[看门狗] loadMessages 卡住超过 15 秒，强制释放锁并重试');
        isLoading = false;
        loadMessages();
    }
}, 5000);

// 输入区状态与 ＋ 抽屉。
// 位置必须在 pendingImageUrl / pendingFiles 声明之后，否则会命中 let 的暂时性死区而抛错。
// ＋ 已移入输入框内部右端，与发送按钮各占其位，因此这里只负责发送按钮的可用状态。
// 底部面板互斥：表情抽屉 / 加号面板 / 语音模式，同一时间只允许开一个。
// 必须点名"要保留的那个"，其余全部收起——否则关掉了旧的隐藏占位元素、
// 真正在显示的新面板（pickerPanel）没关，就会出现两个面板同时撑开。
window.closeBottomPanels = function (except) {
    if (except !== 'more') {
        const more = document.getElementById('morePanel');
        if (more) more.classList.remove('show');
        const plus = document.getElementById('plusBtn');
        if (plus) { plus.classList.remove('active'); plus.setAttribute('aria-expanded', 'false'); }
    }
    if (except !== 'picker') {
        const picker = document.getElementById('pickerPanel');
        if (picker) picker.classList.remove('active');
        const emoji = document.getElementById('emojiPanel');      // 旧版占位，保留兼容
        if (emoji) emoji.classList.remove('active');
        const stk = document.getElementById('stickerPanel');
        if (stk) stk.classList.remove('active');
    }
};
(function () {
    const inp = document.getElementById('messageInput');
    const btn = document.getElementById('sendBtn');
    const plus = document.getElementById('plusBtn');
    const more = document.getElementById('morePanel');
    if (!inp || !btn) return;
    const hasAttach = () => !!pendingImageUrl || (Array.isArray(pendingFiles) && pendingFiles.length > 0);
    const closeMore = () => {
        if (more) more.classList.remove('show');
        if (plus) { plus.classList.remove('active'); plus.setAttribute('aria-expanded', 'false'); }
    };
    const sync = () => {
        const has = (inp.value.trim() !== '' || hasAttach());
        btn.disabled = !has;
    };
    inp.addEventListener('input', sync);
    document.addEventListener('click', () => setTimeout(sync, 0));   // 选完附件/发送后同步一次
    sync();
    // 刷新/后退时浏览器会自动回填输入框内容——这是浏览器的"会话恢复"行为，
    // 不是我们自己存了草稿（代码里没有任何草稿逻辑），也不是自动填充的账号密码。
    // 但结果一样让人困扰：上一条没发出去的消息会一直粘在输入框里，所以这里主动清空。
    const clearRestoredInput = () => {
        if (!inp.value) return;
        inp.value = '';
        // 派发 input 事件让字数统计等既有监听一起复位，不用在这里重复维护状态
        inp.dispatchEvent(new Event('input', { bubbles: true }));
        sync();
    };
    // pageshow 同时覆盖首次加载与 bfcache 返回（前进/后退）；
    // 再加一次 setTimeout 兜底，防止个别浏览器在脚本执行之后才回填。
    window.addEventListener('pageshow', clearRestoredInput);
    setTimeout(clearRestoredInput, 0);
    if (!plus || !more) return;
    plus.addEventListener('click', e => {
        e.stopPropagation();        // 避免被下面的"点击外部关闭"立刻收起
        const opening = !more.classList.contains('show');
        // 与表情抽屉互斥（反方向在表情按钮那里处理）
        if (opening) window.closeBottomPanels('more');
        more.classList.toggle('show', opening);
        plus.classList.toggle('active', opening);   // 激活时 ＋ 旋转 45° 变成 ×
        plus.setAttribute('aria-expanded', opening ? 'true' : 'false');
    });
    document.addEventListener('click', e => {
        if (!more.classList.contains('show')) return;
        if (more.contains(e.target) || plus === e.target || plus.contains(e.target)) return;
        closeMore();
    });
    // 点面板里的任一功能后自动收起
    more.addEventListener('click', e => { if (e.target.closest('.plus-item')) setTimeout(closeMore, 0); });
    // 打开表情面板时把加号面板收起（反方向互斥）
    const eb = document.getElementById('emojiBtn');
    if (eb) eb.addEventListener('click', () => closeMore());
})();

/* ================= 表情 / 表情包面板（重做版）=================
 * 单一区域：分类页签在上、内容在下，一次只渲染当前分类，不再"上下两段"。
 *   默认表情包   = 内置表情(emoji) + 内置表情包(服务端 stickers/*.svg)
 *   自定义表情包 = 用户上传的表情包，首格为上传 ＋
 * 打开面板默认落在"默认表情包"，因此不会一打开就是空的。 */
const PICKER_EMOJIS = ['😀','😂','😍','😎','😢','😡','👍','👎','🎉','❤️','🔥','😊','🤔','🙄','😴','😇','🤗','🫡','💪','👏','🙏','💔','✨','🌟','💯','🎶','🍕','☕','📷','💻','😜','😋','🥳'];
window.PICKER = { tab: 'builtin', builtin: null, user: null };

/* ---- 表情的"最近使用"与"常用(收藏)"：只存本地，不上服务端，换设备不互通 ----
 * 之所以放在本地：这两个是纯个人习惯数据，没必要为它加接口和存储文件。 */
const PICKER_KEY_RECENT = 'chatroom_sticker_recent';
const PICKER_KEY_FAV = 'chatroom_sticker_fav';
function pickerReadStore(key) {
    try { const v = JSON.parse(localStorage.getItem(key) || 'null'); return Array.isArray(v) ? v : []; } catch (e) { return []; }
}
let pickerRecent = pickerReadStore(PICKER_KEY_RECENT);   // [{t:'emoji'|'sticker', v:值}]
let pickerFav = pickerReadStore(PICKER_KEY_FAV);
let pickerRenderList = [];                               // 本次渲染的项，点击时按 data-idx 取回

function pickerSaveStore() {
    try {
        localStorage.setItem(PICKER_KEY_RECENT, JSON.stringify(pickerRecent.slice(0, 24)));
        localStorage.setItem(PICKER_KEY_FAV, JSON.stringify(pickerFav.slice(0, 60)));
    } catch (e) {}
}
function pickerKeyOf(it) { return it.t + ':' + it.v; }
function pickerIsFav(it) { const k = pickerKeyOf(it); return pickerFav.some(x => pickerKeyOf(x) === k); }
function pickerRecordRecent(it) {
    const k = pickerKeyOf(it);
    pickerRecent = pickerRecent.filter(x => pickerKeyOf(x) !== k);
    pickerRecent.unshift(it);
    if (pickerRecent.length > 24) pickerRecent.length = 24;
    pickerSaveStore();
}
window.pickerToggleFavAt = function (idx) {
    const it = pickerRenderList[idx];
    if (!it) return;
    const k = pickerKeyOf(it);
    const i = pickerFav.findIndex(x => pickerKeyOf(x) === k);
    if (i === -1) pickerFav.unshift(it); else pickerFav.splice(i, 1);
    pickerSaveStore();
    pickerRender();
    showToast(i === -1 ? '已加入常用' : '已移出常用');
};

// 生成一格表情。idx 为空时不渲染星标（"常用"栏里的项不再重复给收藏入口）
function pickerCell(it, idx) {
    const hasIdx = (idx !== undefined && idx !== null);
    const star = !hasIdx ? '' :
        `<button type="button" class="pi-star${pickerIsFav(it) ? ' on' : ''}" data-idx="${idx}" title="${pickerIsFav(it) ? '移出常用' : '加入常用'}">`
        + '<svg viewBox="0 0 24 24"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg></button>';
    const data = hasIdx ? ` data-idx="${idx}"` : '';
    if (it.t === 'emoji') return `<div class="pi"${data}>${it.v}${star}</div>`;
    return `<div class="pi pi-pic"${data} title="点击发送"><img src="${escapeHtml(it.v)}" alt="表情包">${star}</div>`;
}

function pickerRender() {
    const body = document.getElementById('pickerBody');
    if (!body) return;
    const tab = window.PICKER.tab;
    document.querySelectorAll('#pickerTabs .picker-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
    pickerRenderList = [];
    if (tab === 'builtin') {
        // 只放 emoji，不混入图片表情包（按需求：这一栏就是纯表情）
        let html = '<div class="picker-grid">';
        PICKER_EMOJIS.forEach(e => {
            const it = { t:'emoji', v:e };
            pickerRenderList.push(it);
            html += pickerCell(it, pickerRenderList.length - 1);
        });
        html += '</div>';
        body.innerHTML = html;
        return;
    }
    if (tab === 'common') {
        // 常用 = 收藏 + 最近使用。两栏都空时给一句说明，不留白屏
        let html = '';
        const grid = list => {
            let g = '<div class="picker-grid pic-grid">';
            list.forEach(it => { g += pickerCell(it, null); });
            return g + '</div>';
        };
        if (pickerFav.length) html += '<div class="picker-section-title">常用</div>' + grid(pickerFav);
        if (pickerRecent.length) html += '<div class="picker-section-title">最近使用</div>' + grid(pickerRecent);
        if (!pickerFav.length && !pickerRecent.length) {
            html += '<div class="picker-hint">还没有常用表情。发过的表情会自动进"最近使用"；把鼠标移到表情上，点右上角星标即可加入常用。</div>';
        }
        body.innerHTML = html;
        return;
    }
    // 自定义分类：首格是上传 ＋，其余是已上传的表情包
    const items = window.PICKER.user || [];
    let html = '<div class="picker-grid pic-grid">'
        + '<div class="pi pi-add" title="上传表情包" onclick="document.getElementById(\'stickerFileInput\').click()">＋</div>';
    items.forEach(s => {
        const it = { t:'sticker', v:s.url };
        pickerRenderList.push(it);
        html += pickerCell(it, pickerRenderList.length - 1);
    });
    html += '</div>';
    if (!items.length) html += '<div class="picker-hint">还没有自定义表情包，点左上角 ＋ 上传</div>';
    body.innerHTML = html;
}

// 点格子 = 用掉这个表情。事件委托挂在 #pickerBody 上：每次渲染都是 innerHTML 重建，
// 逐格 addEventListener 会不断堆积监听器。
(function initPickerBodyClicks() {
    const body = document.getElementById('pickerBody');
    if (!body) return;
    body.addEventListener('click', e => {
        const star = e.target.closest ? e.target.closest('.pi-star') : null;
        if (star) { e.stopPropagation(); window.pickerToggleFavAt(parseInt(star.dataset.idx, 10)); return; }
        const cell = e.target.closest ? e.target.closest('.pi') : null;
        if (!cell || cell.dataset.idx === undefined) return;   // ＋ 上传格没有 idx，走它自己的 onclick
        const it = pickerRenderList[parseInt(cell.dataset.idx, 10)];
        if (it) pickerUse(it);
    });
})();

function pickerUse(it) {
    pickerRecordRecent(it);
    if (it.t === 'emoji') {
        const inp = document.getElementById('messageInput');
        if (inp) { inp.value += it.v; inp.dispatchEvent(new Event('input', { bubbles: true })); }
    } else {
        sendSticker(it.v);
    }
    if (window.PICKER.tab === 'common') pickerRender();   // 最近使用变了，立刻反映出来
}

function pickerLoad(force) {
    if (!force && window.PICKER.builtin && window.PICKER.user) { pickerRender(); return; }
    const body = document.getElementById('pickerBody');
    if (body) body.innerHTML = '<div class="picker-grid"><div class="picker-hint">加载中…</div></div>';
    fetch('?action=get_stickers').then(r => r.json()).then(d => {
        const all = (d && d.stickers) || [];
        window.PICKER.builtin = all.filter(s => (s.type || 'builtin') === 'builtin');
        window.PICKER.user = all.filter(s => s.type === 'user');
        pickerRender();
    }).catch(() => {
        window.PICKER.builtin = window.PICKER.builtin || [];
        window.PICKER.user = window.PICKER.user || [];
        pickerRender();
    });
}

window.pickerSwitchTab = function (tab) {
    window.PICKER.tab = tab;
    // 切到自定义分类时重新拉取，保证刚上传的表情包立刻可见；"常用"用的是本地数据，直接渲染
    if (tab === 'user') pickerLoad(1); else pickerRender();
};

(function () {
    const panel = document.getElementById('pickerPanel');
    const btn = document.getElementById('emojiBtn');
    if (!panel || !btn) return;
    btn.addEventListener('click', e => {
        e.stopPropagation();                       // 防止被下面的"点外部收起"立刻关掉
        const opening = !panel.classList.contains('active');
        // 与加号面板互斥（反方向在加号按钮那里处理）
        if (opening) window.closeBottomPanels('picker');
        panel.classList.toggle('active', opening);
        if (opening) {
            window.PICKER.tab = 'builtin';          // 打开默认落在"默认表情包"
            pickerLoad(1);                          // 每次打开都取最新，上传后立刻能看到
        }
    });
    panel.addEventListener('click', e => e.stopPropagation());   // 面板内部点击一律不收起
    document.addEventListener('click', e => {
        if (!panel.classList.contains('active')) return;
        if (panel.contains(e.target) || btn === e.target || btn.contains(e.target)) return;
        panel.classList.remove('active');
    });
})();
// 关闭/刷新页面时立即回收通话：否则通话记录会留在服务端一直占线（"对方正在通话中"）
window.addEventListener('pagehide', () => { if (CALL.active) notifyCallEnd(); });
window.addEventListener('beforeunload', () => { if (CALL.active) notifyCallEnd(); });
document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
        startPolling(true);
    } else {
        startPolling(false);
        // 切回来立即刷新一次
        loadMessages();
        loadRooms();
        updateUnreadBadge();
        loadGlobalNotice();
    }
});

function clearChatHistory() {
    customConfirm('确定清空当前聊天记录吗？（仅清空本地显示，不影响服务器）', async () => {
        document.getElementById('messagesBox').innerHTML = '';
        addedIds.clear();
        lastDateStr = '';
        recalledSince = 0;
        editedSince = 0;
        reactionsSince = 0;
        roomVisibleFromTs = 0;
        roomCache[currentRoom] = null;
        try {
            const res = await fetch(`?action=get_messages&last_id=0&room=${encodeURIComponent(currentRoom)}`);
            const d = await res.json();
            const top = d.last_id || 0;
            lastId = top;
            setClearWatermark(currentRoom, top);
        } catch (e) {
            lastId = 0;
        }
        showToast('聊天记录已清空（仅本地）');
    });
}

function initDarkMode() {
    const saved = localStorage.getItem('chatroom_darkmode');
    if (saved === 'true') {
        document.documentElement.setAttribute('data-theme', 'dark');
        document.getElementById('darkModeLabel').textContent = '亮色模式';
    }
}
function toggleDarkMode() {
    const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
    if (isDark) {
        document.documentElement.removeAttribute('data-theme');
        document.getElementById('darkModeLabel').textContent = '暗色模式';
        localStorage.setItem('chatroom_darkmode', 'false');
    } else {
        document.documentElement.setAttribute('data-theme', 'dark');
        document.getElementById('darkModeLabel').textContent = '亮色模式';
        localStorage.setItem('chatroom_darkmode', 'true');
    }
}

// 消息提示音
let soundEnabled = true;
let audioCtx = null;
function initSound() {
    const saved = localStorage.getItem('chatroom_sound');
    if (saved === 'false') {
        soundEnabled = false;
        document.getElementById('soundLabel').textContent = '已关闭';
    }
}
function toggleSound() {
    soundEnabled = !soundEnabled;
    localStorage.setItem('chatroom_sound', soundEnabled ? 'true' : 'false');
    document.getElementById('soundLabel').textContent = soundEnabled ? '已开启' : '已关闭';
    if (soundEnabled) playNotificationSound();
}
function playNotificationSound() {
    if (!soundEnabled) return;
    try {
        if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
        if (audioCtx.state === 'suspended') audioCtx.resume();
        const now = audioCtx.currentTime;
        // 第一声
        const osc1 = audioCtx.createOscillator();
        const gain1 = audioCtx.createGain();
        osc1.type = 'sine';
        osc1.frequency.setValueAtTime(880, now);
        osc1.frequency.exponentialRampToValueAtTime(1320, now + 0.06);
        gain1.gain.setValueAtTime(0.001, now);
        gain1.gain.exponentialRampToValueAtTime(0.25, now + 0.02);
        gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
        osc1.connect(gain1).connect(audioCtx.destination);
        osc1.start(now);
        osc1.stop(now + 0.16);
        // 第二声
        const osc2 = audioCtx.createOscillator();
        const gain2 = audioCtx.createGain();
        osc2.type = 'sine';
        osc2.frequency.setValueAtTime(660, now + 0.12);
        osc2.frequency.exponentialRampToValueAtTime(990, now + 0.18);
        gain2.gain.setValueAtTime(0.001, now + 0.12);
        gain2.gain.exponentialRampToValueAtTime(0.22, now + 0.14);
        gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
        osc2.connect(gain2).connect(audioCtx.destination);
        osc2.start(now + 0.12);
        osc2.stop(now + 0.31);
    } catch(e) {}
}
let _lastSoundTime = 0;
function notifySoundThrottled() {
    const now = Date.now();
    if (now - _lastSoundTime < 2000) return;
    _lastSoundTime = now;
    playNotificationSound();
}

let mentionMembers = [];
let mentionIndex = -1;
let mentionStartPos = -1;

document.getElementById('messageInput').addEventListener('input', function(e) {
    const input = this;
    const val = input.value;
    const cursorPos = input.selectionStart;
    
    let atPos = -1;
    for (let i = cursorPos - 1; i >= 0; i--) {
        if (val[i] === '@') { atPos = i; break; }
        if (val[i] === ' ' || val[i] === '\n' || val[i] === ',') break;
    }
    
    if (atPos >= 0 && (atPos === 0 || val[atPos-1] === ' ' || val[atPos-1] === '\n')) {
        const filter = val.slice(atPos + 1, cursorPos).toLowerCase();
        mentionStartPos = atPos;
        showMentionDropdown(filter);
    } else {
        hideMentionDropdown();
        mentionStartPos = -1;
    }
});

function showMentionDropdown(filter) {
    const dropdown = document.getElementById('mentionDropdown');
    mentionMembers = [];
    mentionIndex = -1;
    
    if (currentRoomType === 'group') {
        const groupName = currentRoom.replace('group:', '');
        fetch(`?action=get_group_info&group=${encodeURIComponent(groupName)}`)
            .then(r => r.json())
            .then(d => {
                mentionMembers = (d.members || []).filter(m => m !== currentUser);
                if (filter) mentionMembers = mentionMembers.filter(m => m.toLowerCase().includes(filter));
                renderMentionDropdown();
            });
    } else if (currentRoomType === 'friend') {
        const friend = getPMRoomUser(currentRoom);
        mentionMembers = [friend];
        if (filter && !friend.toLowerCase().includes(filter)) mentionMembers = [];
        renderMentionDropdown();
    } else {
        fetch('?action=get_all_users')
            .then(r => r.json())
            .then(d => {
                mentionMembers = (d.users || []).map(u => u.name).filter(n => n !== currentUser);
                if (filter) mentionMembers = mentionMembers.filter(m => m.toLowerCase().includes(filter));
                renderMentionDropdown();
            });
    }
}

function renderMentionDropdown() {
    const dropdown = document.getElementById('mentionDropdown');
    if (mentionMembers.length === 0) {
        dropdown.classList.remove('active');
        return;
    }
    let html = '';
    mentionMembers.forEach((m, i) => {
        html += `<div class="mention-item${i===0?' selected':''}" onclick="selectMention(${jsq(m)})">
            <div class="mention-avatar" style="background:${stringToColor(m)}">${m.charAt(0).toUpperCase()}</div>
            ${escapeHtml(m)}</div>`;
    });
    dropdown.innerHTML = html;
    dropdown.classList.add('active');
    mentionIndex = 0;
}

function hideMentionDropdown() {
    document.getElementById('mentionDropdown').classList.remove('active');
    mentionIndex = -1;
}

function selectMention(name) {
    const input = document.getElementById('messageInput');
    const before = input.value.slice(0, mentionStartPos);
    const after = input.value.slice(input.selectionStart);
    input.value = before + '@' + name + ' ' + after;
    const newPos = mentionStartPos + name.length + 2;
    input.setSelectionRange(newPos, newPos);
    input.focus();
    hideMentionDropdown();
    mentionStartPos = -1;
}

const origKeypress = document.getElementById('messageInput').onkeypress;
document.getElementById('messageInput').addEventListener('keydown', function(e) {
    const dropdown = document.getElementById('mentionDropdown');
    if (!dropdown.classList.contains('active')) return;
    
    if (e.key === 'ArrowDown') {
        e.preventDefault();
        if (mentionIndex < mentionMembers.length - 1) mentionIndex++;
        updateMentionSelection();
    } else if (e.key === 'ArrowUp') {
        e.preventDefault();
        if (mentionIndex > 0) mentionIndex--;
        updateMentionSelection();
    } else if (e.key === 'Enter' || e.key === 'Tab') {
        e.preventDefault();
        if (mentionIndex >= 0 && mentionIndex < mentionMembers.length) {
            selectMention(mentionMembers[mentionIndex]);
        }
    } else if (e.key === 'Escape') {
        hideMentionDropdown();
    }
});

function updateMentionSelection() {
    const items = document.querySelectorAll('#mentionDropdown .mention-item');
    items.forEach((item, i) => {
        item.classList.toggle('selected', i === mentionIndex);
        if (i === mentionIndex) item.scrollIntoView({block:'nearest'});
    });
}

let searchResults = [];
let searchCurrentIdx = -1;

function toggleMsgSearch() {
    const bar = document.getElementById('msgSearchBar');
    if (bar.classList.contains('active')) {
        closeMsgSearch();
    } else {
        bar.classList.add('active');
        document.getElementById('msgSearchInput').focus();
    }
}

function closeMsgSearch() {
    const bar = document.getElementById('msgSearchBar');
    bar.classList.remove('active');
    document.getElementById('msgSearchInput').value = '';
    document.getElementById('searchCount').textContent = '';
    clearSearchHighlights();
    hideSearchResultList();
    searchResults = [];
    searchCurrentIdx = -1;
}

function hideSearchResultList() {
    const list = document.getElementById('searchResultList');
    list.classList.remove('active');
    list.innerHTML = '';
}

const SR_MAX = 50;   // 结果面板最多列出的条数（命中更多时只提示总数，避免一次塞几千个节点）

function highlightKeyword(text, q) {
    const safe = escapeHtml(text);
    if (!q) return safe;
    try { return safe.replace(new RegExp('(' + escapeRegex(q) + ')', 'gi'), '<mark>$1</mark>'); }
    catch (e) { return safe; }
}

function searchMessages() {
    const query = document.getElementById('msgSearchInput').value.trim();
    clearSearchHighlights();
    searchResults = [];
    searchCurrentIdx = -1;

    if (query === '') {
        document.getElementById('searchCount').textContent = '';
        hideSearchResultList();
        return;
    }

    const q = query.toLowerCase();
    // 按 DOM 顺序（即时间顺序）扫全部已加载消息；房间消息是一次性全量拉下来的，
    // 所以这里覆盖的就是该会话的全部历史，不需要再跑一趟服务端。
    const rows = [...document.querySelectorAll('#messagesBox .msg-row')].filter(r => r.dataset.mid);
    const hits = rows.filter(r => {
        const t = (r.dataset.text || '').toLowerCase();
        // 附件摘要也参与匹配，否则搜"图片/文件"这类关键词永远搜不到纯图片、纯文件消息
        const a = (r.dataset.att || '').toLowerCase();
        return t.includes(q) || a.includes(q) || (r.dataset.user || '').toLowerCase().includes(q);
    });

    if (!hits.length) {
        document.getElementById('searchCount').textContent = '0/0';
        const list = document.getElementById('searchResultList');
        list.innerHTML = '<div class="sr-empty">没有匹配的消息</div>';
        list.classList.add('active');
        return;
    }

    // 面板按"最新在前"排列，和 searchResults 的下标一一对应，上一条/下一条就是按这个顺序走
    searchResults = hits.slice(-SR_MAX).reverse();
    let html = '';
    if (hits.length > SR_MAX) html += `<div class="sr-note">共命中 ${hits.length} 条，以下显示最近 ${SR_MAX} 条</div>`;
    searchResults.forEach((row, i) => {
        const ts = parseInt(row.dataset.ts, 10) || 0;
        html += `<div class="sr-item" data-idx="${i}" onclick="jumpSearchResult(${i})">
            <span class="sr-user">${escapeHtml(row.dataset.user || '系统')}</span>
            <span class="sr-time">${ts ? escapeHtml(formatTime(ts)) : ''}</span>
            <span class="sr-text">${highlightKeyword(row.dataset.text || row.dataset.att || '', query)}</span>
        </div>`;
    });
    const list = document.getElementById('searchResultList');
    list.innerHTML = html;
    list.classList.add('active');

    searchCurrentIdx = 0;
    highlightSearchResult(0);
    updateSearchCount();
}

function updateSearchCount() {
    const total = searchResults.length;
    const cur = total ? searchCurrentIdx + 1 : 0;
    document.getElementById('searchCount').textContent = `${cur}/${total}`;
}

window.jumpSearchResult = function (idx) {
    if (!searchResults[idx]) return;
    searchCurrentIdx = idx;
    highlightSearchResult(idx);
    updateSearchCount();
};

function navigateSearch(dir) {
    if (searchResults.length === 0) return;
    searchCurrentIdx = (searchCurrentIdx + dir + searchResults.length) % searchResults.length;
    highlightSearchResult(searchCurrentIdx);
    updateSearchCount();
}

function highlightSearchResult(idx) {
    const row = searchResults[idx];
    if (!row) return;
    row.scrollIntoView({behavior:'smooth', block:'center'});
    clearSearchHighlights();
    const target = row.querySelector('.bubble') || row.querySelector('.sys-notice') || row;
    target.style.boxShadow = '0 0 0 3px var(--accent)';
    target.style.transition = 'box-shadow .2s';
    const items = document.querySelectorAll('#searchResultList .sr-item');
    items.forEach((el, i) => el.classList.toggle('active', i === idx));
    const active = document.querySelector('#searchResultList .sr-item.active');
    if (active) active.scrollIntoView({block:'nearest'});
}

function clearSearchHighlights() {
    document.querySelectorAll('#messagesBox .bubble, #messagesBox .sys-notice').forEach(b => {
        b.style.boxShadow = '';
    });
}

// ==================== 多选：批量转发 / 撤回 / 导出 ====================
let selectMode = false;
const selectedMsgs = new Set();

function selectableRows() {
    // id <= 0 是"发送失败"的本地占位气泡（tempId 为负数），服务端并不存在这条消息，
    // 让它参与多选/转发只会在服务端报"原消息不存在"，所以直接排除。
    return [...document.querySelectorAll('#messagesBox .msg-row:not(.system)')]
        .filter(r => parseInt(r.dataset.mid, 10) > 0);
}
function allRows() {
    return [...document.querySelectorAll('#messagesBox .msg-row')].filter(r => r.dataset.mid);
}

function enterSelectMode(id) {
    closeBubblePopup();
    closeRoomDropdown();
    selectMode = true;
    selectedMsgs.clear();
    document.querySelectorAll('#messagesBox .msg-row.selected').forEach(r => r.classList.remove('selected'));
    document.getElementById('messagesBox').classList.add('select-mode');
    document.getElementById('selectBar').classList.add('active');
    if (id) selectMsgById(id);
    updateSelectBar();
}

function exitSelectMode() {
    selectMode = false;
    selectedMsgs.clear();
    document.getElementById('messagesBox').classList.remove('select-mode');
    document.getElementById('selectBar').classList.remove('active');
    document.querySelectorAll('#messagesBox .msg-row.selected').forEach(r => r.classList.remove('selected'));
    updateSelectBar();
}

function selectMsgById(id) {
    const row = document.getElementById('msg-' + id);
    if (!row || row.classList.contains('system')) return;
    if (parseInt(id, 10) <= 0) return;   // 发送失败的本地占位气泡不参与多选
    selectedMsgs.add(id);
    row.classList.add('selected');
}

function toggleSelectMsg(id, row) {
    if (selectedMsgs.has(id)) { selectedMsgs.delete(id); row.classList.remove('selected'); }
    else { selectedMsgs.add(id); row.classList.add('selected'); }
    updateSelectBar();
}

function updateSelectBar() {
    const n = selectedMsgs.size;
    document.getElementById('selCount').textContent = n;
    document.getElementById('selExportBtn').disabled = n === 0;
    document.getElementById('selDownloadBtn').disabled = n === 0;
    document.getElementById('selFwdBtn').disabled = n === 0;
    document.getElementById('selRecallBtn').disabled = n === 0;
    const rows = selectableRows();
    const allSelected = rows.length > 0 && rows.every(r => selectedMsgs.has(parseInt(r.dataset.mid, 10)));
    document.getElementById('selAllBtn').textContent = allSelected ? '取消全选' : '全选';
}

// 把 POST 的下载结果转成本地下载。走 fetch 而不是表单提交，是为了能拿到服务端的
// 错误说明（表单提交失败会直接把浏览器导航到一个显示纯文本错误的新页面）。
async function downloadViaPost(action, params, fallbackName) {
    try {
        const fd = new FormData();
        Object.entries(params).forEach(([k, v]) => {
            if (Array.isArray(v)) v.forEach(x => fd.append(k + '[]', x));
            else fd.append(k, v);
        });
        const res = await fetch('?action=' + action, { method: 'POST', body: fd });
        if (!res.ok) {
            let msg = '下载失败';
            try { const t = (await res.text()).trim(); if (t) msg = t; } catch (e) {}
            showToast(msg);
            return;
        }
        const blob = await res.blob();
        let name = fallbackName || 'download';
        const cd = res.headers.get('Content-Disposition') || '';
        const m = /filename\*=UTF-8''([^;]+)/i.exec(cd);
        if (m) { try { name = decodeURIComponent(m[1]); } catch (e) {} }
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = name;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        setTimeout(() => URL.revokeObjectURL(url), 15000);
    } catch (e) {
        showToast('下载失败，请重试');
    }
}

// 多选消息的附件下载：先本地筛一遍是否真的有附件，避免把"什么都没选到"的请求发到服务端
function downloadSelectedMessages() {
    if (!selectedMsgs.size) { showToast('请先选择要下载的消息'); return; }
    const ids = [...selectedMsgs].filter(id => {
        const row = document.getElementById('msg-' + id);
        return row && (row.dataset.att || '').trim() !== '';
    });
    if (!ids.length) { showToast('选中的消息里没有可下载的附件'); return; }
    if (ids.length > 200) { showToast('一次最多下载 200 条消息的附件'); return; }
    showToast(ids.length > 1 ? ('正在打包 ' + ids.length + ' 个附件…') : '开始下载');
    downloadViaPost('download_message_files', { room: currentRoom, ids }, '聊天文件');
}

function selectAllVisible() {
    const rows = selectableRows();
    const allSelected = rows.length > 0 && rows.every(r => selectedMsgs.has(parseInt(r.dataset.mid, 10)));
    rows.forEach(r => {
        const id = parseInt(r.dataset.mid, 10);
        if (allSelected) { selectedMsgs.delete(id); r.classList.remove('selected'); }
        else { selectedMsgs.add(id); r.classList.add('selected'); }
    });
    updateSelectBar();
}

// 多选态下点击整行即勾选。用捕获阶段拦截：气泡里的图片/文件/语音/操作条都带自己的
// click 处理（stopPropagation + 打开图片之类），不拦下来的话点图片会直接弹新窗口。
document.getElementById('messagesBox').addEventListener('click', e => {
    if (!selectMode) return;
    const row = e.target.closest('.msg-row');
    if (!row || !row.dataset.mid || row.classList.contains('system')) return;
    e.preventDefault();
    e.stopPropagation();
    const id = parseInt(row.dataset.mid, 10);
    if (id <= 0) { showToast('这条消息还没发送成功，点它的时间位置可重发'); return; }
    toggleSelectMsg(id, row);
}, true);

function selectedRows() {
    return allRows().filter(r => selectedMsgs.has(parseInt(r.dataset.mid, 10)));
}

function absDateLabel(ts) {
    const d = new Date(ts * 1000), p = n => (n < 10 ? '0' : '') + n;
    return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`;
}

function msgLineFromRow(row) {
    const kind = row.dataset.kind || 'chat';
    const ts = parseInt(row.dataset.ts, 10) || 0;
    const time = ts ? formatTime(ts) : '';
    const user = row.dataset.user || '';
    const text = row.dataset.text || '';
    const att = row.dataset.att || '';
    if (kind === 'system' || kind === 'call') return `[${time}] ${text || att}`;
    let line = `[${time}] ${user}：`;
    if (row.dataset.quoteUser) line += `（引用 ${row.dataset.quoteUser}：${row.dataset.quoteText || ''}）`;
    line += text || att || '';
    return line;
}

function buildChatTxt(rows) {
    const title = document.getElementById('roomTitle').textContent || currentRoom;
    const lines = [];
    lines.push(`===== ${title} 聊天记录 =====`);
    lines.push(`导出时间：${formatTime(Math.floor(Date.now()/1000))}`);
    lines.push(`消息条数：${rows.length}`);
    lines.push('');
    let lastDate = '';
    rows.forEach(row => {
        const ts = parseInt(row.dataset.ts, 10) || 0;
        if (ts) {
            const ds = absDateLabel(ts);
            if (ds !== lastDate) { lastDate = ds; lines.push(''); lines.push(`———— ${ds} ————`); }
        }
        lines.push(msgLineFromRow(row));
    });
    return lines.join('\r\n');
}

function downloadTxt(filename, content) {
    // BOM 不能省：Windows 记事本按本地编码打开无 BOM 的 UTF-8 会整片乱码
    const blob = new Blob(['\ufeff' + content], { type:'text/plain;charset=utf-8' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = filename;
    document.body.appendChild(a); a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 4000);
}

function exportTxtName(tag) {
    const d = new Date(), p = n => (n < 10 ? '0' : '') + n;
    const room = (document.getElementById('roomTitle').textContent || 'chat').replace(/[\\/:*?"<>|]/g, '_').slice(0, 24);
    return `${room}_${tag}_${d.getFullYear()}${p(d.getMonth()+1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}.txt`;
}

function exportSelectedMessages() {
    const rows = selectedRows();
    if (!rows.length) { showToast('请先勾选要导出的消息'); return; }
    downloadTxt(exportTxtName('所选'), buildChatTxt(rows));
    showToast(`已导出 ${rows.length} 条消息`);
}

function exportRoomChat() {
    const rows = allRows();
    if (!rows.length) { showToast('当前会话还没有消息'); return; }
    downloadTxt(exportTxtName('全部'), buildChatTxt(rows));
    showToast(`已导出 ${rows.length} 条消息`);
}

function forwardSelectedMessages() {
    const rows = selectedRows();
    if (!rows.length) { showToast('请先勾选要转发的消息'); return; }
    forwardBatch = rows.map(r => ({ id: parseInt(r.dataset.mid, 10), room: currentRoom }));
    openForwardModal(true);
}

function recallSelectedMessages() {
    const rows = selectedRows();
    if (!rows.length) { showToast('请先勾选要撤回的消息'); return; }
    const mine = rows.filter(r => (r.dataset.user || '') === currentUser && r.dataset.kind !== 'recalled');
    if (!mine.length) { showToast('只能撤回自己发送的消息'); return; }
    const skipped = rows.length - mine.length;
    const tip = `确定撤回选中的 ${mine.length} 条消息吗？`
        + (skipped ? `（另有 ${skipped} 条不是自己发送的或已撤回，将跳过）` : '');
    customConfirm(tip, async () => {
        let ok = 0;
        for (const row of mine) {
            const id = parseInt(row.dataset.mid, 10);
            try {
                const res = await fetch('?action=delete_message', { method:'POST', body:new URLSearchParams({ id, room: currentRoom }) });
                const d = await res.json();
                if (d.success) { ok++; applyRecalledRow(id, d.recall_msg); }
            } catch (e) {}
        }
        recalledSince = Math.floor(Date.now() / 1000);
        saveRoomCache();
        showToast(`已撤回 ${ok} 条`);
        if (ok) exitSelectMode();
    });
}

function searchKeyNav(e) {
    if (e.key === 'Enter') {
        e.preventDefault();
        navigateSearch(e.shiftKey ? -1 : 1);
    } else if (e.key === 'Escape') {
        closeMsgSearch();
    }
}

document.addEventListener('keydown', function(e) {
    if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
        e.preventDefault();
        toggleMsgSearch();
    }
    if ((e.ctrlKey || e.metaKey) && e.key === 'f' && document.activeElement !== document.getElementById('messageInput')) {
        e.preventDefault();
        toggleMsgSearch();
    }
});

// 点搜索栏以外的地方收起结果面板（保留已跳转的高亮）。不这么做的话面板会一直盖着消息区，
// 用户想"边看结果边翻上下文"反而做不到。
document.addEventListener('click', e => {
    const bar = document.getElementById('msgSearchBar');
    if (!bar.classList.contains('active')) return;
    if (e.target.closest('#msgSearchBar')) return;
    const list = document.getElementById('searchResultList');
    if (list.classList.contains('active')) hideSearchResultList();
});

let announcementData = null;

// ---- 全局公告弹窗：后台发布后，以弹窗展示，需手动点“知道了”或用 “X” 关闭 ----
let globalNoticeKey = localStorage.getItem('chatroom_seen_global_notice') || '';
let readNotices = [];
try { readNotices = JSON.parse(localStorage.getItem('chatroom_read_notices') || '[]'); } catch(e) { readNotices = []; }
if (globalNoticeKey && readNotices.indexOf(globalNoticeKey) === -1) readNotices.push(globalNoticeKey);
function isNoticeRead(t) { return readNotices.indexOf(String(t)) !== -1; }
function markNoticeRead(t) {
    const s = String(t);
    if (readNotices.indexOf(s) === -1) readNotices.push(s);
    localStorage.setItem('chatroom_read_notices', JSON.stringify(readNotices));
}
async function loadGlobalNotice() {
    try {
        const res = await fetch('?action=get_global_notice');
        const d = await res.json();
        const newTag = document.getElementById('sideBellDot');
        if (newTag) newTag.style.display = (d.notice && !isNoticeRead(d.notice.time)) ? '' : 'none';
    } catch(e) {}
}

// ---- 公告中心（下拉面板 + 单条详情弹窗） ----
let globalNoticesCache = [];
async function openNoticesPop() {
    const pop = document.getElementById('noticesPop');
    if (pop.classList.contains('active')) { closeNoticesPop(); return; }
    try {
        const res = await fetch('?action=get_global_notice');
        const d = await res.json();
        globalNoticesCache = (d.notices || []).filter(n => n && n.text);
    } catch(e) { globalNoticesCache = []; }
    renderNoticesPop();
    pop.classList.add('active');
}
function renderNoticesPop() {
    const list = document.getElementById('noticesPopList');
    if (!list) return;
    if (!globalNoticesCache.length) { list.innerHTML = '<div class="np-empty">暂无公告</div>'; return; }
    list.innerHTML = globalNoticesCache.map(n => {
        const read = isNoticeRead(n.time);
        return `<div class="np-item ${read ? '' : 'unread'}" onclick='openNoticeDetail(${JSON.stringify(n).replace(/'/g,"&#39;")})'>
            <span class="np-dot ${read ? 'read' : 'new'}"></span>
            <div class="np-c">
                <div class="np-t">
                    <span class="np-author">${escapeHtml(n.admin || '管理员')}${read ? '' : '  <span style="color:var(--danger);font-size:11px">[新]</span>'}</span>
                    <span class="np-time">${formatDate(n.time)}${read ? ' · 已读' : ''}</span>
                </div>
                <div class="np-prev">${escapeHtml(n.text.split('\n')[0])}</div>
            </div>
        </div>`;
    }).join('');
}
function closeNoticesPop() {
    document.getElementById('noticesPop').classList.remove('active');
}
// 打开单条公告详情
function openNoticeDetail(n) {
    document.getElementById('ndMeta').innerHTML = `<span style="font-weight:600;color:var(--text)">${escapeHtml(n.admin || '管理员')}</span>` +
        `<span>·</span><span>${formatDate(n.time)}</span>`;
    document.getElementById('ndBody').textContent = n.text || '';
    markNoticeRead(n.time);
    if (String(n.time) !== globalNoticeKey) {
        globalNoticeKey = String(n.time);
        localStorage.setItem('chatroom_seen_global_notice', globalNoticeKey);
        const newTag = document.getElementById('sideBellDot');
        if (newTag) newTag.style.display = 'none';
    }
    renderNoticesPop();
    document.getElementById('noticeDetailModal').classList.add('active');
}
function closeNoticeDetail() {
    document.getElementById('noticeDetailModal').classList.remove('active');
}
function toggleNoticesPop() { openNoticesPop(); }
document.addEventListener('click', function(e){
    if (!e.target.closest('.notices-pop') && !e.target.closest('.side-bell-btn')) closeNoticesPop();
});

async function loadAnnouncement() {
    if (currentRoomType !== 'group') {
        document.getElementById('announcementBar').classList.remove('active');
        return;
    }
    const groupName = currentRoom.replace('group:', '');
    try {
        const res = await fetch(`?action=get_announcement&group=${encodeURIComponent(groupName)}`);
        const d = await res.json();
        if (d.announcement) {
            announcementData = d.announcement;
            document.getElementById('annContent').textContent = d.announcement.content;
            const t = new Date(d.announcement.set_at * 1000);
            document.getElementById('annMeta').textContent = `— ${d.announcement.set_by} · ${formatDate(d.announcement.set_at)}`;
            document.getElementById('announcementBar').classList.add('active');
        } else {
            announcementData = null;
            document.getElementById('announcementBar').classList.remove('active');
        }
    } catch(e) {}
}

function closeAnnouncement() {
    document.getElementById('announcementBar').classList.remove('active');
}

function openAnnouncementModal() {
    const modal = document.getElementById('announcementModal');
    if (!modal.dataset.group) {
        const groupName = currentRoom.replace('group:', '');
        modal.dataset.group = groupName;
    }
    document.getElementById('announcementInput').value = announcementData ? announcementData.content : '';
    modal.classList.add('active');
}

function closeAnnouncementModal() {
    document.getElementById('announcementModal').classList.remove('active');
    document.getElementById('announcementModal').dataset.group = '';
}
document.getElementById('announcementModal').addEventListener('click', function(e) {
    if (e.target === this) closeAnnouncementModal();
});
document.getElementById('announcementInput').addEventListener('keydown', function(e) {
    if (e.key === 'Escape') closeAnnouncementModal();
});

async function saveAnnouncement() {
    const groupName = document.getElementById('announcementModal').dataset.group;
    const content = document.getElementById('announcementInput').value.trim();
    if (!content) { showToast('请输入公告内容'); return; }
    const res = await fetch('?action=set_announcement', { method:'POST', body:new URLSearchParams({group:groupName, content}) });
    const d = await res.json();
    if (d.success) { showToast('公告已更新'); closeAnnouncementModal(); loadAnnouncement(); }
    else showToast(d.error || '操作失败');
}

async function clearAnnouncement() {
    const groupName = document.getElementById('announcementModal').dataset.group;
    const res = await fetch('?action=set_announcement', { method:'POST', body:new URLSearchParams({group:groupName, content:''}) });
    const d = await res.json();
    if (d.success) { showToast('公告已清除'); closeAnnouncementModal(); announcementData = null; document.getElementById('announcementBar').classList.remove('active'); }
    else showToast(d.error || '操作失败');
}

let editMessageId = null;

function openEditMessage(id, currentText) {
    editMessageId = id;
    document.getElementById('editMessageInput').value = currentText;
    document.getElementById('editMessageModal').classList.add('active');
    setTimeout(() => document.getElementById('editMessageInput').focus(), 100);
}

function closeEditMessage() {
    document.getElementById('editMessageModal').classList.remove('active');
    editMessageId = null;
}

async function saveEditMessage() {
    const newText = document.getElementById('editMessageInput').value.trim();
    if (!newText) { showToast('消息不能为空'); return; }
    if (!editMessageId) return;
    
    const res = await fetch('?action=edit_message', { method:'POST', body:new URLSearchParams({id:editMessageId, message:newText, room:currentRoom}) });
    const d = await res.json();
    if (d.success) {
        const el = document.getElementById('msg-'+editMessageId);
        if (el) {
            const bubble = el.querySelector('.bubble');
            if (bubble) {
                const quote = bubble.querySelector('.quote-block');
                const img = bubble.querySelector('img');
                const file = bubble.querySelector('.file-attach');
                
                let newHtml = '';
                if (quote) newHtml += quote.outerHTML;
                if (img) newHtml += img.outerHTML;
                if (file) newHtml += file.outerHTML;
                newHtml += linkify(escapeHtml(newText)) + '<span class="msg-edited-tag">（已编辑）</span>';
                bubble.innerHTML = newHtml;
                bubble.classList.remove('img-only');   // 编辑后必定带文字，恢复常规气泡外观
            }
        }
        showToast('消息已编辑');
        closeEditMessage();
        saveRoomCache();
    } else {
        showToast(d.error || '编辑失败');
    }
}

document.getElementById('editMessageModal').addEventListener('click', function(e) {
    if (e.target === this) closeEditMessage();
});
document.getElementById('editMessageInput').addEventListener('keydown', function(e) {
    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveEditMessage(); }
    if (e.key === 'Escape') closeEditMessage();
});

const origSwitchRoom = switchRoom;
switchRoom = async function(room, type, displayName = null) {
    await origSwitchRoom(room, type, displayName);
    loadAnnouncement();
};

const STATUS_PRESETS = [
    { key: 'online', label: '在线', color: '#07c160' },
    { key: 'busy', label: '忙碌', color: '#e74c3c' },
    { key: 'away', label: '离开', color: '#f39c12' },
    { key: 'invisible', label: '隐身', color: '#999' },
    { key: 'offline', label: '离线', color: '#999' },
];
async function loadMyStatus() {
    try {
        const res = await fetch('?action=get_status');
        const d = await res.json();
        const myStatus = d.statuses[currentUser] || '';
        const el = document.getElementById('sidebarStatusText');
        const preset = STATUS_PRESETS.find(p => p.key === myStatus);
        if (preset) {
            el.textContent = preset.label;
            el.style.color = preset.color;
        } else if (myStatus) {
            el.textContent = myStatus;
            el.style.color = '';
        } else {
            el.textContent = '在线';
            el.style.color = '#07c160';
        }
    } catch(e) {}
}
function editMyStatus(e) {
    e.stopPropagation();
    const popup = document.getElementById('statusPopup');
    if (popup.classList.contains('active')) { popup.classList.remove('active'); return; }
    const current = document.getElementById('sidebarStatusText').textContent.trim();
    let html = '';
    STATUS_PRESETS.forEach(p => {
        const active = current === p.label ? ' active' : '';
        html += `<div class="status-option${active}" onclick="event.stopPropagation();setMyStatus('${p.key}')" style="color:${p.color};">${p.label}</div>`;
    });
    popup.innerHTML = html;
    const rect = document.getElementById('sidebarStatusText').getBoundingClientRect();
    popup.style.left = Math.min(rect.left, window.innerWidth - 140) + 'px';
    popup.style.top = Math.min(rect.top - 170, window.innerHeight - 200) + 'px';
    popup.classList.add('active');
    setTimeout(() => document.addEventListener('click', closeStatusPopup, { once: true }), 100);
}
function closeStatusPopup() {
    document.getElementById('statusPopup').classList.remove('active');
}
async function setMyStatus(statusKey) {
    const preset = STATUS_PRESETS.find(p => p.key === statusKey);
    const statusLabel = preset ? preset.key : '';
    const res = await fetch('?action=set_status', { method:'POST', body:new URLSearchParams({status: statusLabel}) });
    const d = await res.json();
    if (d.success) {
        const el = document.getElementById('sidebarStatusText');
        if (preset) {
            el.textContent = preset.label;
            el.style.color = preset.color;
        } else {
            el.textContent = '在线';
            el.style.color = '#07c160';
        }
        closeStatusPopup();
    }
}

loadRooms();
loadMyStatus();
updateGroupMenuVisibility();
document.getElementById('sidebarUserName').textContent = currentUser;
updateUnreadBadge();
loadGlobalNotice();
(async () => {
    await getAvatar(currentUser);
    updateAllOwnAvatars();
})();
initDarkMode();
initSound();
</script>
</body>
</html>