/* mchat UI 胶水层：把 MchatClient 的事件渲染到锁定 UI，并处理交互。 */
(function (global) {
  'use strict';
  const $ = (id) => document.getElementById(id);
  const client = new global.MchatClient();
  const Art = global.Art;
  const HELPER = '文件传输助手';   // 自同步会话（微信"文件传输助手"同款，跨设备互发消息/文件）
  const BOT_UID = 'usr7BOXA1XkL3o';   // 太白 AI 助手 uid：渲染处统一标「AI 助手」，让人一眼知道是机器人、知道怎么用
  // 全部 AI 助手 uid：太白=全局；rich(openclaw)/nega(hermes)=apple 本机私密 agent（仅 apple 可见可用）。渲染处统一标「AI 助手」
  const BOT_UIDS = new Set([BOT_UID, 'usrSfQOXYh_zfU', 'usrm618yfMKQGI', 'usrssOhHysYVoM', 'usrI2Q-FybJjh0', 'usr46tdpYoSGmw']);  // 太白/rich/nega(apple)/小何her/大哥broter(dalan)/阿里(VPS)
  const isBot = (u) => BOT_UIDS.has(u);
  const CLOUD_FILE_MAX = 500 * 1024 * 1024;
  const CLOUD_AUTO_MAX = 200 * 1024 * 1024;   // 云通道(公网中转)单文件上限:大文件占满上行会堵住全员消息,只许局域网直传
  const INLINE_ORIGINAL_MAX = 1.5 * 1024 * 1024;
  const AVATAR_GIF_MAX = 2 * 1024 * 1024;
  const LINK_PREVIEW_MAX_URLS = 24;
  const LINK_PREVIEW_BUDGET_MS = 1500;
  const LINK_PREVIEW_GATEWAY_TIMEOUT_MS = 2500;
  const UPDATE_CHECK_MS = 10 * 60 * 1000;
  const UPDATE_CHECK_MIN_GAP = 60 * 1000;
  let lastUpdateCheckAt = 0;

  const state = {
    contacts: new Map(),   // name -> normSub
    active: null,          // 当前会话 name
    messages: new Map(),   // name -> [normMsg]（按 seq 升序）
    members: new Map(),    // name -> [member]
    reply: null,           // {seq,from,fromName,plain}
    typingTimers: new Map(),
    myAvatarSeed: null,
    pendingAvatar: null,
    announces: new Map(),   // topic -> 最新公告文本
    directory: new Map(),   // uid -> {uid,name,online}  全体伙伴目录(来自群成员)
    muted: false,           // 托盘静音联动
    drafts: new Map(),      // topic -> 未发送草稿文本(切会话保留)
    _openSeq: 0,
    _historyExpanded: new Set(),
  };

  // 会话置顶(localStorage 持久) + 群里@我检测
  function pinnedSet() { try { return new Set(JSON.parse(localStorage.getItem('mchat.pinned') || '[]')); } catch (e) { return new Set(); } }
  function togglePin(topic) { const s = pinnedSet(); if (s.has(topic)) s.delete(topic); else s.add(topic); localStorage.setItem('mchat.pinned', JSON.stringify([...s])); flash(s.has(topic) ? '已置顶' : '已取消置顶'); renderLists(); }
  // 单会话免打扰(localStorage 持久):静音会话不响铃/不弹通知/不计入总未读角标(行内仍显🔕+小灰点,可见但不打扰)
  function mutedSet() { try { return new Set(JSON.parse(localStorage.getItem('mchat.muted') || '[]')); } catch (e) { return new Set(); } }
  function isMuted(topic) { return mutedSet().has(topic); }
  function toggleMute(topic) { const s = mutedSet(); if (s.has(topic)) s.delete(topic); else s.add(topic); localStorage.setItem('mchat.muted', JSON.stringify([...s])); flash(s.has(topic) ? '已设为免打扰' : '已取消免打扰'); renderLists(); updateBadge(); updateMuteButton(); }
  function toggleActiveMute() { if (!state.active) { flash('先选择一个会话'); return; } toggleMute(state.active); }
  function updateMuteButton() {
    const b = $('muteBtn'); if (!b) return;
    const on = !!(state.active && isMuted(state.active));
    b.classList.toggle('on', on);
    b.title = on ? '已免打扰 · 点击取消' : '消息免打扰';
  }
  function unreadAnchor(c) {
    if (!c) return 0;
    const unread = +c.unread || 0, seq = +c.seq || 0, read = +c.read || 0;
    if (unread > 0 && seq > 0) return Math.max(0, seq - unread);
    return read;
  }
  function isMentionMe(msg) {
    if (!msg || msg.mine) return false;
    const txt = ('' + (msg.plain || '')).toLowerCase();
    if (txt.indexOf('@所有人') >= 0) return true;
    const me = ('' + (client.myName || client.uname || '')).toLowerCase();
    return !!me && txt.indexOf('@' + me) >= 0;
  }
  function isSubscribedTopic(name) {
    const tp = name && client.topics && client.topics.get(name);
    try { return !!(tp && tp.isSubscribed && tp.isSubscribed()); } catch (e) { return false; }
  }

  /* ===== 启动 ===== */
  async function boot() {
    if (global.mchatNative && global.mchatNative.isDesktop) { global.MCHAT_PLATFORM = global.mchatNative.platform || 'desktop'; document.body.classList.add('is-desktop'); }
    bindGlobal();
    bindLanProgress();
    bindSwipeBack();
    startPresenceRefresh();
    if (global.mchatNative && global.mchatNative.onMute) global.mchatNative.onMute((m) => { state.muted = !!m; });
    client.on('connect', () => setConn(true))
      .on('disconnect', () => setConn(false))
      .on('reconnected', () => { showReconnected(); scheduleUpdateCheck(true); })
      .on('contactsReset', onContactsReset)
      .on('contact', onContact)
      .on('contactsDone', onContactsDone)
      .on('ready', onReady)
      .on('sessionCount', onSessionCount)
      .on('pres', onPres)
      .on('topicPres', onTopicPres)
      .on('message', onMessage)
      .on('info', onInfo)
      .on('members', onMembers)
      .on('authExpired', () => { try { localStorage.removeItem('mchat.token'); localStorage.removeItem('mchat.uname'); } catch (e) {} hideSplash(); $('app').style.display = 'none'; $('loginBg').style.display = 'grid'; $('loginErr').textContent = '登录已过期，请重新登录'; $('loginUser').focus(); })
      .on('error', (e) => console.error(e));
    // 测试用：?u=alice&p=alice123 自动登录（仅 localhost 生效，便于自检/无头截图）
    const qp = new URLSearchParams(location.search);
    if (qp.get('u') && qp.get('p') && /^(127\.0\.0\.1|localhost)$/.test(location.hostname)) {
      $('loginUser').value = qp.get('u'); $('loginPass').value = qp.get('p');
      try { await client.connectAndLogin(qp.get('u'), qp.get('p')); showApp(); return; }
      catch (e) { $('loginErr').textContent = '自动登录失败：' + ((e && e.message) || e); }
    }
    // 没 token → 直接登录页(登出态);有 token → 先用本地缓存秒显会话列表(像微信),再后台连服务器刷新
    if (!localStorage.getItem('mchat.token')) { showLogin(); return; }
    const hasCache = loadConvCache();   // 有缓存 → 立刻填充 state.contacts + 渲染
    loadDirCache();                     // 伙伴目录同样先用本地名单秒显(离线态),服务器到货后刷新
    if (hasCache) showApp();            // 秒进聊天界面(带上次的会话列表),不必等服务器往返
    try {
      const ok = await client.connectWithToken();
      // 登录一成功就恢复上次会话(缓存消息立即渲染,不等 ready/联系人 meta 到齐——手机高延迟下差距很大);onReady 里的调用作兜底
      if (ok) { if (!hasCache) showApp(); restoreLastOpen(); return; }
    } catch (e) { /* token 失效 */ }
    if (!hasCache) showLogin();   // 没缓存且登录失败 → 登录页;有缓存的(罕见 token 失效)由 authExpired 兜底切回登录
  }

  async function doLogin() {
    const u = $('loginUser').value.trim(), p = $('loginPass').value;
    if (!u || !p) { $('loginErr').textContent = '请输入用户名和密码'; return; }
    $('loginGo').disabled = true; $('loginErr').textContent = '登录中…';
    try {
      await client.connectAndLogin(u, p);
      showApp();
    } catch (e) {
      $('loginErr').textContent = '登录失败：' + ((e && e.message) || e);
      $('loginGo').disabled = false;
    }
  }

  // 登录/注册切换
  let registerMode = false;
  function toggleRegister() {
    registerMode = !registerMode;
    $('loginFn').style.display = registerMode ? 'block' : 'none';
    $('loginGo').style.display = registerMode ? 'none' : 'block';
    $('registerGo').style.display = registerMode ? 'block' : 'none';
    $('loginHint').innerHTML = registerMode
      ? '已有账号？<a onclick="UI.toggleRegister()" style="color:var(--accent-deep);cursor:pointer;font-weight:600">去登录</a>'
      : '没有账号？<a onclick="UI.toggleRegister()" style="color:var(--accent-deep);cursor:pointer;font-weight:600">注册一个</a>';
    const sub = document.querySelector('.login p'); if (sub) sub.textContent = registerMode ? '公司内部即时通讯 · 注册' : '公司内部即时通讯 · 登录';
    $('loginErr').textContent = ''; $('loginErr').style.color = '';
  }
  async function doRegister() {
    const u = $('loginUser').value.trim(), fn = $('loginFn').value.trim(), p = $('loginPass').value;
    if (!u || !p) { $('loginErr').style.color = ''; $('loginErr').textContent = '请填用户名和密码'; return; }
    if (!/^[a-z0-9_]{4,32}$/.test(u)) { $('loginErr').style.color = ''; $('loginErr').textContent = '登录名用4-32位小写字母、数字或下划线；机器名可填昵称'; return; }
    if (p.length < 4) { $('loginErr').style.color = ''; $('loginErr').textContent = '密码至少4位'; return; }
    $('registerGo').disabled = true; $('loginErr').style.color = ''; $('loginErr').textContent = '提交中…';
    try {
      const uid = await client.register(u, p, fn || u);
      toggleRegister();                                  // 切回登录态
      $('loginErr').style.color = 'var(--accent-deep)';
      $('loginErr').textContent = '✓ 注册成功！把弹出的「申请码」发给管理员通过';
      $('loginPass').value = '';
      const copied = await copyText(uid);
      global.prompt('注册成功！把下面这串「申请码」发给管理员，他通过后你用刚才的用户名+密码登录即可' + (copied ? '（已自动复制到剪贴板）' : '（如未自动复制，请手动复制）') + ':', uid);
    } catch (e) {
      const dup = /exists|duplicate|409|in use/i.test((e && e.message) || '');
      const policy = /policy|422/i.test((e && e.message) || '');
      $('loginErr').style.color = ''; $('loginErr').textContent = dup ? '该用户名已被注册，换一个' : (policy ? '注册失败：登录名或密码不符合规则' : ('注册失败：' + ((e && e.message) || e)));
    } finally { $('registerGo').disabled = false; }
  }
  // 登录后一个会话都没有 → 多半是待审核(新注册未入群),给明确提示
  function onSessionCount(n) {
    if (n !== 0) return;
    setTimeout(() => {
      if (state.active == null && $('stream')) {
        $('stream').innerHTML = '<div class="empty">⏳ 账号审核中<br><span style="font-size:12.5px;color:var(--muted);margin-top:8px;display:inline-block">你的账号已注册成功，请等管理员通过<br>通过后会把你拉进公司群，重开 App 即可开始聊天</span></div>';
      }
    }, 400);
  }

  // 启动屏淡出(token 登录完成/确定要登录时);showLogin 显示登录页前先收掉 splash
  function hideSplash() { const s = $('splash'); if (s && !s.classList.contains('hide')) { s.classList.add('hide'); setTimeout(() => { try { s.style.display = 'none'; } catch (e) {} }, 260); } }
  function showLogin() { hideSplash(); $('loginBg').style.display = 'grid'; $('loginUser').focus(); }

  // 会话列表本地缓存:开 App 先从本地秒显上次的列表(像微信),再后台连服务器刷新+对账删除已无的会话。
  const _liveTopics = new Set();    // 本次连接服务器实际下发的会话 topic(对账用)
  let _contactsSyncPending = false;
  let _contactsReady = false;
  let _contactsDone = false;
  function convCacheKey() { return 'mchat.convs.' + (localStorage.getItem('mchat.uname') || ''); }
  function loadConvCache() {
    try {
      const arr = JSON.parse(localStorage.getItem(convCacheKey()) || '[]');
      if (!Array.isArray(arr) || !arr.length) return false;
      arr.forEach(c => { c.touched = c.touched ? new Date(c.touched) : null; c.photo = typeof c.photo === 'string' && !/^data:/i.test(c.photo) ? c.photo : null; c.online = false; c.mentioned = false; state.contacts.set(c.topic, c); });
      renderLists();
      return true;
    } catch (e) { return false; }
  }
  /* ===== 伙伴目录本地缓存:重开 app 先显示上次的名单(离线态+卡通头像),服务器到货后覆盖 ===== */
  function dirCacheKey() { return 'mchat.dir.' + (localStorage.getItem('mchat.uname') || ''); }
  function loadDirCache() {
    try {
      const arr = JSON.parse(localStorage.getItem(dirCacheKey()) || '[]');
      if (!Array.isArray(arr)) return;
      arr.forEach(m => { if (m && m.uid && !state.directory.has(m.uid)) state.directory.set(m.uid, { uid: m.uid, name: m.name, online: false, photo: null }); });
    } catch (e) {}
  }
  function saveDirCache() {
    try {
      const arr = [];
      state.directory.forEach(m => arr.push({ uid: m.uid, name: m.name }));   // 只存名字,头像大(base64)不入 localStorage
      localStorage.setItem(dirCacheKey(), JSON.stringify(arr.slice(0, 300)));
    } catch (e) {}
  }

  /* ===== 消息本地缓存(IndexedDB):进会话先秒出上次内容,云端到货按 seq 去重合并(无感刷新地基) ===== */
  const _storageWarned = new Set();
  function storageWarnOnce(tag, e) {
    if (_storageWarned.has(tag)) return;
    _storageWarned.add(tag);
    console.warn('[storage ' + tag + ']', e);
    flash('本机存储失败，部分聊天缓存可能无法保存');
  }
  let _mdb = null;
  function msgDb() {
    if (_mdb !== null) return _mdb;
    _mdb = new Promise((res) => {
      try {
        const open = (ver) => {
          const req = ver ? indexedDB.open('mchat-msgs', ver) : indexedDB.open('mchat-msgs');
          req.onupgradeneeded = () => { try { if (!req.result.objectStoreNames.contains('msgs')) req.result.createObjectStore('msgs'); } catch (e) {} };
          req.onsuccess = () => {
            const db = req.result;
            // 库在但表缺(异常残留/半途升级失败) → 升一版补建,自愈而不是永久静默失效
            if (!db.objectStoreNames.contains('msgs')) { const v = db.version + 1; db.close(); open(v); return; }
            res(db);
          };
          req.onerror = () => { storageWarnOnce('messages', req.error); res(null); };
        };
        open();
      } catch (e) { storageWarnOnce('messages', e); res(null); }
    });
    return _mdb;
  }
  function msgCacheKey(name) { return (localStorage.getItem('mchat.uname') || '') + '|' + name; }
  async function loadMsgCache(name) {
    const db = await msgDb(); if (!db) return [];
    return new Promise((res) => {
      try {
        const rq = db.transaction('msgs', 'readonly').objectStore('msgs').get(msgCacheKey(name));
        rq.onsuccess = () => { const arr = rq.result || []; arr.forEach(m => { m.ts = m.ts ? new Date(m.ts) : null; }); res(arr); };
        rq.onerror = () => res([]);
      } catch (e) { res([]); }
    });
  }
  const _msgSaveT = new Map();
  function saveMsgCache(name) {
    if (!name || _msgSaveT.has(name)) return;   // 800ms 去抖
    _msgSaveT.set(name, setTimeout(async () => {
      _msgSaveT.delete(name);
      const db = await msgDb(); if (!db) return;
      const arr = (state.messages.get(name) || []).slice(-30).map(m => Object.assign({}, m, { ts: (m.ts && m.ts.toISOString) ? m.ts.toISOString() : null }));
      try {
        const tx = db.transaction('msgs', 'readwrite');
        tx.onerror = () => storageWarnOnce('messages', tx.error);
        tx.onabort = () => storageWarnOnce('messages', tx.error || new Error('IndexedDB transaction aborted'));
        tx.objectStore('msgs').put(arr, msgCacheKey(name));
      } catch (e) { storageWarnOnce('messages', e); }
    }, 800));
  }

  let _saveConvT = null;
  function saveConvCache() {
    if (_saveConvT) return;   // 800ms 去抖,不每次渲染都写
    _saveConvT = setTimeout(() => {
      _saveConvT = null;
      try {
        const arr = [];
        state.contacts.forEach(c => { if (c && (c.kind === 'grp' || c.kind === 'p2p')) arr.push({ topic: c.topic, name: c.name, kind: c.kind, photo: typeof c.photo === 'string' && !/^data:/i.test(c.photo) ? c.photo : null, lastMsg: c.lastMsg || '', touched: (c.touched && c.touched.toISOString) ? c.touched.toISOString() : null, unread: c.unread || 0, seq: c.seq || 0, read: c.read || 0 }); });
        localStorage.setItem(convCacheKey(), JSON.stringify(arr.slice(0, 120)));
      } catch (e) { storageWarnOnce('conversations', e); }
    }, 800);
  }

  function showApp() {
    $('loginBg').style.display = 'none';
    $('loginErr').textContent = '';
    $('loginGo').disabled = false;
    $('app').style.display = 'grid';
    hideSplash();   // app 已铺好 → 启动屏淡出露出聊天界面(平滑,不再闪登录页)
    state.myAvatarSeed = localStorage.getItem('mchat.avseed') || ((client.uname || localStorage.getItem('mchat.uname') || 'me') + 'wave');
    paintMe();
    $('syncCore').textContent = '云端';
    $('syncVps').textContent = '正常 · 主用';
    setConn(true);
    if (!isMobile()) $('app').classList.add('collapsed');   // 桌面默认折叠右侧详情栏(点「详情」按钮展开)
    $('stream').innerHTML = '<div class="empty">选个会话，开始聊天<br><span style="font-size:12px;color:var(--faint);margin-top:7px;display:inline-block">想找人？左侧「伙伴」里点一个名字即可私聊</span></div>';
  }

  // 自己头像的真实图:优先服务器 public.photo(全端一致,含GIF),其次本机存的自选图(内置卡通仅本机)
  function myPhotoUrl() {
    return client.myPhoto || localStorage.getItem('mchat.avurl') || null;
  }
  function paintMe() {
    Art.paintAvatar($('meAvatar'), { seed: state.myAvatarSeed, photo: myPhotoUrl() });
    $('meAvatar').textContent = '';
  }

  /* ===== 会话列表 ===== */
  function onContactsReset() {
    _liveTopics.clear();
    _contactsSyncPending = true;
    _contactsReady = false;
    _contactsDone = false;
  }
  function onContact(sub) {
    _liveTopics.add(sub.topic);   // 记录服务器实际下发的会话(对账用)
    // 当前打开的会话保持已清零，避免服务端旧 unread 把刚读的消息又标回未读（未读计数漂移）
    if (sub.topic === state.active) sub.unread = 0;
    state.contacts.set(sub.topic, sub);
    scheduleRenderLists();
  }
  function reconcileContacts() {
    if (!_contactsSyncPending || !_contactsReady || !_contactsDone) return false;
    _contactsSyncPending = false;
    let activeRemoved = false;
    for (const topic of [...state.contacts.keys()]) {
      const c = state.contacts.get(topic);
      if (c && (c.kind === 'grp' || c.kind === 'p2p') && !_liveTopics.has(topic)) {
        state.contacts.delete(topic); state.messages.delete(topic);
        if (state.active === topic) activeRemoved = true;
      }
    }
    if (activeRemoved) {
      state.active = null; state._openSeq++; clearReply();
      $('stream').innerHTML = '<div class="empty">该会话已不可用，请选择其他会话</div>';
      if (isMobile()) doMobileBack();
    }
    renderLists();
    return true;
  }
  function onContactsDone(topics) {
    (topics || []).forEach(topic => _liveTopics.add(topic));
    _contactsDone = true;
    reconcileContacts();
  }
  function onReady() {
    _contactsReady = true;
    if (!reconcileContacts()) renderLists();
    loadDirectory();
    restoreLastOpen();
  }

  /* ===== 重开 app 回到上次的地方(双重机制) =====
     1 分钟内重开 → 自动进入上次打开的会话(短暂切走无缝续聊);
     超过 1 分钟 → 停在列表,但把上次的会话行高亮,点一下就回去。 */
  function lastOpenKey() { return 'mchat.lastopen.' + (localStorage.getItem('mchat.uname') || ''); }
  function stampLastOpen() {
    if (!state.active) return;
    try { localStorage.setItem(lastOpenKey(), JSON.stringify({ topic: state.active, ts: Date.now() })); } catch (e) {}
  }
  function restoreLastOpen(attempt) {
    if (state._restoredLast || state.active) return;   // 已恢复过/用户已手动点进会话,不打扰
    let j = null;
    try { j = JSON.parse(localStorage.getItem(lastOpenKey()) || 'null'); } catch (e) {}
    if (!j || !j.topic) { state._restoredLast = true; return; }
    // ready 时会话数据往往还没从服务器到齐(且对账会先清缓存条目) → 查不到就每 600ms 重试,最多 10 次
    if (!state.contacts.has(j.topic)) {
      if ((attempt || 0) < 10) setTimeout(() => restoreLastOpen((attempt || 0) + 1), 600);
      else state._restoredLast = true;
      return;
    }
    state._restoredLast = true;
    if (Date.now() - (j.ts || 0) < 60000) { selectTopic(j.topic); }
    else { state.lastOpenTopic = j.topic; renderLists(); }
  }

  // 构建「伙伴」目录：汇总你所在群的全部成员（即公司通讯录），点一下即可私聊
  async function loadDirectory(attempt) {
    const groups = Array.from(state.contacts.values()).filter(c => c.kind === 'grp');
    if (!groups.length && (attempt || 0) < 5) { setTimeout(() => loadDirectory((attempt || 0) + 1), 1000); return; }
    for (const g of groups) {
      try {
        const members = await client.ensureMembers(g.topic);
        members.forEach(m => { if (m.uid && !m.me) state.directory.set(m.uid, { uid: m.uid, name: m.name, online: m.online, photo: m.photo }); });
      } catch (e) {}
    }
    saveDirCache();
    renderLists();
  }

  let _renderListsT = null;
  function scheduleRenderLists() {
    if (_renderListsT) return;
    _renderListsT = setTimeout(() => { _renderListsT = null; renderLists(); }, 100);
  }
  function renderLists() {
    if (_renderListsT) { clearTimeout(_renderListsT); _renderListsT = null; }
    saveConvCache();   // 去抖写入本地缓存,供下次开 App 秒显
    const all = Array.from(state.contacts.values())
      .filter(c => c.kind === 'grp' || c.kind === 'p2p');
    // 会话排序：文件传输助手永远置顶，其余按最近活跃
    const pins = pinnedSet();
    const convs = all.slice().sort((a, b) => {
      const pa = pins.has(a.topic) ? 1 : 0, pb = pins.has(b.topic) ? 1 : 0;
      if (pa !== pb) return pb - pa;   // 用户置顶的排最前
      const ha = a.name === HELPER ? 1 : 0, hb = b.name === HELPER ? 1 : 0;
      if (ha !== hb) return hb - ha;
      const ta = a.touched ? a.touched.getTime() : 0, tb = b.touched ? b.touched.getTime() : 0;
      return tb - ta;
    });
    const q = ($('searchInput').value || '').trim().toLowerCase();
    const cl = $('convList'); cl.innerHTML = '';
    let shown = 0;
    convs.forEach(c => {
      if (q && !(c.name || '').toLowerCase().includes(q)) return;
      cl.appendChild(rowEl(c)); shown++;
    });
    $('convCt').textContent = shown;
    // 搜索聊天记录内容（q 非空时，命中消息追加到会话列表底部，点击跳转到该会话该条）
    if (q) {
      // 命中高亮:匹配片段包 <mark>,其余 esc 防注入
      const hl = (text) => { const t = String(text || ''); const i = t.toLowerCase().indexOf(q); if (i < 0) return esc(t); return esc(t.slice(0, i)) + '<mark style="background:#ffe08a;color:inherit;border-radius:2px;padding:0 1px">' + esc(t.slice(i, i + q.length)) + '</mark>' + esc(t.slice(i + q.length)); };
      const hits = [];
      state.messages.forEach((msgs, topic) => (msgs || []).forEach(m => {
        if (m.deleted) return;
        // 同时搜文件名(局域网卡 + 云附件):这团队常按文件名找"那个 PSD/图"
        const fileName = (m.lanCard && m.lanCard.name) || (m.attachments || []).map(a => a && a.name).filter(Boolean).join(' ');
        const hay = ((m.plain || '') + ' ' + (fileName || '')).toLowerCase();
        if (!hay.includes(q)) return;
        const preview = m.plain || (fileName ? '📎 ' + fileName : ((m.images || []).length ? '[图片]' : ''));
        hits.push({ topic, seq: m.seq, plain: preview, fromName: m.fromName, ts: m.ts });
      }));
      hits.sort((a, b) => (b.ts ? b.ts.getTime() : 0) - (a.ts ? a.ts.getTime() : 0));
      if (hits.length) {
        const lab = document.createElement('div'); lab.style.cssText = 'padding:8px 13px 3px;color:var(--faint);font-size:10px;font-weight:600'; lab.textContent = '聊天记录 · ' + hits.length + ' 条命中'; cl.appendChild(lab);
        hits.slice(0, 30).forEach(h => {
          const c = state.contacts.get(h.topic);
          const el = document.createElement('div'); el.className = 'row';
          el.innerHTML = '<div class="r-main"><div class="r-top"><span class="r-name"></span><span class="r-time">' + hm(h.ts) + '</span></div><div class="r-sub"></div></div>';
          el.querySelector('.r-name').textContent = (c ? c.name : h.topic) + ' · ' + (h.fromName || '');
          el.querySelector('.r-sub').innerHTML = hl(h.plain);
          el.onclick = () => { selectTopic(h.topic); scrollToMsg(h.seq, 0); };
          cl.appendChild(el);
        });
      }
    }

    // 伙伴目录：公司群全体成员（含未私聊过的），点一下即可私聊
    const dir = Array.from(state.directory.values());
    // 合并 p2p 联系人的在线态
    const p2p = new Map(all.filter(c => c.kind === 'p2p').map(c => [c.topic, c]));
    dir.forEach(d => { const c = p2p.get(d.uid); if (c) d.online = c.online; });
    const onCt = dir.filter(d => d.online).length;
    const ol = $('onlineList'); ol.innerHTML = '';
    dir.sort((a, b) => (b.online ? 1 : 0) - (a.online ? 1 : 0) || (a.name || '').localeCompare(b.name || ''));
    let dirShown = 0;
    dir.forEach(d => { if (q && !(d.name || '').toLowerCase().includes(q)) return; ol.appendChild(dirEl(d)); dirShown++; });   // 搜索同时过滤伙伴（修"搜不到没建过会话的人"）
    if (!dir.length) ol.innerHTML = '<div class="r-sub" style="padding:8px 13px;color:var(--faint)">加载伙伴中…</div>';
    else if (!dirShown && q) ol.innerHTML = '<div class="r-sub" style="padding:8px 13px;color:var(--faint)">没有匹配的伙伴</div>';
    $('onlineCt').textContent = onCt + ' / ' + dir.length;
    updateMuteButton();
    updateBadge();
  }

  // 伙伴条目（点击→私聊）
  function dirEl(d) {
    const el = document.createElement('div'); el.className = 'dev'; el.dataset.topic = d.uid;
    const av = document.createElement('div'); Art.paintAvatar(av, { seed: (d.name || d.uid) + (d.uid ? d.uid.slice(-3) : ''), photo: d.photo, square: isBot(d.uid) });
    av.style.width = '30px'; av.style.height = '30px';
    const main = document.createElement('div'); main.className = 'r-main';
    main.innerHTML = '<div class="r-top"><span class="r-name" style="font-size:12.5px"></span>' +
      (isBot(d.uid) ? '<span class="tag bot">AI 助手</span>' : '') + '<span class="pres ' +
      (d.online ? 'on' : 'off') + '"></span></div><div class="r-sub">' +
      (isBot(d.uid) ? 'AI 助手 · 点我直接提问' : (d.online ? '在线' : '离线') + ' · 点击私聊') + '</div>';
    main.querySelector('.r-name').textContent = d.name;
    el.appendChild(av); el.appendChild(main);
    el.onclick = () => selectTopic(d.uid);
    return el;
  }

  function rowEl(c) {
    const row = document.createElement('div');
    row.className = 'row' + (c.topic === state.active ? ' on' : '') + (pinnedSet().has(c.topic) ? ' pinned' : '') + (c.topic === state.lastOpenTopic ? ' lastopen' : '');
    row.dataset.topic = c.topic;
    const av = document.createElement('div');
    const isHelper = c.name === HELPER;
    if (isHelper) { av.className = 'av sq'; av.style.background = 'var(--accent)'; av.innerHTML = '<svg viewBox="0 0 24 24" style="width:17px;height:17px;stroke:#fff;fill:none;stroke-width:1.8"><rect x="7" y="3" width="10" height="18" rx="2"/><path d="M11 18h2"/></svg>'; }
    else if (c.kind === 'grp') { av.className = 'av sq'; av.style.background = 'var(--ink)'; av.textContent = '#'; }
    else Art.paintAvatar(av, { seed: seedOf(c), photo: c.photo, square: isBot(c.topic) });
    const main = document.createElement('div'); main.className = 'r-main';
    main.innerHTML = '<div class="r-top"><span class="r-name"></span><span class="r-time">' +
      relTime(c.touched) + '</span></div><div class="r-sub"></div>';
    main.querySelector('.r-name').textContent = c.name;
    const draft = state.drafts.get(c.topic);
    const sub = main.querySelector('.r-sub');
    if (draft) { sub.innerHTML = '<span style="color:#c0392b;font-weight:600">[草稿]</span> '; sub.appendChild(document.createTextNode(draft.slice(0, 28))); }
    else if (c.mentioned) { sub.innerHTML = '<span style="color:#c0392b;font-weight:600">[有人@我]</span> '; sub.appendChild(document.createTextNode((c.lastMsg || '').slice(0, 24))); }
    else sub.textContent = c.lastMsg || (isHelper ? '在你的手机/电脑间同步消息和文件' : (c.kind === 'grp' ? '群聊' : '私聊'));
    row.appendChild(av); row.appendChild(main);
    if (isMuted(c.topic)) { row.classList.add('muted-conv'); const m = document.createElement('span'); m.textContent = '🔕'; m.style.cssText = 'font-size:11px;opacity:.45;margin-left:4px'; main.querySelector('.r-top').insertBefore(m, main.querySelector('.r-time')); }
    if (c.unread > 0) {
      if (isMuted(c.topic)) { const d = document.createElement('span'); d.className = 'badge'; d.style.cssText = 'min-width:9px;width:9px;height:9px;padding:0;border-radius:50%;background:#c2c2c2'; row.appendChild(d); }   // 免打扰:小灰点代替数字
      else { const b = document.createElement('span'); b.className = 'badge'; b.textContent = c.unread > 99 ? '99+' : c.unread; row.appendChild(b); }
    }
    const sheetItems = [
      { label: pinnedSet().has(c.topic) ? '取消置顶' : '置顶聊天', fn: () => togglePin(c.topic) },
      { label: isMuted(c.topic) ? '取消免打扰' : '消息免打扰', fn: () => toggleMute(c.topic) },
      { label: '复制最近消息', fn: () => copyAndFlash(c.lastMsg || c.name) },
    ];
    if (c.kind === 'grp') sheetItems.push({ label: '退出 / 解散群聊', danger: true, fn: async () => { await selectTopic(c.topic); leaveOrDissolve(); } });   // 群:打开拿到权限后走退出/解散(owner→解散,成员→退出)
    else sheetItems.push({ label: '删除会话', danger: true, fn: () => removeConv(c.topic) });   // p2p:仅本地从列表移除
    const openMenu = (e) => showContextMenu(sheetItems, row, e, 'row');
    bindLongPress(row, openMenu);
    row.addEventListener('contextmenu', (e) => { e.preventDefault(); openMenu(e); });
    row.onclick = () => selectTopic(c.topic);
    return row;
  }

  function devEl(p) {
    const d = document.createElement('div'); d.className = 'dev'; d.dataset.topic = p.topic;
    const av = document.createElement('div'); Art.paintAvatar(av, { seed: seedOf(p), photo: p.photo, square: isBot(p.topic) });
    av.style.width = '30px'; av.style.height = '30px';
    const main = document.createElement('div'); main.className = 'r-main';
    main.innerHTML = '<div class="r-top"><span class="r-name" style="font-size:12.5px"></span><span class="pres ' +
      (p.online ? 'on' : 'off') + '"></span></div><div class="r-sub">' +
      (p.online ? '在线' : '离线 · ' + relTime(p.touched)) + '</div>';
    main.querySelector('.r-name').textContent = p.name;
    d.appendChild(av); d.appendChild(main);
    d.onclick = () => selectTopic(p.topic);
    return d;
  }

  function seedOf(c) { return (c.name || c.topic) + (c.topic ? c.topic.slice(-3) : ''); }

  /* ===== 打开会话 ===== */
  async function selectTopic(name) {
    if (state.active === name) { if (isMobile()) appEl().classList.add('show-chat'); return; }
    const tok = ++state._openSeq;
    if (state.active) {
      const prev = state.active;
      const d = ($('cinput').textContent || ''); if (d.trim()) state.drafts.set(prev, d); else state.drafts.delete(prev);   // 切走前存草稿
      const prevMessages = state.messages.get(prev) || [];
      if (prevMessages.length > 500) prevMessages.splice(0, prevMessages.length - 500);
      state._historyExpanded.delete(prev);
    }
    clearReply();
    state.active = name;
    document.querySelectorAll('.row').forEach(r => r.classList.toggle('on', r.dataset.topic === name));
    const c = state.contacts.get(name);
    const unreadCount = (c && (+c.unread || 0)) || 0;
    state._unreadAnchor = { topic: name, seq: unreadAnchor(c), hadUnread: unreadCount > 0 };   // 记打开前已读位置，给未读分隔线 + 跳转用
    state.lastOpenTopic = null; stampLastOpen();   // 记住"上次打开的会话"(重开app恢复用),并清除列表上的旧高亮
    $('chatTitle').textContent = c ? c.name : name;
    $('chatRoom').textContent = client.isGroup(name) ? '#' : '@';
    $('chatSub').textContent = '加载中…';
    updateMuteButton();
    $('stream').innerHTML = '<div class="empty">加载消息…</div>';
    $('cinput').dataset.ph = client.isGroup(name) ? '在群里发言，@ 提及成员…' : '发消息…';
    // 清未读
    $('cinput').textContent = state.drafts.get(name) || '';   // 恢复本会话草稿
    if (c) { c.unread = 0; c.mentioned = false; renderLists(); }
    if (isMobile()) { const wasChat = appEl().classList.contains('show-chat'); appEl().classList.add('show-chat'); if (!wasChat) pushMobileView(); }   // 移动端：滑入会话视图 + 压入返回栈
    // 本地缓存先出画面(0 网络往返),云端到货后按 seq 去重合并、再整体刷新一次
    if (!(state.messages.get(name) || []).length) {
      const cached = await loadMsgCache(name);
      if (tok !== state._openSeq || state.active !== name) return;
      if (cached.length && !(state.messages.get(name) || []).length) {
        state.messages.set(name, cached);
        renderStream(name, 'open');
      }
    }
    try {
      // 首屏轻量:只拉最近 24 条(手机网络下内联图片消息很重,拉 40-240 条是"点开会话慢"的主因);
      // 未读多时最多拉到 60,再早的未读由顶部"加载更多"按钮提示补拉
      await client.openTopic(name, { limit: Math.min(60, Math.max(24, unreadCount + 20)) });
      if (tok !== state._openSeq || state.active !== name) return;
      // openTopic 订阅后历史通过 message 事件回灌；这里等微任务后渲染
      setTimeout(() => {
        if (tok !== state._openSeq || state.active !== name) return;
        renderStream(name, 'open'); renderMembers(name); showAnnounce(name); renderFiles(name); setupScrollBtn();
        // 打开会话即对最大 seq 发已读回执（修复历史未读不清、对端看不到已读）
        const arr = state.messages.get(name) || [];
        const maxSeq = arr.reduce((m, x) => Math.max(m, x.seq || 0), 0);
        if (maxSeq) client.noteRead(name, maxSeq);
      }, 300);
    } catch (e) {
      if (tok !== state._openSeq || state.active !== name) return;
      // 已有本地缓存画面 → 不用错误页盖掉,轻提示即可(无感降级);完全没内容才显示错误页
      if ((state.messages.get(name) || []).length) flash('网络还没接上，先显示本机保存的消息');
      else $('stream').innerHTML = '<div class="empty">连接慢了没打开成功，<a style="color:var(--accent-deep);cursor:pointer;font-weight:600" onclick="UI.retryOpen()">点此重试</a><br><span style="font-size:11px;color:var(--faint);margin-top:6px;display:inline-block">' + esc((e && e.message) || '') + '</span></div>';
    }
  }

  function onMessage(name, msg) {
    const arr = state.messages.get(name) || [];
    const last = arr[arr.length - 1];
    let i = -1;
    if (!last || (msg.seq || 0) > (last.seq || 0)) arr.push(msg);
    else {
      i = arr.findIndex(m => m.seq === msg.seq);
      if (i >= 0) arr[i] = msg;
      else { arr.push(msg); arr.sort((a, b) => (a.seq || 0) - (b.seq || 0)); }
    }
    const maxMessages = state._historyExpanded.has(name) ? 800 : 500;
    const trimmed = arr.length > maxMessages + 50;
    if (trimmed) arr.splice(0, arr.length - maxMessages);
    state.messages.set(name, arr);
    // 桌面端自动接收他人发来的局域网文件卡（仅新到的 5 分钟内卡，避免历史重复下载）→ 直接落 ~/Desktop/mshare
    if (msg.lanCard && !msg.mine && i < 0 && global.mchatNative && global.mchatNative.isDesktop && msg.ts && (Date.now() - msg.ts.getTime() < 5 * 60 * 1000)) autoReceiveLan(name, msg.lanCard, msg.from);
    if (msg.announce) { state.announces.set(name, msg.announce); if (name === state.active) showAnnounce(name); }
    // 更新会话列表预览
    const c = state.contacts.get(name);
    if (c) {
      // 仅当该消息比当前会话的最近时间更新时才更新预览/排序（避免历史回灌把会话错误下沉）
      const ts = msg.ts ? msg.ts.getTime() : 0;
      const cur = c.touched ? c.touched.getTime() : 0;
      if (ts >= cur) {
        let preview = msg.plain || '';
        if (!preview && (msg.images || []).length) preview = '[图片]';
        else if (!preview && msg.lanCard) preview = '[文件] ' + (msg.lanCard.name || '');
        else if (!preview && (msg.attachments || []).some(a => a && a.ref)) { const a = (msg.attachments || []).find(x => x && x.ref) || {}; preview = '[文件] ' + (a.name || ''); }
        c.lastMsg = (msg.mine ? '你：' : '') + preview.slice(0, 30); c.touched = msg.ts || c.touched;
      }
    }
    // 响铃/通知门控:别人发的要响;"文件传输助手"里"我另一台设备"发来的也要响(本机发的回显不响,杜绝发送端自己响)
    const fromOtherDev = msg.mine && msg.head && msg.head.mchatDev && client.devId && msg.head.mchatDev !== client.devId();
    const ringable = !msg.mine || ((c && c.name === HELPER) && fromOtherDev);
    if (name === state.active) {
      const isLast = arr.length && arr[arr.length - 1].seq === msg.seq;
      const stream = $('stream');
      // 增量渲染：常规新消息只 append（不再每条整列 innerHTML 重建→消除图片闪烁/长会话卡顿/滚动与选择丢失）；
      // 撤回·编辑(已存在 seq)·乱序·空列 才回退整列重排。
      if (trimmed || i >= 0 || !isLast || !stream.querySelector('.msg')) renderStream(name);
      else appendMessage(name, msg);
      if (msg.lanCard || (msg.images || []).length) renderFiles(name);   // 新文件/图片实时进右栏群文件
      if (ringable) {
        if (global.document.hasFocus()) { if (msg.seq) client.noteRead(name, msg.seq); }
        else if (!isMuted(name)) playPing();   // 当前会话但窗口没聚焦，也响一下(免打扰会话除外)
        bumpScrollBtn();   // 不在底部时浮标 +N，新消息有锚点
      }
    } else if (ringable) {
      if (c) { c.unread = (c.unread || 0) + 1; if (client.isGroup(name) && isMentionMe(msg)) c.mentioned = true; }
      notify(c ? c.name : name, msg, name);
    }
    scheduleRenderLists();
  }

  function renderStream(name, jump) {
    // 撤回的消息保留并显示「已撤回」墓碑；本机删除的已从数组移除，不在此列
    const arr = (state.messages.get(name) || []);
    const stream = $('stream');
    const atBottom = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 80;
    stream.innerHTML = '';
    if (!arr.length) { stream.innerHTML = '<div class="empty">' + (name === BOT_UID ? '我是 AI 助手太白 🐾 有什么直接问我就行；在群里记得 @太白 我才会回哦。' : isBot(name) ? '我是你本机的 AI 助手，有什么直接问我就行～' : '还没有消息，发送第一条吧。') + '</div>'; return; }
    const hist = document.createElement('div'); hist.className = 'histbar';
    // 未读锚点比已加载的最早一条还早 → 说明还有没拉下来的未读,按钮上明说
    const oldestSeq = (arr[0] && arr[0].seq) || 0;
    const anchorEarlier = state._unreadAnchor && state._unreadAnchor.topic === name && state._unreadAnchor.hadUnread && state._unreadAnchor.seq > 0 && state._unreadAnchor.seq < oldestSeq - 1;
    hist.innerHTML = '<button>' + (anchorEarlier ? '↑ 还有更早的未读消息，点此加载' : '↑ 浏览更早的聊天记录') + '</button>';
    hist.querySelector('button').onclick = () => loadMore(name);
    stream.appendChild(hist);
    // 未读分隔线锚点 = 进会话时「打开前的已读位置」；第一条更新的(别人发的)消息前插「以下为新消息」
    const anchor = (state._unreadAnchor && state._unreadAnchor.topic === name) ? state._unreadAnchor.seq : 0;
    const wantUnread = !!(state._unreadAnchor && state._unreadAnchor.topic === name && state._unreadAnchor.hadUnread);
    let lastDay = '', divider = null;
    arr.forEach(m => {
      const day = dayKey(m.ts);
      if (day !== lastDay) { lastDay = day; const d = document.createElement('div'); d.className = 'day'; d.textContent = day; stream.appendChild(d); }
      if ((wantUnread || anchor > 0) && !divider && (m.seq || 0) > anchor && !m.mine) {
        divider = document.createElement('div'); divider.className = 'unread-div'; divider.innerHTML = '<span>以下为新消息</span>';
        stream.appendChild(divider);
      }
      stream.appendChild(msgEl(name, m));
    });
    // 进会话(jump==='open')：有未读→停在分隔线，否则→最新；其余场景沿用「贴底才跟随」
    if (jump === 'open') { if (divider) divider.scrollIntoView({ block: 'start' }); else if (wantUnread) stream.scrollTop = 0; else stream.scrollTop = stream.scrollHeight; }
    else if (atBottom) stream.scrollTop = stream.scrollHeight;
    setTimeout(syncScrollBtn, 0);
    saveMsgCache(name);   // 每次渲染后去抖落盘,下次进会话/重开app秒出
  }

  // 增量追加单条新消息（跨天补 day 分隔，贴底时跟随滚动）——配合 onMessage 消除全量重建
  function appendMessage(name, m) {
    const stream = $('stream');
    const atBottom = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 120;
    const arr = state.messages.get(name) || [];
    const prev = arr.length > 1 ? arr[arr.length - 2] : null;   // 仅由顺序尾追加路径调用，不再重复扫描整段消息
    if (!prev || dayKey(prev.ts) !== dayKey(m.ts)) { const d = document.createElement('div'); d.className = 'day'; d.textContent = dayKey(m.ts); stream.appendChild(d); }
    stream.appendChild(msgEl(name, m));
    if (atBottom) stream.scrollTop = stream.scrollHeight;
    saveMsgCache(name);
  }

  // 回到底部浮标：翻历史时新消息给「↓N」锚点，不再悄无声息塞 DOM 底找不回
  function setupScrollBtn() {
    const stream = $('stream'); if (!stream || stream._sb) return;
    const par = stream.parentElement || stream;   // .chat 已在 CSS 设 position:relative 作为浮标/mpick 的统一定位基准
    const btn = document.createElement('div'); btn.className = 'scrollbtn';
    btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg><span class="sb-n"></span>';
    btn.onclick = () => { stream.scrollTop = stream.scrollHeight; btn._n = 0; btn.querySelector('.sb-n').textContent = ''; syncScrollBtn(); };
    par.appendChild(btn); stream._sb = btn;
    stream.addEventListener('scroll', syncScrollBtn);
    setTimeout(syncScrollBtn, 0);
  }
  function syncScrollBtn() {
    const stream = $('stream'), btn = stream && stream._sb; if (!btn) return;
    if (stream.scrollHeight - stream.scrollTop - stream.clientHeight >= 120) btn.classList.add('show');
    else { btn.classList.remove('show'); btn._n = 0; btn.querySelector('.sb-n').textContent = ''; }
  }
  function bumpScrollBtn() {
    const stream = $('stream'), btn = stream && stream._sb; if (!btn) return;
    if (stream.scrollHeight - stream.scrollTop - stream.clientHeight >= 120) { btn._n = (btn._n || 0) + 1; btn.querySelector('.sb-n').textContent = btn._n > 99 ? '99+' : btn._n; btn.classList.add('show'); }
  }

  // 只更新「我方消息」的已读/已送达态，不整列重建（已读回执到达时调用）
  function patchReadStates(name) {
    const stream = $('stream');
    (state.messages.get(name) || []).forEach(m => {
      if (!m.mine || m.deleted) return;
      const wrap = stream.querySelector('[data-seq="' + m.seq + '"]'); if (!wrap) return;
      const r = wrap.querySelector('.read'); if (!r) return;
      r.innerHTML = '<svg viewBox="0 0 24 24"><path d="M5 12l5 5L20 7"/></svg>' + (isReadByOthers(name, m.seq) ? '已读' : '已送达');
    });
  }

  function msgEl(name, m) {
    const wrap = document.createElement('div');
    wrap.className = 'msg' + (m.mine ? ' me' : '');
    wrap.dataset.seq = m.seq;
    wrap.onclick = () => toggleSel(wrap);
    // 移动端长按 / 桌面右键消息 → 贴近消息的小菜单（替代底部大 sheet）
    if (!m.deleted) bindMessageMenu(wrap, (e) => showContextMenu(messageActions(name, m), wrap, e, 'message'));

    const check = document.createElement('div'); check.className = 'check';
    const av = document.createElement('div'); av.className = 'av click';
    if (m.from) av.dataset.uid = m.from;
    if (m.mine) Art.paintAvatar(av, { seed: state.myAvatarSeed, photo: myPhotoUrl() });
    else Art.paintAvatar(av, { seed: (m.fromName || m.from) + (m.from ? m.from.slice(-3) : ''), photo: m.photo || client.photoOf(m.from), square: isBot(m.from) });
    av.title = '与 ' + m.fromName + ' 私聊';
    av.onclick = (e) => { e.stopPropagation(); if (!appEl().classList.contains('selecting') && m.from && m.from !== client.uid) selectTopic(m.from); };

    const body = document.createElement('div'); body.className = 'body';
    const meta = document.createElement('div'); meta.className = 'meta';
    if (m.mine) meta.innerHTML = '<span class="tm">' + hm(m.ts) + '</span><span class="nm">' + esc(m.fromName) + '</span>';
    else meta.innerHTML = '<span class="nm">' + esc(m.fromName) + '</span>' + (isBot(m.from) ? '<span class="tag bot">AI</span>' : '') + '<span class="tm">' + hm(m.ts) + '</span>';
    body.appendChild(meta);

    // 局域网大文件信令卡 → 渲染 .fcard（不套气泡）
    if (m.voice && !m.deleted) {
      body.appendChild(voiceCardEl(m.voice));
      if (m.mine) { const r = document.createElement('div'); r.className = 'read'; r.innerHTML = '<svg viewBox="0 0 24 24"><path d="M5 12l5 5L20 7"/></svg>' + (isReadByOthers(name, m.seq) ? '已读' : '已送达'); body.appendChild(r); }
      wrap.appendChild(check); wrap.appendChild(av); wrap.appendChild(body); return wrap;
    }
    if (m.lanCard && !m.deleted) {
      body.appendChild(fileCardEl(name, m));
      if (m.mine) { const r = document.createElement('div'); r.className = 'read'; r.innerHTML = '<svg viewBox="0 0 24 24"><path d="M5 12l5 5L20 7"/></svg>' + (isReadByOthers(name, m.seq) ? '已读' : '已送达'); body.appendChild(r); }
      wrap.appendChild(check); wrap.appendChild(av); wrap.appendChild(body);
      return wrap;
    }
    // 云文件(走云的 EX 附件,排除图片——图片走内联 IM)→ 全端可下载/打开
    const docs = (m.attachments || []).filter(a => a && a.ref && !(a.mime || '').startsWith('image/'));
    if (docs.length && !m.deleted) {
      docs.forEach(att => body.appendChild(cloudFileCardEl(att, name, m)));
      if (m.mine) { const r = document.createElement('div'); r.className = 'read'; r.innerHTML = '<svg viewBox="0 0 24 24"><path d="M5 12l5 5L20 7"/></svg>' + (isReadByOthers(name, m.seq) ? '已读' : '已送达'); body.appendChild(r); }
      wrap.appendChild(check); wrap.appendChild(av); wrap.appendChild(body);
      return wrap;
    }

    const bub = document.createElement('div'); bub.className = 'bub';
    if (m.deleted) { bub.classList.add('del'); bub.textContent = '此消息已撤回'; }
    else {
      bub.innerHTML = m.text || '';
      // 内联图片（IM 实体）：base64 内联 或 已上传 ref
      (m.images || []).forEach(a => {
        if (a && (a.ref || a.val)) {
          const img = document.createElement('img'); img.className = 'inl'; img.draggable = true;
          img.src = a.ref ? client.fileURL(a.ref) : ('data:' + (a.mime || 'image/jpeg') + ';base64,' + a.val);   // ref 图走 fileURL(带 /tinode 前缀);authorizeURL 缺前缀会图裂
          img.onclick = (e) => { e.stopPropagation(); openLightbox(img.src); };
          img.ondragstart = (e) => { try { e.dataTransfer.setData('DownloadURL', (a.mime || 'image/jpeg') + ':mchat-' + Date.now() + '.jpg:' + img.src); } catch (_) {} };   // 拖到桌面/Finder 保存
          bub.appendChild(img);
        }
      });
      if (!m.text && (m.images || []).length) bub.style.padding = '6px';
      bub.appendChild(hoverTools(name, m));
    }
    body.appendChild(bub);
    if (!m.deleted && m.linkPreviews && m.linkPreviews.length) body.appendChild(linkCardsEl(m.linkPreviews));

    if (m.mine && !m.deleted) {
      const r = document.createElement('div'); r.className = 'read';
      const read = isReadByOthers(name, m.seq);
      r.innerHTML = '<svg viewBox="0 0 24 24"><path d="M5 12l5 5L20 7"/></svg>' + (read ? '已读' : '已送达');
      body.appendChild(r);
    }

    wrap.appendChild(check); wrap.appendChild(av); wrap.appendChild(body);
    return wrap;
  }

  function hoverTools(name, m) {
    const hov = document.createElement('div'); hov.className = 'hov';
    const mk = (title, path, fn) => { const b = document.createElement('b'); b.title = title; b.innerHTML = '<svg viewBox="0 0 24 24">' + path + '</svg>'; b.onclick = (e) => { e.stopPropagation(); fn(); }; return b; };
    hov.appendChild(mk('复制', '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>', () => copyAndFlash(messageCopyText(m))));
    hov.appendChild(mk('引用', '<path d="M9 14L4 9l5-5"/><path d="M4 9h11a4 4 0 0 1 4 4v3"/>', () => setReply(m)));
    hov.appendChild(mk('转发', '<path d="M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7"/><path d="M16 6l-4-4-4 4"/><path d="M12 2v13"/>', () => forwardOne(m)));
    // 表情回应(点赞)未接服务端，不放"点了弹假提示"的按钮——待 Tinode reaction 能力接入再加
    if (m.mine) hov.appendChild(mk('撤回（3分钟内）', '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 4v4h4"/>', () => recall(name, m.seq)));
    if (m.mine) hov.appendChild(mk('对所有人删除', '<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M10 11v5M14 11v5"/><path d="M19 6l-1 14H6L5 6"/>', () => deleteForAll(name, m.seq)));
    hov.appendChild(mk('本机删除', '<path d="M3 6h18"/><path d="M19 6l-1 14H6L5 6"/>', () => delLocal(name, m.seq)));
    return hov;
  }
  function messageActions(name, m) {
    return [
      { label: '复制', fn: () => copyAndFlash(messageCopyText(m)) },
      { label: '引用', fn: () => setReply(m) },
      { label: '转发', fn: () => forwardOne(m) },
      { label: '多选', fn: () => enterSelectWithSeq(m.seq) },
    ]
      .concat(m.mine ? [{ label: '撤回（3分钟内）', fn: () => recall(name, m.seq) }, { label: '对所有人删除', danger: true, fn: () => deleteForAll(name, m.seq) }] : [])
      .concat([{ label: '删除（仅本机）', danger: true, fn: () => delLocal(name, m.seq) }]);
  }
  function messageCopyText(m) {
    if (m.copyText) return m.copyText;   // 保留换行/排版的复制文本(plain 会把换行吞成空格)
    if (m.plain) return m.plain;
    if (m.lanCard) return m.lanCard.name || '局域网直传文件';
    const att = (m.attachments || []).find(a => a && a.name);
    if (att) return att.name;
    if ((m.images || []).length) return '[图片]';
    if (m.voice) return '语音消息';
    return '';
  }
  // 裸域名(无协议无www)只认常见真实 TLD；刻意不含 app/sh/md/zip 等与文件名撞车的 TLD，
  // 避免把 Codex.app、config.toml、版本号 2.0.0.0 误判成链接（带 https:// 或 www. 的不受此限）。
  const LINK_BARE_TLDS = new Set(('com net org cn io ai co dev me tv cc xyz top club site online tech store blog cloud '
    + 'link live news pro vip wiki fun chat info biz edu gov moe im gg one run page space world life group team plus '
    + 'red email video design zone work city tw hk jp kr us uk de fr ru eu ca au sg').split(' '));
  function cleanUrlCandidate(raw) {
    let u = String(raw || '').trim();
    while (/[.,!?;:，。！？；：、)）\]}】>]+$/.test(u)) u = u.slice(0, -1);
    if (/^https?:\/\//i.test(u)) return u;
    if (/^www\./i.test(u)) return 'https://' + u;
    const m = u.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)(?:\/[^\s<>"'，。！？；：、]*)?$/i);
    if (m) {
      const labels = m[1].split('.');
      const tld = labels[labels.length - 1].toLowerCase();
      const domain = labels[labels.length - 2] || '';
      if (LINK_BARE_TLDS.has(tld) && /[a-z]/i.test(domain)) return 'https://' + u;
    }
    return '';
  }
  function extractUrls(text) {
    const out = [], seen = new Set();
    String(text || '').replace(/https?:\/\/[^\s<>"'，。！？；：、]+|(?:www\.)[^\s<>"'，。！？；：、]+|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+(?:\/[^\s<>"'，。！？；：、]*)?/gi, (m, offset, whole) => {
      if (offset > 0 && whole[offset - 1] === '@') return m;
      const u = cleanUrlCandidate(m);
      if (u && !seen.has(u)) { seen.add(u); out.push(u); }
      return m;
    });
    return out.slice(0, LINK_PREVIEW_MAX_URLS);
  }
  function linkHost(url) {
    try { return new URL(url).hostname.replace(/^www\./i, ''); } catch (e) { return url; }
  }
  function agentGatewayBase() {
    try {
      if (location.protocol === 'http:' || location.protocol === 'https:') return location.origin + '/agent-gateway';
    } catch (e) {}
    return 'https://morewater.top/agent-gateway';
  }
  function cleanLinkPreview(url, raw) {
    return {
      url,
      finalUrl: raw && raw.finalUrl ? String(raw.finalUrl) : '',
      title: String((raw && raw.title) || '').replace(/\s+/g, ' ').trim().slice(0, 260),
    };
  }
  async function fetchNativeLinkPreview(url) {
    const native = global.mchatNative;
    if (!(native && typeof native.linkPreview === 'function')) return null;
    try {
      const j = await native.linkPreview(url);
      const out = cleanLinkPreview(url, j);
      return out.title ? out : null;
    } catch (e) { return null; }
  }
  async function fetchGatewayLinkPreview(url) {
    const ac = global.AbortController ? new AbortController() : null;
    const timer = ac ? setTimeout(() => ac.abort(), LINK_PREVIEW_GATEWAY_TIMEOUT_MS) : null;
    try {
      const r = await fetch(agentGatewayBase() + '/link-preview?url=' + encodeURIComponent(url), {
        cache: 'no-store',
        signal: ac && ac.signal,
      });
      if (!r.ok) return null;
      const j = await r.json();
      return cleanLinkPreview(url, j);
    } catch (e) { return null; }
    finally { if (timer) clearTimeout(timer); }
  }
  async function fetchLinkPreview(url) {
    return (await fetchNativeLinkPreview(url)) || (await fetchGatewayLinkPreview(url)) || { url, title: '' };
  }
  async function buildLinkPreviews(text) {
    const urls = extractUrls(text);
    if (!urls.length) return [];
    const completed = [];
    const pending = urls.map((url, i) => fetchLinkPreview(url).then(item => { completed[i] = item; }));
    let timer = null;
    const all = Promise.all(pending).then(() => { if (timer) clearTimeout(timer); return completed.filter(Boolean); });
    const budget = new Promise(resolve => { timer = setTimeout(() => resolve(completed.filter(Boolean)), LINK_PREVIEW_BUDGET_MS); });
    return Promise.race([all, budget]);
  }
  function linkCardsEl(items) {
    const box = document.createElement('div');
    box.className = 'linkcards';
    items.forEach(it => {
      if (!it || !it.url) return;
      const a = document.createElement('a');
      a.className = 'linkcard';
      a.href = it.finalUrl || it.url;
      a.target = '_blank';
      a.rel = 'noopener noreferrer';
      a.onclick = e => e.stopPropagation();
      const host = document.createElement('div'); host.className = 'lc-host'; host.textContent = linkHost(it.url);
      const title = document.createElement('div'); title.className = 'lc-title'; title.textContent = it.title || it.url;
      a.appendChild(host); a.appendChild(title); box.appendChild(a);
    });
    return box;
  }
  function lanTarget(ls) {
    if (!ls) return '';
    if (ls.grantId && ls.savePath) return { grantId: ls.grantId, path: ls.savePath };
    return ls.grantId || ls.savePath || '';
  }
  function fileCardActions(name, m, card) {
    const c = m.lanCard || {};
    const native = global.mchatNative;
    const ls = getLanState(c.fileId);
    const target = lanTarget(ls);
    const items = [];
    if (target && native && native.revealInFolder) items.push({ label: '打开文件位置', fn: () => native.revealInFolder(target) });
    if (native && native.openMshareFolder) items.push({ label: '打开 mshare 文件夹', fn: () => openMshareFolder() });
    if (!m.mine && native && native.isDesktop) items.push({ label: target ? '重新接收' : '下载到本机', fn: () => downloadLan(card, c, m.from) });
    return items.concat(messageActions(name, m));
  }
  async function openMshareFolder() {
    const native = global.mchatNative;
    if (!(native && native.openMshareFolder)) return;
    try {
      const r = await native.openMshareFolder();
      flash(r && r.ok ? '已打开桌面 mshare 文件夹' : ('打开失败：' + ((r && r.error) || '未知错误')));
    } catch (e) { flash('打开失败：' + ((e && e.message) || e)); }
  }
  function cloudFileActions(name, m, att) {
    const native = global.mchatNative;
    const saved = getCloudState(att);
    const target = saved && saved.grantId && saved.path ? { grantId: saved.grantId, path: saved.path } : (saved && (saved.grantId || saved.path));
    const items = [];
    if (target && native && native.revealInFolder) items.push({ label: '打开保存位置', fn: () => native.revealInFolder(target) });
    if (native && native.openMshareFolder) items.push({ label: '打开 mshare 文件夹', fn: () => openMshareFolder() });
    return items.concat([
      { label: '下载 / 另存为', fn: () => downloadCloudFile(att) },
      { label: '打开预览', fn: () => openCloudFile(att) },
    ]).concat(messageActions(name, m));
  }

  /* ===== 局域网大文件卡片 ===== */
  function fmtSize(n) { n = +n || 0; if (n >= 1 << 30) return (n / (1 << 30)).toFixed(2) + ' GB'; if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + ' MB'; if (n >= 1 << 10) return (n / (1 << 10)).toFixed(0) + ' KB'; return n + ' B'; }
  function isVideo(n) { return /\.(mp4|mov|webm|mkv|avi|m4v)$/i.test(n || ''); }
  // 语音消息卡片:▶/❚❚ + 音波 + 时长，点击播放/暂停(微信式)
  function voiceCardEl(voice) {
    const card = document.createElement('div'); card.className = 'voicecard';
    const dur = Math.max(1, Math.round(voice.dur || 0));
    card.innerHTML = '<span class="vc-btn">▶</span><span class="vc-wave">' + '<i></i>'.repeat(16) + '</span><span class="vc-dur">' + dur + '″</span>';
    let audio = null;
    card.onclick = (e) => {
      e.stopPropagation();
      if (!audio) { audio = new Audio('data:' + (voice.mime || 'audio/mpeg') + ';base64,' + voice.val); audio.onended = () => { card.classList.remove('playing'); card.querySelector('.vc-btn').textContent = '▶'; }; }
      if (audio.paused) { audio.play().catch(() => flash('播放失败')); card.classList.add('playing'); card.querySelector('.vc-btn').textContent = '❚❚'; }
      else { audio.pause(); card.classList.remove('playing'); card.querySelector('.vc-btn').textContent = '▶'; }
    };
    return card;
  }

  /* ===== 语音录制（微信式：按住录 / 松手发 / 上滑取消） ===== */
  let _rec = null;                       // 录音会话状态；null=未在录音
  let _recPending = false;               // 正在请求麦克风（await 期间）；防"快速按一下就松"的 race 导致录音卡死
  function recActive() { return !!_rec || _recPending; }

  async function startRec() {
    if (_rec || _recPending) return;
    if (!state.active) { flash('先选择一个会话'); return; }
    const target = state.active;
    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { flash('当前环境不支持录音'); return; }
    _recPending = true;
    let stream;
    try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
    catch (e) { _recPending = false; flash('需要麦克风权限才能发语音'); return; }
    if (!_recPending) { stream.getTracks().forEach(t => t.stop()); return; }   // 授权期间用户已松手 → 放弃本次
    let mime = '';
    for (const t of ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus']) {
      try { if (global.MediaRecorder && MediaRecorder.isTypeSupported(t)) { mime = t; break; } } catch (e) {}
    }
    let mr;
    try { mr = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined); }
    catch (e) { try { mr = new MediaRecorder(stream); } catch (e2) { stream.getTracks().forEach(t => t.stop()); flash('录音初始化失败'); return; } }
    const rec = { mr, chunks: [], stream, startTs: Date.now(), cancel: false, mime: mr.mimeType || mime || 'audio/webm', timer: null, raf: null, ac: null, target };
    mr.ondataavailable = (e) => { if (e.data && e.data.size) rec.chunks.push(e.data); };
    mr.onstop = onRecStop;
    _rec = rec;
    _recPending = false;
    try { mr.start(); } catch (e) { _rec = null; stream.getTracks().forEach(t => t.stop()); flash('录音启动失败'); return; }
    const btn = $('recBtn'); if (btn) btn.classList.add('recording');
    const hb = $('holdBtn'); if (hb) hb.classList.add('holding');
    if (global.navigator && navigator.vibrate) { try { navigator.vibrate(30); } catch (_) {} }   // 触觉反馈:确知已开始录音
    showRec(); startMeter(rec); startTimer(rec);
  }

  function moveRec(clientY) {             // 指针上移到屏幕上 45% → 取消态（微信"上滑取消"）
    if (!_rec) return;
    const cancel = clientY < global.innerHeight * 0.45;
    if (cancel !== _rec.cancel) {
      _rec.cancel = cancel;
      $('recOverlay').classList.toggle('cancel', cancel);
      $('recHint').textContent = cancel ? '松开手指，取消发送' : '松开发送 · 上滑取消';
    }
  }

  function endRec() {                     // 松手 → 停录（onstop 回调里决定发送/取消）
    if (_recPending) { _recPending = false; return; }   // 还在请求麦克风就松手 → 取消本次（不算录音）
    if (!_rec) return;
    try { _rec.mr.stop(); } catch (e) { onRecStop(); }
  }

  async function onRecStop() {
    const rec = _rec; _rec = null;        // 立即清空 → 防 onstop 与 endRec catch 双触发
    if (!rec) return;
    const btn = $('recBtn'); if (btn) btn.classList.remove('recording');
    const hb = $('holdBtn'); if (hb) hb.classList.remove('holding');
    hideRec();
    if (rec.timer) clearInterval(rec.timer);
    if (rec.raf) cancelAnimationFrame(rec.raf);
    if (rec.ac) { try { rec.ac.close(); } catch (e) {} }
    if (rec.stream) rec.stream.getTracks().forEach(t => t.stop());
    const dur = Math.round((Date.now() - rec.startTs) / 1000);
    if (rec.cancel) return;
    if (dur < 1) { flash('说话时间太短'); return; }
    const blob = new Blob(rec.chunks, { type: rec.mime });
    if (!blob.size) { flash('没录到声音'); return; }
    if (blob.size > 500 * 1024) { flash('语音太长了，请控制在 1 分钟内'); return; }   // 体积保护：避免 base64 后超 Tinode 单条消息限制
    let base64;
    try { base64 = await blobToBase64(blob); } catch (e) { flash('语音处理失败'); return; }
    const name = rec.target; if (!name) return;
    const switched = name !== state.active;
    try {
      await client.sendAudio(name, { mime: rec.mime, dur, val: base64 });
      if (switched) {
        const c = state.contacts.get(name) || state.directory.get(name);
        flash('语音已发送到「' + ((c && c.name) || name) + '」');
      }
    }
    catch (e) { flash('语音发送失败'); }
  }

  function blobToBase64(blob) {
    return new Promise((res, rej) => {
      const r = new FileReader();
      r.onload = () => { const s = String(r.result || ''); const i = s.indexOf(','); res(i >= 0 ? s.slice(i + 1) : s); };
      r.onerror = rej; r.readAsDataURL(blob);
    });
  }

  function showRec() { const o = $('recOverlay'); o.classList.add('on'); o.classList.remove('cancel'); $('recHint').textContent = '松开发送 · 上滑取消'; $('recTime').textContent = '0:00'; }
  function hideRec() { $('recOverlay').classList.remove('on', 'cancel'); }

  function startTimer(rec) {
    rec.timer = setInterval(() => {
      const s = Math.floor((Date.now() - rec.startTs) / 1000);
      $('recTime').textContent = Math.floor(s / 60) + ':' + (s % 60 < 10 ? '0' : '') + (s % 60);
      if (s >= 60) endRec();              // 60s 上限自动停发
    }, 250);
  }

  function startMeter(rec) {              // 实时音量驱动波形条（AnalyserNode）
    try {
      const AC = global.AudioContext || global.webkitAudioContext; if (!AC) return;
      const ac = new AC(); rec.ac = ac;
      const src = ac.createMediaStreamSource(rec.stream);
      const an = ac.createAnalyser(); an.fftSize = 64; an.smoothingTimeConstant = 0.7; src.connect(an);
      const bars = $('recWave').querySelectorAll('i');
      const data = new Uint8Array(an.frequencyBinCount);
      const tick = () => {
        if (_rec !== rec) return;          // 录音已结束 → 停更新
        an.getByteFrequencyData(data);
        bars.forEach((b, i) => { const v = data[(i * 2) % data.length] / 255; b.style.transform = 'scaleY(' + Math.max(0.18, Math.min(1, v * 1.6)).toFixed(2) + ')'; });
        rec.raf = requestAnimationFrame(tick);
      };
      tick();
    } catch (e) {}
  }

  // 微信式语音模式切换:点🎙 → 输入框变「按住说话」长条;点⌨ → 切回键盘
  function toggleVoiceMode() {
    if (!state.active) { flash('先选择一个会话'); return; }
    const c = document.querySelector('.composer');
    const on = c.classList.toggle('voicemode');
    if (!on) { const inp = $('cinput'); if (inp) inp.focus(); }   // 回文字模式 → 聚焦输入框
  }

  function fileCardEl(name, m) {
    const c = m.lanCard;
    const card = document.createElement('div'); card.className = 'fcard'; card.dataset.fileId = c.fileId;
    const ext = isVideo(c.name) ? '▶' : (c.name.split('.').pop() || '').toUpperCase().slice(0, 4);
    card.innerHTML =
      '<div class="ft"><div class="fc-ico">' + esc(ext) + '</div><div style="min-width:0"><div class="fc-nm">' + esc(c.name) + '</div>' +
      '<div class="fc-meta">' + fmtSize(c.size) + ' · ' + (m.mine ? '由你发送' : '来自 ' + esc(m.fromName)) + '</div></div></div>' +
      '<div class="fc-route"><span class="d"></span>同一 WiFi 直接传，又快又稳</div>' +
      '<div class="bar" style="display:none"><i style="width:0"></i></div>' +
      '<div class="fc-foot"><span class="fc-status"></span><a class="fc-act"></a></div>';
    const statusEl = card.querySelector('.fc-status');
    const actEl = card.querySelector('.fc-act');
    const native = global.mchatNative;
    card.addEventListener('contextmenu', (e) => {
      e.preventDefault(); e.stopPropagation();
      showContextMenu(fileCardActions(name, m, card), card, e, 'file');
    });
    const ls = getLanState(c.fileId);
    if (m.mine) {
      if (lanTarget(ls)) applyLanState(card, c, m.from, ls);
      else statusEl.textContent = '已发出 · 等对方接收';
    } else if (!native || !native.isDesktop) {
      statusEl.textContent = '请在桌面端下载'; actEl.style.display = 'none';
    } else {
      // 持久化的终态(失效/失败/已收到)优先:扛住消息列表重渲染,提示不会被重置回"点右侧下载"
      if (ls) { applyLanState(card, c, m.from, ls); }
      else {
        statusEl.textContent = '点右侧下载（同一 WiFi 直传）';
        actEl.textContent = '下载';
        actEl.onclick = (e) => { e.stopPropagation(); downloadLan(card, c, m.from); };
      }
    }
    return card;
  }
  // LAN 卡片终态持久化:fileId -> {text, act, onact:'retry'|'reveal'|'open', savePath, grantId}
  const lanState = new Map();
  function rememberLanState(fileId, ls) {
    if (lanState.has(fileId)) lanState.delete(fileId);
    lanState.set(fileId, ls);
    if (lanState.size > 300) lanState.delete(lanState.keys().next().value);
  }
  function lanCacheKey() { return 'mchat.lan.received.' + (localStorage.getItem('mchat.uname') || ''); }
  function lanCache() { try { return JSON.parse(localStorage.getItem(lanCacheKey()) || '{}') || {}; } catch (e) { return {}; } }
  function saveLanCache(fileId, ls) {
    if (!fileId || !ls || !ls.savePath) return;
    try {
      const all = lanCache();
      all[fileId] = { text: ls.text || '已接收', act: ls.act || '打开文件位置', onact: ls.onact || 'reveal', savePath: ls.savePath, grantId: ls.grantId || '', ts: Date.now() };
      const keys = Object.keys(all).sort((a, b) => (all[b].ts || 0) - (all[a].ts || 0));
      keys.slice(200).forEach(k => delete all[k]);
      localStorage.setItem(lanCacheKey(), JSON.stringify(all));
    } catch (e) {}
  }
  function setLanState(fileId, ls) { if (!fileId) return; rememberLanState(fileId, ls); saveLanCache(fileId, ls); }
  function getLanState(fileId) {
    if (!fileId) return null;
    if (lanState.has(fileId)) return lanState.get(fileId);
    const saved = lanCache()[fileId];
    if (saved && saved.savePath) { rememberLanState(fileId, saved); return saved; }
    return null;
  }
  function applyLanState(card, c, fromUid, ls) {
    const native = global.mchatNative;
    const statusEl = card.querySelector('.fc-status'), actEl = card.querySelector('.fc-act');
    const bar = card.querySelector('.bar'); if (bar) bar.style.display = 'none';
    statusEl.textContent = ls.text;
    const target = lanTarget(ls);
    if (target && native && native.revealInFolder) card.title = '右键打开文件位置';
    if (!ls.act) { actEl.style.display = 'none'; return; }
    actEl.style.display = ''; actEl.textContent = ls.act;
    if (ls.onact === 'retry') actEl.onclick = (e) => { e.stopPropagation(); downloadLan(card, c, fromUid); };
    else if (target && ls.onact === 'open' && native && native.openPath) actEl.onclick = (e) => { e.stopPropagation(); native.openPath(target); };
    else if (target && ls.onact === 'reveal' && native && native.revealInFolder) actEl.onclick = (e) => { e.stopPropagation(); native.revealInFolder(target); };
    else actEl.style.display = 'none';
  }
  // 云文件卡片(走云 EX 附件):名/大小 + 「打开」→ 浏览器/Safari 打开(手机 PDF 直接看、Office 下载用 WPS 等打开)
  function cloudFileCardEl(att, name, m) {
    const card = document.createElement('div'); card.className = 'fcard';
    card.dataset.cfk = cloudFileKey(att);
    const ext = isVideo(att.name) ? '▶' : ((att.name || '').split('.').pop() || 'FILE').toUpperCase().slice(0, 4);
    card.innerHTML =
      '<div class="ft"><div class="fc-ico">' + esc(ext) + '</div><div style="min-width:0"><div class="fc-nm">' + esc(att.name || '文件') + '</div>' +
      '<div class="fc-meta">' + fmtSize(att.size) + ' · 云文件</div></div></div>' +
      '<div class="fc-route"><span class="d"></span>云端保存（上限 500MB），可下载到本机</div>' +
      '<div class="bar" style="display:none"><i></i></div>' +
      '<div class="fc-foot"><span class="fc-status">点右侧下载 / 另存为</span><a class="fc-act">下载</a></div>';
    card.querySelector('.fc-act').onclick = (e) => { e.stopPropagation(); downloadCloudFile(att, card); };
    card.addEventListener('dblclick', (e) => { e.stopPropagation(); openCloudFile(att); });
    card.addEventListener('contextmenu', (e) => {
      e.preventDefault(); e.stopPropagation();
      showContextMenu(cloudFileActions(name, m, att), card, e, 'file');
    });
    const saved = getCloudState(att);
    if (saved) markCloudCardSaved(card, att, saved);   // 已下载过:重渲染/重开app后不再显示"下载",而是"打开文件位置"
    return card;
  }
  function markCloudCardSaved(card, att, saved) {
    const st = card.querySelector('.fc-status'), act = card.querySelector('.fc-act');
    if (!st || !act) return;
    st.textContent = '已保存到本机';
    const native = global.mchatNative;
    const target = (saved.grantId && saved.path) ? { grantId: saved.grantId, path: saved.path } : (saved.grantId || saved.path || '');
    if (native && native.revealInFolder && target) {
      act.textContent = '打开文件位置';
      act.onclick = (e) => { e.stopPropagation(); native.revealInFolder(target); };
    } else {
      act.textContent = '重新下载';
      act.onclick = (e) => { e.stopPropagation(); downloadCloudFile(att, card); };
    }
  }
  function openCloudFile(att) {
    const url = client.fileURL(att.ref);
    if (!url) { flash('文件链接缺失'); return; }
    try { window.open(url, '_blank'); } catch (e) { location.href = url; }   // 桌面→系统浏览器,手机→Safari(PDF直接看/Office下载交给WPS)
  }
  const _cloudProg = new Map();   // tag -> 进度更新回调(壳 mchat:cloud-progress 事件路由)
  async function downloadCloudFile(att, card) {
    const url = client.fileURL(att.ref);
    if (!url) { flash('文件链接缺失'); return; }
    if (!card) card = document.querySelector('.fcard[data-cfk="' + (cloudFileKey(att) || '').replace(/"/g, '') + '"]') || null;   // 右键菜单入口没有卡片引用,按键找回
    const native = global.mchatNative;
    const statusEl = card && card.querySelector('.fc-status');
    const bar = card && card.querySelector('.bar'), fill = bar && bar.querySelector('i');
    const setPct = (recv, total) => {
      if (statusEl) statusEl.textContent = total ? ('下载中 ' + Math.min(99, Math.round(recv / total * 100)) + '%') : ('下载中 ' + fmtSize(recv));
      if (bar && total) { bar.style.display = 'block'; fill.style.width = Math.min(99, Math.round(recv / total * 100)) + '%'; }
    };
    const resetUi = (text) => { if (bar) bar.style.display = 'none'; if (statusEl && text) statusEl.textContent = text; };
    if (native && native.saveCloudFile) {
      flash('请选择保存位置…');
      const tag = 'cf' + Date.now() + Math.random().toString(36).slice(2, 6);
      if (native.onCloudProgress) {
        if (!global.__cloudProgBound) { global.__cloudProgBound = true; native.onCloudProgress((d) => { const h = d && _cloudProg.get(d.tag); if (h) h(d.recv, d.total); }); }
        _cloudProg.set(tag, setPct);
      }
      if (statusEl) statusEl.textContent = '正在保存到本机…';   // 旧壳无进度事件也至少有状态,不再毫无反馈
      try {
        const r = await native.saveCloudFile(url, att.name || 'mchat-file', tag);
        if (r && r.ok && r.path) {
          rememberCloudPath(att, r.path, r.grantId);
          resetUi(''); markCloudCardSaved(card, att, { path: r.path, grantId: r.grantId || '' });
        } else resetUi('点右侧下载 / 另存为');
        flash(r && r.ok ? ('已保存：' + (r.path || '').split('/').pop()) : '已取消保存');
      } catch (e) { resetUi('保存失败，点右侧重试'); flash('保存失败：' + ((e && e.message) || e)); }
      finally { _cloudProg.delete(tag); }
      return;
    }
    // 手机/网页:流式下载(实时进度) → Blob 另存
    try {
      if (statusEl) statusEl.textContent = '下载中…';
      const resp = await fetch(url);
      if (!resp.ok) throw new Error('HTTP ' + resp.status);
      const total = +(resp.headers.get('content-length') || att.size || 0);
      const reader = resp.body && resp.body.getReader ? resp.body.getReader() : null;
      let blob;
      if (reader) {
        const chunks = []; let recv = 0;
        for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); recv += value.length; setPct(recv, total); }
        blob = new Blob(chunks, { type: resp.headers.get('content-type') || 'application/octet-stream' });
      } else blob = await resp.blob();
      const ourl = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = ourl; a.download = att.name || 'mchat-file'; a.rel = 'noopener';
      document.body.appendChild(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(ourl), 10000);
      resetUi('已下载');
      flash('已下载：' + (att.name || '文件'));
    } catch (e) { resetUi('下载失败，点右侧重试'); flash('下载失败：' + ((e && e.message) || e)); }
  }
  function cloudCacheKey() { return 'mchat.cloud.saved.' + (localStorage.getItem('mchat.uname') || ''); }
  function cloudFileKey(att) { return String((att && att.ref) || ((att && att.name) + ':' + (att && att.size)) || ''); }
  function cloudCache() { try { return JSON.parse(localStorage.getItem(cloudCacheKey()) || '{}') || {}; } catch (e) { return {}; } }
  function rememberCloudPath(att, filePath, grantId) {
    const k = cloudFileKey(att); if (!k || !filePath) return;
    try {
      const all = cloudCache();
      all[k] = { path: filePath, grantId: grantId || '', ts: Date.now() };
      const keys = Object.keys(all).sort((a, b) => (all[b].ts || 0) - (all[a].ts || 0));
      keys.slice(200).forEach(x => delete all[x]);
      localStorage.setItem(cloudCacheKey(), JSON.stringify(all));
    } catch (e) {}
  }
  function getCloudState(att) {
    const k = cloudFileKey(att); if (!k) return '';
    const hit = cloudCache()[k];
    return hit || null;
  }

  async function downloadLan(card, c, fromUid) {
    const native = global.mchatNative; if (!native) return;
    const bar = card.querySelector('.bar'), fill = bar.querySelector('i');
    const statusEl = card.querySelector('.fc-status'), actEl = card.querySelector('.fc-act');
    // 点卡片前先看发送方在不在线:LAN 文件服务与 Tinode 连接同在发送方 App 内,App 一关两者同没;
    // 离线则秒提示失效,免去对端不可达时 fetchFile 要等满 20s 连接超时的"干等"
    if (lanSenderOffline(fromUid)) {
      const ls = { text: '对方已离线，文件失效，请让其重新发送', act: '重试', onact: 'retry' };
      setLanState(c.fileId, ls); applyLanState(card, c, fromUid, ls);
      return;
    }
    lanState.delete(c.fileId);   // 进入"传输中":清掉旧终态,避免重渲染把进度盖成旧失败
    bar.style.display = 'block'; actEl.style.display = ''; actEl.textContent = ''; statusEl.textContent = '连接对端…';
    const pe = { fill, statusEl, prog: false };
    lanProgress.set(c.fileId, pe);
    // 兜底:6s 内无任何字节流动 → 对端基本不可达(对端已死时底层 fetch 会挂满 20s 才超时),
    // 先给用户交代别傻等;真实 fetch 仍在后台收尾,最终结果会覆盖此提示
    const cap = setTimeout(() => {
      if (pe.prog) return;   // 已有数据在流 = 正常(大文件慢),不打断
      const ls = { text: '对方可能已离线或网络不通，请让对方重新发送', act: '重试', onact: 'retry' };
      setLanState(c.fileId, ls); applyLanState(card, c, fromUid, ls);
    }, 6000);
    try {
      const r = await native.lanFetch(c, c.fileId);
      clearTimeout(cap);
      if (r && r.ok) {
        fill.style.width = '100%';
        const vid = isVideo(c.name) && native.openPath;
        const ls = { text: '已接收 · ' + fmtSize(r.bytes), act: vid ? '▶ 播放' : '打开文件位置', onact: vid ? 'open' : 'reveal', savePath: r.savePath, grantId: r.grantId || '' };
        setLanState(c.fileId, ls); applyLanState(card, c, fromUid, ls);
      } else if (r && r.canceled) { lanState.delete(c.fileId); statusEl.textContent = '已取消'; bar.style.display = 'none'; actEl.textContent = '下载'; actEl.onclick = (e) => { e.stopPropagation(); downloadLan(card, c, fromUid); }; }
      else { const ls = { text: friendlyLanError(r && r.error), act: '重试', onact: 'retry' }; setLanState(c.fileId, ls); applyLanState(card, c, fromUid, ls); }
    } catch (e) { clearTimeout(cap); const ls = { text: friendlyLanError(e && (e.message || e)), act: '重试', onact: 'retry' }; setLanState(c.fileId, ls); applyLanState(card, c, fromUid, ls); }
    finally { lanProgress.delete(c.fileId); }
  }
  // 发送方离线判断:仅在明确 online===false 时返回 true(在线/未知都不阻断,避免 presence 未就绪误杀正常传输)
  function lanSenderOffline(uid) {
    if (!uid || uid === client.uid) return false;
    const d = state.directory.get(uid); if (d && d.online === false) return true;
    const ct = state.contacts.get(uid); if (ct && ct.online === false) return true;
    return false;
  }
  // 把底层报错翻译成可行动的人话(替代"失败：connect ECONNREFUSED"之类吓人原文)
  function friendlyLanError(err) {
    const e = String(err || '');
    if (/410|gone|过期/i.test(e)) return '文件已过期（发送超过 30 分钟），请让对方重发';
    if (/超时|timeout|连接对端失败|ECONN|EHOSTUNREACH|对端返回 4/i.test(e)) return '对方可能已离线或不在同一 WiFi，请让对方重新发送';
    return '接收失败，请重试或请对方重发';
  }
  // 桌面端自动接收：收到他人的文件卡，静默拉取到 ~/Desktop/mshare（无则建），文件夹自动解压，完成给提示
  const _autoRecv = new Set();
  async function autoReceiveLan(name, card, fromUid) {
    if (!card || !card.fileId || _autoRecv.has(card.fileId)) return;
    if (_autoRecv.size >= 300) _autoRecv.delete(_autoRecv.values().next().value);
    _autoRecv.add(card.fileId);
    const failHint = (err) => {   // 自动接收失败不再静默:写入持久终态(扛重渲染)+ 给可点的「重试」入口
      _autoRecv.delete(card.fileId);
      const ls = { text: friendlyLanError(err), act: '重试', onact: 'retry' };
      setLanState(card.fileId, ls);
      const fc = document.querySelector('.fcard[data-file-id="' + card.fileId + '"]');
      if (fc) applyLanState(fc, card, fromUid, ls);
    };
    try {
      const r = await global.mchatNative.lanFetch(card, card.fileId);   // savePath 省略 → 主进程自动落 mshare
      if (r && r.ok) {
        flash('已收到 ' + (card.dirName || card.name) + '，已存到桌面 mshare 文件夹');
        const vid = isVideo(card.name) && r.savePath && global.mchatNative.openPath;
        const ls = { text: vid ? '已收到 · 点右侧播放' : '已收到 · 桌面 mshare 文件夹', act: vid ? '▶ 播放' : '打开文件位置', onact: vid ? 'open' : 'reveal', savePath: r.savePath, grantId: r.grantId || '' };
        setLanState(card.fileId, ls);
        const fc = document.querySelector('.fcard[data-file-id="' + card.fileId + '"]');
        if (fc) applyLanState(fc, card, fromUid, ls);
      } else { failHint(r && r.error); }
    } catch (e) { failHint(e && (e.message || e)); }
  }
  const lanProgress = new Map();
  function bindLanProgress() {
    if (!(global.mchatNative && global.mchatNative.onLanProgress)) return;
    global.mchatNative.onLanProgress(({ fileId, recv, total }) => {
      const p = lanProgress.get(fileId); if (!p) return;
      p.prog = true;   // 标记"有字节在流动",供 downloadLan 的 6s 无进度兜底判断
      const pct = total ? Math.round(recv / total * 100) : 0;
      p.fill.style.width = pct + '%';
      p.statusEl.textContent = fmtSize(recv) + ' / ' + fmtSize(total) + ' · ' + pct + '%';
    });
  }
  async function sendLanFile() {
    if (!state.active) { flash('先选择一个会话'); return; }
    const native = global.mchatNative;
    const items = [{ label: '发送文件', fn: () => pickDocFile() }];
    if (native && native.isDesktop) items.push({ label: '发送文件夹（局域网直传）', fn: () => doLanSend(true) });   // 文件夹仅桌面 P2P
    showSheet(items);
  }
  // 选文件 → ≤500MB 走云(全端可收可下载/另存) / >500MB 走局域网 P2P(仅桌面)
  function pickDocFile() {
    let inp = $('docInput');
    if (!inp) { inp = document.createElement('input'); inp.type = 'file'; inp.id = 'docInput'; inp.style.display = 'none'; document.body.appendChild(inp); }
    inp.value = '';
    inp.onchange = async () => {
      const file = inp.files && inp.files[0]; inp.value = ''; if (!file) return;
      const name = state.active; if (!name) return;
      const native = global.mchatNative;
      const lanSend = async () => {
        const ref = await native.fileGrant(file);
        if (ref && ref.grantId) serveLanAndSend(ref, name); else flash('无法获取文件授权，请用 ➕ 菜单「发送文件」');
      };
      // 自动选路:对方局域网可达→直传;否则云(>200MB 拒发保护公网管道)
      if (lanReachable(name)) { await lanSend(); return; }
      if (file.size > CLOUD_AUTO_MAX) {
        flash('对方不在局域网在线：不支持发送 200MB 以上大文件，请等对方电脑上线走局域网直传');
        return;
      }
      await sendCloudWithProgress(name, file);
    };
    inp.click();
  }
  async function doLanSend(isFolder) {
    const native = global.mchatNative, name = state.active; if (!name) return;
    const fileRef = isFolder ? (native.pickFolder && await native.pickFolder()) : await native.pickFile();
    if (!fileRef) return;
    flash(isFolder ? '正在打包文件夹…' : '正在准备发送…');
    const r = await native.lanServe(fileRef);
    if (!r || !r.ok) { flash('发送准备失败：' + ((r && r.error) || '未知')); return; }
    rememberSentLanPath(r.card, r.grantId || fileRef.grantId);
    try { await client.sendLanCard(name, r.card); flash(isFolder ? '已发送文件夹，等待对方接收' : '已发送文件，等待对方接收'); }
    catch (e) { flash('发送失败：' + ((e && e.message) || e)); }
  }
  function rememberSentLanPath(card, grantId) {
    if (!card || !card.fileId || !grantId) return;
    setLanState(card.fileId, { text: '已发出 · 本机原文件', act: '打开文件位置', onact: 'reveal', grantId });
  }

  /* ===== 图片内联发送（修 #7：图片发群里对面可见）===== */
  function pickImage() {
    if (!state.active) { flash('先选择一个会话'); return; }
    showSheet([
      { label: '发送图片(自动压缩)', fn: () => doPickImage(false) },
      { label: '发送原图(不压缩)', fn: () => doPickImage(true) },
    ]);
  }
  function doPickImage(original) {
    let inp = $('imgInput');
    if (!inp) { inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.id = 'imgInput'; inp.style.display = 'none'; document.body.appendChild(inp); }
    inp.multiple = true;   // 相册里一次多选,批量进待发区(上限 9 张由 addPendingImage 把关,像微信)
    inp.onchange = () => { const fs = Array.from(inp.files || []); inp.value = ''; fs.forEach(f => handleImageFile(f, original)); };
    inp.click();
  }
  // 局域网直传指定文件授权（拖拽文件用）。target:确认单期间用户可能切会话,固定发起时的目标
  async function serveLanAndSend(fileRef, target) {
    const native = global.mchatNative, name = target || state.active; if (!native || !name) return;
    flash('正在准备发送…'); const r = await native.lanServe(fileRef);
    if (!r || !r.ok) { flash('发送准备失败：' + ((r && r.error) || '未知')); return; }
    rememberSentLanPath(r.card, r.grantId || (fileRef && fileRef.grantId));
    try { await client.sendLanCard(name, r.card); flash('已发送，等待对方接收'); } catch (e) { flash('发送失败：' + ((e && e.message) || e)); }
  }
  // 从拖拽事件提取条目:用 webkitGetAsEntry 判断是否文件夹;真实磁盘路径只在 preload 内换成授权 id
  // 注意:webkitGetAsEntry 只在 drop 事件同步阶段有效,故此函数须在 await 前同步调用。
  function collectDropItems(e) {
    const dt = e.dataTransfer; if (!dt) return [];
    const files = dt.files ? Array.from(dt.files) : [];
    const items = dt.items ? Array.from(dt.items) : [];
    return files.map((f, i) => {
      let isDir = false;
      try { const en = items[i] && items[i].webkitGetAsEntry && items[i].webkitGetAsEntry(); if (en) isDir = !!en.isDirectory; } catch (_) {}
      if (!items.length && f && f.type === '' && f.size === 0) isDir = true;   // 兜底:无 entry 时,空类型且0字节多半是文件夹
      return { file: f, isDir };
    });
  }
  // 云文件上传:乐观气泡显示实时进度。大文件上传会占满上行带宽,期间消息收发同管道会变慢——
  // 让"正在传、传到哪"可见,不再像死机(实测30MB上传11s主线程最大冻结仅69ms,"卡死"感=带宽挤兑+无反馈)
  async function sendCloudWithProgress(name, file) {
    let bub = null, pctEl = null;
    if (name === state.active) {
      bub = pendingBubble(name, '📎 ' + file.name + ' · ' + fmtSize(file.size));
      const r = bub.querySelector('.read'); if (r) { r.textContent = '上传中 0%'; pctEl = r; }
    }
    try {
      await client.sendCloudFile(name, file, (p) => { if (pctEl) pctEl.textContent = '上传中 ' + Math.min(99, Math.round((p || 0) * 100)) + '%'; });
      if (bub) bub.remove();
      flash('已发送：' + file.name);
    } catch (e) {
      if (bub) bub.remove();
      flash('发送失败：' + ((e && e.message) || e) + '（' + file.name + '）');
    }
  }
  // 自动选路探测:对方"局域网可达"≈ p2p + presence 在线 + 最近从桌面端发过消息 + 我也是桌面。
  // (团队桌面机同一内网,该近似在本环境成立;误判时 LAN 卡片会挂"等待接收",对方桌面上线仍可收)
  function lanReachable(name) {
    const native = global.mchatNative;
    if (!(native && native.isDesktop && native.fileGrant)) return false;   // 我不是桌面,发不了 LAN
    if (client.isGroup(name)) return false;                                // 群聊多人多端 → 云
    const c = state.contacts.get(name) || state.directory.get(name);
    if (!c || !c.online) return false;                                     // 对方不在线
    const arr = state.messages.get(name) || [];
    for (let i = arr.length - 1; i >= 0; i--) {
      const m = arr[i];
      if (!m.mine && m.head && m.head.mchatPlat) return m.head.mchatPlat === 'desktop';
    }
    return false;                                                          // 无平台证据 → 走云稳妥
  }
  // 拖拽:图片→待发区(内联,待发区本身就是确认);文件/文件夹→先弹确认再发(防误拖直发),点发送才开始上传
  async function handleDroppedFiles(items) {
    if (!state.active) { flash('先选择一个会话'); return; }
    const native = global.mchatNative;
    const files = [], dirs = [], bigs = [];
    for (const it of (items || [])) {
      const f = (it && it.file) ? it.file : it;          // 兼容旧式直接传 File
      const isDir = !!(it && it.isDir);
      if (isDir) { dirs.push(f); continue; }
      if (f && f.type && f.type.indexOf('image') === 0) { handleImageFile(f, false); continue; }
      if (f && f.size <= CLOUD_FILE_MAX) files.push(f);
      else bigs.push(f);
    }
    if (!files.length && !dirs.length && !bigs.length) return;
    const name = state.active;                            // 固定目标会话:确认期间切走也发原会话
    const c = state.contacts.get(name);
    const parts = [];
    if (files.length) parts.push(files.length + ' 个文件 (' + fmtSize(files.reduce((s, f) => s + f.size, 0)) + ')');
    if (dirs.length) parts.push(dirs.length + ' 个文件夹');
    if (bigs.length) parts.push(bigs.length + ' 个超大文件');
    // 局域网直传封装:文件/文件夹/超大文件同一条路(fileGrant→LAN 卡片,对端桌面自动收进 mshare)
    const sendViaLan = async (arr) => {
      for (const f of arr) {
        if (native && native.isDesktop && native.fileGrant) {
          const ref = await native.fileGrant(f);
          if (ref && ref.grantId) serveLanAndSend(ref, name); else flash('无法获取授权：' + (f.name || '文件夹'));
        } else flash('局域网直传请用电脑版');
      }
    };
    const target = '「' + ((c && c.name) || name) + '」';
    // 自动选路:对方局域网可达→全部直传(不占公网);否则文件走云(>200MB 拒发保护公网管道),文件夹/超大文件只能等对方电脑上线
    if (lanReachable(name)) {
      showSheet([{ label: '⚡ 发送 ' + parts.join('、') + ' 给 ' + target + '（局域网直传·不占公网）', fn: () => sendViaLan(files.concat(dirs, bigs)) }]);
      return;
    }
    const tooBig = files.filter(f => f.size > CLOUD_AUTO_MAX);
    const ok = files.filter(f => f.size <= CLOUD_AUTO_MAX);
    if (tooBig.length) flash('对方不在局域网在线：不支持发送 200MB 以上大文件（' + tooBig.map(f => f.name).join('、').slice(0, 60) + '），请等对方电脑上线走局域网直传');
    if (dirs.length || bigs.length) flash('文件夹/超大文件需对方电脑在同一局域网在线');
    if (!ok.length) return;
    const okParts = ok.length + ' 个文件 (' + fmtSize(ok.reduce((s, f) => s + f.size, 0)) + ')';
    showSheet([{ label: '☁️ 发送 ' + okParts + ' 给 ' + target + '（云端中转·全端可收）', fn: async () => { for (const f of ok) await sendCloudWithProgress(name, f); } }]);
  }
  function handleImageFile(file, original) {
    if (original && file.size > INLINE_ORIGINAL_MAX) {
      const name = state.active;
      if (!name) { flash('先选择一个会话'); return; }
      flash('原图较大，按文件发送（对方点击查看）');
      sendCloudWithProgress(name, file);
      return;
    }
    const reader = new FileReader();
    reader.onload = () => {
      const img = new Image();
      img.onload = () => {
        if (original) {   // 原图模式：不压缩，发原始数据（像微信"原图"）
          const b64 = String(reader.result).split(',')[1];
          addPendingImage({ mime: file.type || 'image/jpeg', base64: b64, width: img.width, height: img.height, size: Math.round(b64.length * 0.75), filename: file.name });
          return;
        }
        // 压缩到最长边 ≤1600，JPEG 0.85，控制内联体积（对端直接可见，不依赖上传）
        // 2026-07-11 从 1280/0.82 上调:手机照片/截图文字在 1280 下明显发糊,1600/0.85 清晰度肉眼提升,单张约 300KB 可接受
        const max = 1600; let w = img.width, h = img.height;
        if (Math.max(w, h) > max) { const s = max / Math.max(w, h); w = Math.round(w * s); h = Math.round(h * s); }
        const cv = document.createElement('canvas'); cv.width = w; cv.height = h;
        cv.getContext('2d').drawImage(img, 0, 0, w, h);
        const dataUrl = cv.toDataURL('image/jpeg', 0.85);
        const base64 = dataUrl.split(',')[1];
        const size = Math.round(base64.length * 0.75);
        // srcFile/origW/origH:保留原始文件引用,供待发区"原图"勾选在发送时切换为不压缩数据(拖拽/粘贴/按钮三条路都能发原图)
        addPendingImage({ mime: 'image/jpeg', base64, width: w, height: h, size, filename: file.name.replace(/\.\w+$/, '') + '.jpg', srcFile: file, origW: img.width, origH: img.height });
      };
      img.onerror = () => flash('图片解析失败');
      img.src = reader.result;
    };
    reader.onerror = () => flash('读取图片失败');
    reader.readAsDataURL(file);
  }
  async function sendImageNow(img) {
    const name = state.active; if (!name) return;
    try { await client.sendImage(name, img); }
    catch (e) { flash('图片发送失败：' + ((e && e.message) || e)); }
  }
  // 图片进"待发区"(微信式:先预览,可加多张/删,再连同文字一起发)
  function addPendingImage(img) {
    state.pendingImages = state.pendingImages || [];
    if (state.pendingImages.length >= 9) { flash('一次最多 9 张'); return; }
    state.pendingImages.push(img); renderPendingImages();
    const inp = $('cinput'); if (inp) inp.focus();
  }
  function renderPendingImages() {
    const box = $('imgPrev'); if (!box) return;
    const list = state.pendingImages || [];
    box.innerHTML = ''; box.classList.toggle('on', list.length > 0);
    list.forEach((img, i) => {
      const el = document.createElement('div'); el.className = 'pv';
      const im = document.createElement('img'); im.src = 'data:' + img.mime + ';base64,' + img.base64;
      const x = document.createElement('span'); x.className = 'pvx'; x.textContent = '×';
      x.onclick = () => { state.pendingImages.splice(i, 1); renderPendingImages(); };
      el.appendChild(im); el.appendChild(x); box.appendChild(el);
    });
    // 微信式"原图"勾选:勾上则本次发送用原始文件(>1.5MB 自动转云文件);发送后自动复位
    if (list.some(im => im.srcFile)) {
      const lab = document.createElement('label'); lab.className = 'pv-orig';
      const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!state.sendOriginal;
      cb.onchange = () => { state.sendOriginal = cb.checked; };
      lab.appendChild(cb); lab.appendChild(document.createTextNode('原图'));
      box.appendChild(lab);
    }
  }

  async function recall(name, seq) {
    const arr = state.messages.get(name) || []; const m0 = arr.find(x => x.seq === seq);
    if (m0 && m0.ts && (Date.now() - m0.ts.getTime() > 180000)) { flash('超过 3 分钟，不能撤回了'); return; }   // 微信式 3 分钟撤回窗口
    try { await client.recall(name, seq); const m = arr.find(x => x.seq === seq); if (m) m.deleted = true; renderStream(name); flash('已撤回'); }
    catch (e) { flash('撤回失败：' + ((e && e.message) || e)); }
  }
  // 对所有人删除:不限时间删自己发的消息(全端 delMessages hard),显示「已撤回」墓碑
  async function deleteForAll(name, seq) {
    if (!global.confirm('对所有人删除这条消息？删除后所有人都看不到（不限时间）。')) return;
    const arr = state.messages.get(name) || [];
    try { await client.recall(name, seq); const m = arr.find(x => x.seq === seq); if (m) m.deleted = true; renderStream(name); flash('已对所有人删除'); }
    catch (e) { flash('删除失败：' + ((e && e.message) || e)); }
  }
  async function delLocal(name, seq) {
    try { await client.delLocal(name, seq); state.messages.set(name, (state.messages.get(name) || []).filter(m => m.seq !== seq)); renderStream(name); flash('已删除（仅本机）'); }
    catch (e) { flash('删除失败：' + ((e && e.message) || e)); }
  }

  function isReadByOthers(name, seq) {
    const tp = client.topics.get(name); if (!tp || !seq) return false;
    try { return tp.msgReadCount(seq) > 0; } catch (e) { return false; }
  }

  /* ===== 窗口回到前台 → 对当前会话补发已读（修"切走再回来对端已读迟迟不更新"）===== */
  function markActiveRead() {
    if (document.visibilityState === 'hidden') return;
    const name = state.active; if (!name) return;
    const arr = state.messages.get(name) || [];
    const maxSeq = arr.reduce((m, x) => Math.max(m, x.seq || 0), 0);
    if (maxSeq) { try { client.noteRead(name, maxSeq); } catch (e) {} }
  }

  /* ===== info（已读/输入态）===== */
  function onInfo(name, info) {
    if (!info) return;
    if (info.what === 'kp' && name === state.active) showTyping(name, info.from);
    if ((info.what === 'read' || info.what === 'recv') && name === state.active) patchReadStates(name);
  }
  function showTyping(name, from) {
    const d = state.directory.get(from), c = state.contacts.get(from);
    $('typing').textContent = ((d && d.name) || (c && c.name) || '对方') + ' 正在输入…';
    clearTimeout(state.typingTimers.get(name));
    state.typingTimers.set(name, setTimeout(() => { $('typing').textContent = ''; }, 3000));
  }

  /* ===== 群文件聚合：会话里发过的文件/图片汇总到右栏，滚走也找得回(修复"群文件"假占位) ===== */
  function renderFiles(name) {
    const box = $('fileList'); if (!box) return;
    const items = [];
    (state.messages.get(name) || []).forEach(m => {
      if (m.deleted) return;
      if (m.lanCard) {
        const c = m.lanCard;
        const ext = c.isDir ? '夹' : (((c.name || '').split('.').pop() || '').toUpperCase().slice(0, 4) || '文件');
        items.push({ ico: ext, nm: c.name || '文件', sub: fmtSize(c.size) + ' · ' + (m.mine ? '你' : m.fromName) + ' · ' + hm(m.ts), on: () => scrollToMsg(m.seq) });
      }
      (m.images || []).forEach(a => {
        if (!a || !(a.ref || a.val)) return;
        const src = a.ref ? client.fileURL(a.ref) : ('data:' + (a.mime || 'image/jpeg') + ';base64,' + a.val);
        items.push({ ico: '图', nm: '图片', sub: (m.mine ? '你' : m.fromName) + ' · 点击查看 · ' + hm(m.ts), on: () => openLightbox(src) });
      });
    });
    if (!items.length) { box.innerHTML = '<div class="fm-s" style="color:var(--faint)">暂无文件</div>'; return; }
    box.innerHTML = '';
    items.reverse().forEach(it => {   // 最新在上
      const el = document.createElement('div'); el.className = 'fm-item';
      el.innerHTML = '<div class="fm-ico"></div><div style="min-width:0;flex:1"><div class="fm-n"></div><div class="fm-s"></div></div>';
      el.querySelector('.fm-ico').textContent = it.ico;
      el.querySelector('.fm-n').textContent = it.nm;
      el.querySelector('.fm-s').textContent = it.sub;
      el.onclick = it.on;
      box.appendChild(el);
    });
  }
  function scrollToMsg(seq, tries) {
    const el = document.querySelector('.msg[data-seq="' + seq + '"]');
    if (!el) { if ((tries || 0) < 8) { setTimeout(() => scrollToMsg(seq, (tries || 0) + 1), 250); return; } flash('该消息较早，已为你打开会话，请向上翻'); return; }
    el.scrollIntoView({ behavior: 'smooth', block: 'center' });
    el.classList.add('flash-hl'); setTimeout(() => el.classList.remove('flash-hl'), 1300);
  }

  /* ===== 成员 ===== */
  function onMembers(name, members) { state.members.set(name, members); if (name === state.active) { renderMembers(name); refreshStreamAvatars(); } }
  // 成员 desc(含头像)常晚于消息渲染到达 → desc 到位后就地补绘消息流里还挂着卡通占位的头像,不整屏重绘
  function refreshStreamAvatars() {
    document.querySelectorAll('#stream .av[data-uid]').forEach(av => {
      const uid = av.dataset.uid; if (!uid || uid === client.uid) return;
      const photo = client.photoOf(uid);
      if (photo && av.dataset.avPhoto !== Art.safeUrl(photo)) {
        Art.paintAvatar(av, { seed: av.dataset.seed, photo, square: isBot(uid) });
      }
    });
  }
  function renderMembers(name) {
    const members = client.getMembers(name);
    state.members.set(name, members);
    const onCt = members.filter(m => m.online).length;
    $('memberHead').textContent = '成员 · ' + members.length;
    $('chatSub').textContent = client.isGroup(name) ? (members.length + ' 名成员 · ' + onCt + ' 在线') : (members.find(m => !m.me) || {}).online ? '在线' : '离线';
    const isGrp = client.isGroup(name);
    const canManage = isGrp && client.canInvite(name);
    const ml = $('memberList'); ml.innerHTML = '';
    members.forEach(m => {
      const row = document.createElement('div'); row.className = 'member';
      const av = document.createElement('div'); Art.paintAvatar(av, { seed: (m.name || m.uid) + (m.uid ? m.uid.slice(-3) : ''), photo: m.photo, square: isBot(m.uid) });
      av.style.width = '30px'; av.style.height = '30px';
      const info = document.createElement('div'); info.style.flex = '1'; info.style.minWidth = '0';
      info.innerHTML = '<div class="mn">' + esc(m.name) + (m.me ? '<span class="tag">本机</span>' : '') + (isBot(m.uid) ? '<span class="tag bot">AI 助手</span>' : '') + '</div><div class="ms">' + (m.uid === BOT_UID ? '群里 @太白 提问' : isBot(m.uid) ? 'AI 助手' : (m.online ? '在线' : '离线')) + '</div>';
      row.appendChild(av); row.appendChild(info);
      // 群主/管理员可移除其他成员（不显示在自己身上）
      if (canManage && !m.me) { const k = document.createElement('div'); k.className = 'm-kick'; k.title = '移出群'; k.innerHTML = '<svg viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></svg>'; k.onclick = (e) => { e.stopPropagation(); doKick(name, m.uid, m.name); }; row.appendChild(k); }
      ml.appendChild(row);
    });
    buildMpick(members);
    const ib = $('inviteBtn'); if (ib) ib.style.display = canManage ? '' : 'none';
    // 群管理区：群聊才显示；群主→「解散群聊」+ 可改名，成员→「退出群聊」
    const ga = $('groupAdmin');
    if (ga) {
      ga.style.display = isGrp ? '' : 'none';
      if (isGrp) { $('gaLeaveTxt').textContent = client.isOwner(name) ? '解散群聊' : '退出群聊'; $('gaRename').style.display = canManage ? '' : 'none'; }
    }
  }

  /* ===== 发起群聊 / 邀请成员 ===== */
  let gmMode = 'new';
  const gmPicks = new Set();
  function openNewGroup() {
    gmMode = 'new'; gmPicks.clear();
    $('gmTitle').textContent = '发起群聊';
    $('gmSub').textContent = '选几个伙伴一起建群，建群后你是群主，可随时再拉人。';
    $('gmNameWrap').style.display = '';
    $('gmName').value = ''; $('gmSearch').value = '';
    $('gmGo').textContent = '创建群聊';
    buildGroupPicker(Array.from(state.directory.values()));
    updateGmCount();
    $('groupModal').classList.add('open');
    setTimeout(() => $('gmName').focus(), 60);
  }
  function openAddMembers() {
    if (!state.active || !client.isGroup(state.active)) return flash('先打开一个群聊');
    gmMode = 'add'; gmPicks.clear();
    const here = new Set((state.members.get(state.active) || []).map(m => m.uid));
    const cands = Array.from(state.directory.values()).filter(d => !here.has(d.uid));
    $('gmTitle').textContent = '邀请成员进群';
    $('gmSub').textContent = '把伙伴拉进「' + ((state.contacts.get(state.active) || {}).name || '本群') + '」。';
    $('gmNameWrap').style.display = 'none';
    $('gmSearch').value = '';
    $('gmGo').textContent = '拉进群';
    buildGroupPicker(cands);
    updateGmCount();
    $('groupModal').classList.add('open');
  }
  function buildGroupPicker(list) {
    const box = $('gmList');
    if (!list || !list.length) { box.innerHTML = '<div class="r-sub" style="padding:16px;color:var(--faint);text-align:center">没有可选的伙伴</div>'; return; }
    box.innerHTML = '';
    list.slice().sort((a, b) => (a.name || '').localeCompare(b.name || '')).forEach(d => {
      const el = document.createElement('div'); el.className = 'pick'; el.dataset.uid = d.uid;
      const av = document.createElement('div'); Art.paintAvatar(av, { seed: (d.name || d.uid) + (d.uid ? d.uid.slice(-3) : ''), photo: d.photo, square: isBot(d.uid) });
      av.style.width = '32px'; av.style.height = '32px'; av.style.flex = 'none';
      const nm = document.createElement('div'); nm.className = 'pn'; nm.textContent = d.name || d.uid;
      if (isBot(d.uid)) { const t = document.createElement('span'); t.className = 'po'; t.textContent = 'AI'; nm.appendChild(t); }
      const pk = document.createElement('div'); pk.className = 'pk';
      el.appendChild(av); el.appendChild(nm); el.appendChild(pk);
      if (gmPicks.has(d.uid)) el.classList.add('on');
      el.onclick = () => togglePick(d.uid, el);
      box.appendChild(el);
    });
  }
  function togglePick(uid, el) {
    if (gmPicks.has(uid)) { gmPicks.delete(uid); el.classList.remove('on'); }
    else { gmPicks.add(uid); el.classList.add('on'); }
    updateGmCount();
  }
  function updateGmCount() {
    $('gmCount').textContent = '已选 ' + gmPicks.size + ' 人';
    $('gmGo').disabled = gmPicks.size === 0;
  }
  function filterGroupPicks() {
    const q = ($('gmSearch').value || '').trim().toLowerCase();
    $('gmList').querySelectorAll('.pick').forEach(el => {
      const nm = (el.querySelector('.pn').textContent || '').toLowerCase();
      el.style.display = (!q || nm.includes(q)) ? '' : 'none';
    });
  }
  async function submitGroup() {
    const picks = Array.from(gmPicks);
    if (!picks.length) { flash('至少选一个伙伴'); return; }
    const oldLabel = $('gmGo').textContent;
    $('gmGo').disabled = true;
    try {
      if (gmMode === 'new') {
        const name = ($('gmName').value || '').trim();
        if (!name) { flash('给群起个名字'); $('gmGo').disabled = false; $('gmName').focus(); return; }
        $('gmGo').textContent = '创建中…';
        const res = await client.createGroup(name, picks);
        // 主动把新群加进会话列表(不等 me 异步推送→无感刷新;否则建完看不到新群)
        if (!state.contacts.has(res.name)) state.contacts.set(res.name, { topic: res.name, name: name, kind: 'grp', online: false, unread: 0, seq: 0, read: 0, touched: new Date(), lastMsg: '群聊' });
        closeModals();
        flash('群「' + name + '」已建好，拉入 ' + res.invited + ' 人');
        renderLists();
        selectTopic(res.name);
      } else {
        $('gmGo').textContent = '拉人中…';
        const ok = await client.inviteMembers(state.active, picks);
        closeModals();
        flash(ok ? ('已拉入 ' + ok + ' 人') : '没拉成功，可能没有邀请权限');
        setTimeout(() => { if (state.active) renderMembers(state.active); }, 700);
      }
    } catch (e) {
      flash('操作失败：' + ((e && e.message) || e));
      $('gmGo').disabled = false; $('gmGo').textContent = oldLabel;
    }
  }

  /* ===== 群管理：退出 / 解散 / 改名 / 移除成员 ===== */
  // 会话从本地彻底移除（退群/解散/被踢后调用）
  function dropConv(name) {
    state.contacts.delete(name); state.messages.delete(name); state.members.delete(name);
    if (state.active === name) {
      state.active = null;
      const ga = $('groupAdmin'); if (ga) ga.style.display = 'none';
      $('chatTitle').textContent = '选择一个会话'; $('chatSub').textContent = '';
      $('stream').innerHTML = '<div class="empty">选个会话，开始聊天</div>';
      if (isMobile()) doMobileBack();
    }
    renderLists();
  }
  async function leaveOrDissolve() {
    const name = state.active;
    if (!name || !client.isGroup(name)) return;
    const owner = client.isOwner(name);
    const gname = (state.contacts.get(name) || {}).name || '本群';
    const tip = owner ? ('解散群「' + gname + '」？群会被彻底删除、所有成员移出，无法恢复。') : ('退出群「' + gname + '」？退出后不再接收该群消息。');
    if (!global.confirm(tip)) return;
    try {
      if (owner) await client.dissolveGroup(name); else await client.leaveGroup(name);
      dropConv(name);
      flash(owner ? '群已解散' : '已退出群聊');
    } catch (e) { flash((owner ? '解散' : '退出') + '失败：' + ((e && e.message) || e)); }
  }
  async function renameGroup() {
    const name = state.active;
    if (!name || !client.isGroup(name)) return;
    const cur = (state.contacts.get(name) || {}).name || '';
    const nn = (global.prompt('新群名：', cur) || '').trim();
    if (!nn || nn === cur) return;
    try {
      await client.renameGroup(name, nn);
      const c = state.contacts.get(name); if (c) c.name = nn;
      $('chatTitle').textContent = nn; renderLists();
      flash('群名已改为「' + nn + '」');
    } catch (e) { flash('改群名失败：' + ((e && e.message) || e)); }
  }
  async function doKick(name, uid, who) {
    if (!global.confirm('把「' + who + '」移出群？')) return;
    try { await client.kickMember(name, uid); flash('已移出「' + who + '」'); setTimeout(() => { if (state.active === name) renderMembers(name); }, 500); }
    catch (e) { flash('移出失败：' + ((e && e.message) || e)); }
  }

  // 刷新:重新加载页面 → token 免密自动重连、从服务器重拉会话列表(清掉已退/已解散的残留)、并加载最新版前端
  function refresh() {
    const b = $('refreshBtn'); if (b) b.classList.add('spin');
    try { flash('正在刷新…'); } catch (e) {}
    setTimeout(hardReload, 220);
  }
  // 缓存击穿重载:换带时间戳的 URL 强制全新加载(WKWebView 对 no-cache 也常顽固缓存;新 URL 必取新 index→?v 的 JS 也跟着取新)
  function hardReload() { try { global.location.replace(global.location.pathname + '?_v=' + Date.now()); } catch (e) { try { global.location.reload(); } catch (_) {} } }

  /* ===== 版本检测 + 一键更新(启动/前台/联网/重连 + 定期轻量检查) ===== */
  function scheduleUpdateCheck(force) {
    const now = Date.now();
    if (!force && now - lastUpdateCheckAt < UPDATE_CHECK_MIN_GAP) return;
    lastUpdateCheckAt = now;
    checkUpdate();
  }
  async function checkUpdate() {
    lastUpdateCheckAt = Date.now();
    try {
      const r = await fetch('version.json?t=' + Date.now(), { cache: 'no-store' });
      if (!r.ok) return;
      const v = await r.json();
      const cur = global.MCHAT_BUILD || '';
      // 客户端内嵌 build(部署时写死) ≠ VPS 最新 build → 有新版;排除本地占位 + 本次已点"稍后"的版本
      if (v && v.build && v.build !== '__BUILD__' && cur && cur !== '__BUILD__' && v.build > cur) {   // > 而非 !==:只有更新的 build 才弹(回滚到旧版不误报"新版本")
        let dz = ''; try { dz = sessionStorage.getItem('mchat.update.dismiss') || ''; } catch (e) {}
        if (dz === v.build) return;
        const bar = $('updateBar'); if (!bar) return;
        const sp = bar.querySelector('span'); if (sp) sp.textContent = '新版·' + String(v.note || '体验优化').slice(0, 10);   // 胶囊要短:无图标,概要硬截10字
        bar.dataset.build = v.build; bar.classList.add('show');
      }
    } catch (e) {}
  }
  function applyUpdate() { hardReload(); }
  function dismissUpdate() { const bar = $('updateBar'); if (bar) { try { sessionStorage.setItem('mchat.update.dismiss', bar.dataset.build || ''); } catch (e) {} bar.classList.remove('show'); } }

  /* ===== presence ===== */
  function setPresence(uid, what) {
    let changed = false;
    const c = state.contacts.get(uid);
    if (c) { const on = what === 'on'; if (c.online !== on && (what === 'on' || what === 'off')) { c.online = on; changed = true; } }
    const d = state.directory.get(uid);
    if (d) { const on = what === 'on'; if (d.online !== on && (what === 'on' || what === 'off')) { d.online = on; changed = true; } }
    if (changed) scheduleRenderLists();
    if (state.active && client.isGroup(state.active)) renderMembers(state.active);
  }
  function onPres(pres) {
    if (!pres || !pres.src) return;
    if (pres.what === 'gone') { dropConv(pres.src); return; }   // 群被解散 / 我被移出 → 实时从列表移除（不必重开）
    // me 主题推送：未打开会话有新消息 → 更新未读 + 提示音 + 通知
    if (pres.what === 'msg') {
      if (isSubscribedTopic(pres.src)) return;   // 已订阅会话由 onMessage 处理，避免同一条消息被 pres/msg 双计数
      const c = state.contacts.get(pres.src);
      if (c && pres.src !== state.active) {
        c.unread = (c.unread || 0) + 1; c.touched = new Date();
        if (!c.lastMsg || c.lastMsg === '群聊' || c.lastMsg === '私聊') c.lastMsg = '［新消息］';
        scheduleRenderLists();
        notify(c.name, { fromName: c.name, plain: '发来新消息', mine: false }, pres.src);
      }
      return;
    }
    setPresence(pres.src, pres.what);
  }
  // 群成员上下线的实时推送（之前没监听 → 看不到别人在线）
  function onTopicPres(topic, pres) { if (pres && pres.src) setPresence(pres.src, pres.what); }
  // 兜底：定时刷新当前群成员在线态（presence 推送偶有缺失时仍能反映）
  function startPresenceRefresh() {
    setInterval(async () => {
      if (!state.active || !client.isGroup(state.active)) return;
      try {
        const members = await client.ensureMembers(state.active);
        members.forEach(m => { if (m.uid) { const d = state.directory.get(m.uid); if (d) d.online = m.online; } });
        renderMembers(state.active); scheduleRenderLists();
      } catch (e) {}
    }, 20000);
  }

  /* ===== 发送 ===== */
  async function sendNow(retryText, retryReply, retryLinks) {
    const name = state.active; if (!name) { flash('先选择一个会话'); return; }
    const inp = $('cinput');
    const text = retryText != null ? retryText : (inp.innerText || '').trim();
    const imgs = (retryText == null) ? (state.pendingImages || []).slice() : [];   // 待发图片(重发不带图)
    if (!text && !imgs.length) return;
    if (retryText == null) { inp.innerHTML = ''; state.pendingImages = []; renderPendingImages(); state.drafts.delete(name); }   // 发送后清本会话草稿
    $('sendBtn').disabled = true;
    try {
      const reply = retryReply !== undefined ? retryReply : currentReply();
      let sendImgs = imgs;
      if (state.sendOriginal && imgs.length) {           // "原图"勾选:小图重读原始数据内联,>1.5MB 自动转云文件(沿用原图护栏)
        state.sendOriginal = false;
        sendImgs = [];
        for (const im of imgs) {
          if (!im.srcFile) { sendImgs.push(im); continue; }
          if (im.srcFile.size > INLINE_ORIGINAL_MAX) {
            await sendCloudWithProgress(name, im.srcFile);   // 大原图转云文件,带进度气泡
            continue;
          }
          try {
            const durl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(im.srcFile); });
            const mt = /^data:([^;]+);base64,(.+)$/.exec(String(durl));
            if (mt) { sendImgs.push(Object.assign({}, im, { mime: mt[1], base64: mt[2], size: Math.round(mt[2].length * 0.75), width: im.origW || im.width, height: im.origH || im.height, filename: im.srcFile.name })); continue; }
          } catch (e) {}
          sendImgs.push(im);   // 原始文件读取失败 → 退回压缩版照发,不丢消息
        }
      }
      if (sendImgs.length) {                             // 图文合一:一条消息(文字 + 内联图),对端/agent 一次性理解(不再拆成多条)
        const pend = pendingBubble(name, text || '🖼 图片');
        const links = retryLinks !== undefined ? retryLinks : await buildLinkPreviews(text);
        try { await client.sendMixed(name, text, sendImgs, { reply, links }); if (retryReply === undefined) clearReply(); pend.remove(); }
        catch (e) { markPendingFailed(pend, text, reply, links); }
      } else if (text) {                                 // 纯文字(带乐观气泡 + 失败重发)
        const pend = pendingBubble(name, text);
        const links = retryLinks !== undefined ? retryLinks : await buildLinkPreviews(text);
        try { await client.send(name, text, { reply, links }); if (retryReply === undefined) clearReply(); pend.remove(); }
        catch (e) { markPendingFailed(pend, text, reply, links); }
      }
    } finally { $('sendBtn').disabled = false; inp.focus(); }
  }
  // 临时"发送中"气泡：独立 DOM，不进 state.messages（避免污染排序/与真实消息重复）
  function pendingBubble(name, text) {
    const stream = $('stream');
    const m = { seq: 'tmp-' + Date.now(), mine: true, plain: text, text: '', ts: new Date(), fromName: '你', from: client.uid };
    const wrap = msgEl(name, m).cloneNode(true);   // 克隆去掉长按/点选监听：临时气泡不可撤回/选中(防 tmp seq 发出坏的删除区间)
    wrap.classList.add('pending');
    const bub = wrap.querySelector('.bub'); if (bub) bub.textContent = text;
    const r = wrap.querySelector('.read'); if (r) r.textContent = '发送中…';
    stream.appendChild(wrap); stream.scrollTop = stream.scrollHeight;
    return wrap;
  }
  function markPendingFailed(wrap, text, reply, links) {
    wrap.classList.remove('pending'); wrap.classList.add('failed');
    const r = wrap.querySelector('.read');
    if (r) { r.innerHTML = '⚠ 发送失败 · 点此重发'; r.style.cursor = 'pointer'; r.onclick = (e) => { e.stopPropagation(); wrap.remove(); sendNow(text, reply, links); }; }
  }

  function setReply(m) {
    state.reply = { seq: m.seq, from: m.from, fromName: m.fromName, plain: m.plain };
    $('replyName').textContent = m.fromName;
    $('replyText').textContent = (m.plain || '').slice(0, 40);
    $('replyPill').classList.add('on');
    $('cinput').focus();
  }
  function currentReply() {
    const pill = $('replyPill');
    if (!pill || !pill.classList.contains('on') || !state.reply || state.reply.seq == null) return null;
    return { seq: state.reply.seq, from: state.reply.from, fromName: state.reply.fromName, plain: state.reply.plain };
  }
  function clearReply(e) {
    if (e && e.stopPropagation) e.stopPropagation();
    state.reply = null;
    const pill = $('replyPill'); if (pill) pill.classList.remove('on');
    const rn = $('replyName'); if (rn) rn.textContent = '';
    const rt = $('replyText'); if (rt) rt.textContent = '';
  }

  // 无边框窗口控制（仅桌面 App）
  function winMin() { if (global.mchatNative && global.mchatNative.winMin) global.mchatNative.winMin(); }
  function winMax() { if (global.mchatNative && global.mchatNative.winMax) global.mchatNative.winMax(); }
  function winClose() { if (global.mchatNative && global.mchatNative.winClose) global.mchatNative.winClose(); }

  function logout() {
    if (!global.confirm('确定退出登录？')) return;
    try { client.logout(); } catch (e) {}
    try { localStorage.removeItem('mchat.token'); localStorage.removeItem('mchat.uname'); } catch (e) {}
    global.location.reload();
  }

  /* ===== 群公告 ===== */
  function showAnnounce(name) {
    const txt = state.announces.get(name);
    const el = $('announce');
    if (txt && client.isGroup(name)) { $('announceTxt').textContent = txt; el.style.display = 'flex'; }
    else el.style.display = 'none';
  }
  function setAnnounce() {
    const name = state.active; if (!name) { flash('先选择一个会话'); return; }
    if (!client.isGroup(name)) { flash('仅群聊可设公告'); return; }
    $('announceInput').value = state.announces.get(name) || '';
    $('announceModal').classList.add('open');
    setTimeout(() => $('announceInput').focus(), 60);
  }
  async function saveAnnounce() {
    const name = state.active; if (!name || !client.isGroup(name)) return;
    const t = ($('announceInput').value || '').trim();
    if (!t) { flash('公告内容不能为空'); return; }
    try { await client.setAnnounce(name, t); state.announces.set(name, t); showAnnounce(name); closeModals(); flash('群公告已更新'); }
    catch (e) { flash('设置失败：' + ((e && e.message) || e)); }
  }

  /* ===== @ 提及 ===== */
  function buildMpick(members) {
    const mp = $('mpick'); mp.innerHTML = '';
    const all = document.createElement('div'); all.className = 'mi';
    all.innerHTML = '<div class="av sq" style="background:color-mix(in srgb,var(--accent) 16%,transparent);color:var(--accent-deep)">@</div><span class="nm2">所有人</span><span class="all">通知全体</span>';
    all.onclick = () => insertMention('所有人');
    mp.appendChild(all);
    members.filter(m => !m.me).forEach(m => {
      const mi = document.createElement('div'); mi.className = 'mi';
      const av = document.createElement('div'); Art.paintAvatar(av, { seed: (m.name || m.uid) + (m.uid ? m.uid.slice(-3) : ''), square: isBot(m.uid) });
      mi.appendChild(av);
      const nm = document.createElement('span'); nm.className = 'nm2'; nm.textContent = m.name; mi.appendChild(nm);
      mi.onclick = () => insertMention(m.name);
      mp.appendChild(mi);
    });
  }
  function toggleMpick() { $('mpick').classList.toggle('open'); }
  // 在 contenteditable 光标处稳健插入文本（iOS WKWebView 的 execCommand 不可靠）
  function insertAtCursor(text) {
    const inp = $('cinput'); inp.focus();
    try {
      const sel = global.getSelection();
      if (sel && sel.rangeCount && inp.contains(sel.anchorNode)) {
        const range = sel.getRangeAt(0); range.deleteContents();
        const node = document.createTextNode(text); range.insertNode(node);
        range.setStartAfter(node); range.collapse(true);
        sel.removeAllRanges(); sel.addRange(range);
      } else { inp.appendChild(document.createTextNode(text)); }
    } catch (e) { inp.textContent = (inp.innerText || '') + text; }
    inp.dispatchEvent(new Event('input'));
  }
  function insertMention(nm) { $('mpick').classList.remove('open'); insertAtCursor('@' + nm + ' '); }

  /* ===== 头像 / 表情 选择器 ===== */
  function buildAvatarPicker() {
    const grid = $('avSwatches'); grid.innerHTML = '';
    const samples = Art.avatarSamples(client.uname || 'me');
    samples.forEach((url, i) => {
      const sw = document.createElement('div'); sw.className = 'sw dice'; sw.style.backgroundImage = "url('" + url + "')";
      sw.dataset.url = url;
      sw.onclick = () => { grid.querySelectorAll('.sw').forEach(x => x.classList.remove('sel')); sw.classList.add('sel'); $('avPrev').style.backgroundImage = "url('" + url + "')"; state.pendingAvatar = url; };
      grid.appendChild(sw);
    });
    $('avName').value = client.myName || client.uname || '我';
    $('avPrev').style.backgroundImage = $('meAvatar').style.backgroundImage;
  }
  // 上传自己的头像:选图 → 居中裁正方形 + 压缩到 256px JPEG → 预览(点保存才上传 Tinode)
  function uploadAvatar() {
    let inp = $('avFileInput');
    if (!inp) { inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.id = 'avFileInput'; inp.style.display = 'none'; document.body.appendChild(inp); }
    inp.value = '';
    inp.onchange = () => {
      const f = inp.files && inp.files[0]; inp.value = ''; if (!f) return;
      const reader = new FileReader();
      const isGif = f.type === 'image/gif' || /\.gif$/i.test(f.name || '');
      if (isGif) {
        if (f.size > AVATAR_GIF_MAX) { flash('GIF 头像请控制在 2MB 内'); return; }
        reader.onload = () => {
          state.pendingAvatar = reader.result;
          $('avPrev').style.backgroundImage = "url('" + state.pendingAvatar + "')";
          document.querySelectorAll('#avSwatches .sw').forEach(x => x.classList.remove('sel'));
          flash('已选择 GIF 动态头像，点「保存」生效');
        };
        reader.onerror = () => flash('GIF 读取失败，换一张试试');
        reader.readAsDataURL(f);
        return;
      }
      reader.onload = () => {
        const img = new Image();
        img.onload = () => {
          const S = 256, cv = document.createElement('canvas'); cv.width = S; cv.height = S;
          const side = Math.min(img.width, img.height), sx = (img.width - side) / 2, sy = (img.height - side) / 2;
          cv.getContext('2d').drawImage(img, sx, sy, side, side, 0, 0, S, S);   // 居中裁正方形 → 256×256(头像够清,base64 小)
          state.pendingAvatar = cv.toDataURL('image/jpeg', 0.85);
          $('avPrev').style.backgroundImage = "url('" + state.pendingAvatar + "')";
          document.querySelectorAll('#avSwatches .sw').forEach(x => x.classList.remove('sel'));
          flash('已选好头像，点「保存」生效');
        };
        img.onerror = () => flash('图片读取失败，换一张试试');
        img.src = reader.result;
      };
      reader.readAsDataURL(f);
    };
    inp.click();
  }
  function saveAvatar() {
    const newName = ($('avName').value || '').trim();
    if (newName && newName !== (client.myName || client.uname)) {
      client.setMyName(newName).then(() => { flash('显示名已更新为「' + newName + '」'); renderLists(); }).catch(e => flash('改名失败：' + ((e && e.message) || e)));
    }
    if (state.pendingAvatar) {
      $('meAvatar').style.backgroundImage = "url('" + state.pendingAvatar + "')";
      try { localStorage.setItem('mchat.avurl', state.pendingAvatar); } catch (e) {}
      // data url(上传的自定义头像)→ 上传 Tinode public.photo 全端可见;外部 url(内置卡通)仅本机
      if (/^data:/.test(state.pendingAvatar)) {
        client.setMyAvatar(state.pendingAvatar).then(() => { flash('头像已更新（全端可见）'); renderLists(); }).catch(e => flash('头像上传失败：' + ((e && e.message) || e)));
      } else flash('头像已更新（本机）');
    }
    closeModals();
  }
  function buildEmojiPicker() {
    const g = $('emojiGrid'); g.innerHTML = '';
    Art.EMOJI.forEach(hex => {
      const sw = document.createElement('div'); sw.className = 'sw flu'; sw.style.backgroundImage = "url('" + Art.emojiUrl(hex) + "')";
      sw.onclick = () => { insertEmoji(hex); };
      g.appendChild(sw);
    });
    const sg = $('stickerGrid'); sg.innerHTML = '';
    const add = document.createElement('div'); add.className = 'sw addst'; add.textContent = '＋'; add.title = '添加贴纸（图片/GIF）';
    sg.appendChild(add);
  }
  function insertEmoji(hex) {
    closeModals();
    let ch = '😀';
    try { ch = String.fromCodePoint.apply(null, hex.split('-').map(h => parseInt(h, 16))); } catch (e) {}
    insertAtCursor(ch);
  }

  /* ===== 选择/转发/导出/删除 ===== */
  function selectedSeqs() { return Array.from(document.querySelectorAll('.msg.sel')).map(m => +m.dataset.seq); }
  function enterSelectWithSeq(seq) {
    $('app').classList.add('selecting');
    if (seq != null) {
      document.querySelectorAll('.msg.sel').forEach(m => m.classList.remove('sel'));
      const el = Array.from(document.querySelectorAll('.msg')).find(m => String(m.dataset.seq) === String(seq));
      if (el) el.classList.add('sel');
    }
    updateCnt();
  }
  function exportSelected() {
    const seqs = selectedSeqs(); const arr = (state.messages.get(state.active) || []).filter(m => seqs.includes(m.seq));
    const text = arr.map(m => '[' + hm(m.ts) + '] ' + m.fromName + ': ' + m.plain).join('\n');
    const blob = new Blob([text], { type: 'text/plain' }); const a = document.createElement('a');
    const url = URL.createObjectURL(blob);
    a.href = url; a.download = 'mchat-导出-' + (state.active || '') + '.txt'; a.click();
    setTimeout(() => URL.revokeObjectURL(url), 10000);
    exitSelect(); flash('已导出 ' + arr.length + ' 条');
  }
  async function deleteSelected() {
    const seqs = selectedSeqs(); for (const s of seqs) await client.delLocal(state.active, s);
    state.messages.set(state.active, (state.messages.get(state.active) || []).filter(m => !seqs.includes(m.seq)));
    renderStream(state.active); exitSelect(); flash('已删除 ' + seqs.length + ' 条（仅本机）');
  }
  function forwardSelected() {
    const seqs = selectedSeqs();
    const arr = (state.messages.get(state.active) || []).filter(m => seqs.includes(m.seq) && !m.deleted);
    if (!arr.length) { flash('请先选择要转发的消息'); return; }
    showForwardTargets(arr);
  }
  function forwardOne(m) {
    if (!m || m.deleted) { flash('这条消息不可转发'); return; }
    showForwardTargets([m]);
  }
  function showForwardTargets(arr) {
    const targets = [];
    Array.from(state.contacts.values()).filter(c => (c.kind === 'grp' || c.kind === 'p2p') && c.topic !== state.active)
      .sort((a, b) => (b.touched ? b.touched.getTime() : 0) - (a.touched ? a.touched.getTime() : 0))
      .forEach(c => targets.push({ label: '↪ ' + c.name + (c.kind === 'grp' ? '（群）' : ''), fn: () => doForward(c.topic, arr) }));
    Array.from(state.directory.values()).filter(d => d.uid !== state.active && !state.contacts.has(d.uid))
      .forEach(d => targets.push({ label: '↪ ' + d.name, fn: () => doForward(d.uid, arr) }));
    if (!targets.length) { flash('暂无可转发的目标'); return; }
    showSheet(targets, { scrollable: true });
  }
  async function doForward(target, arr) {
    let ok = 0, skip = 0;
    for (const m of arr) {
      try { await client.forwardMessage(target, m); ok++; }
      catch (e) { skip++; }
    }
    exitSelect();
    const c = state.contacts.get(target), d = state.directory.get(target);
    const to = (c && c.name) || (d && d.name) || '对方';
    if (ok) flash('已转发 ' + ok + ' 条到 ' + to + (skip ? '（' + skip + ' 条局域网文件需到原会话重发）' : ''));
    else if (skip) flash('局域网直传文件暂不转发，请到原会话重新发送（' + skip + ' 项）');
    else flash('所选无可转发内容');
  }

  function loadMore(name) {
    const tp = client.topics.get(name); if (!tp) return;
    const tok = state._openSeq;
    const stream = $('stream');
    const beforeH = stream.scrollHeight, beforeTop = stream.scrollTop;   // 记锚点
    state._historyExpanded.add(name);   // 翻页期间及停留本会话时保留更多历史，离开会话再收回常驻窗口
    const arr0 = state.messages.get(name) || [];
    const limit = Math.min(24, 800 - arr0.length);
    if (limit <= 0) { flash('已加载本会话可浏览的 800 条消息'); return; }   // 不推进拉取范围，避免新拉历史随即被窗口裁掉
    // 不能用 getMessagesPage:与 openTopic 同一个 SDK 坑(受 read=seq 影响拉不到,实测返回 0 条)。
    // 改用 withData 显式范围:拉"当前已加载最早一条"之前的 limit 条。
    const minSeq = arr0.reduce((m, x) => (x.seq && x.seq < m ? x.seq : m), Infinity);
    const prevCount = arr0.length;
    tp.getMeta(tp.startMetaQuery().withData(undefined, isFinite(minSeq) ? minSeq : undefined, limit).build()).then(() => setTimeout(() => {
      if (tok !== state._openSeq || state.active !== name) return;
      if ((state.messages.get(name) || []).length <= prevCount) { flash('没有更早的消息了'); return; }
      renderStream(name);
      stream.scrollTop = beforeTop + (stream.scrollHeight - beforeH);   // 旧消息加在顶部，按高度差把视口锚回原处（不再被顶到底/顶）
    }, 300)).catch(() => {
      state._historyExpanded.delete(name);
      if (tok !== state._openSeq || state.active !== name) return;
      flash('没有更早的消息了');
    });
  }

  /* ===== 通知 ===== */
  function notify(title, msg, topic) {
    if (topic && isMuted(topic)) return;   // 单会话免打扰:不响铃、不弹通知
    playPing();
    if (state.muted) return;
    const body = (msg.mine ? '你：' : msg.fromName + '：') + (msg.plain || '').slice(0, 60);
    // 桌面壳：走原生通知（Electron 主进程，受托盘静音控制）
    if (global.mchatNative && global.mchatNative.isDesktop) { try { global.mchatNative.notify('mchat · ' + title, body); } catch (e) {} return; }
    if (!('Notification' in global)) return;
    if (Notification.permission === 'granted') { try { new Notification('mchat · ' + title, { body }); } catch (e) {} }
    else if (Notification.permission !== 'denied') Notification.requestPermission().catch(() => {});
  }
  // 总未读 → 应用内右上角数字 + Dock 角标
  function updateBadge() {
    let total = 0; state.contacts.forEach(c => { total += isMuted(c.topic) ? 0 : (c.unread || 0); });
    const el = $('unreadTotal');
    if (el) { if (total > 0) { el.textContent = total > 99 ? '99+' : total; el.classList.add('on'); } else el.classList.remove('on'); }
    if (global.mchatNative && global.mchatNative.setBadge) global.mchatNative.setBadge(total);   // 桌面：驱动托盘绿色慢闪(不用 Dock 红角标)
  }
  // 轻提示音（Web Audio 合成的柔和 ding，无需音频文件）
  function playPing() {
    if (state.muted) return;
    const _now = Date.now(); if (_now - (state._lastPing || 0) < 350) return; state._lastPing = _now;   // 同条消息双路到达时不重复响
    try {
      const AC = global.AudioContext || global.webkitAudioContext; if (!AC) return;
      const ctx = global.__mchatAC || (global.__mchatAC = new AC());
      if (ctx.state === 'suspended') ctx.resume().catch(() => {});
      const t0 = ctx.currentTime;
      // 简洁高级：两声柔和清脆的点(纯五度 E6+B6)，快起快落、克制不刺耳，贴合安静调性
      const tone = (freq, at, dur, peak) => {
        const o = ctx.createOscillator(), g = ctx.createGain();
        o.type = 'sine'; o.frequency.value = freq;
        g.gain.setValueAtTime(0.0001, t0 + at);
        g.gain.exponentialRampToValueAtTime(peak, t0 + at + 0.012);
        g.gain.exponentialRampToValueAtTime(0.0001, t0 + at + dur);
        o.connect(g); g.connect(ctx.destination); o.start(t0 + at); o.stop(t0 + at + dur + 0.02);
      };
      tone(1318.5, 0, 0.17, 0.07);      // E6 主音
      tone(1975.5, 0.045, 0.14, 0.045); // B6 轻点缀
    } catch (e) {}
  }

  /* ===== 连接态 ===== */
  function setConn(ok) {
    const cs = $('constate');
    if (cs) cs.classList.remove('show', 'ok');
    const pill = $('linkPill'); if (!pill) return;
    pill.classList.toggle('off', !ok);
    pill.title = ok ? '云端已连接' : '云端连接中断，正在自动重连';
    pill.innerHTML = '<span class="d"></span>云端';
  }
  // 重连成功只恢复顶部信号灯,避免横幅刷屏。
  function showReconnected() {
    setConn(true);
    if (state.active) markActiveRead();   // 顺手把当前会话标记已读
  }

  /* ===== 工具 ===== */
  function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
  function appEl() { return $('app'); }
  function isMobile() { return global.matchMedia && global.matchMedia('(max-width: 760px)').matches; }
  function retryOpen() { const n = state.active; if (n) { state.active = null; selectTopic(n); } }
  // 移动端返回：history 驱动——左缘滑(iOS 原生手势)/安卓返回键/返回按钮 统一走 popstate
  function pushMobileView() { if (isMobile()) { try { history.pushState({ mchat: 1 }, ''); } catch (e) {} } }
  function doMobileBack() {   // 真正退一层（不动 history，避免递归）
    const a = appEl();
    if (a.classList.contains('show-detail')) { a.classList.remove('show-detail'); return true; }
    if (a.classList.contains('show-chat')) { a.classList.remove('show-chat'); return true; }
    return false;
  }
  function mobileBack() { if (isMobile() && history.state && history.state.mchat) history.back(); else doMobileBack(); }
  // 左→右滑返回:左半屏起手、横向位移主导即逐级返回。
  // 关键修复——改用 touchmove 实时判定(原来只在 touchend 判 dy<48&&dt<700 太苛刻，自然滑动几乎触发不了)。
  function bindSwipeBack() {
    if (global.__mchatNativeBack) return;   // iOS 壳用原生边缘手势(WKWebView)负责返回，跳过 JS 兜底以免退两层
    let sx = 0, sy = 0, t0 = 0, tracking = false, fired = false;
    document.addEventListener('touchstart', (e) => {
      tracking = false; fired = false;
      if (!isMobile() || !e.touches[0]) return;
      const a = appEl();
      if (!a.classList.contains('show-chat') && !a.classList.contains('show-detail')) return;
      const x = e.touches[0].clientX;
      if (x > global.innerWidth * 0.5) return;          // 仅左半屏起手才算返回手势
      tracking = true; sx = x; sy = e.touches[0].clientY; t0 = Date.now();
    }, { passive: true, capture: true });
    document.addEventListener('touchmove', (e) => {
      if (!tracking || fired || !e.touches[0]) return;
      const dx = e.touches[0].clientX - sx, dy = e.touches[0].clientY - sy;
      if (dx > 52 && Math.abs(dy) < dx * 0.85 && (Date.now() - t0) < 1200) { fired = true; tracking = false; mobileBack(); }
      else if (Math.abs(dy) > 56 && Math.abs(dy) > Math.abs(dx)) tracking = false;   // 明显竖向滚动 → 让位
    }, { passive: true, capture: true });
    document.addEventListener('touchcancel', () => { tracking = false; }, { passive: true, capture: true });
  }
  function pad(n) { return n < 10 ? '0' + n : '' + n; }
  function hm(d) { return d ? pad(d.getHours()) + ':' + pad(d.getMinutes()) : ''; }
  function dayKey(d) { if (!d) return ''; const now = new Date(); const same = d.toDateString() === now.toDateString(); const y = new Date(now - 864e5); if (same) return '今天'; if (d.toDateString() === y.toDateString()) return '昨天'; return (d.getMonth() + 1) + '月' + d.getDate() + '日'; }
  function relTime(d) { if (!d) return ''; const s = (Date.now() - d.getTime()) / 1000; if (s < 60) return '刚刚'; if (s < 3600) return Math.floor(s / 60) + ' 分'; if (s < 86400) return Math.floor(s / 3600) + ' 时'; return Math.floor(s / 86400) + ' 天'; }
  function flash(t) { const el = $('flash'); el.textContent = t; el.style.opacity = '1'; clearTimeout(el._t); el._t = setTimeout(() => el.style.opacity = '0', 1800); }

  /* ===== Agent Gateway 控制台 ===== */
  function agentBase() { return String(global.MCHAT_AGENT_GATEWAY || '/agent-gateway').replace(/\/+$/, ''); }
  function agentService() { const el = $('agentService'); return el ? el.value : 'agent_gateway'; }
  function redactAgentText(text) {
    return String(text == null ? '' : text)
      .replace(/(Bearer\s+)[A-Za-z0-9._~+/-]+/g, '$1[redacted]')
      .replace(/\b([A-Z0-9_]*(TOKEN|KEY|PASS|PASSWORD)[A-Z0-9_]*)=([^\s]+)/gi, '$1=[redacted]');
  }
  function agentOut(data) {
    const el = $('agentOut'); if (!el) return;
    if (typeof data === 'string') el.textContent = redactAgentText(data);
    else el.textContent = redactAgentText(JSON.stringify(data, null, 2));
  }
  function setAgentHealth(text, ok) {
    const el = $('agentHealth'); if (!el) return;
    el.textContent = text;
    el.style.color = ok == null ? '' : (ok ? 'var(--accent-deep)' : '#c0392b');
  }
  function agentToken() {
    const input = $('agentToken');
    const value = (input && input.value.trim()) || (safeSessionGet('mchat.agent.token') || '').trim();
    if (!value) { flash('请输入 Gateway token'); throw new Error('missing token'); }
    safeSessionSet('mchat.agent.token', value);
    return value;
  }
  function safeSessionGet(key) { try { return sessionStorage.getItem(key) || ''; } catch (e) { return ''; } }
  function safeSessionSet(key, value) { try { sessionStorage.setItem(key, value); } catch (e) {} }
  function formatAgentStatus(data) {
    const lines = ['host: ' + (data.host || '-')];
    (data.services || []).forEach(s => lines.push((s.ok ? 'OK   ' : 'FAIL ') + s.id + ' · ' + (s.active || '-')));
    return lines.join('\n');
  }
  async function agentHealth() {
    setAgentHealth('检查中…', null); agentOut('checking health...');
    try {
      const r = await fetch(agentBase() + '/health', { cache: 'no-store' });
      const data = await r.json();
      setAgentHealth(data.ok ? '正常' : '异常', !!data.ok);
      agentOut(formatAgentStatus(data));
      return data;
    } catch (e) {
      setAgentHealth('不可达', false); agentOut('health failed: ' + ((e && e.message) || e));
      return null;
    }
  }
  async function agentTask(action, params, confirmed) {
    const r = await fetch(agentBase() + '/task', {
      method: 'POST',
      headers: { 'Authorization': 'Bearer ' + agentToken(), 'Content-Type': 'application/json' },
      body: JSON.stringify({ action, params: params || {}, confirm: confirmed === true }),
    });
    const text = await r.text();
    let data; try { data = JSON.parse(text || '{}'); } catch (e) { data = { ok: false, error: text || r.statusText }; }
    if (!r.ok) data.httpStatus = r.status;
    return data;
  }
  async function agentStatus() {
    try {
      agentOut('loading status...');
      const data = await agentTask('status');
      setAgentHealth(data.ok ? '正常' : '异常', !!data.ok);
      agentOut(formatAgentStatus(data));
    } catch (e) { agentOut('status failed: ' + ((e && e.message) || e)); }
  }
  async function agentLogs() {
    try {
      agentOut('loading logs...');
      const data = await agentTask('logs', { service: agentService(), lines: 80 });
      agentOut((data.stdout || '') + (data.stderr ? '\nSTDERR:\n' + data.stderr : '') || data);
    } catch (e) { agentOut('logs failed: ' + ((e && e.message) || e)); }
  }
  async function agentRestart() {
    const service = agentService();
    if (!global.confirm('确认重启 ' + service + '？')) return;
    try {
      agentOut('restarting ' + service + '...');
      const data = await agentTask('restart_service', { service }, true);
      agentOut(data);
      setTimeout(agentStatus, 900);
    } catch (e) { agentOut('restart failed: ' + ((e && e.message) || e)); }
  }
  function initAgentPanel() {
    const input = $('agentToken');
    if (!input) return;
    input.value = safeSessionGet('mchat.agent.token');
    input.addEventListener('input', () => safeSessionSet('mchat.agent.token', input.value.trim()));
    if (global.MCHAT_AGENT_GATEWAY || /(^|\.)im\.morewater\.top$/i.test(location.hostname)) setTimeout(agentHealth, 800);
  }

  /* ===== 移动端：长按 + 操作菜单(actionsheet) ===== */
  function bindMessageMenu(el, fn) {
    bindLongPress(el, fn);
    el.addEventListener('contextmenu', (e) => {
      if (e.target && e.target.closest && e.target.closest('.fcard,.voicecard,img.inl,.hov,.read,.av')) return;
      e.preventDefault();
      fn(e);
    });
  }
  function bindLongPress(el, fn) {
    let timer = null, moved = false, sx = 0, sy = 0;
    el.addEventListener('touchstart', (e) => {
      if (!e.touches[0]) return; moved = false; sx = e.touches[0].clientX; sy = e.touches[0].clientY;
      timer = setTimeout(() => { if (!moved) { el._longPressedUntil = Date.now() + 700; try { navigator.vibrate && navigator.vibrate(12); } catch (_) {} fn(e); } }, 460);
    }, { passive: true });
    el.addEventListener('touchmove', (e) => { if (e.touches[0] && (Math.abs(e.touches[0].clientX - sx) > 10 || Math.abs(e.touches[0].clientY - sy) > 10)) { moved = true; clearTimeout(timer); } }, { passive: true });
    el.addEventListener('touchend', () => clearTimeout(timer), { passive: true });
    el.addEventListener('touchcancel', () => clearTimeout(timer), { passive: true });
    el.addEventListener('click', (e) => { if (el._longPressedUntil && Date.now() < el._longPressedUntil) { e.preventDefault(); e.stopPropagation(); } }, true);
  }
  function showSheet(items, opts) {
    opts = opts || {};
    const bg = $('sheetBg'), sheet = $('actionSheet'); if (!bg || !sheet) return;
    sheet.innerHTML = '';
    sheet.classList.toggle('scrollable', !!opts.scrollable || items.length > 8);
    items.forEach(it => { const b = document.createElement('div'); b.className = 'sheet-item' + (it.danger ? ' danger' : ''); b.textContent = it.label; b.onclick = () => { closeSheet(); setTimeout(() => { try { it.fn(); } catch (e) {} }, 50); }; sheet.appendChild(b); });
    const cancel = document.createElement('div'); cancel.className = 'sheet-item cancel'; cancel.textContent = '取消'; cancel.onclick = closeSheet; sheet.appendChild(cancel);
    bg.classList.add('on');
  }
  function showContextMenu(items, anchor, ev, kind) {
    closeContextMenu();
    if (!items || !items.length) return;
    const bg = document.createElement('div'); bg.className = 'ctx-bg';
    const menu = document.createElement('div'); menu.className = 'ctx-menu';
    items.forEach(it => {
      const b = document.createElement('div');
      b.className = 'ctx-item' + (it.danger ? ' danger' : '');
      b.textContent = it.label;
      b.onclick = (e) => { e.stopPropagation(); closeContextMenu(); setTimeout(() => { try { it.fn(); } catch (_) {} }, 30); };
      menu.appendChild(b);
    });
    bg.onclick = closeContextMenu;
    document.body.appendChild(bg);
    document.body.appendChild(menu);
    const rect = contextAnchorRect(anchor, ev, kind);
    const vw = global.innerWidth || document.documentElement.clientWidth || 360;
    const vh = global.innerHeight || document.documentElement.clientHeight || 640;
    const mw = menu.offsetWidth, mh = menu.offsetHeight;
    const minTop = document.body.classList.contains('is-desktop') ? 42 : 8;   // 桌面壳顶部 34px 是窗口拖拽条,菜单避开它
    let left = kind === 'message' && anchor && anchor.classList && anchor.classList.contains('me') ? rect.right - mw : rect.left;
    let top = kind === 'row' ? rect.bottom + 4 : rect.top - mh - 8;
    if (top < minTop) top = rect.bottom + 8;
    left = Math.max(8, Math.min(left, vw - mw - 8));
    top = Math.max(minTop, Math.min(top, vh - mh - 8));
    menu.style.left = left + 'px';
    menu.style.top = top + 'px';
  }
  function contextAnchorRect(anchor, ev, kind) {
    const node = anchor && (anchor.querySelector && anchor.querySelector('.bub,.fcard,.voicecard')) || anchor;
    if (node && node.getBoundingClientRect) return node.getBoundingClientRect();
    const x = ev && ev.clientX || 20, y = ev && ev.clientY || 20;
    return { left: x, right: x, top: y, bottom: y, width: 0, height: 0 };
  }
  function closeContextMenu() {
    document.querySelectorAll('.ctx-bg,.ctx-menu').forEach(el => el.remove());
  }
  function closeSheet() { const bg = $('sheetBg'), sheet = $('actionSheet'); if (bg) bg.classList.remove('on'); if (sheet) sheet.classList.remove('scrollable'); }
  /* 图片放大查看 + 保存 */
  let _lbSrc = '';
  function openLightbox(src) {
    _lbSrc = src; const img = $('lbImg'); img.src = src; img.style.transform = '';
    $('lightbox').classList.add('on'); bindLightboxZoom(img);
  }
  function closeLightbox() { const img = $('lbImg'); if (img && img._lbReset) img._lbReset(); $('lightbox').classList.remove('on'); }
  // lightbox 双指/滚轮缩放 + 拖动 + 双击放大-还原(单点放大后还能继续放大细看)
  function bindLightboxZoom(img) {
    if (img._zoomBound) return; img._zoomBound = true;
    let scale = 1, tx = 0, ty = 0, startDist = 0, startScale = 1, panning = false, sx = 0, sy = 0;
    const apply = () => { img.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')'; img.style.cursor = scale > 1 ? 'grab' : 'zoom-in'; };
    const reset = () => { scale = 1; tx = 0; ty = 0; apply(); };
    img._lbReset = reset;
    const dist = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
    img.addEventListener('touchstart', (e) => {
      if (e.touches.length === 2) { startDist = dist(e.touches); startScale = scale; }
      else if (e.touches.length === 1 && scale > 1) { panning = true; sx = e.touches[0].clientX - tx; sy = e.touches[0].clientY - ty; }
    }, { passive: false });
    img.addEventListener('touchmove', (e) => {
      if (e.touches.length === 2) { e.preventDefault(); scale = Math.min(6, Math.max(1, startScale * dist(e.touches) / (startDist || 1))); if (scale <= 1.001) { tx = 0; ty = 0; } apply(); }
      else if (panning && e.touches.length === 1) { e.preventDefault(); tx = e.touches[0].clientX - sx; ty = e.touches[0].clientY - sy; apply(); }
    }, { passive: false });
    img.addEventListener('touchend', (e) => { if (e.touches.length === 0) panning = false; });
    img.addEventListener('wheel', (e) => { e.preventDefault(); scale = Math.min(6, Math.max(1, scale * (e.deltaY < 0 ? 1.12 : 0.89))); if (scale <= 1.001) { tx = 0; ty = 0; } apply(); }, { passive: false });
    img.addEventListener('dblclick', (e) => { e.stopPropagation(); if (scale > 1) reset(); else { scale = 2.4; apply(); } });
  }
  function saveLightbox() {
    if (!_lbSrc) return;
    if (global.mchatNative && global.mchatNative.saveImage) { global.mchatNative.saveImage(_lbSrc).then((r) => flash(r && r.ok ? ('已保存到「下载」文件夹：' + r.path.split('/').pop()) : '保存取消')).catch(() => flash('保存失败')); return; }
    try { const a = document.createElement('a'); a.href = _lbSrc; a.download = 'mchat-' + Date.now() + '.jpg'; document.body.appendChild(a); a.click(); a.remove(); flash('已保存'); } catch (e) { flash('保存失败'); }
  }
  async function copyText(t) {
    t = String(t == null ? '' : t);
    if (!t) return false;
    try {
      if (global.mchatNative && global.mchatNative.writeClipboard) {
        const ok = await global.mchatNative.writeClipboard(t);
        if (ok !== false) return true;
      }
    } catch (e) {}
    try {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        await navigator.clipboard.writeText(t);
        return true;
      }
    } catch (e) {}
    try {
      const ta = document.createElement('textarea');
      ta.value = t;
      ta.setAttribute('readonly', '');
      ta.style.position = 'fixed';
      ta.style.left = '-9999px';
      ta.style.top = '0';
      document.body.appendChild(ta);
      ta.focus();
      ta.select();
      ta.setSelectionRange(0, ta.value.length);
      const ok = document.execCommand('copy');
      document.body.removeChild(ta);
      return !!ok;
    } catch (e) { return false; }
  }
  async function copyAndFlash(t) {
    const ok = await copyText(t);
    flash(ok ? '已复制' : '复制失败，请手动选择后复制');
  }
  function removeConv(topic) {
    if (!global.confirm('从列表删除该会话？(消息记录保留在云端，重新打开仍可见)')) return;
    state.contacts.delete(topic); state.messages.delete(topic);
    if (state.active === topic) { state.active = null; doMobileBack(); $('stream').innerHTML = '<div class="empty">选个会话，开始聊天</div>'; }
    renderLists(); flash('已从列表删除');
  }

  /* ===== 全局交互（HTML inline onclick 调用） ===== */
  function bindGlobal() {
    const inp = $('cinput');
    inp.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendNow(); }
      else if (e.key === '@' && state.active && client.isGroup(state.active)) { e.preventDefault(); insertAtCursor('@'); setTimeout(() => $('mpick').classList.add('open'), 0); }   // 打"@"自动弹选人(preventDefault 防与 insertMention 的@叠成@@)
      else if (e.key === 'Escape') $('mpick').classList.remove('open');
    });
    inp.addEventListener('input', () => { if (state.active) client.noteTyping(state.active); });
    inp.addEventListener('dragover', (e) => e.preventDefault());
    inp.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); handleDroppedFiles(collectDropItems(e)); });   // 拖到输入框也能发文件/文件夹(防塞进 contenteditable + stopPropagation 不冒泡到 .chat 重复处理)
    // 粘贴：图片(截图)直接发送；其余只取纯文本，杜绝外部富文本/HTML 注入打乱输入框
    inp.addEventListener('paste', (e) => {
      const items = (e.clipboardData && e.clipboardData.items) || [];
      for (const it of items) { if (it.type && it.type.indexOf('image') === 0) { const f = it.getAsFile(); if (f) { e.preventDefault(); handleImageFile(f); return; } } }
      const text = e.clipboardData && e.clipboardData.getData ? e.clipboardData.getData('text/plain') : '';
      e.preventDefault(); if (text) insertAtCursor(text);
    });
    $('searchInput').addEventListener('input', renderLists);
    initAgentPanel();
    $('stream').addEventListener('click', (e) => { if (appEl().classList.contains('selecting') && e.target === $('stream')) exitSelect(); });   // 选择模式点消息区空白处 → 退出(手机友好)
    // 多选时点消息任意位置=只勾选(像微信):捕获阶段拦下链接跳转/图片放大等默认行为
    $('stream').addEventListener('click', (e) => {
      if (!appEl().classList.contains('selecting')) return;
      const msg = e.target.closest && e.target.closest('.msg');
      if (!msg) return;
      if (msg._longPressedUntil && Date.now() < msg._longPressedUntil) { e.preventDefault(); e.stopPropagation(); return; }   // 长按刚进多选的抬手点击,吞掉不误勾
      e.preventDefault(); e.stopPropagation();
      toggleSel(msg);
    }, true);
    // 拖拽文件/图片进整个聊天区(不只输入框) → 图片进待发区预览(支持多张) / 文件走局域网直传
    const dropZone = document.querySelector('.chat');
    if (dropZone) {
      const hideDrag = () => dropZone.classList.remove('drag');
      dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag'); });
      dropZone.addEventListener('dragleave', (e) => { if (!dropZone.contains(e.relatedTarget)) hideDrag(); });
      dropZone.addEventListener('drop', (e) => {
        e.preventDefault(); hideDrag();
        handleDroppedFiles(collectDropItems(e));
      });
      // ★遮罩兜底:无论在哪里松手都要清掉「松手，把图片放进来」。捕获阶段先于输入框 drop 的 stopPropagation
      // 执行,所以落在输入框、拖出窗口、ESC 取消、拖拽结束等情况都能收掉遮罩,不再卡住。
      global.addEventListener('drop', hideDrag, true);
      global.addEventListener('dragend', hideDrag, true);
      global.addEventListener('dragleave', (e) => { if (!e.relatedTarget) hideDrag(); });   // 拖出浏览器窗口
    }
    global.addEventListener('visibilitychange', () => { markActiveRead(); if (document.hidden) stampLastOpen(); else { scheduleUpdateCheck(); try { client && client.resume(); } catch (e) {} } });   // 切走盖离开时间戳;回前台探活/重连(僵尸socket兜底)
    global.addEventListener('pagehide', stampLastOpen);   // 页面卸载/壳杀进程前留档,重开按"离开时间"判断回会话还是回列表
    global.addEventListener('focus', () => { markActiveRead(); scheduleUpdateCheck(); });
    global.addEventListener('online', () => { scheduleUpdateCheck(true); if (!document.hidden) { try { client && client.resume(); } catch (e) {} } });   // 前台时网络恢复即重连;后台保持离线走推送
    global.addEventListener('popstate', () => { if (isMobile()) doMobileBack(); });   // 边缘滑/返回键/返回按钮 统一退层
    $('loginPass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
    // 全局 Esc：按优先级关掉最上层弹层（桌面用户肌肉记忆，缺了它每次都要摸鼠标）
    document.addEventListener('keydown', (e) => {
      if (e.key !== 'Escape') return;
      if ($('app').classList.contains('selecting')) return exitSelect();   // 选择/转发/导出栏 → Esc 退出
      if (document.querySelector('.ctx-menu')) return closeContextMenu();
      if ($('lightbox') && $('lightbox').classList.contains('on')) return closeLightbox();
      if ($('sheetBg') && $('sheetBg').classList.contains('on')) return closeSheet();
      if (document.querySelector('.modal-bg.open')) return closeModals();
      if ($('mpick') && $('mpick').classList.contains('open')) { $('mpick').classList.remove('open'); return; }
      if (state.reply) clearReply();
    });
    document.querySelectorAll('.modal-bg').forEach(m => m.addEventListener('click', e => { if (e.target === m) closeModals(); }));
    document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => { t.parentElement.querySelectorAll('.tab').forEach(x => x.classList.remove('on')); t.classList.add('on'); }));
    // 栏宽拖拽
    (function () { const rail = $('rail'), app = $('app'); let down = false; rail.addEventListener('mousedown', e => { if (e.offsetX > rail.offsetWidth - 8) { down = true; document.body.style.cursor = 'col-resize'; e.preventDefault(); } }); global.addEventListener('mousemove', e => { if (down) { const w = Math.min(380, Math.max(220, e.clientX)); app.style.setProperty('--c1', w + 'px'); } }); global.addEventListener('mouseup', () => { down = false; document.body.style.cursor = ''; }); })();
    // 输入框高度拖拽
    (function () { const h = $('cresize'), inp = $('cinput'); let y0, h0, drag = false; h.addEventListener('mousedown', e => { drag = true; y0 = e.clientY; h0 = inp.offsetHeight; document.body.style.cursor = 'row-resize'; e.preventDefault(); }); global.addEventListener('mousemove', e => { if (drag) { const nh = Math.min(240, Math.max(22, h0 + (y0 - e.clientY))); inp.style.setProperty('--ch', nh + 'px'); inp.style.height = nh + 'px'; } }); global.addEventListener('mouseup', () => { drag = false; document.body.style.cursor = ''; }); })();
    // 录音按钮：按住录 / 松手发 / 上滑取消（鼠标 + 触摸；松手在 document 上收，按住后移出按钮也能正确结束）
    const recBtn = $('recBtn');
    if (recBtn) {
      const startHandler = (e) => { e.preventDefault(); startRec(); };
      recBtn.addEventListener('mousedown', startHandler);
      recBtn.addEventListener('touchstart', startHandler, { passive: false });
      recBtn.addEventListener('contextmenu', (e) => e.preventDefault());
      global.addEventListener('mousemove', (e) => { if (recActive()) moveRec(e.clientY); });
      global.addEventListener('touchmove', (e) => { if (recActive() && e.touches && e.touches[0]) moveRec(e.touches[0].clientY); }, { passive: true });
      global.addEventListener('mouseup', () => { if (recActive()) endRec(); });
      global.addEventListener('touchend', () => { if (recActive()) endRec(); });
      global.addEventListener('touchcancel', () => { if (recActive()) endRec(); });
    }
    // 微信式「按住说话」长条(手机端语音模式):按住录,松手/上滑复用上面的全局 mouseup/touchmove/touchend
    const holdBtn = $('holdBtn');
    if (holdBtn) {
      const holdStart = (e) => { e.preventDefault(); startRec(); };
      holdBtn.addEventListener('mousedown', holdStart);
      holdBtn.addEventListener('touchstart', holdStart, { passive: false });
      holdBtn.addEventListener('contextmenu', (e) => e.preventDefault());
    }
  }

  // 暴露给 HTML inline 调用（client 暴露便于控制台调试/自检）
  global.UI = { boot, doLogin, toggleRegister, doRegister, sendNow, clearReply, toggleMpick, saveAvatar, exportSelected, deleteSelected, forwardSelected, sendLanFile, mobileBack, pickImage, setAnnounce, toggleActiveMute, logout, winMin, winMax, winClose, retryOpen, closeSheet, closeLightbox, saveLightbox, toggleVoiceMode, openNewGroup, openAddMembers, submitGroup, filterGroupPicks, leaveOrDissolve, renameGroup, refresh, saveAnnounce, applyUpdate, dismissUpdate, checkUpdate, uploadAvatar, agentHealth, agentStatus, agentLogs, agentRestart };
  // 移动端原生壳(iOS/安卓)拿到 FCM 推送 token 后调这个,交给 client 上报服务端(登录后生效)
  global.mchatSetPushToken = (token) => { try { client && client.setDeviceToken(token); } catch (e) {} };
  // 移动端切后台/回前台:断开↔重连 Tinode(后台断=立刻离线→新消息走推送,像微信;回前台重连取回消息)
  global.mchatPause = () => { try { stampLastOpen(); client && client.pause(); } catch (e) {} };
  global.mchatResume = () => { try { client && client.resume(); } catch (e) {} };
  global.mchatClient = client;
  global.toggleGrp = function (el) {
    const b = el.closest('.grp').nextElementSibling;                                  // .gbody
    const willClose = !el.classList.contains('closed');                               // 用 .closed 类判状态(原代码用 style.height,初始''走错分支→折不了)
    el.classList.toggle('closed');
    b.style.height = b.scrollHeight + 'px';                                           // 固定当前高度作过渡起点
    if (willClose) requestAnimationFrame(() => { b.style.height = '0px'; });          // 展开高→0:折叠
    else setTimeout(() => { if (!el.classList.contains('closed')) b.style.height = ''; }, 260);  // 0→内容高:展开,完后恢复 auto
  };
  global.toggleDetail = function () { if (isMobile()) { const a = $('app'); const willShow = !a.classList.contains('show-detail'); a.classList.toggle('show-detail'); if (willShow) pushMobileView(); else if (history.state && history.state.mchat) history.back(); } else $('app').classList.toggle('collapsed'); };
  global.openAvatar = function () { buildAvatarPicker(); $('avatarModal').classList.add('open'); };
  global.openEmoji = function () { buildEmojiPicker(); $('emojiModal').classList.add('open'); };
  global.closeModals = function () { document.querySelectorAll('.modal-bg').forEach(m => m.classList.remove('open')); };
  global.enterSelect = function () { enterSelectWithSeq(); };
  global.exitSelect = function () { $('app').classList.remove('selecting'); document.querySelectorAll('.msg.sel').forEach(m => m.classList.remove('sel')); };
  global.toggleSel = function (m) { if (!$('app').classList.contains('selecting')) return; m.classList.toggle('sel'); updateCnt(); };
  function updateCnt() { $('selCnt').textContent = '已选 ' + document.querySelectorAll('.msg.sel').length + ' 条'; }

  document.addEventListener('DOMContentLoaded', () => { boot(); scheduleUpdateCheck(true); setInterval(() => scheduleUpdateCheck(true), UPDATE_CHECK_MS); });
})(window);
