a158 hace 2 semanas
padre
commit
484d516c07

+ 6 - 0
Client/Client.vcxproj

@@ -185,8 +185,14 @@ if exist "$(ProjectDir)3rdparty\7z\x64\7za.exe" copy /Y "$(ProjectDir)3rdparty\7
     <ClCompile Include="src\auth\ForgotPasswordDialog.cpp" />
     <ClCompile Include="src\auth\ChangePasswordDialog.cpp" />
     <ClInclude Include="src\mainframe\SettingsDialog.h" />
+    <ClInclude Include="src\mainframe\ClientInternal.h" />
+    <ClInclude Include="src\mainframe\ClientImpl.h" />
     <ClInclude Include="src\mainframe\PickFriendDialog.h" />
     <ClCompile Include="src\mainframe\Client.cpp" />
+    <ClCompile Include="src\mainframe\ClientChat.cpp" />
+    <ClCompile Include="src\mainframe\ClientConv.cpp" />
+    <ClCompile Include="src\mainframe\ClientSend.cpp" />
+    <ClCompile Include="src\mainframe\ClientSession.cpp" />
     <ClCompile Include="src\mainframe\SettingsDialog.cpp" />
     <ClCompile Include="src\mainframe\PickFriendDialog.cpp" />
     <ClCompile Include="src\mainframe\AddFriendDialog.cpp" />

+ 18 - 0
Client/Client.vcxproj.filters

@@ -57,6 +57,24 @@
     <ClCompile Include="src\mainframe\Client.cpp">
       <Filter>src\mainframe</Filter>
     </ClCompile>
+    <ClCompile Include="src\mainframe\ClientChat.cpp">
+      <Filter>src\mainframe</Filter>
+    </ClCompile>
+    <ClCompile Include="src\mainframe\ClientConv.cpp">
+      <Filter>src\mainframe</Filter>
+    </ClCompile>
+    <ClCompile Include="src\mainframe\ClientSend.cpp">
+      <Filter>src\mainframe</Filter>
+    </ClCompile>
+    <ClCompile Include="src\mainframe\ClientSession.cpp">
+      <Filter>src\mainframe</Filter>
+    </ClCompile>
+    <ClInclude Include="src\mainframe\ClientInternal.h">
+      <Filter>src\mainframe</Filter>
+    </ClInclude>
+    <ClInclude Include="src\mainframe\ClientImpl.h">
+      <Filter>src\mainframe</Filter>
+    </ClInclude>
     <ClCompile Include="src\mainframe\SettingsDialog.cpp">
       <Filter>src\mainframe</Filter>
     </ClCompile>

+ 633 - 4533
Client/src/mainframe/Client.cpp

@@ -1,4651 +1,751 @@
-#include "Client.h"
-#include "AddFriendDialog.h"
-#include "FriendRequestDialog.h"
-#include "ChangePasswordDialog.h"
-#include "SettingsDialog.h"
-#include "FavoritePanel.h"
-#include "PickFriendDialog.h"
-#include "StickerCache.h"
-#include "AvatarCache.h"
-#include "FileCache.h"
-#include "FileTypeIcon.h"
-#include "ImagePreviewDialog.h"
-#include "StickerPanel.h"
-#include "UiHelpers.h"
-#include "AppTheme.h"
-#include "AppConfig.h"
-#include "TitleBar.h"
-#include "FramelessHelper.h"
-#include "ConfirmDialog.h"
-#include "PromptDialog.h"
-#include "UserSettings.h"
-#include "LocalStore.h"
-#include "ImClient.h"
-#include "Protocol.h"
-#include "Updater.h"
-#include "Logger.h"
+// Client.cpp — 窗口壳:构造、导航布局、窗口事件、设置/退出
+#include "ClientImpl.h"
 
-#include <algorithm>
-
-#include <QAbstractItemView>
-#include <QCheckBox>
-#include <QClipboard>
-#include <QAction>
-#include <QApplication>
-#include <QCloseEvent>
-#include <QCursor>
-#include <QDate>
-#include <QDateTime>
-#include <QDesktopServices>
-#include <QFile>
-#include <QFileDialog>
-#include <QFileInfo>
-#include <QMimeDatabase>
-#include <QStandardPaths>
-#include <QUrl>
-#include <QDialog>
-#include <QElapsedTimer>
-#include <QEvent>
-#include <QFontMetrics>
-#include <QShowEvent>
-#include <QFrame>
-#include <QImage>
-#include <QMouseEvent>
-#include <QGridLayout>
-#include <QHBoxLayout>
-#include <QIcon>
-#include <QJsonArray>
-#include <QJsonDocument>
-#include <QJsonObject>
-#include <QKeyEvent>
-#include <QLabel>
-#include <QLayout>
-#include <QLayoutItem>
-#include <QContextMenuEvent>
-#include <QLineEdit>
-#include <QListView>
-#include <QListWidget>
-#include <QListWidgetItem>
-#include <QMap>
-#include <QMenu>
-#include <QObject>
-#include <QPainter>
-#include <QPainterPath>
-#include <QPolygonF>
-#include <QPalette>
-#include <QPen>
-#include <QPointer>
-#include <QProgressBar>
-#include <QPushButton>
-#include <QRect>
-#include <QResizeEvent>
-#include <QMenuBar>
-#include <QScrollArea>
-#include <QScrollBar>
-#include <QSizePolicy>
-#include <QSplitter>
-#include <QStackedWidget>
-#include <QStatusBar>
-#include <QStyle>
-#include <QSystemTrayIcon>
-#include <QTextEdit>
-#include <QTextLayout>
-#include <QTextOption>
-#include <QTimer>
-#include <QVariant>
-#include <QtMath>
-#include <QVBoxLayout>
-
-#include <functional>
+Client::Client(const QString &account, const QString &nickname, QWidget *parent)
+    : QMainWindow(parent)
+    , m_account(account.isEmpty() ? ImClient::instance().account() : account)
+    , m_nickname(nickname.isEmpty() ? ImClient::instance().nickname() : nickname)
+    , m_uid(ImClient::instance().uid())
+{
+    if (m_nickname.isEmpty())
+        m_nickname = m_account;
+    setWindowTitle(QStringLiteral("NIM"));
+    setObjectName(QStringLiteral("ChromeWindow"));
+    setMinimumSize(760, 500);
+    resize(1000, 712);
+    setWindowFlags(windowFlags() | Qt::FramelessWindowHint);
+    setWindowIcon(UiHelpers::appIcon());
+    if (statusBar())
+        statusBar()->hide();
+    if (menuBar())
+        menuBar()->hide();
+    LocalStore::instance().openForUser(m_uid);
+    buildUi();
+    QTimer::singleShot(400, this, [this]() { Updater::promptIfNeeded(this); });
+}
 
-#ifdef Q_OS_WIN
-#ifndef WIN32_LEAN_AND_MEAN
-#define WIN32_LEAN_AND_MEAN
-#endif
-#include <windows.h>
-#endif
+Client::~Client()
+{
+    m_chatPages.clear();
+    LocalStore::instance().close();
+}
 
-namespace
+void Client::buildUi()
 {
-    enum {
-        RolePeerUid = Qt::UserRole,
-        RoleName = Qt::UserRole + 1,
-        RoleUnread = Qt::UserRole + 2,
-        RolePreview = Qt::UserRole + 3,
-        RoleTs = Qt::UserRole + 4,
-        RoleAccount = Qt::UserRole + 5,
-        RoleSignature = Qt::UserRole + 6,
-        RoleRemark = Qt::UserRole + 7,
-        RoleNick = Qt::UserRole + 8,
-        RoleAvatarVer = Qt::UserRole + 9,
-        RoleOnline = Qt::UserRole + 10
-    };
+    connect(&AvatarCache::instance(), &AvatarCache::updated, this, &Client::refreshAvatar);
 
-    constexpr int kReconnectMaxTries = 3;
-    constexpr int kReconnectIntervalSec = 10;
+    auto *shell = new QWidget(this);
+    auto *root = new QVBoxLayout(shell);
+    root->setContentsMargins(0, 0, 0, 0);
+    root->setSpacing(0);
 
-    QString fmtMs(qint64 nsecs)
+    m_titleBar = new TitleBar(shell);
+    m_titleBar->setTitle(QStringLiteral("NIM"));
+    m_titleBar->setTheme(TitleBar::Light);
+    m_titleBar->setMaximizable(true);
+    m_titleBar->bind(this, true);
+    if (auto *helper = m_titleBar->framelessHelper())
     {
-        return QString::number(nsecs / 1000000.0, 'f', 1);
+        m_geomSave = new QTimer(this);
+        m_geomSave->setSingleShot(true);
+        m_geomSave->setInterval(400);
+        connect(m_geomSave, &QTimer::timeout, this, &Client::persistWindow);
+        connect(helper, &FramelessHelper::normalGeometryChanged, this, [this]() {
+            if (m_geomSave)
+                m_geomSave->start();
+        });
     }
+    root->addWidget(m_titleBar);
 
-    class SpinRing : public QWidget
-    {
-    public:
-        explicit SpinRing(QWidget *parent = nullptr)
-            : QWidget(parent)
-        {
-            setFixedSize(36, 36);
-            auto *t = new QTimer(this);
-            connect(t, &QTimer::timeout, this, [this]() {
-                m_angle = (m_angle + 30) % 360;
-                update();
-            });
-            t->start(80);
-        }
-
-    protected:
-        void paintEvent(QPaintEvent *) override
-        {
-            QPainter p(this);
-            p.setRenderHint(QPainter::Antialiasing, true);
-            const QRectF r = QRectF(rect()).adjusted(3.5, 3.5, -3.5, -3.5);
-            QPen back(QColor(7, 193, 96, 40), 3, Qt::SolidLine, Qt::RoundCap);
-            p.setPen(back);
-            p.drawEllipse(r);
-            QPen pen(QColor("#07C160"), 3, Qt::SolidLine, Qt::RoundCap);
-            p.setPen(pen);
-            p.drawArc(r, -m_angle * 16, 220 * 16);
-        }
-
-    private:
-        int m_angle = 0;
-    };
+    auto *body = new QWidget(shell);
+    auto *bodyLay = new QHBoxLayout(body);
+    bodyLay->setContentsMargins(0, 0, 0, 0);
+    bodyLay->setSpacing(0);
+    bodyLay->addWidget(buildNav());
 
-    struct Conv
-    {
-        qint64 uid = 0;
-        QString name;
-        QString preview;
-        QString time;
-        int unread = 0;
-        int avatarVer = 0;
-        bool online = false;
-    };
+    auto *split = new QSplitter(Qt::Horizontal, body);
+    split->setHandleWidth(1);
+    split->setChildrenCollapsible(false);
+    split->addWidget(buildConversationPane());
+    split->addWidget(buildChatPane());
+    split->setStretchFactor(0, 0);
+    split->setStretchFactor(1, 1);
+    split->setSizes({260, 740});
 
-    QString formatTs(qint64 ms)
-    {
-        if (ms <= 0)
-            return QString();
-        const QDateTime dt = QDateTime::fromMSecsSinceEpoch(ms);
-        const QDate today = QDate::currentDate();
-        if (dt.date() == today)
-            return dt.toString(QStringLiteral("HH:mm"));
-        if (dt.date() == today.addDays(-1))
-            return QStringLiteral("昨天");
-        if (dt.date().year() == today.year())
-            return dt.toString(QStringLiteral("MM-dd"));
-        return dt.toString(QStringLiteral("yyyy-MM-dd"));
-    }
+    bodyLay->addWidget(split, 1);
 
-    class BubbleText : public QWidget
-    {
-    public:
-        explicit BubbleText(const QString &text, QWidget *parent = nullptr)
-            : QWidget(parent)
-            , m_text(text)
-        {
-            setObjectName(QStringLiteral("chatText"));
-            setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
-            setCursor(Qt::IBeamCursor);
-        }
+    auto *bodyWrap = new QWidget(shell);
+    auto *wrapLay = new QGridLayout(bodyWrap);
+    wrapLay->setContentsMargins(0, 0, 0, 0);
+    wrapLay->setSpacing(0);
+    wrapLay->addWidget(body, 0, 0);
+    setupReconnectOverlay(bodyWrap);
+    wrapLay->addWidget(m_reconnectMask, 0, 0);
+    root->addWidget(bodyWrap, 1);
+    setCentralWidget(shell);
 
-        QString text() const { return m_text; }
+    connect(&ImClient::instance(), &ImClient::chatNotify, this, &Client::onChatNotify);
+    connect(&ImClient::instance(), &ImClient::friendRequestNotify, this, &Client::onFriendRequestNotify);
+    connect(&ImClient::instance(), &ImClient::friendAcceptedNotify, this, &Client::onFriendAcceptedNotify);
+    connect(&ImClient::instance(), &ImClient::presenceNotify, this, &Client::onPresenceNotify);
+    connect(&ImClient::instance(), &ImClient::kicked, this, &Client::onKicked);
+    connect(&ImClient::instance(), &ImClient::disconnected, this, &Client::onDisconnected);
+    connect(&ImClient::instance(), &ImClient::connected, this, &Client::onConnected);
+    connect(&ImClient::instance(), &ImClient::connectFailed, this, &Client::onConnectFailed);
 
-        void applyMaxWidth(int maxOuter)
-        {
-            const int ml = 10;
-            const int mr = 10;
-            const int mt = 8;
-            const int mb = 8;
-            const int innerMax = qMax(1, maxOuter - ml - mr);
-            rebuild(innerMax);
-            setFixedSize(m_size);
-            if (QWidget *box = parentWidget())
-            {
-                if (auto *lay = qobject_cast<QVBoxLayout *>(box->layout()))
-                    lay->setContentsMargins(ml, mt, mr, mb);
-                box->setFixedSize(m_size.width() + ml + mr, m_size.height() + mt + mb);
-            }
-            update();
-        }
+    StickerCache::instance().sync();
+    setupTray();
+    loadLocalConvs();
+    refreshConvList();
+    refreshFriendList();
+    refreshAvatar(m_uid);
+}
 
-    protected:
-        void paintEvent(QPaintEvent *) override
-        {
-            QPainter p(this);
-            p.setRenderHint(QPainter::TextAntialiasing, true);
-            p.setPen(QColor(QStringLiteral("#191919")));
-            m_layout.draw(&p, QPointF(0, 0));
-        }
+QWidget *Client::buildNav()
+{
+    auto *nav = new QWidget(this);
+    nav->setFixedWidth(56);
+    nav->setStyleSheet(QStringLiteral("background:#2E2E2E;"));
 
-    private:
-        void rebuild(int innerMax)
-        {
-            m_layout.setText(m_text);
-            m_layout.setFont(font());
-            QTextOption opt;
-            opt.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
-            m_layout.setTextOption(opt);
-            m_layout.setCacheEnabled(true);
-            qreal y = 0;
-            qreal maxLine = 0;
-            m_layout.beginLayout();
-            for (;;)
-            {
-                QTextLine line = m_layout.createLine();
-                if (!line.isValid())
-                    break;
-                line.setLineWidth(innerMax);
-                line.setPosition(QPointF(0, y));
-                y += line.height();
-                maxLine = qMax(maxLine, line.naturalTextWidth());
-            }
-            m_layout.endLayout();
-            if (y < 1)
-                y = QFontMetrics(font()).height();
-            m_size = QSize(qBound(1, qCeil(maxLine), innerMax), qCeil(y));
-        }
+    auto *lay = new QVBoxLayout(nav);
+    lay->setContentsMargins(0, 16, 0, 12);
+    lay->setSpacing(4);
 
-        QString m_text;
-        QTextLayout m_layout;
-        QSize m_size;
-    };
+    auto *avatar = new QLabel(nav);
+    avatar->setFixedSize(36, 36);
+    avatar->setCursor(Qt::PointingHandCursor);
+    AvatarCache::instance().applyTo(
+        avatar, m_uid, ImClient::instance().avatarVer(), m_nickname, 36);
+    avatar->installEventFilter(this);
+    m_navAvatar = avatar;
 
-    void applyTextBubbleWidth(BubbleText *lab, int maxW)
-    {
-        if (lab)
-            lab->applyMaxWidth(maxW);
-    }
+    m_navChat = navBtn(0, QStringLiteral("消息"), nav);
+    m_navFriends = navBtn(1, QStringLiteral("好友"), nav);
+    m_navFavorites = navBtn(3, QStringLiteral("收藏"), nav);
+    m_navSettings = navBtn(2, QStringLiteral("设置"), nav, false);
+    m_navChat->setChecked(true);
 
-    class ClickWidget : public QFrame
-    {
-    public:
-        std::function<void()> onClick;
-        std::function<void()> onForward;
-        std::function<void()> onSaveAs;
-        std::function<void()> onFavorite;
-        std::function<void()> onCopy;
-        std::function<void()> onDelete;
-        std::function<void()> onMultiSelect;
-        explicit ClickWidget(QWidget *parent = nullptr) : QFrame(parent)
-        {
-            setCursor(Qt::PointingHandCursor);
-        }
+    lay->addWidget(avatar, 0, Qt::AlignHCenter);
+    lay->addSpacing(18);
+    lay->addWidget(m_navChat, 0, Qt::AlignHCenter);
+    lay->addWidget(m_navFriends, 0, Qt::AlignHCenter);
+    lay->addWidget(m_navFavorites, 0, Qt::AlignHCenter);
+    lay->addStretch();
+    lay->addWidget(m_navSettings, 0, Qt::AlignHCenter);
 
-    protected:
-        bool eventFilter(QObject *watched, QEvent *event) override
-        {
-            if (event->type() == QEvent::ContextMenu)
-            {
-                contextMenuEvent(static_cast<QContextMenuEvent *>(event));
-                return true;
-            }
-            return QFrame::eventFilter(watched, event);
-        }
+    connect(m_navChat, &QPushButton::clicked, this, [this]() { applyNavSelection(0); });
+    connect(m_navFriends, &QPushButton::clicked, this, [this]() { applyNavSelection(1); });
+    connect(m_navFavorites, &QPushButton::clicked, this, [this]() { applyNavSelection(2); });
+    connect(m_navSettings, &QPushButton::clicked, this, [this]() { showSettings(1); });
+    return nav;
+}
 
-        void mouseReleaseEvent(QMouseEvent *e) override
-        {
-            if (e->button() != Qt::LeftButton)
-                return;
-            QWidget *row = parentWidget();
-            if (row && row->property("selecting").toBool())
-            {
-                if (auto *cb = row->findChild<QCheckBox *>(QStringLiteral("msgCheck")))
-                    cb->toggle();
-                return;
-            }
-            if (onClick)
-                onClick();
-        }
+QWidget *Client::buildConversationPane()
+{
+    auto *pane = new QWidget(this);
+    pane->setMinimumWidth(220);
+    pane->setMaximumWidth(360);
+    pane->setStyleSheet(QStringLiteral("background:#F5F5F5;"));
 
-        void contextMenuEvent(QContextMenuEvent *e) override
-        {
-            QWidget *row = parentWidget();
-            const bool selecting = row && row->property("selecting").toBool();
-            if (!onCopy && !onForward && !onSaveAs && !onFavorite && !onDelete && !onMultiSelect)
-            {
-                QFrame::contextMenuEvent(e);
-                return;
-            }
-            QMenu menu;
-            QAction *copy = onCopy ? menu.addAction(QStringLiteral("复制")) : nullptr;
-            QAction *fav = onFavorite ? menu.addAction(QStringLiteral("收藏")) : nullptr;
-            QAction *fwd = onForward ? menu.addAction(QStringLiteral("转发")) : nullptr;
-            QAction *save = onSaveAs ? menu.addAction(QStringLiteral("另存为")) : nullptr;
-            if ((copy || fav || fwd || save) && (onDelete || onMultiSelect))
-                menu.addSeparator();
-            QAction *del = onDelete ? menu.addAction(QStringLiteral("删除")) : nullptr;
-            QAction *multi = nullptr;
-            if (onMultiSelect)
-                multi = menu.addAction(selecting ? QStringLiteral("取消多选")
-                                                 : QStringLiteral("多选"));
-            QAction *picked = menu.exec(e->globalPos());
-            if (picked && picked == copy && onCopy)
-                onCopy();
-            else if (picked && picked == fav && onFavorite)
-                onFavorite();
-            else if (picked && picked == fwd && onForward)
-                onForward();
-            else if (picked && picked == save && onSaveAs)
-                onSaveAs();
-            else if (picked && picked == del && onDelete)
-                onDelete();
-            else if (picked && picked == multi && onMultiSelect)
-                onMultiSelect();
-        }
-    };
+    auto *lay = new QVBoxLayout(pane);
+    lay->setContentsMargins(0, 0, 0, 0);
+    lay->setSpacing(0);
 
-    QPixmap fileCtlIcon(bool pause, bool upload = false)
-    {
-        const int size = 22;
-        const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
-        QPixmap pm(qRound(size * dpr), qRound(size * dpr));
-        pm.fill(Qt::transparent);
-        pm.setDevicePixelRatio(dpr);
-        QPainter p(&pm);
-        p.setRenderHint(QPainter::Antialiasing);
-        const QColor fg(QStringLiteral("#576B95"));
-        p.setPen(QPen(fg, 1.4));
-        p.setBrush(Qt::NoBrush);
-        p.drawEllipse(QRectF(1.2, 1.2, size - 2.4, size - 2.4));
-        if (pause)
-        {
-            p.setPen(Qt::NoPen);
-            p.setBrush(fg);
-            p.drawRoundedRect(QRectF(7.2, 6.4, 2.6, 9.2), 0.8, 0.8);
-            p.drawRoundedRect(QRectF(12.2, 6.4, 2.6, 9.2), 0.8, 0.8);
-        }
-        else
-        {
-            p.setPen(QPen(fg, 1.6, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
-            if (upload)
-            {
-                p.drawLine(QPointF(11.0, 14.4), QPointF(11.0, 8.0));
-                p.drawLine(QPointF(7.8, 10.4), QPointF(11.0, 6.4));
-                p.drawLine(QPointF(14.2, 10.4), QPointF(11.0, 6.4));
-            }
-            else
-            {
-                p.drawLine(QPointF(11.0, 6.2), QPointF(11.0, 12.6));
-                p.drawLine(QPointF(7.8, 10.4), QPointF(11.0, 14.4));
-                p.drawLine(QPointF(14.2, 10.4), QPointF(11.0, 14.4));
-            }
-        }
-        return pm;
-    }
+    auto *searchWrap = new QWidget(pane);
+    searchWrap->setFixedHeight(56);
+    searchWrap->setStyleSheet(QStringLiteral("background:#F5F5F5; border-bottom:1px solid #E7E7E7;"));
+    auto *sLay = new QHBoxLayout(searchWrap);
+    sLay->setContentsMargins(10, 12, 10, 12);
+    sLay->setSpacing(6);
+    m_search = new QLineEdit(searchWrap);
+    m_search->setPlaceholderText(QStringLiteral("搜索"));
+    m_search->setFixedHeight(28);
+    m_search->setStyleSheet(QStringLiteral(
+        "QLineEdit { background:#FFFFFF; border:1px solid #E5E5E5; padding:3px 8px; border-radius:2px; }"));
+    m_addFriendBtn = new QPushButton(QStringLiteral("+"), searchWrap);
+    m_addFriendBtn->setFixedSize(28, 28);
+    m_addFriendBtn->setCursor(Qt::PointingHandCursor);
+    m_addFriendBtn->setStyleSheet(QStringLiteral(
+        "QPushButton { background:#FFFFFF; border:1px solid #E5E5E5; color:#07C160; font-size:18px; border-radius:2px; }"
+        "QPushButton:hover { background:#F5F5F5; }"));
+    m_addFriendBtn->hide();
+    sLay->addWidget(m_search, 1);
+    sLay->addWidget(m_addFriendBtn);
 
-    QPushButton *fileCtlBtn(QWidget *parent, bool pause, bool upload = false)
-    {
-        auto *btn = new QPushButton(parent);
-        btn->setFixedSize(22, 22);
-        btn->setCursor(Qt::PointingHandCursor);
-        btn->setFocusPolicy(Qt::NoFocus);
-        btn->setFlat(true);
-        btn->setIcon(QIcon(fileCtlIcon(pause, upload)));
-        btn->setIconSize(QSize(22, 22));
-        btn->setStyleSheet(QStringLiteral(
-            "QPushButton { background:transparent; border:none; padding:0; }"
-            "QPushButton:disabled { opacity:0.35; }"));
-        return btn;
-    }
+    m_sideStack = new QStackedWidget(pane);
 
-    class FileBubble : public ClickWidget
-    {
-    public:
-        FileBubble(QWidget *parent, qint64 fileId, const QString &name, qint64 size,
-                   const QString &url, const QString &mime, bool mine)
-            : ClickWidget(parent)
-            , m_fileId(fileId)
-            , m_size(size)
-            , m_url(url)
-            , m_mime(mime)
-            , m_name(name)
-            , m_mine(mine)
-        {
-            setObjectName(QStringLiteral("fileBubble"));
-            setStyleSheet(QStringLiteral("background:#FFFFFF; border:none; border-radius:4px;"));
-            setFixedWidth(252);
+    auto makeEmpty = [](QWidget *parent, const QString &text) {
+        auto *lab = new QLabel(text, parent);
+        lab->setAlignment(Qt::AlignCenter);
+        lab->setWordWrap(true);
+        lab->setStyleSheet(QStringLiteral(
+            "color:#B0B0B0; font-size:13px; background:#F5F5F5; padding:24px;"));
+        return lab;
+    };
 
-            FileCache::instance().bindMeta(fileId, url, mime, name, size);
+    m_convStack = new QStackedWidget(m_sideStack);
+    m_convList = new QListWidget(m_convStack);
+    m_convList->setFrameShape(QFrame::NoFrame);
+    m_convList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+    m_convList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
+    m_convList->setResizeMode(QListView::Adjust);
+    m_convList->setFocusPolicy(Qt::NoFocus);
+    m_convList->setStyleSheet(QStringLiteral(
+        "QListWidget { background:#F5F5F5; border:none; outline:0; color:transparent; }"
+        "QListWidget::item { height:68px; padding:0; margin:0; border:none; color:transparent; }"
+        "QListWidget::item:hover { background:#EDEDED; }"
+        "QListWidget::item:selected { background:#C6C6C6; }"
+        "QListWidget::item:selected:hover { background:#C6C6C6; }"));
+    m_convList->setContextMenuPolicy(Qt::CustomContextMenu);
 
-            auto *root = new QVBoxLayout(this);
-            root->setContentsMargins(8, 8, 10, 8);
-            root->setSpacing(6);
+    m_convEmpty = makeEmpty(m_convStack, QStringLiteral("暂无消息"));
+    m_convStack->addWidget(m_convList);
+    m_convStack->addWidget(m_convEmpty);
 
-            auto *top = new QHBoxLayout();
-            top->setContentsMargins(0, 0, 0, 0);
-            top->setSpacing(8);
+    m_sideStack->addWidget(m_convStack);
+    m_sideStack->addWidget(buildFriendPane());
+    m_sideStack->addWidget(buildFavoritePane());
 
-            auto *icon = new QLabel(this);
-            icon->setFixedSize(40, 40);
-            icon->setScaledContents(true);
-            icon->setStyleSheet(QStringLiteral("background:transparent; border:none;"));
-            if (FileCache::looksLikeImage(mime, name))
-            {
-                const QPixmap pm = FileCache::instance().thumbnail(fileId, 40);
-                icon->setPixmap(pm.isNull() ? FileTypeIcon::pixmap(name, 40) : pm);
-            }
-            else
-            {
-                icon->setPixmap(FileTypeIcon::pixmap(name, 40));
-            }
+    lay->addWidget(searchWrap);
+    lay->addWidget(m_sideStack, 1);
 
-            auto *info = new QVBoxLayout();
-            info->setContentsMargins(0, 0, 0, 0);
-            info->setSpacing(2);
-            auto *n = new QLabel(name, this);
-            n->setWordWrap(true);
-            n->setStyleSheet(QStringLiteral(
-                "color:#191919; font-size:13px; background:transparent; border:none;"));
-            m_statusLab = new QLabel(this);
-            m_statusLab->setStyleSheet(QStringLiteral(
-                "color:#888888; font-size:11px; background:transparent; border:none;"));
-            info->addWidget(n);
-            info->addWidget(m_statusLab);
-            info->addStretch();
-
-            top->addWidget(icon, 0, Qt::AlignTop);
-            top->addLayout(info, 1);
-
-            m_bar = new QProgressBar(this);
-            m_bar->setRange(0, 1000);
-            m_bar->setValue(0);
-            m_bar->setTextVisible(false);
-            m_bar->setToolTip(QString());
-            m_bar->setFixedHeight(6);
-            m_bar->setStyleSheet(QStringLiteral(
-                "QProgressBar { background:rgba(0,0,0,0.08); border:none; border-radius:3px; }"
-                "QProgressBar::chunk { background:#07C160; border-radius:3px; }"));
-
-            auto *meta = new QHBoxLayout();
-            meta->setContentsMargins(0, 0, 0, 0);
-            meta->setSpacing(8);
-            m_sizeLab = new QLabel(this);
-            m_sizeLab->setStyleSheet(QStringLiteral(
-                "color:#888888; font-size:11px; background:transparent; border:none;"));
-            m_speedLab = new QLabel(this);
-            m_speedLab->setStyleSheet(QStringLiteral(
-                "color:#888888; font-size:11px; background:transparent; border:none;"));
-            meta->addWidget(m_sizeLab, 1);
-            meta->addWidget(m_speedLab, 0, Qt::AlignRight);
-
-            root->addLayout(top);
-            root->addWidget(m_bar);
-            root->addLayout(meta);
-
-            m_bar->hide();
-            m_sizeLab->hide();
-            m_speedLab->hide();
-
-            connect(&FileCache::instance(), &FileCache::statusChanged, this, [this](qint64 id) {
-                if (id == m_fileId)
-                    syncUi();
-            });
-            connect(&FileCache::instance(), &FileCache::progress, this,
-                    [this](qint64 id, qint64, qint64, double) {
-                if (id == m_fileId)
-                    syncUi();
-            });
-            connect(&FileCache::instance(), &FileCache::failed, this,
-                    [this](qint64 id, const QString &error) {
-                if (id != m_fileId)
-                    return;
-                const bool uploading = FileCache::instance().needsUpload(m_fileId)
-                    || FileCache::instance().status(m_fileId) == FileCache::Uploading;
-                if (!uploading)
-                {
-                    UiHelpers::showToast(window(),
-                                         error.isEmpty() ? QStringLiteral("下载失败") : error,
-                                         true);
-                }
-                syncUi();
-            });
-
-            onClick = [this]() {
-                const QString path = FileCache::instance().localPath(m_fileId);
-                if (!path.isEmpty())
-                {
-                    if (!QDesktopServices::openUrl(QUrl::fromLocalFile(path)))
-                        UiHelpers::showToast(window(), QStringLiteral("无法打开文件"), true);
-                    return;
-                }
-                if (FileCache::instance().needsUpload(m_fileId))
-                {
-                    FileCache::instance().startUpload(m_fileId);
-                    return;
-                }
-                m_wantReceive = true;
-                FileCache::instance().startDownload(m_fileId, m_url, m_mime, m_name, m_size);
-                syncUi();
-            };
-            onSaveAs = [this]() {
-                const QString src = FileCache::instance().localPath(m_fileId);
-                if (src.isEmpty())
-                {
-                    UiHelpers::showToast(window(), QStringLiteral("请先下载文件"), true);
-                    return;
-                }
-                const QString dest = QFileDialog::getSaveFileName(window(), QStringLiteral("保存文件"), m_name);
-                if (dest.isEmpty())
-                    return;
-                QFile::remove(dest);
-                if (!QFile::copy(src, dest))
-                    UiHelpers::showToast(window(), QStringLiteral("保存失败"), true);
-                else
-                    UiHelpers::showToast(window(), QStringLiteral("已保存"));
-            };
-
-            syncUi();
-        }
-
-        void bindActionSlot(QWidget *slot, QLabel *sendMark)
-        {
-            m_actionSlot = slot;
-            m_sendMark = sendMark;
-            if (!m_actionSlot)
-                return;
-            m_dl = fileCtlBtn(m_actionSlot, false, false);
-            m_pause = fileCtlBtn(m_actionSlot, true);
-            m_dl->setParent(m_actionSlot);
-            m_pause->setParent(m_actionSlot);
-            m_dl->move(0, 0);
-            m_pause->move(0, 0);
-            m_dl->raise();
-            m_pause->raise();
-            connect(m_dl, &QPushButton::clicked, this, [this]() {
-                if (FileCache::instance().needsUpload(m_fileId))
-                    FileCache::instance().startUpload(m_fileId);
-                else
-                {
-                    m_wantReceive = true;
-                    FileCache::instance().startDownload(m_fileId, m_url, m_mime, m_name, m_size);
-                }
-            });
-            connect(m_pause, &QPushButton::clicked, this, [this]() {
-                if (FileCache::instance().needsUpload(m_fileId)
-                    || FileCache::instance().status(m_fileId) == FileCache::Uploading)
-                    FileCache::instance().cancelUpload(m_fileId);
-                else
-                    FileCache::instance().cancelDownload(m_fileId);
-            });
-            syncUi();
-        }
-
-        void refreshActionSlot()
-        {
-            syncUi();
-        }
-
-    private:
-        void syncUi()
+    connect(m_convList, &QListWidget::itemPressed, this, [this](QListWidgetItem *item) {
+        if (!item || (QApplication::mouseButtons() & Qt::RightButton))
+            return;
+        const int row = m_convList->row(item);
+        const qint64 uid = item->data(RolePeerUid).toLongLong();
+        if (row == m_convList->currentRow() && m_currentPeerUid == uid
+            && m_chatStack && m_chatStack->currentWidget() == m_chatBody)
+            return;
+        selectConversation(row);
+    });
+    connect(m_convList, &QListWidget::customContextMenuRequested, this, &Client::onConvMenu);
+    connect(m_addFriendBtn, &QPushButton::clicked, this, [this]() {
+        if (m_sideStack && m_sideStack->currentIndex() == 2)
         {
-            const FileCache::Status st = FileCache::instance().status(m_fileId);
-            const bool pendingUp = m_mine && FileCache::instance().needsUpload(m_fileId);
-            qint64 rec = 0, tot = m_size;
-            double bps = 0;
-            FileCache::instance().progressOf(m_fileId, &rec, &tot, &bps);
-            if (tot <= 0)
-                tot = m_size;
-
-            const bool received = (st == FileCache::Received) && !pendingUp;
-            const bool transferring = (st == FileCache::Downloading) || (st == FileCache::Uploading);
-            const bool transferStarted = transferring || (rec > 0 && !received);
-            const bool sentOnServer = m_mine && !pendingUp && m_fileId > 0;
-            const bool showAsSent = sentOnServer && !m_wantReceive && !transferStarted;
-
-            QString status = FileCache::statusText(st);
-            if (pendingUp && st != FileCache::Uploading)
-                status = QStringLiteral("已暂停");
-            else if (st == FileCache::NotReceived && rec > 0)
-                status = QStringLiteral("已暂停");
-            if (showAsSent)
-                status = QStringLiteral("已发送");
-            m_statusLab->setText(status);
-
-            if (showAsSent)
-            {
-                m_bar->hide();
-                m_sizeLab->hide();
-                m_speedLab->hide();
-            }
-            else if (transferStarted)
-            {
-                m_bar->show();
-                m_sizeLab->show();
-                m_speedLab->show();
-                if (tot > 0)
-                    m_bar->setValue(
-                        static_cast<int>(qBound(qint64(0), rec * 1000 / tot, qint64(1000))));
-                else
-                    m_bar->setValue(0);
-                m_sizeLab->setText(QStringLiteral("%1 / %2")
-                                       .arg(FileCache::formatSize(rec),
-                                            tot > 0 ? FileCache::formatSize(tot)
-                                                    : QStringLiteral("--")));
-                if (transferring)
-                    m_speedLab->setText(FileCache::formatSpeed(bps));
-                else
-                    m_speedLab->setText(QStringLiteral("已暂停"));
-            }
-            else
-            {
-                m_bar->hide();
-                m_sizeLab->hide();
-                m_speedLab->hide();
-            }
-
-            if (!m_actionSlot || !m_dl || !m_pause)
-                return;
-
-            const int sendState = parentWidget()
-                                      ? parentWidget()->property("sendStatus").toInt()
-                                      : LocalMsg::Ok;
-            const bool sendBusy = m_mine
-                && (sendState == LocalMsg::Sending || sendState == LocalMsg::Failed);
-            if (sendBusy)
-            {
-                m_dl->hide();
-                m_pause->hide();
-                if (m_actionSlot)
-                    m_actionSlot->show();
-                if (m_sendMark)
-                    m_sendMark->show();
-                return;
-            }
-
-            if (received || showAsSent)
+            if (m_favDetail)
             {
-                m_dl->hide();
-                m_pause->hide();
-                if (m_sendMark)
-                    m_sendMark->hide();
-                if (m_actionSlot)
-                    m_actionSlot->hide();
-                return;
+                m_favDetail->startNewNote();
+                if (m_chatStack)
+                    m_chatStack->setCurrentWidget(m_favDetail);
             }
-
-            if (m_actionSlot)
-                m_actionSlot->show();
-
-            if (m_sendMark)
-                m_sendMark->hide();
-            m_dl->setIcon(QIcon(fileCtlIcon(false, pendingUp)));
-            m_dl->setVisible(!transferring);
-            m_pause->setVisible(transferring);
-        }
-
-        qint64 m_fileId = 0;
-        qint64 m_size = 0;
-        QString m_url;
-        QString m_mime;
-        QString m_name;
-        bool m_mine = false;
-        bool m_wantReceive = false;
-        QWidget *m_actionSlot = nullptr;
-        QLabel *m_sendMark = nullptr;
-        QPushButton *m_dl = nullptr;
-        QPushButton *m_pause = nullptr;
-        QLabel *m_statusLab = nullptr;
-        QProgressBar *m_bar = nullptr;
-        QLabel *m_sizeLab = nullptr;
-        QLabel *m_speedLab = nullptr;
-    };
-
-    void refreshRowFileAction(QWidget *row)
-    {
-        if (!row)
             return;
-        if (auto *fb = row->findChild<FileBubble *>(QStringLiteral("fileBubble")))
-            fb->refreshActionSlot();
-    }
-
-    using ElideLabel = UiHelpers::ElideLabel;
-
-    class TrayPreviewRow : public QWidget
-    {
-    public:
-        std::function<void()> onActivate;
-
-        explicit TrayPreviewRow(QWidget *parent = nullptr)
-            : QWidget(parent)
-        {
-            setAttribute(Qt::WA_Hover);
-            setCursor(Qt::PointingHandCursor);
-            setAutoFillBackground(true);
-            setMinimumHeight(48);
-            applyHover(false);
-        }
-
-    protected:
-        void enterEvent(QEvent *event) override
-        {
-            applyHover(true);
-            QWidget::enterEvent(event);
         }
+        openAddFriend();
+    });
+    connect(m_search, &QLineEdit::textChanged, this, [this](const QString &) { applyListFilter(); });
+    applyListFilter();
+    return pane;
+}
 
-        void leaveEvent(QEvent *event) override
-        {
-            applyHover(false);
-            QWidget::leaveEvent(event);
-        }
+QWidget *Client::buildFriendPane()
+{
+    auto *page = new QWidget(m_sideStack);
+    auto *lay = new QVBoxLayout(page);
+    lay->setContentsMargins(0, 0, 0, 0);
+    lay->setSpacing(0);
 
-        void mouseReleaseEvent(QMouseEvent *event) override
-        {
-            if (event->button() == Qt::LeftButton && rect().contains(event->pos()) && onActivate)
-                onActivate();
-            QWidget::mouseReleaseEvent(event);
-        }
+    m_newFriendsBtn = new QPushButton(page);
+    m_newFriendsBtn->setCursor(Qt::PointingHandCursor);
+    m_newFriendsBtn->setFixedHeight(52);
+    m_newFriendsBtn->setStyleSheet(QStringLiteral(
+        "QPushButton { background:#F5F5F5; border:none; border-bottom:1px solid #E7E7E7; text-align:left; padding-left:12px; color:#1A1A1A; }"
+        "QPushButton:hover { background:#EDEDED; }"));
+    auto *barLay = new QHBoxLayout(m_newFriendsBtn);
+    barLay->setContentsMargins(12, 0, 12, 0);
+    auto *title = new QLabel(QStringLiteral("新的朋友"), m_newFriendsBtn);
+    title->setStyleSheet(QStringLiteral("background:transparent; font-size:14px;"));
+    title->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+    m_pendingBadge = new QLabel(m_newFriendsBtn);
+    m_pendingBadge->setObjectName(QStringLiteral("unreadBadge"));
+    m_pendingBadge->setAlignment(Qt::AlignCenter);
+    m_pendingBadge->setFixedSize(18, 18);
+    m_pendingBadge->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
+    m_pendingBadge->setStyleSheet(QStringLiteral(
+        "QLabel { background:#FA5151; color:#FFFFFF; font-size:10px; border:none; border-radius:9px;"
+        " min-width:18px; max-width:18px; min-height:18px; max-height:18px; }"));
+    m_pendingBadge->hide();
+    barLay->setAlignment(Qt::AlignVCenter);
+    barLay->addWidget(title, 1, Qt::AlignVCenter);
+    barLay->addWidget(m_pendingBadge, 0, Qt::AlignVCenter | Qt::AlignRight);
+    connect(m_newFriendsBtn, &QPushButton::clicked, this, &Client::openFriendRequests);
 
-    private:
-        void applyHover(bool on)
-        {
-            QPalette pal = palette();
-            pal.setColor(QPalette::Window, on ? QColor("#F2F2F2") : QColor("#FFFFFF"));
-            setPalette(pal);
-        }
-    };
+    m_friendStack = new QStackedWidget(page);
+    m_friendList = new QListWidget(m_friendStack);
+    m_friendList->setFrameShape(QFrame::NoFrame);
+    m_friendList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+    m_friendList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
+    m_friendList->setResizeMode(QListView::Adjust);
+    m_friendList->setFocusPolicy(Qt::NoFocus);
+    m_friendList->setStyleSheet(QStringLiteral(
+        "QListWidget { background:#F5F5F5; border:none; outline:0; color:transparent; }"
+        "QListWidget::item { height:48px; padding:0; margin:0; border:none; color:transparent; }"
+        "QListWidget::item:hover { background:#EDEDED; }"
+        "QListWidget::item:selected { background:#C6C6C6; }"));
+    m_friendList->setContextMenuPolicy(Qt::CustomContextMenu);
+    m_friendEmpty = new QLabel(QStringLiteral("暂无好友"), m_friendStack);
+    m_friendEmpty->setAlignment(Qt::AlignCenter);
+    m_friendEmpty->setWordWrap(true);
+    m_friendEmpty->setStyleSheet(QStringLiteral(
+        "color:#B0B0B0; font-size:13px; background:#F5F5F5; padding:24px;"));
+    m_friendStack->addWidget(m_friendList);
+    m_friendStack->addWidget(m_friendEmpty);
 
-    QPixmap navIcon(int kind, const QColor &color)
-    {
-        const int size = 22;
-        QPixmap pm(size, size);
-        pm.fill(Qt::transparent);
-        QPainter p(&pm);
-        p.setRenderHint(QPainter::Antialiasing, true);
-        p.setPen(QPen(color, 1.6));
-        p.setBrush(Qt::NoBrush);
-        if (kind == 0)
-        {
-            p.drawRoundedRect(QRectF(2.5, 3.5, 17, 12.5), 2.5, 2.5);
-            QPainterPath tail;
-            tail.moveTo(6.5, 16);
-            tail.lineTo(8.5, 20);
-            tail.lineTo(12, 16);
-            p.setBrush(color);
-            p.setPen(Qt::NoPen);
-            p.drawPath(tail);
-        }
-        else if (kind == 1)
-        {
-            p.drawEllipse(QRectF(6.5, 3.2, 9, 9));
-            p.drawArc(QRect(3, 12, 16, 12), 20 * 16, 140 * 16);
-        }
-        else if (kind == 3)
-        {
-            QPolygonF star;
-            for (int i = 0; i < 5; ++i)
-            {
-                const qreal a = -1.5707963 + i * 1.25663706;
-                star << QPointF(11 + qCos(a) * 8.2, 11 + qSin(a) * 8.2);
-                const qreal b = a + 0.62831853;
-                star << QPointF(11 + qCos(b) * 3.4, 11 + qSin(b) * 3.4);
-            }
-            p.setBrush(color);
-            p.setPen(Qt::NoPen);
-            p.drawPolygon(star);
-        }
-        else
-        {
-            const QPointF c(11, 11);
-            for (int i = 0; i < 6; ++i)
-            {
-                const qreal a = i * 3.14159265 / 3.0;
-                const QPointF d(qCos(a), qSin(a));
-                p.drawLine(c + d * 5.2, c + d * 8.6);
-            }
-            p.drawEllipse(QRectF(6.6, 6.6, 8.8, 8.8));
-            p.drawEllipse(QRectF(8.6, 8.6, 4.8, 4.8));
-        }
-        return pm;
-    }
+    lay->addWidget(m_newFriendsBtn);
+    lay->addWidget(m_friendStack, 1);
 
-    QPixmap chatToolIcon(int kind, const QColor &color, int size = 20)
-    {
-        const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
-        QPixmap pm(qRound(size * dpr), qRound(size * dpr));
-        pm.fill(Qt::transparent);
-        pm.setDevicePixelRatio(dpr);
-        QPainter p(&pm);
-        p.setRenderHint(QPainter::Antialiasing, true);
-        p.setPen(QPen(color, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
-        p.setBrush(Qt::NoBrush);
-        const qreal s = size;
-        if (kind == 0)
-        {
-            p.drawEllipse(QRectF(2.2, 3.2, s - 4.4, s - 5.2));
-            p.drawArc(QRectF(5.5, 7.5, s - 11.0, s - 12.0), 200 * 16, 140 * 16);
-            p.setBrush(color);
-            p.setPen(Qt::NoPen);
-            p.drawEllipse(QRectF(6.4, 7.2, 2.2, 2.2));
-            p.drawEllipse(QRectF(s - 8.6, 7.2, 2.2, 2.2));
-        }
-        else if (kind == 1)
-        {
-            p.drawRoundedRect(QRectF(2.5, 4.2, s - 5.0, s - 8.0), 2.0, 2.0);
-            p.setBrush(color);
-            p.setPen(Qt::NoPen);
-            p.drawEllipse(QRectF(5.2, 6.4, 3.0, 3.0));
-            QPolygonF hill;
-            hill << QPointF(3.6, s - 5.2) << QPointF(8.2, 10.2) << QPointF(11.2, 13.0)
-                 << QPointF(14.4, 9.4) << QPointF(s - 3.6, s - 5.2);
-            p.drawPolygon(hill);
-        }
-        else
-        {
-            p.drawRoundedRect(QRectF(5.0, 2.8, s - 9.2, s - 5.4), 1.2, 1.2);
-            p.drawLine(QPointF(5.0, 6.4), QPointF(s - 4.2, 6.4));
-            p.drawLine(QPointF(7.4, 9.4), QPointF(s - 7.2, 9.4));
-            p.drawLine(QPointF(7.4, 12.2), QPointF(s - 7.2, 12.2));
-        }
-        return pm;
-    }
+    connect(m_friendList, &QListWidget::itemPressed, this, [this](QListWidgetItem *item) {
+        if (!item || (QApplication::mouseButtons() & Qt::RightButton))
+            return;
+        showFriendCard(item->data(RolePeerUid).toLongLong());
+    });
+    connect(m_friendList, &QListWidget::customContextMenuRequested, this, &Client::onFriendMenu);
+    return page;
+}
 
-    QPixmap settingsNavIcon()
-    {
-        static QPixmap cached;
-        if (!cached.isNull())
-            return cached;
-        QPixmap src(QStringLiteral(":/icons/settings.png"));
-        if (src.isNull())
-        {
-            cached = navIcon(2, QColor("#A6A6A6"));
-            return cached;
-        }
-        QImage img = src.toImage().convertToFormat(QImage::Format_ARGB32);
-        for (int y = 0; y < img.height(); ++y)
+QWidget *Client::buildFavoritePane()
+{
+    m_favPanel = new FavoritePanel(m_sideStack);
+    connect(m_favPanel, &FavoritePanel::itemActivated, this, [this](qint64 id) {
+        if (m_favDetail)
         {
-            auto *line = reinterpret_cast<QRgb *>(img.scanLine(y));
-            for (int x = 0; x < img.width(); ++x)
-            {
-                if (qRed(line[x]) < 40 && qGreen(line[x]) < 40 && qBlue(line[x]) < 40)
-                    line[x] = qRgba(0, 0, 0, 0);
-            }
+            m_favDetail->load(id);
+            if (m_chatStack)
+                m_chatStack->setCurrentWidget(m_favDetail);
         }
-        cached = QPixmap::fromImage(img).scaled(22, 22, Qt::KeepAspectRatio, Qt::SmoothTransformation);
-        return cached;
-    }
-
-    QPushButton *navBtn(int kind, const QString &tip, QWidget *parent, bool checkable = true)
-    {
-        auto *btn = new QPushButton(parent);
-        btn->setCheckable(checkable);
-        btn->setFixedSize(56, 48);
-        btn->setCursor(Qt::PointingHandCursor);
-        btn->setIconSize(QSize(22, 22));
-        btn->setIcon(QIcon(kind == 2 ? settingsNavIcon() : navIcon(kind, QColor("#A6A6A6"))));
-        btn->setStyleSheet(QStringLiteral(
-            "QPushButton { background:transparent; border:none; border-radius:0; padding:0; }"
-            "QPushButton:hover { background:#3A3A3A; }"
-            "QPushButton:checked { background:transparent; }"));
-        if (checkable)
+    });
+    connect(m_favPanel, &FavoritePanel::createNoteRequested, this, [this]() {
+        if (m_favDetail)
         {
-            QObject::connect(btn, &QPushButton::toggled, btn, [btn, kind](bool on) {
-                btn->setIcon(QIcon(navIcon(kind, on ? QColor("#07C160") : QColor("#A6A6A6"))));
-            });
+            m_favDetail->startNewNote();
+            if (m_chatStack)
+                m_chatStack->setCurrentWidget(m_favDetail);
         }
-        return btn;
-    }
-
-    QLabel *makeOnlineDot(QWidget *parent, bool online)
-    {
-        auto *dot = new QLabel(parent);
-        dot->setFixedSize(8, 8);
-        dot->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
-        dot->setAttribute(Qt::WA_StyledBackground, true);
-        dot->setStyleSheet(online
-            ? QStringLiteral("background:#07C160; border:none; border-radius:4px;")
-            : QStringLiteral("background:#C8C8C8; border:none; border-radius:4px;"));
-        return dot;
-    }
+    });
+    connect(m_favPanel, &FavoritePanel::itemDeleted, this, [this](qint64 id) {
+        if (m_favDetail && m_favDetail->currentId() == id)
+            m_favDetail->showEmpty();
+    });
+    return m_favPanel;
+}
 
-    Conv convFromItem(const QListWidgetItem *item)
+void Client::applyNavSelection(int index)
+{
+    m_navChat->setChecked(index == 0);
+    m_navFriends->setChecked(index == 1);
+    if (m_navFavorites)
+        m_navFavorites->setChecked(index == 2);
+    m_sideStack->setCurrentIndex(index);
+    m_search->setPlaceholderText(index == 1 ? QStringLiteral("搜索好友")
+                                 : (index == 2 ? QStringLiteral("搜索收藏") : QStringLiteral("搜索")));
+    m_addFriendBtn->setVisible(index == 1 || index == 2);
+    m_search->clear();
+    applyListFilter();
+    if (index != 0)
+        exitMsgSelectMode();
+    if (index == 0 && m_chatStack)
     {
-        Conv c;
-        c.uid = item->data(RolePeerUid).toLongLong();
-        c.name = item->data(RoleName).toString();
-        c.preview = item->data(RolePreview).toString();
-        c.time = formatTs(item->data(RoleTs).toLongLong());
-        c.unread = item->data(RoleUnread).toInt();
-        c.avatarVer = item->data(RoleAvatarVer).toInt();
-        c.online = item->data(RoleOnline).toInt() == 1;
-        return c;
+        if (m_currentPeerUid > 0 && m_chatBody)
+            m_chatStack->setCurrentWidget(m_chatBody);
+        else if (m_chatEmpty)
+            m_chatStack->setCurrentWidget(m_chatEmpty);
     }
-
-    bool listOnlineOf(const QListWidget *list, qint64 uid)
+    if (index == 1)
     {
-        if (!list)
-            return false;
-        for (int i = 0; i < list->count(); ++i)
-        {
-            auto *item = list->item(i);
-            if (item && item->data(RolePeerUid).toLongLong() == uid)
-                return item->data(RoleOnline).toInt() == 1;
-        }
-        return false;
+        refreshFriendList();
+        if (m_chatStack && m_friendHintPage)
+            m_chatStack->setCurrentWidget(m_friendHintPage);
+        if (m_friendList)
+            m_friendList->clearSelection();
     }
-
-    QIcon blankTrayIcon()
+    if (index == 2)
     {
-        QPixmap pm(32, 32);
-        pm.fill(Qt::transparent);
-        return QIcon(pm);
+        if (m_favPanel)
+            m_favPanel->refresh();
+        if (m_chatStack && m_favDetail)
+            m_chatStack->setCurrentWidget(m_favDetail);
     }
+}
 
-    QWidget *makeFriendRow(const QString &name, bool online, qint64 uid, int avatarVer, QWidget *parent)
-    {
-        auto *row = new QWidget(parent);
-        row->setFixedHeight(48);
-        row->setMinimumWidth(0);
-        row->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed);
-        row->setAttribute(Qt::WA_TranslucentBackground);
-        row->setStyleSheet(QStringLiteral("background:transparent;"));
-
-        auto *lay = new QHBoxLayout(row);
-        lay->setContentsMargins(12, 8, 12, 8);
-        lay->setSpacing(10);
-
-        auto *avatar = new QLabel(row);
-        avatar->setFixedSize(32, 32);
-        avatar->setPixmap(AvatarCache::instance().pixmap(uid, avatarVer, name, 32));
+QWidget *Client::buildChatPane()
+{
+    auto *pane = new QWidget(this);
+    pane->setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    m_chatStack = new QStackedWidget(pane);
+    auto *outer = new QVBoxLayout(pane);
+    outer->setContentsMargins(0, 0, 0, 0);
+    outer->addWidget(m_chatStack);
 
-        auto *nameLab = new ElideLabel(name, row);
-        nameLab->setStyleSheet(QStringLiteral("color:#191919; font-size:14px; background:transparent;"));
-
-        lay->addWidget(avatar, 0, Qt::AlignVCenter);
-        lay->addWidget(nameLab, 1, Qt::AlignVCenter);
-        if (online)
-            lay->addWidget(makeOnlineDot(row, true), 0, Qt::AlignVCenter);
-        return row;
-    }
-
-    QWidget *makeConvRow(const Conv &c, QWidget *parent)
-    {
-        auto *row = new QWidget(parent);
-        row->setFixedHeight(68);
-        row->setMinimumWidth(0);
-        row->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed);
-        row->setAttribute(Qt::WA_TranslucentBackground);
-        row->setStyleSheet(QStringLiteral("background:transparent;"));
-
-        auto *grid = new QGridLayout(row);
-        grid->setContentsMargins(12, 10, 12, 10);
-        grid->setHorizontalSpacing(10);
-        grid->setVerticalSpacing(3);
-        grid->setColumnStretch(1, 1);
-        grid->setColumnMinimumWidth(1, 0);
-
-        auto *avatarBox = new QWidget(row);
-        avatarBox->setFixedSize(44, 44);
-        auto *avatar = new QLabel(avatarBox);
-        avatar->setGeometry(0, 0, 40, 40);
-        avatar->setPixmap(AvatarCache::instance().pixmap(c.uid, c.avatarVer, c.name, 40));
-        avatar->setScaledContents(false);
-        if (c.online)
-        {
-            auto *dot = makeOnlineDot(avatarBox, true);
-            dot->setFixedSize(10, 10);
-            dot->move(30, 30);
-            dot->setStyleSheet(QStringLiteral(
-                "background:#07C160; border:2px solid #FFFFFF; border-radius:5px;"));
-            dot->raise();
-        }
-        if (c.unread > 0)
-        {
-            auto *badge = new QLabel(c.unread > 99 ? QStringLiteral("99") : QString::number(c.unread), avatarBox);
-            badge->setObjectName(QStringLiteral("unreadBadge"));
-            badge->setAlignment(Qt::AlignCenter);
-            badge->setFixedSize(16, 16);
-            badge->move(28, 0);
-            badge->setStyleSheet(QStringLiteral(
-                "background:#FA5151; color:#FFFFFF; font-size:10px; border:none; border-radius:8px;"));
-            badge->raise();
-        }
-
-        auto *nameRow = new QWidget(row);
-        nameRow->setMinimumWidth(0);
-        nameRow->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
-        nameRow->setStyleSheet(QStringLiteral("background:transparent;"));
-        auto *nameLay = new QHBoxLayout(nameRow);
-        nameLay->setContentsMargins(0, 0, 0, 0);
-        nameLay->setSpacing(6);
-        auto *name = new ElideLabel(c.name, nameRow);
-        name->setStyleSheet(QStringLiteral("color:#191919; font-size:14px; background:transparent;"));
-        nameLay->addWidget(name, 1, Qt::AlignVCenter);
-
-        auto *prevWrap = new QWidget(row);
-        prevWrap->setMinimumWidth(0);
-        prevWrap->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
-        prevWrap->setStyleSheet(QStringLiteral("background:transparent;"));
-        auto *prevLay = new QHBoxLayout(prevWrap);
-        prevLay->setContentsMargins(0, 0, 0, 0);
-        prevLay->setSpacing(4);
-        QString previewText = c.preview;
-        int iconKind = -1;
-        if (previewText.startsWith(QStringLiteral("[表情]")))
-        {
-            iconKind = 0;
-            previewText = previewText.mid(4);
-            if (previewText.isEmpty())
-                previewText = QStringLiteral("表情");
-        }
-        else if (previewText.startsWith(QStringLiteral("[图片]")))
-        {
-            iconKind = 1;
-            previewText = previewText.mid(4);
-            if (previewText.isEmpty())
-                previewText = QStringLiteral("图片");
-        }
-        else if (previewText.startsWith(QStringLiteral("[文件]")))
-        {
-            iconKind = 2;
-            previewText = previewText.mid(4).trimmed();
-            if (previewText.isEmpty())
-                previewText = QStringLiteral("文件");
-        }
-        if (iconKind >= 0)
-        {
-            auto *ic = new QLabel(prevWrap);
-            ic->setFixedSize(14, 14);
-            ic->setPixmap(chatToolIcon(iconKind, QColor("#9A9A9A"), 14));
-            ic->setStyleSheet(QStringLiteral("background:transparent; border:none;"));
-            prevLay->addWidget(ic, 0, Qt::AlignVCenter);
-        }
-        auto *preview = new ElideLabel(previewText, prevWrap);
-        preview->setStyleSheet(QStringLiteral("color:#9A9A9A; font-size:12px; background:transparent;"));
-        prevLay->addWidget(preview, 1);
-
-        auto *time = new QLabel(c.time, row);
-        time->setStyleSheet(QStringLiteral("color:#B2B2B2; font-size:11px; background:transparent;"));
-        time->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
-        time->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred);
-        time->setMinimumWidth(time->fontMetrics().horizontalAdvance(
-            c.time.isEmpty() ? QStringLiteral("00:00") : c.time));
-
-        grid->addWidget(avatarBox, 0, 0, 2, 1, Qt::AlignVCenter);
-        grid->addWidget(nameRow, 0, 1);
-        grid->addWidget(time, 0, 2, Qt::AlignRight | Qt::AlignVCenter);
-        grid->addWidget(prevWrap, 1, 1, 1, 2);
-        return row;
-    }
-}
-
-Client::Client(const QString &account, const QString &nickname, QWidget *parent)
-    : QMainWindow(parent)
-    , m_account(account.isEmpty() ? ImClient::instance().account() : account)
-    , m_nickname(nickname.isEmpty() ? ImClient::instance().nickname() : nickname)
-    , m_uid(ImClient::instance().uid())
-{
-    if (m_nickname.isEmpty())
-        m_nickname = m_account;
-    setWindowTitle(QStringLiteral("NIM"));
-    setObjectName(QStringLiteral("ChromeWindow"));
-    setMinimumSize(760, 500);
-    resize(1000, 712);
-    setWindowFlags(windowFlags() | Qt::FramelessWindowHint);
-    setWindowIcon(UiHelpers::appIcon());
-    if (statusBar())
-        statusBar()->hide();
-    if (menuBar())
-        menuBar()->hide();
-    LocalStore::instance().openForUser(m_uid);
-    buildUi();
-    QTimer::singleShot(400, this, [this]() { Updater::promptIfNeeded(this); });
-}
-
-Client::~Client()
-{
-    m_chatPages.clear();
-    LocalStore::instance().close();
-}
-
-void Client::buildUi()
-{
-    connect(&AvatarCache::instance(), &AvatarCache::updated, this, &Client::refreshAvatar);
-
-    auto *shell = new QWidget(this);
-    auto *root = new QVBoxLayout(shell);
-    root->setContentsMargins(0, 0, 0, 0);
-    root->setSpacing(0);
-
-    m_titleBar = new TitleBar(shell);
-    m_titleBar->setTitle(QStringLiteral("NIM"));
-    m_titleBar->setTheme(TitleBar::Light);
-    m_titleBar->setMaximizable(true);
-    m_titleBar->bind(this, true);
-    if (auto *helper = m_titleBar->framelessHelper())
-    {
-        m_geomSave = new QTimer(this);
-        m_geomSave->setSingleShot(true);
-        m_geomSave->setInterval(400);
-        connect(m_geomSave, &QTimer::timeout, this, &Client::persistWindow);
-        connect(helper, &FramelessHelper::normalGeometryChanged, this, [this]() {
-            if (m_geomSave)
-                m_geomSave->start();
-        });
-    }
-    root->addWidget(m_titleBar);
-
-    auto *body = new QWidget(shell);
-    auto *bodyLay = new QHBoxLayout(body);
-    bodyLay->setContentsMargins(0, 0, 0, 0);
-    bodyLay->setSpacing(0);
-    bodyLay->addWidget(buildNav());
-
-    auto *split = new QSplitter(Qt::Horizontal, body);
-    split->setHandleWidth(1);
-    split->setChildrenCollapsible(false);
-    split->addWidget(buildConversationPane());
-    split->addWidget(buildChatPane());
-    split->setStretchFactor(0, 0);
-    split->setStretchFactor(1, 1);
-    split->setSizes({260, 740});
-
-    bodyLay->addWidget(split, 1);
-
-    auto *bodyWrap = new QWidget(shell);
-    auto *wrapLay = new QGridLayout(bodyWrap);
-    wrapLay->setContentsMargins(0, 0, 0, 0);
-    wrapLay->setSpacing(0);
-    wrapLay->addWidget(body, 0, 0);
-    setupReconnectOverlay(bodyWrap);
-    wrapLay->addWidget(m_reconnectMask, 0, 0);
-    root->addWidget(bodyWrap, 1);
-    setCentralWidget(shell);
-
-    connect(&ImClient::instance(), &ImClient::chatNotify, this, &Client::onChatNotify);
-    connect(&ImClient::instance(), &ImClient::friendRequestNotify, this, &Client::onFriendRequestNotify);
-    connect(&ImClient::instance(), &ImClient::friendAcceptedNotify, this, &Client::onFriendAcceptedNotify);
-    connect(&ImClient::instance(), &ImClient::kicked, this, &Client::onKicked);
-    connect(&ImClient::instance(), &ImClient::disconnected, this, &Client::onDisconnected);
-    connect(&ImClient::instance(), &ImClient::connected, this, &Client::onConnected);
-    connect(&ImClient::instance(), &ImClient::connectFailed, this, &Client::onConnectFailed);
-
-    StickerCache::instance().sync();
-    setupTray();
-    loadLocalConvs();
-    refreshConvList();
-    refreshFriendList();
-    refreshAvatar(m_uid);
-}
-
-QWidget *Client::buildNav()
-{
-    auto *nav = new QWidget(this);
-    nav->setFixedWidth(56);
-    nav->setStyleSheet(QStringLiteral("background:#2E2E2E;"));
-
-    auto *lay = new QVBoxLayout(nav);
-    lay->setContentsMargins(0, 16, 0, 12);
-    lay->setSpacing(4);
-
-    auto *avatar = new QLabel(nav);
-    avatar->setFixedSize(36, 36);
-    avatar->setCursor(Qt::PointingHandCursor);
-    AvatarCache::instance().applyTo(
-        avatar, m_uid, ImClient::instance().avatarVer(), m_nickname, 36);
-    avatar->installEventFilter(this);
-    m_navAvatar = avatar;
-
-    m_navChat = navBtn(0, QStringLiteral("消息"), nav);
-    m_navFriends = navBtn(1, QStringLiteral("好友"), nav);
-    m_navFavorites = navBtn(3, QStringLiteral("收藏"), nav);
-    m_navSettings = navBtn(2, QStringLiteral("设置"), nav, false);
-    m_navChat->setChecked(true);
-
-    lay->addWidget(avatar, 0, Qt::AlignHCenter);
-    lay->addSpacing(18);
-    lay->addWidget(m_navChat, 0, Qt::AlignHCenter);
-    lay->addWidget(m_navFriends, 0, Qt::AlignHCenter);
-    lay->addWidget(m_navFavorites, 0, Qt::AlignHCenter);
-    lay->addStretch();
-    lay->addWidget(m_navSettings, 0, Qt::AlignHCenter);
-
-    connect(m_navChat, &QPushButton::clicked, this, [this]() { applyNavSelection(0); });
-    connect(m_navFriends, &QPushButton::clicked, this, [this]() { applyNavSelection(1); });
-    connect(m_navFavorites, &QPushButton::clicked, this, [this]() { applyNavSelection(2); });
-    connect(m_navSettings, &QPushButton::clicked, this, [this]() { showSettings(1); });
-    return nav;
-}
-
-QWidget *Client::buildConversationPane()
-{
-    auto *pane = new QWidget(this);
-    pane->setMinimumWidth(220);
-    pane->setMaximumWidth(360);
-    pane->setStyleSheet(QStringLiteral("background:#F5F5F5;"));
-
-    auto *lay = new QVBoxLayout(pane);
-    lay->setContentsMargins(0, 0, 0, 0);
-    lay->setSpacing(0);
-
-    auto *searchWrap = new QWidget(pane);
-    searchWrap->setFixedHeight(56);
-    searchWrap->setStyleSheet(QStringLiteral("background:#F5F5F5; border-bottom:1px solid #E7E7E7;"));
-    auto *sLay = new QHBoxLayout(searchWrap);
-    sLay->setContentsMargins(10, 12, 10, 12);
-    sLay->setSpacing(6);
-    m_search = new QLineEdit(searchWrap);
-    m_search->setPlaceholderText(QStringLiteral("搜索"));
-    m_search->setFixedHeight(28);
-    m_search->setStyleSheet(QStringLiteral(
-        "QLineEdit { background:#FFFFFF; border:1px solid #E5E5E5; padding:3px 8px; border-radius:2px; }"));
-    m_addFriendBtn = new QPushButton(QStringLiteral("+"), searchWrap);
-    m_addFriendBtn->setFixedSize(28, 28);
-    m_addFriendBtn->setCursor(Qt::PointingHandCursor);
-    m_addFriendBtn->setStyleSheet(QStringLiteral(
-        "QPushButton { background:#FFFFFF; border:1px solid #E5E5E5; color:#07C160; font-size:18px; border-radius:2px; }"
-        "QPushButton:hover { background:#F5F5F5; }"));
-    m_addFriendBtn->hide();
-    sLay->addWidget(m_search, 1);
-    sLay->addWidget(m_addFriendBtn);
-
-    m_sideStack = new QStackedWidget(pane);
-
-    auto makeEmpty = [](QWidget *parent, const QString &text) {
-        auto *lab = new QLabel(text, parent);
-        lab->setAlignment(Qt::AlignCenter);
-        lab->setWordWrap(true);
-        lab->setStyleSheet(QStringLiteral(
-            "color:#B0B0B0; font-size:13px; background:#F5F5F5; padding:24px;"));
-        return lab;
-    };
-
-    m_convStack = new QStackedWidget(m_sideStack);
-    m_convList = new QListWidget(m_convStack);
-    m_convList->setFrameShape(QFrame::NoFrame);
-    m_convList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
-    m_convList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
-    m_convList->setResizeMode(QListView::Adjust);
-    m_convList->setFocusPolicy(Qt::NoFocus);
-    m_convList->setStyleSheet(QStringLiteral(
-        "QListWidget { background:#F5F5F5; border:none; outline:0; color:transparent; }"
-        "QListWidget::item { height:68px; padding:0; margin:0; border:none; color:transparent; }"
-        "QListWidget::item:hover { background:#EDEDED; }"
-        "QListWidget::item:selected { background:#C6C6C6; }"
-        "QListWidget::item:selected:hover { background:#C6C6C6; }"));
-    m_convList->setContextMenuPolicy(Qt::CustomContextMenu);
-
-    m_convEmpty = makeEmpty(m_convStack, QStringLiteral("暂无消息"));
-    m_convStack->addWidget(m_convList);
-    m_convStack->addWidget(m_convEmpty);
-
-    m_sideStack->addWidget(m_convStack);
-    m_sideStack->addWidget(buildFriendPane());
-    m_sideStack->addWidget(buildFavoritePane());
-
-    lay->addWidget(searchWrap);
-    lay->addWidget(m_sideStack, 1);
-
-    connect(m_convList, &QListWidget::itemPressed, this, [this](QListWidgetItem *item) {
-        if (!item || (QApplication::mouseButtons() & Qt::RightButton))
-            return;
-        const int row = m_convList->row(item);
-        const qint64 uid = item->data(RolePeerUid).toLongLong();
-        if (row == m_convList->currentRow() && m_currentPeerUid == uid
-            && m_chatStack && m_chatStack->currentWidget() == m_chatBody)
-            return;
-        selectConversation(row);
-    });
-    connect(m_convList, &QListWidget::customContextMenuRequested, this, &Client::onConvMenu);
-    connect(m_addFriendBtn, &QPushButton::clicked, this, [this]() {
-        if (m_sideStack && m_sideStack->currentIndex() == 2)
-        {
-            if (m_favDetail)
-            {
-                m_favDetail->startNewNote();
-                if (m_chatStack)
-                    m_chatStack->setCurrentWidget(m_favDetail);
-            }
-            return;
-        }
-        openAddFriend();
-    });
-    connect(m_search, &QLineEdit::textChanged, this, [this](const QString &) { applyListFilter(); });
-    applyListFilter();
-    return pane;
-}
-
-QWidget *Client::buildFriendPane()
-{
-    auto *page = new QWidget(m_sideStack);
-    auto *lay = new QVBoxLayout(page);
-    lay->setContentsMargins(0, 0, 0, 0);
-    lay->setSpacing(0);
-
-    m_newFriendsBtn = new QPushButton(page);
-    m_newFriendsBtn->setCursor(Qt::PointingHandCursor);
-    m_newFriendsBtn->setFixedHeight(52);
-    m_newFriendsBtn->setStyleSheet(QStringLiteral(
-        "QPushButton { background:#F5F5F5; border:none; border-bottom:1px solid #E7E7E7; text-align:left; padding-left:12px; color:#1A1A1A; }"
-        "QPushButton:hover { background:#EDEDED; }"));
-    auto *barLay = new QHBoxLayout(m_newFriendsBtn);
-    barLay->setContentsMargins(12, 0, 12, 0);
-    auto *title = new QLabel(QStringLiteral("新的朋友"), m_newFriendsBtn);
-    title->setStyleSheet(QStringLiteral("background:transparent; font-size:14px;"));
-    title->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
-    m_pendingBadge = new QLabel(m_newFriendsBtn);
-    m_pendingBadge->setObjectName(QStringLiteral("unreadBadge"));
-    m_pendingBadge->setAlignment(Qt::AlignCenter);
-    m_pendingBadge->setFixedSize(18, 18);
-    m_pendingBadge->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
-    m_pendingBadge->setStyleSheet(QStringLiteral(
-        "QLabel { background:#FA5151; color:#FFFFFF; font-size:10px; border:none; border-radius:9px;"
-        " min-width:18px; max-width:18px; min-height:18px; max-height:18px; }"));
-    m_pendingBadge->hide();
-    barLay->setAlignment(Qt::AlignVCenter);
-    barLay->addWidget(title, 1, Qt::AlignVCenter);
-    barLay->addWidget(m_pendingBadge, 0, Qt::AlignVCenter | Qt::AlignRight);
-    connect(m_newFriendsBtn, &QPushButton::clicked, this, &Client::openFriendRequests);
-
-    m_friendStack = new QStackedWidget(page);
-    m_friendList = new QListWidget(m_friendStack);
-    m_friendList->setFrameShape(QFrame::NoFrame);
-    m_friendList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
-    m_friendList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
-    m_friendList->setResizeMode(QListView::Adjust);
-    m_friendList->setFocusPolicy(Qt::NoFocus);
-    m_friendList->setStyleSheet(QStringLiteral(
-        "QListWidget { background:#F5F5F5; border:none; outline:0; color:transparent; }"
-        "QListWidget::item { height:48px; padding:0; margin:0; border:none; color:transparent; }"
-        "QListWidget::item:hover { background:#EDEDED; }"
-        "QListWidget::item:selected { background:#C6C6C6; }"));
-    m_friendList->setContextMenuPolicy(Qt::CustomContextMenu);
-    m_friendEmpty = new QLabel(QStringLiteral("暂无好友"), m_friendStack);
-    m_friendEmpty->setAlignment(Qt::AlignCenter);
-    m_friendEmpty->setWordWrap(true);
-    m_friendEmpty->setStyleSheet(QStringLiteral(
-        "color:#B0B0B0; font-size:13px; background:#F5F5F5; padding:24px;"));
-    m_friendStack->addWidget(m_friendList);
-    m_friendStack->addWidget(m_friendEmpty);
-
-    lay->addWidget(m_newFriendsBtn);
-    lay->addWidget(m_friendStack, 1);
-
-    connect(m_friendList, &QListWidget::itemPressed, this, [this](QListWidgetItem *item) {
-        if (!item || (QApplication::mouseButtons() & Qt::RightButton))
-            return;
-        showFriendCard(item->data(RolePeerUid).toLongLong());
-    });
-    connect(m_friendList, &QListWidget::customContextMenuRequested, this, &Client::onFriendMenu);
-    return page;
-}
-
-QWidget *Client::buildFavoritePane()
-{
-    m_favPanel = new FavoritePanel(m_sideStack);
-    connect(m_favPanel, &FavoritePanel::itemActivated, this, [this](qint64 id) {
-        if (m_favDetail)
-        {
-            m_favDetail->load(id);
-            if (m_chatStack)
-                m_chatStack->setCurrentWidget(m_favDetail);
-        }
-    });
-    connect(m_favPanel, &FavoritePanel::createNoteRequested, this, [this]() {
-        if (m_favDetail)
-        {
-            m_favDetail->startNewNote();
-            if (m_chatStack)
-                m_chatStack->setCurrentWidget(m_favDetail);
-        }
-    });
-    connect(m_favPanel, &FavoritePanel::itemDeleted, this, [this](qint64 id) {
-        if (m_favDetail && m_favDetail->currentId() == id)
-            m_favDetail->showEmpty();
-    });
-    return m_favPanel;
-}
-
-void Client::applyNavSelection(int index)
-{
-    m_navChat->setChecked(index == 0);
-    m_navFriends->setChecked(index == 1);
-    if (m_navFavorites)
-        m_navFavorites->setChecked(index == 2);
-    m_sideStack->setCurrentIndex(index);
-    m_search->setPlaceholderText(index == 1 ? QStringLiteral("搜索好友")
-                                 : (index == 2 ? QStringLiteral("搜索收藏") : QStringLiteral("搜索")));
-    m_addFriendBtn->setVisible(index == 1 || index == 2);
-    m_search->clear();
-    applyListFilter();
-    if (index != 0)
-        exitMsgSelectMode();
-    if (index == 0 && m_chatStack)
-    {
-        if (m_currentPeerUid > 0 && m_chatBody)
-            m_chatStack->setCurrentWidget(m_chatBody);
-        else if (m_chatEmpty)
-            m_chatStack->setCurrentWidget(m_chatEmpty);
-    }
-    if (index == 1)
-    {
-        refreshFriendList();
-        if (m_chatStack && m_friendHintPage)
-            m_chatStack->setCurrentWidget(m_friendHintPage);
-        if (m_friendList)
-            m_friendList->clearSelection();
-    }
-    if (index == 2)
-    {
-        if (m_favPanel)
-            m_favPanel->refresh();
-        if (m_chatStack && m_favDetail)
-            m_chatStack->setCurrentWidget(m_favDetail);
-    }
-}
-
-QWidget *Client::buildChatPane()
-{
-    auto *pane = new QWidget(this);
-    pane->setStyleSheet(QStringLiteral("background:#EDEDED;"));
-    m_chatStack = new QStackedWidget(pane);
-    auto *outer = new QVBoxLayout(pane);
-    outer->setContentsMargins(0, 0, 0, 0);
-    outer->addWidget(m_chatStack);
-
-    m_chatEmpty = new QWidget(m_chatStack);
-    m_chatEmpty->setStyleSheet(QStringLiteral("background:#EDEDED;"));
-    auto *emptyLay = new QVBoxLayout(m_chatEmpty);
-    auto *hint = new QLabel(QStringLiteral("选择一个会话开始聊天"), m_chatEmpty);
-    hint->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:14px; background:transparent;"));
-    hint->setAlignment(Qt::AlignCenter);
-    emptyLay->addStretch();
-    emptyLay->addWidget(hint);
-    emptyLay->addStretch();
-
-    m_friendHintPage = new QWidget(m_chatStack);
-    m_friendHintPage->setStyleSheet(QStringLiteral("background:#EDEDED;"));
-    auto *friendHintLay = new QVBoxLayout(m_friendHintPage);
-    auto *friendHint = new QLabel(QStringLiteral("选择一位好友查看资料"), m_friendHintPage);
-    friendHint->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:14px; background:transparent;"));
-    friendHint->setAlignment(Qt::AlignCenter);
-    friendHintLay->addStretch();
-    friendHintLay->addWidget(friendHint);
-    friendHintLay->addStretch();
-
-    m_chatBody = new QWidget(m_chatStack);
-    auto *body = new QVBoxLayout(m_chatBody);
-    body->setContentsMargins(0, 0, 0, 0);
-    body->setSpacing(0);
-
-    auto *header = new QWidget(m_chatBody);
-    header->setFixedHeight(56);
-    header->setStyleSheet(QStringLiteral("background:#F7F7F7; border-bottom:1px solid #E5E5E5;"));
-    auto *hLay = new QHBoxLayout(header);
-    hLay->setContentsMargins(16, 0, 16, 0);
-    m_chatTitle = new QLabel(header);
-    m_chatTitle->setCursor(Qt::PointingHandCursor);
-    m_chatTitle->setStyleSheet(QStringLiteral("font-size:15px; font-weight:bold; background:transparent;"));
-    m_chatTitle->installEventFilter(this);
-    hLay->addWidget(m_chatTitle);
-    hLay->addStretch();
-
-    m_chatPageStack = new QStackedWidget(m_chatBody);
-    m_chatPageStack->setStyleSheet(QStringLiteral("background:#EDEDED;"));
-    ChatPage first = createChatPage();
-    m_scroll = first.scroll;
-    m_bubbleHost = first.host;
-    m_bubbleLay = first.lay;
-
-    auto *inputWrap = new QWidget(m_chatBody);
-    m_inputWrap = inputWrap;
-    inputWrap->setFixedHeight(148);
-    inputWrap->setStyleSheet(QStringLiteral("background:#FFFFFF; border-top:1px solid #E5E5E5;"));
-    auto *iLay = new QVBoxLayout(inputWrap);
-    iLay->setContentsMargins(0, 0, 0, 0);
-    iLay->setSpacing(0);
-
-    auto *toolbar = new QWidget(inputWrap);
-    toolbar->setFixedHeight(36);
-    toolbar->setStyleSheet(QStringLiteral("background:#FFFFFF;"));
-    auto *tLay = new QHBoxLayout(toolbar);
-    tLay->setContentsMargins(12, 0, 12, 0);
-    auto makeChatTool = [toolbar](int kind, const QString &tip) {
-        auto *btn = new QPushButton(toolbar);
-        btn->setCursor(Qt::PointingHandCursor);
-        btn->setFlat(true);
-        btn->setFixedSize(28, 28);
-        btn->setIconSize(QSize(20, 20));
-        btn->setIcon(QIcon(chatToolIcon(kind, QColor("#07C160"), 20)));
-        btn->setStyleSheet(QStringLiteral(
-            "QPushButton { background:transparent; border:none; padding:0; }"
-            "QPushButton:hover { background:#F5F5F5; border-radius:4px; }"));
-        return btn;
-    };
-    auto *emoji = makeChatTool(0, QStringLiteral("表情"));
-    m_emojiBtn = emoji;
-    auto *fileBtn = makeChatTool(2, QStringLiteral("文件"));
-    auto *imgBtn = makeChatTool(1, QStringLiteral("图片"));
-    auto *tip = new QLabel(QStringLiteral("Enter 发送,Shift+Enter 换行"), toolbar);
-    tip->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:11px; background:transparent;"));
-    tLay->addWidget(emoji);
-    tLay->addWidget(fileBtn);
-    tLay->addWidget(imgBtn);
-    tLay->addWidget(tip);
-    tLay->addStretch();
-
-    m_input = new QTextEdit(inputWrap);
-    m_input->setPlaceholderText(QStringLiteral("输入消息"));
-    m_input->setAcceptRichText(false);
-    m_input->installEventFilter(this);
-
-    auto *sendRow = new QHBoxLayout();
-    sendRow->setContentsMargins(12, 0, 12, 10);
-    auto *send = UiHelpers::primaryButton(QStringLiteral("发送"), inputWrap);
-    send->setFixedSize(88, 32);
-    send->setStyleSheet(QStringLiteral(
-        "QPushButton { background-color:#07C160; color:#FFFFFF; border:1px solid #07C160; font-size:13px; border-radius:2px; }"
-        "QPushButton:hover { background-color:#06AD56; border-color:#06AD56; }"
-        "QPushButton:pressed { background-color:#038D4A; border-color:#038D4A; }"));
-    sendRow->addStretch();
-    sendRow->addWidget(send);
-
-    iLay->addWidget(toolbar);
-    iLay->addWidget(m_input, 1);
-    iLay->addLayout(sendRow);
-
-    m_msgSelectBar = new QWidget(m_chatBody);
-    m_msgSelectBar->setFixedHeight(48);
-    m_msgSelectBar->setStyleSheet(QStringLiteral(
-        "background:#FFFFFF; border-top:1px solid #E5E5E5;"));
-    auto *selLay = new QHBoxLayout(m_msgSelectBar);
-    selLay->setContentsMargins(16, 8, 16, 8);
-    m_msgSelectHint = new QLabel(QStringLiteral("已选 0 条"), m_msgSelectBar);
-    m_msgSelectHint->setStyleSheet(QStringLiteral(
-        "color:#191919; font-size:13px; background:transparent;"));
-    auto *selDel = new QPushButton(QStringLiteral("删除"), m_msgSelectBar);
-    selDel->setCursor(Qt::PointingHandCursor);
-    selDel->setFixedSize(72, 28);
-    selDel->setStyleSheet(QStringLiteral(
-        "QPushButton { background:#FA5151; color:#FFFFFF; border:none; border-radius:2px; font-size:13px; }"
-        "QPushButton:hover { background:#E44545; }"));
-    auto *selCancel = UiHelpers::ghostButton(QStringLiteral("取消"), m_msgSelectBar);
-    selCancel->setFixedSize(72, 28);
-    selLay->addWidget(m_msgSelectHint);
-    selLay->addStretch();
-    selLay->addWidget(selDel);
-    selLay->addWidget(selCancel);
-    m_msgSelectBar->hide();
-    connect(selDel, &QPushButton::clicked, this, [this]() {
-        QVector<qint64> ids;
-        for (int i = 0; i < m_bubbleLay->count(); ++i)
-        {
-            QWidget *w = m_bubbleLay->itemAt(i)->widget();
-            if (!w || w->objectName() != QLatin1String("msgRow"))
-                continue;
-            if (auto *cb = w->findChild<QCheckBox *>(QStringLiteral("msgCheck")))
-            {
-                if (cb->isChecked())
-                    ids.push_back(w->property("msgId").toLongLong());
-            }
-        }
-        deleteChatMessages(ids);
-    });
-    connect(selCancel, &QPushButton::clicked, this, [this]() { exitMsgSelectMode(); });
-
-    body->addWidget(header);
-    body->addWidget(m_chatPageStack, 1);
-    body->addWidget(m_msgSelectBar);
-    body->addWidget(inputWrap);
-
-    m_chatStack->addWidget(m_chatEmpty);
-    m_chatStack->addWidget(m_friendHintPage);
-    m_chatStack->addWidget(m_chatBody);
-
-    m_profilePage = new QWidget(m_chatStack);
-    m_profilePage->setStyleSheet(QStringLiteral("background:#EDEDED;"));
-    auto *pLay = new QVBoxLayout(m_profilePage);
-    pLay->setContentsMargins(40, 48, 40, 32);
-    pLay->setSpacing(10);
-    pLay->setAlignment(Qt::AlignHCenter | Qt::AlignTop);
-    m_profileAvatar = new QLabel(m_profilePage);
-    m_profileAvatar->setFixedSize(72, 72);
-    m_profileTitle = new QLabel(m_profilePage);
-    m_profileTitle->setAlignment(Qt::AlignCenter);
-    m_profileTitle->setStyleSheet(QStringLiteral("font-size:18px; color:#1A1A1A; background:transparent; border:none;"));
-    auto metaStyle = QStringLiteral("font-size:13px; color:#6A6A6A; background:transparent; border:none;");
-    m_profileRemark = new QLabel(m_profilePage);
-    m_profileRemark->setAlignment(Qt::AlignCenter);
-    m_profileRemark->setStyleSheet(metaStyle);
-    m_profileNick = new QLabel(m_profilePage);
-    m_profileNick->setAlignment(Qt::AlignCenter);
-    m_profileNick->setStyleSheet(metaStyle);
-    m_profileAccount = new QLabel(m_profilePage);
-    m_profileAccount->setAlignment(Qt::AlignCenter);
-    m_profileAccount->setStyleSheet(metaStyle);
-    m_profileSign = new QLabel(m_profilePage);
-    m_profileSign->setAlignment(Qt::AlignCenter);
-    m_profileSign->setWordWrap(true);
-    m_profileSign->setStyleSheet(QStringLiteral("font-size:13px; color:#8A8A8A; background:transparent; border:none;"));
-    auto *sendMsg = UiHelpers::compactButton(QStringLiteral("发消息"), m_profilePage, true);
-    sendMsg->setFixedWidth(120);
-    auto *shareMsg = UiHelpers::compactButton(QStringLiteral("分享"), m_profilePage);
-    shareMsg->setFixedWidth(120);
-    pLay->addWidget(m_profileAvatar, 0, Qt::AlignHCenter);
-    pLay->addSpacing(8);
-    pLay->addWidget(m_profileTitle);
-    pLay->addWidget(m_profileRemark);
-    pLay->addWidget(m_profileNick);
-    pLay->addWidget(m_profileAccount);
-    pLay->addWidget(m_profileSign);
-    pLay->addSpacing(16);
-    pLay->addWidget(sendMsg, 0, Qt::AlignHCenter);
-    pLay->addWidget(shareMsg, 0, Qt::AlignHCenter);
-    pLay->addStretch();
-    m_chatStack->addWidget(m_profilePage);
-    m_favDetail = new FavoriteDetailPage(m_chatStack);
-    m_chatStack->addWidget(m_favDetail);
-    connect(m_favDetail, &FavoriteDetailPage::changed, this, [this]() {
-        if (m_favPanel)
-            m_favPanel->refresh();
-    });
-    connect(m_favDetail, &FavoriteDetailPage::openCard, this, [this](qint64 uid) {
-        if (uid > 0)
-            showProfile(uid);
-    });
-    connect(m_favDetail, &FavoriteDetailPage::previewImage, this,
-            [this](qint64 fileId, const QString &name, const QString &content) {
-        previewChatImage(fileId, name, content);
-    });
-    m_profileChatBtn = sendMsg;
-    m_profileShareBtn = shareMsg;
-    connect(sendMsg, &QPushButton::clicked, this, [this]() {
-        if (m_profileUid > 0)
-            openChat(m_profileUid, m_profileDisplayName);
-    });
-    connect(shareMsg, &QPushButton::clicked, this, [this]() {
-        shareUserCard(m_profileUser);
-    });
-
-    m_chatStack->setCurrentWidget(m_chatEmpty);
-
-    m_stickerPanel = new StickerPanel(this);
-    m_stickerPanel->setWindowFlags(Qt::Popup | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint);
-    m_stickerPanel->hide();
-    connect(emoji, &QPushButton::clicked, this, [this, inputWrap]() {
-        if (m_stickerPanel->isVisible())
-        {
-            m_stickerPanel->hide();
-            return;
-        }
-        if (QDateTime::currentMSecsSinceEpoch() - m_stickerDismissedAt < 250)
-            return;
-        const QPoint pos = inputWrap->mapToGlobal(QPoint(8, -m_stickerPanel->height() + 12));
-        m_stickerPanel->move(pos);
-        m_stickerPanel->show();
-    });
-    connect(m_stickerPanel, &StickerPanel::dismissed, this, [this]() {
-        m_stickerDismissedAt = QDateTime::currentMSecsSinceEpoch();
-    });
-    connect(m_stickerPanel, &StickerPanel::stickerPicked, this, [this](qint64 packId, const QString &code) {
-        m_stickerPanel->hide();
-        sendSticker(packId, code);
-    });
-    connect(fileBtn, &QPushButton::clicked, this, [this]() { sendFile(false); });
-    connect(imgBtn, &QPushButton::clicked, this, [this]() { sendFile(true); });
-
-    connect(send, &QPushButton::clicked, this, &Client::sendCurrentMessage);
-    return pane;
-}
-
-void Client::refreshConvList()
-{
-    QPointer<Client> self(this);
-    ImClient::instance().convList([self](const QJsonObject &reply) {
-        if (!self)
-            return;
-        if (!ImClient::ok(reply))
-            return;
-        self->applyConvList(reply.value(QStringLiteral("list")).toArray());
-    });
-}
-
-void Client::refreshFriendList()
-{
-    QPointer<Client> self(this);
-    ImClient::instance().friendList([self](const QJsonObject &reply) {
-        if (!self)
-            return;
-        if (!ImClient::ok(reply))
-            return;
-        self->setPendingCount(reply.value(QStringLiteral("pending")).toInt());
-        self->applyFriendList(reply.value(QStringLiteral("list")).toArray());
-    });
-}
-
-void Client::applyConvList(const QJsonArray &list)
-{
-    const qint64 keep = m_currentPeerUid;
-    m_convList->clear();
-    for (const QJsonValue &v : list)
-    {
-        const QJsonObject o = v.toObject();
-        Conv c;
-        c.uid = Proto::jsonI64(o, QStringLiteral("peer_uid"));
-        c.name = o.value(QStringLiteral("nickname")).toString();
-        if (c.name.isEmpty())
-            c.name = o.value(QStringLiteral("account")).toString();
-        c.preview = o.value(QStringLiteral("preview")).toString();
-        c.time = formatTs(Proto::jsonI64(o, QStringLiteral("ts")));
-        c.unread = o.value(QStringLiteral("unread")).toInt();
-        c.avatarVer = o.value(QStringLiteral("avatar_ver")).toInt();
-        c.online = o.value(QStringLiteral("online")).toInt() == 1;
-        auto *item = new QListWidgetItem(m_convList);
-        item->setData(RolePeerUid, c.uid);
-        item->setData(RoleName, c.name);
-        item->setData(RoleUnread, c.unread);
-        item->setData(RolePreview, c.preview);
-        item->setData(RoleTs, Proto::jsonI64(o, QStringLiteral("ts")));
-        item->setData(RoleAvatarVer, c.avatarVer);
-        item->setData(RoleOnline, c.online ? 1 : 0);
-        item->setSizeHint(QSize(10, 68));
-        m_convList->setItemWidget(item, makeConvRow(c, m_convList));
-        persistLocalConv(c.uid, c.name, c.preview, Proto::jsonI64(o, QStringLiteral("ts")), c.unread);
-    }
-    applyListFilter();
-    if (keep > 0)
-    {
-        if (auto *item = findConvItem(keep))
-        {
-            m_convList->blockSignals(true);
-            m_convList->setCurrentItem(item);
-            m_convList->blockSignals(false);
-        }
-    }
-    updateTray();
-}
-
-void Client::applyFriendList(const QJsonArray &list)
-{
-    m_friendList->clear();
-    for (const QJsonValue &v : list)
-    {
-        const QJsonObject o = v.toObject();
-        const qint64 uid = Proto::jsonI64(o, QStringLiteral("uid"));
-        const QString account = o.value(QStringLiteral("account")).toString();
-        const QString nick = o.value(QStringLiteral("nickname")).toString();
-        const QString remark = o.value(QStringLiteral("remark")).toString();
-        QString name = remark.isEmpty() ? nick : remark;
-        if (name.isEmpty())
-            name = account;
-        const bool online = o.value(QStringLiteral("online")).toInt() == 1;
-        const int avatarVer = o.value(QStringLiteral("avatar_ver")).toInt();
-        auto *item = new QListWidgetItem(m_friendList);
-        item->setData(RolePeerUid, uid);
-        item->setData(RoleName, name);
-        item->setData(RoleNick, nick);
-        item->setData(RoleAccount, account);
-        item->setData(RoleSignature, o.value(QStringLiteral("signature")).toString());
-        item->setData(RoleRemark, remark);
-        item->setData(RoleAvatarVer, avatarVer);
-        item->setData(RoleOnline, online ? 1 : 0);
-        item->setSizeHint(QSize(10, 48));
-        m_friendList->setItemWidget(item, makeFriendRow(name, online, uid, avatarVer, m_friendList));
-        if (auto *conv = findConvItem(uid))
-        {
-            const int oldVer = conv->data(RoleAvatarVer).toInt();
-            const bool oldOnline = conv->data(RoleOnline).toInt() == 1;
-            const int ver = qMax(oldVer, avatarVer);
-            conv->setData(RoleAvatarVer, ver);
-            conv->setData(RoleOnline, online ? 1 : 0);
-            if (ver != oldVer || oldOnline != online)
-            {
-                Conv c = convFromItem(conv);
-                c.avatarVer = ver;
-                c.online = online;
-                if (QWidget *old = m_convList->itemWidget(conv))
-                    old->deleteLater();
-                m_convList->setItemWidget(conv, makeConvRow(c, m_convList));
-            }
-        }
-    }
-    applyListFilter();
-}
-
-Client::ChatPage Client::createChatPage()
-{
-    ChatPage p;
-    p.scroll = new QScrollArea(m_chatPageStack);
-    p.scroll->setWidgetResizable(true);
-    p.scroll->setFrameShape(QFrame::NoFrame);
-    p.scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
-    p.scroll->setStyleSheet(QStringLiteral("background:#EDEDED;"));
-    p.host = new QWidget(p.scroll);
-    p.host->setStyleSheet(QStringLiteral("background:#EDEDED;"));
-    p.lay = new QVBoxLayout(p.host);
-    p.lay->setContentsMargins(16, 16, 16, 16);
-    p.lay->setSpacing(12);
-    p.lay->addStretch();
-    p.scroll->setWidget(p.host);
-    bindChatScroll(p.scroll);
-    if (m_chatPageStack)
-        m_chatPageStack->addWidget(p.scroll);
-    return p;
-}
-
-void Client::bindChatScroll(QScrollArea *scroll)
-{
-    if (!scroll)
-        return;
-    connect(scroll->verticalScrollBar(), &QScrollBar::rangeChanged, this, [this, scroll](int, int max) {
-        if (m_scroll != scroll)
-            return;
-        if (m_stickChatToBottom)
-            scroll->verticalScrollBar()->setValue(max);
-    });
-    connect(scroll->verticalScrollBar(), &QScrollBar::valueChanged, this, [this, scroll](int v) {
-        if (m_scroll != scroll)
-            return;
-        const int max = scroll->verticalScrollBar()->maximum();
-        m_stickChatToBottom = (max - v) <= 24;
-    });
-}
-
-void Client::stashChatPage(qint64 peerUid)
-{
-    if (peerUid <= 0)
-        return;
-    auto it = m_chatPages.find(peerUid);
-    if (it == m_chatPages.end())
-        return;
-    it->fp = m_historyFp;
-    it->stickBottom = m_stickChatToBottom;
-    if (it->scroll && it->scroll->viewport())
-        it->laidOutW = it->scroll->viewport()->width();
-    if (it->scroll)
-        it->scrollValue = it->scroll->verticalScrollBar()->value();
-}
-
-void Client::rememberChatPageState()
-{
-    stashChatPage(m_currentPeerUid);
-}
-
-bool Client::activateChatPage(qint64 peerUid)
-{
-    if (peerUid <= 0 || !m_chatPageStack)
-        return false;
-    const bool cached = m_chatPages.contains(peerUid);
-
-    m_chatPageOrder.removeAll(peerUid);
-    m_chatPageOrder.append(peerUid);
-
-    auto it = m_chatPages.find(peerUid);
-    if (it == m_chatPages.end())
-    {
-        ChatPage p;
-        bool owned = false;
-        for (auto pit = m_chatPages.cbegin(); pit != m_chatPages.cend(); ++pit)
-        {
-            if (pit->scroll == m_scroll)
-            {
-                owned = true;
-                break;
-            }
-        }
-        if (m_scroll && m_bubbleHost && m_bubbleLay && !owned)
-        {
-            p.scroll = m_scroll;
-            p.host = m_bubbleHost;
-            p.lay = m_bubbleLay;
-        }
-        else
-        {
-            p = createChatPage();
-        }
-        it = m_chatPages.insert(peerUid, p);
-        m_historyFp.clear();
-        m_historyPeerUid = peerUid;
-        m_stickChatToBottom = true;
-        trimChatPages();
-        it = m_chatPages.find(peerUid);
-        if (it == m_chatPages.end())
-            return false;
-    }
-    else
-    {
-        m_historyFp = it->fp;
-        m_historyPeerUid = peerUid;
-        m_stickChatToBottom = it->stickBottom;
-    }
-
-    m_scroll = it->scroll;
-    m_bubbleHost = it->host;
-    m_bubbleLay = it->lay;
-    if (m_scroll && m_chatPageStack->currentWidget() != m_scroll)
-        m_chatPageStack->setCurrentWidget(m_scroll);
-
-    int viewW = 0;
-    if (m_scroll && m_scroll->viewport())
-        viewW = m_scroll->viewport()->width();
-    if (it->laidOutW > 0 && viewW > 0 && it->laidOutW != viewW)
-        relayoutTextBubbles();
-    return cached;
-}
-
-bool Client::chatPageHasAll(const QJsonArray &list) const
-{
-    if (!m_bubbleLay || m_bubbleLay->count() <= 1)
-        return false;
-    for (const QJsonValue &v : list)
-    {
-        const QJsonObject o = v.toObject();
-        qint64 msgId = Proto::jsonI64(o, QStringLiteral("msg_id"));
-        if (msgId <= 0)
-            msgId = Proto::jsonI64(o, QStringLiteral("id"));
-        if (msgId > 0 && !findMsgRow(msgId))
-            return false;
-    }
-    return true;
-}
-
-void Client::dropChatPage(qint64 uid)
-{
-    auto it = m_chatPages.find(uid);
-    if (it == m_chatPages.end())
-        return;
-    QScrollArea *scroll = it->scroll;
-    m_chatPages.erase(it);
-    m_chatPageOrder.removeAll(uid);
-    const bool droppingCurrent = (m_scroll == scroll);
-    if (scroll)
-    {
-        if (m_chatPageStack)
-            m_chatPageStack->removeWidget(scroll);
-        scroll->deleteLater();
-    }
-    if (!droppingCurrent)
-        return;
-    ChatPage fresh = createChatPage();
-    m_scroll = fresh.scroll;
-    m_bubbleHost = fresh.host;
-    m_bubbleLay = fresh.lay;
-    m_historyFp.clear();
-    m_historyPeerUid = 0;
-    if (m_chatPageStack && m_scroll)
-        m_chatPageStack->setCurrentWidget(m_scroll);
-}
-
-void Client::trimChatPages()
-{
-    constexpr int kMaxPages = 8;
-    while (m_chatPages.size() > kMaxPages)
-    {
-        qint64 victim = 0;
-        for (qint64 id : m_chatPageOrder)
-        {
-            if (id != m_currentPeerUid)
-            {
-                victim = id;
-                break;
-            }
-        }
-        if (victim == 0)
-            break;
-        dropChatPage(victim);
-    }
-}
-
-void Client::applyHistory(const QJsonArray &list)
-{
-    QElapsedTimer clock;
-    clock.start();
-    int histLimit = AppConfig::instance().historyDefault();
-    if (histLimit <= 0)
-        histLimit = 50;
-    QMap<qint64, LocalStore::Msg> byId;
-    int fallbackId = 0;
-    for (const auto &m : LocalStore::instance().listMessages(m_currentPeerUid, histLimit))
-    {
-        if (m.id != 0)
-            byId.insert(m.id, m);
-    }
-    const qint64 sqliteNs = clock.nsecsElapsed();
-    for (const QJsonValue &v : list)
-    {
-        const QJsonObject o = v.toObject();
-        const qint64 from = Proto::jsonI64(o, QStringLiteral("from_uid"));
-        const qint64 to = Proto::jsonI64(o, QStringLiteral("to_uid"));
-        qint64 msgId = Proto::jsonI64(o, QStringLiteral("msg_id"));
-        if (msgId <= 0)
-            msgId = Proto::jsonI64(o, QStringLiteral("id"));
-        const qint64 peer = m_currentPeerUid > 0
-            ? m_currentPeerUid
-            : ((from == m_uid) ? to : from);
-        const int msgType = static_cast<int>(Proto::jsonI64(o, QStringLiteral("msg_type")));
-        const QString text = o.value(QStringLiteral("content")).toString();
-        const qint64 ts = Proto::jsonI64(o, QStringLiteral("ts"));
-        saveLocalMessage(msgId, peer, from, to, msgType > 0 ? msgType : 1, text, ts);
-
-        LocalStore::Msg m;
-        m.id = msgId > 0 ? msgId : --fallbackId;
-        m.peerUid = peer;
-        m.fromUid = from;
-        m.toUid = to;
-        m.msgType = msgType > 0 ? msgType : 1;
-        m.content = text;
-        m.ts = ts;
-        byId.insert(m.id, m);
-    }
-
-    QVector<LocalStore::Msg> rows;
-    rows.reserve(byId.size());
-    for (auto it = byId.constBegin(); it != byId.constEnd(); ++it)
-        rows.push_back(it.value());
-    std::sort(rows.begin(), rows.end(), [](const LocalStore::Msg &a, const LocalStore::Msg &b) {
-        if (a.ts != b.ts)
-            return a.ts < b.ts;
-        return a.id < b.id;
-    });
-    if (rows.size() > histLimit)
-        rows = rows.mid(rows.size() - histLimit);
-
-    QString fp;
-    fp.reserve(rows.size() * 16);
-    for (const LocalStore::Msg &m : rows)
-        fp += QString::number(m.id) + QLatin1Char(',');
-    const qint64 mergeNs = clock.nsecsElapsed();
-    if (m_historyPeerUid == m_currentPeerUid && fp == m_historyFp)
-    {
-        if (m_stickChatToBottom)
-            scrollChatToBottom();
-        LOG_INFO(QStringLiteral("applyHistory peer=%1 src=%2 rows=%3 skip sqlite=%4ms merge=%5ms total=%6ms")
-                     .arg(QString::number(m_currentPeerUid),
-                          list.isEmpty() ? QStringLiteral("local") : QStringLiteral("server"),
-                          QString::number(rows.size()),
-                          fmtMs(sqliteNs), fmtMs(mergeNs - sqliteNs),
-                          fmtMs(clock.nsecsElapsed())));
-        return;
-    }
-
-    if (m_scroll)
-        m_scroll->setUpdatesEnabled(false);
-    if (m_bubbleHost)
-        m_bubbleHost->setUpdatesEnabled(false);
-    clearBubbles();
-    m_buildingHistory = true;
-    QLayoutItem *tail = nullptr;
-    if (m_bubbleLay && m_bubbleLay->count() > 0)
-        tail = m_bubbleLay->takeAt(m_bubbleLay->count() - 1);
-    qint64 lastTs = 0;
-    for (const LocalStore::Msg &m : rows)
-    {
-        const bool mine = (m.fromUid == m_uid);
-        const QString name = mine ? m_nickname : m_currentPeerName;
-        QString time;
-        if (lastTs == 0 || qAbs(m.ts - lastTs) > 5 * 60 * 1000)
-            time = formatTs(m.ts);
-        lastTs = m.ts;
-        int status = m.status;
-        if (status == LocalMsg::Sending && !m_inflightLocalIds.contains(m.id))
-        {
-            status = LocalMsg::Failed;
-            LocalStore::Msg stale = m;
-            stale.status = LocalMsg::Failed;
-            LocalStore::instance().upsertMessage(stale);
-        }
-        addBubble(mine, name, m.content, time, m.msgType, status, m.id, m.peerUid);
-    }
-    if (m_bubbleLay && tail)
-        m_bubbleLay->addItem(tail);
-    m_buildingHistory = false;
-    m_historyPeerUid = m_currentPeerUid;
-    m_historyFp = fp;
-    rememberChatPageState();
-    if (m_bubbleHost)
-        m_bubbleHost->setUpdatesEnabled(true);
-    if (m_scroll)
-        m_scroll->setUpdatesEnabled(true);
-    scrollChatToBottom();
-    LOG_INFO(QStringLiteral("applyHistory peer=%1 src=%2 rows=%3 rebuild sqlite=%4ms merge=%5ms build=%6ms total=%7ms")
-                 .arg(QString::number(m_currentPeerUid),
-                      list.isEmpty() ? QStringLiteral("local") : QStringLiteral("server"),
-                      QString::number(rows.size()),
-                      fmtMs(sqliteNs), fmtMs(mergeNs - sqliteNs),
-                      fmtMs(clock.nsecsElapsed() - mergeNs), fmtMs(clock.nsecsElapsed())));
-}
-
-QListWidgetItem *Client::findConvItem(qint64 peerUid) const
-{
-    for (int i = 0; i < m_convList->count(); ++i)
-    {
-        auto *item = m_convList->item(i);
-        if (item->data(RolePeerUid).toLongLong() == peerUid)
-            return item;
-    }
-    return nullptr;
-}
-
-void Client::upsertConv(qint64 peerUid, const QString &name, const QString &preview,
-                        qint64 ts, int unread)
-{
-    Conv c;
-    c.uid = peerUid;
-    c.name = name;
-    c.preview = preview;
-    c.time = formatTs(ts);
-    c.unread = unread;
-    if (auto *exist = findConvItem(peerUid))
-    {
-        c.avatarVer = exist->data(RoleAvatarVer).toInt();
-        c.online = exist->data(RoleOnline).toInt() == 1;
-    }
-    if (c.avatarVer <= 0)
-        c.avatarVer = avatarVerOf(peerUid);
-    if (!c.online)
-        c.online = listOnlineOf(m_friendList, peerUid);
-    const qint64 keepUid = m_currentPeerUid;
-    m_convList->blockSignals(true);
-    if (auto *item = findConvItem(peerUid))
-    {
-        item->setData(RoleName, name);
-        item->setData(RoleUnread, unread);
-        item->setData(RolePreview, preview);
-        item->setData(RoleTs, ts);
-        item->setData(RoleAvatarVer, c.avatarVer);
-        item->setData(RoleOnline, c.online ? 1 : 0);
-        const int row = m_convList->row(item);
-        if (row > 0)
-        {
-            m_convList->takeItem(row);
-            m_convList->insertItem(0, item);
-        }
-        if (QWidget *old = m_convList->itemWidget(item))
-            old->deleteLater();
-        m_convList->setItemWidget(item, makeConvRow(c, m_convList));
-    }
-    else
-    {
-        auto *created = new QListWidgetItem;
-        created->setData(RolePeerUid, peerUid);
-        created->setData(RoleName, name);
-        created->setData(RoleUnread, unread);
-        created->setData(RolePreview, preview);
-        created->setData(RoleTs, ts);
-        created->setData(RoleAvatarVer, c.avatarVer);
-        created->setData(RoleOnline, c.online ? 1 : 0);
-        created->setSizeHint(QSize(10, 68));
-        m_convList->insertItem(0, created);
-        m_convList->setItemWidget(created, makeConvRow(c, m_convList));
-    }
-    if (keepUid > 0)
-    {
-        if (auto *keep = findConvItem(keepUid))
-            m_convList->setCurrentItem(keep);
-    }
-    m_convList->blockSignals(false);
-    persistLocalConv(peerUid, name, preview, ts, unread);
-    applyListFilter();
-    updateTray();
-}
-
-void Client::selectConversation(int row)
-{
-    if (row < 0)
-        return;
-    auto *item = m_convList->item(row);
-    if (!item)
-        return;
-    openChat(item->data(RolePeerUid).toLongLong(), item->data(RoleName).toString());
-}
-
-void Client::openChat(qint64 peerUid, const QString &name)
-{
-    if (peerUid <= 0)
-        return;
-    QElapsedTimer clock;
-    clock.start();
-    const bool sameChat = (peerUid == m_currentPeerUid)
-        && m_chatStack && m_chatStack->currentWidget() == m_chatBody;
-    exitMsgSelectMode();
-    qint64 stashNs = 0;
-    if (!sameChat)
-    {
-        const qint64 t0 = clock.nsecsElapsed();
-        stashChatPage(m_currentPeerUid);
-        stashNs = clock.nsecsElapsed() - t0;
-    }
-    m_currentPeerUid = peerUid;
-    m_currentPeerName = name;
-    m_chatTitle->setText(name);
-    m_chatStack->setCurrentWidget(m_chatBody);
-    applyNavSelection(0);
-    markPeerRead(peerUid);
-    if (auto *item = findConvItem(peerUid))
-    {
-        m_convList->blockSignals(true);
-        m_convList->setCurrentItem(item);
-        m_convList->blockSignals(false);
-    }
-    else
-    {
-        upsertConv(peerUid, name, QString(), QDateTime::currentMSecsSinceEpoch(), 0);
-        if (auto *item = findConvItem(peerUid))
-        {
-            m_convList->blockSignals(true);
-            m_convList->setCurrentItem(item);
-            m_convList->blockSignals(false);
-        }
-    }
-    const qint64 uiNs = clock.nsecsElapsed() - stashNs;
-    if (sameChat)
-    {
-        LOG_INFO(QStringLiteral("openChat peer=%1 same=1 ui=%2ms total=%3ms")
-                     .arg(QString::number(peerUid), fmtMs(uiNs), fmtMs(clock.nsecsElapsed())));
-        return;
-    }
-
-    const qint64 pageT0 = clock.nsecsElapsed();
-    const bool cached = activateChatPage(peerUid);
-    const qint64 pageNs = clock.nsecsElapsed() - pageT0;
-
-    QPointer<Client> self(this);
-    qint64 localNs = 0;
-    if (!cached)
-    {
-        const qint64 localT0 = clock.nsecsElapsed();
-        applyHistory(QJsonArray());
-        localNs = clock.nsecsElapsed() - localT0;
-    }
-    const int limit = AppConfig::instance().historyDefault();
-    QElapsedTimer net;
-    net.start();
-    ImClient::instance().msgHistory(peerUid, 0, limit, [self, peerUid, net](const QJsonObject &reply) {
-        const qint64 waitNs = net.nsecsElapsed();
-        if (!self || self->m_currentPeerUid != peerUid)
-            return;
-        if (!ImClient::ok(reply))
-        {
-            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
-            LOG_INFO(QStringLiteral("openChat histReply peer=%1 wait=%2ms error")
-                         .arg(QString::number(peerUid), fmtMs(waitNs)));
-            return;
-        }
-        const QJsonArray list = reply.value(QStringLiteral("list")).toArray();
-        QElapsedTimer apply;
-        apply.start();
-        const bool hasAll = self->chatPageHasAll(list);
-        if (!hasAll)
-            self->applyHistory(list);
-        LOG_INFO(QStringLiteral("openChat histReply peer=%1 wait=%2ms n=%3 hasAll=%4 apply=%5ms")
-                     .arg(QString::number(peerUid), fmtMs(waitNs), QString::number(list.size()),
-                          hasAll ? QStringLiteral("1") : QStringLiteral("0"),
-                          fmtMs(apply.nsecsElapsed())));
-    });
-    LOG_INFO(QStringLiteral("openChat peer=%1 cached=%2 stash=%3ms ui=%4ms page=%5ms local=%6ms sync=%7ms")
-                 .arg(QString::number(peerUid),
-                      cached ? QStringLiteral("1") : QStringLiteral("0"),
-                      fmtMs(stashNs), fmtMs(uiNs), fmtMs(pageNs), fmtMs(localNs),
-                      fmtMs(clock.nsecsElapsed())));
-}
-
-void Client::applyUnread(qint64 peerUid, int unread)
-{
-    auto *item = findConvItem(peerUid);
-    if (!item)
-        return;
-    item->setData(RoleUnread, unread);
-    Conv c = convFromItem(item);
-    c.unread = unread;
-    if (QWidget *old = m_convList->itemWidget(item))
-        old->deleteLater();
-    m_convList->setItemWidget(item, makeConvRow(c, m_convList));
-    LocalStore::instance().setUnread(peerUid, unread);
-    updateTray();
-}
-
-void Client::onConvMenu(const QPoint &pos)
-{
-    auto *item = m_convList->itemAt(pos);
-    if (!item)
-        return;
-    const qint64 uid = item->data(RolePeerUid).toLongLong();
-    const QString name = item->data(RoleName).toString();
-    const int unread = item->data(RoleUnread).toInt();
-    QMenu menu(this);
-    QAction *del = menu.addAction(QStringLiteral("删除"));
-    QAction *read = menu.addAction(QStringLiteral("标记已读"));
-    QAction *unreadAct = menu.addAction(QStringLiteral("标记未读"));
-    read->setEnabled(unread > 0);
-    unreadAct->setEnabled(unread <= 0);
-    QAction *picked = menu.exec(m_convList->viewport()->mapToGlobal(pos));
-    if (!picked)
-        return;
-    QPointer<Client> self(this);
-    if (picked == del)
-    {
-        if (!ConfirmDialog::ask(this, QStringLiteral("删除聊天"),
-                                QStringLiteral("删除后只清空你这边与「%1」的聊天记录,对方仍可看到")
-                                    .arg(name)))
-            return;
-        ImClient::instance().deleteConv(uid, [self, uid](const QJsonObject &reply) {
-            if (!self)
-                return;
-            if (!ImClient::ok(reply))
-            {
-                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
-                return;
-            }
-            self->removeLocalConv(uid);
-        });
-        return;
-    }
-    const int next = (picked == read) ? 0 : 1;
-    ImClient::instance().setUnread(uid, next, [self, uid, next](const QJsonObject &reply) {
-        if (!self)
-            return;
-        if (!ImClient::ok(reply))
-        {
-            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
-            return;
-        }
-        self->applyUnread(uid, next);
-    });
-}
-
-void Client::onFriendMenu(const QPoint &pos)
-{
-    auto *item = m_friendList->itemAt(pos);
-    if (!item)
-        return;
-    const qint64 uid = item->data(RolePeerUid).toLongLong();
-    const QString name = item->data(RoleName).toString();
-    const QString remark = item->data(RoleRemark).toString();
-    QMenu menu(this);
-    QAction *del = nullptr;
-    QAction *mark = nullptr;
-    if (uid != im::kFileHelperUid)
-    {
-        del = menu.addAction(QStringLiteral("删除"));
-        mark = menu.addAction(QStringLiteral("备注"));
-    }
-    QAction *card = menu.addAction(QStringLiteral("查看名片"));
-    QAction *picked = menu.exec(m_friendList->viewport()->mapToGlobal(pos));
-    if (!picked)
-        return;
-    if (picked == card)
-    {
-        showProfile(uid);
-        return;
-    }
-    QPointer<Client> self(this);
-    if (picked == mark)
-    {
-        QString next = remark;
-        if (!PromptDialog::ask(this, QStringLiteral("设置备注"),
-                               QStringLiteral("不填则显示昵称"), &next, 32))
-            return;
-        ImClient::instance().setRemark(uid, next, [self, uid, next](const QJsonObject &reply) {
-            if (!self)
-                return;
-            if (!ImClient::ok(reply))
-            {
-                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
-                return;
-            }
-            const QString shown = next.isEmpty()
-                ? reply.value(QStringLiteral("nickname")).toString()
-                : next;
-            self->refreshFriendList();
-            if (auto *it = self->findConvItem(uid))
-            {
-                const qint64 ts = it->data(RoleTs).toLongLong();
-                self->upsertConv(uid, shown.isEmpty() ? it->data(RoleName).toString() : shown,
-                                 it->data(RolePreview).toString(), ts,
-                                 it->data(RoleUnread).toInt());
-            }
-            if (self->m_currentPeerUid == uid && !shown.isEmpty())
-            {
-                self->m_currentPeerName = shown;
-                if (self->m_chatTitle)
-                    self->m_chatTitle->setText(shown);
-            }
-        });
-        return;
-    }
-    if (picked == del)
-    {
-        if (!ConfirmDialog::ask(
-                this, QStringLiteral("删除好友"),
-                QStringLiteral("将好友「%1」删除,并清空你这边的聊天记录?对方记录不受影响。")
-                    .arg(name)))
-            return;
-        ImClient::instance().deleteFriend(uid, [self, uid](const QJsonObject &reply) {
-            if (!self)
-                return;
-            if (!ImClient::ok(reply))
-            {
-                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
-                return;
-            }
-            self->removeLocalConv(uid);
-            self->refreshFriendList();
-        });
-    }
-}
-
-void Client::showProfile(qint64 uid)
-{
-    QPointer<Client> self(this);
-    ImClient::instance().getProfile(uid, [self](const QJsonObject &reply) {
-        if (!self)
-            return;
-        if (!ImClient::ok(reply))
-        {
-            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
-            return;
-        }
-        const qint64 peer = Proto::jsonI64(reply, QStringLiteral("uid"));
-        QString nick = reply.value(QStringLiteral("nickname")).toString().trimmed();
-        const QString account = reply.value(QStringLiteral("account")).toString();
-        const QString remark = reply.value(QStringLiteral("remark")).toString().trimmed();
-        const QString sign = reply.value(QStringLiteral("signature")).toString().trimmed();
-        if (nick.isEmpty())
-            nick = account;
-        const QString shown = remark.isEmpty() ? nick : remark;
-
-        QDialog card(self);
-        card.setObjectName(QStringLiteral("ChromeWindow"));
-        card.setWindowTitle(QStringLiteral("名片"));
-        card.setFixedSize(360, 400);
-        UiHelpers::setupFramelessDialog(&card);
-
-        auto *root = new QVBoxLayout(&card);
-        root->setContentsMargins(0, 0, 0, 0);
-        root->setSpacing(0);
-        auto *bar = new TitleBar(&card);
-        bar->setTitle(QStringLiteral("名片"));
-        bar->setTheme(TitleBar::Light);
-        bar->setMaximizable(false);
-        bar->bind(&card, false);
-        if (peer != im::kFileHelperUid)
-            bar->setActionText(QStringLiteral("分享"));
-        root->addWidget(bar);
-
-        auto *page = new QWidget(&card);
-        auto *lay = new QVBoxLayout(page);
-        lay->setContentsMargins(28, 20, 28, 22);
-        lay->setSpacing(8);
-        auto *avatar = new QLabel(page);
-        avatar->setFixedSize(64, 64);
-        avatar->setPixmap(AvatarCache::instance().pixmap(
-            peer, reply.value(QStringLiteral("avatar_ver")).toInt(), shown, 64));
-        auto *nameLab = new QLabel(shown, page);
-        nameLab->setAlignment(Qt::AlignCenter);
-        nameLab->setStyleSheet(QStringLiteral("font-size:16px; color:#1A1A1A; background:transparent; border:none;"));
-        const QString meta = QStringLiteral("font-size:13px; color:#6A6A6A; background:transparent; border:none;");
-        auto *remarkLab = new QLabel(QStringLiteral("备注  %1").arg(remark.isEmpty() ? QStringLiteral("未设置") : remark), page);
-        remarkLab->setAlignment(Qt::AlignCenter);
-        remarkLab->setStyleSheet(meta);
-        auto *nickLab = new QLabel(QStringLiteral("昵称  %1").arg(nick), page);
-        nickLab->setAlignment(Qt::AlignCenter);
-        nickLab->setStyleSheet(meta);
-        auto *accLab = new QLabel(QStringLiteral("账号  %1").arg(account), page);
-        accLab->setAlignment(Qt::AlignCenter);
-        accLab->setStyleSheet(meta);
-        auto *signLab = new QLabel(sign.isEmpty() ? QStringLiteral("这个人很懒,什么都没写") : sign, page);
-        signLab->setAlignment(Qt::AlignCenter);
-        signLab->setWordWrap(true);
-        signLab->setStyleSheet(QStringLiteral("font-size:13px; color:#8A8A8A; background:transparent; border:none;"));
-        auto *chatBtn = UiHelpers::compactButton(QStringLiteral("发消息"), page, true);
-        const int rel = reply.value(QStringLiteral("relation")).toInt();
-        const bool canChat = peer != self->m_uid
-            && (peer == im::kFileHelperUid || rel == im::FriendRel::Friend);
-        chatBtn->setVisible(canChat);
-        lay->addWidget(avatar, 0, Qt::AlignHCenter);
-        lay->addWidget(nameLab);
-        lay->addWidget(remarkLab);
-        lay->addWidget(nickLab);
-        lay->addWidget(accLab);
-        lay->addWidget(signLab);
-        lay->addStretch();
-        lay->addWidget(chatBtn, 0, Qt::AlignHCenter);
-        root->addWidget(page, 1);
-        QObject::connect(chatBtn, &QPushButton::clicked, &card, [&card, self, peer, shown]() {
-            card.accept();
-            if (self)
-                self->openChat(peer, shown);
-        });
-        QObject::connect(bar, &TitleBar::actionClicked, &card, [self, reply, &card]() {
-            // 先关掉名片,避免在已有 exec() 里再套一层转发框(Windows 上经常不显示)。
-            card.accept();
-            if (self)
-                self->shareUserCard(reply);
-        });
-        card.exec();
-    });
-}
-
-void Client::showFriendCard(qint64 uid)
-{
-    if (uid <= 0)
-        return;
-    QPointer<Client> self(this);
-    ImClient::instance().getProfile(uid, [self](const QJsonObject &reply) {
-        if (!self)
-            return;
-        if (!ImClient::ok(reply))
-        {
-            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
-            return;
-        }
-        self->fillProfilePage(reply);
-        if (self->m_chatStack && self->m_profilePage)
-            self->m_chatStack->setCurrentWidget(self->m_profilePage);
-    });
-}
-
-void Client::fillProfilePage(const QJsonObject &user)
-{
-    m_profileUser = user;
-    m_profileUid = Proto::jsonI64(user, QStringLiteral("uid"));
-    m_profileAvatarVer = user.value(QStringLiteral("avatar_ver")).toInt();
-    QString nick = user.value(QStringLiteral("nickname")).toString().trimmed();
-    const QString account = user.value(QStringLiteral("account")).toString();
-    const QString remark = user.value(QStringLiteral("remark")).toString().trimmed();
-    const QString sign = user.value(QStringLiteral("signature")).toString().trimmed();
-    if (nick.isEmpty())
-        nick = account;
-    m_profileDisplayName = remark.isEmpty() ? nick : remark;
-    if (m_profileAvatar)
-        m_profileAvatar->setPixmap(peerAvatar(m_profileUid, m_profileDisplayName, 72));
-    if (m_profileTitle)
-        m_profileTitle->setText(m_profileDisplayName);
-    if (m_profileRemark)
-        m_profileRemark->setText(QStringLiteral("备注  %1").arg(remark.isEmpty() ? QStringLiteral("未设置") : remark));
-    if (m_profileNick)
-        m_profileNick->setText(QStringLiteral("昵称  %1").arg(nick));
-    if (m_profileAccount)
-        m_profileAccount->setText(QStringLiteral("账号  %1").arg(account));
-    if (m_profileSign)
-        m_profileSign->setText(sign.isEmpty() ? QStringLiteral("这个人很懒,什么都没写") : sign);
-    const int rel = user.value(QStringLiteral("relation")).toInt();
-    const bool canChat = m_profileUid != m_uid
-        && (m_profileUid == im::kFileHelperUid || rel == im::FriendRel::Friend);
-    if (m_profileChatBtn)
-        m_profileChatBtn->setVisible(canChat);
-    if (m_profileShareBtn)
-        m_profileShareBtn->setVisible(m_profileUid > 0 && m_profileUid != im::kFileHelperUid);
-}
-
-void Client::removeLocalConv(qint64 uid)
-{
-    if (m_currentPeerUid == uid)
-    {
-        m_currentPeerUid = 0;
-        m_currentPeerName.clear();
-        dropChatPage(uid);
-        if (m_chatStack && m_chatEmpty)
-            m_chatStack->setCurrentWidget(m_chatEmpty);
-    }
-    else
-    {
-        dropChatPage(uid);
-    }
-    if (m_profileUid == uid)
-    {
-        m_profileUid = 0;
-        if (m_chatStack && m_chatStack->currentWidget() == m_profilePage)
-        {
-            if (m_currentPeerUid > 0 && m_chatBody)
-                m_chatStack->setCurrentWidget(m_chatBody);
-            else if (m_chatEmpty)
-                m_chatStack->setCurrentWidget(m_chatEmpty);
-        }
-    }
-    if (auto *it = findConvItem(uid))
-        delete it;
-    LocalStore::instance().deletePeer(uid);
-    applyListFilter();
-    updateTray();
-}
-
-void Client::persistWindow()
-{
-    QRect r;
-    if (auto *h = m_titleBar ? m_titleBar->framelessHelper() : nullptr)
-        r = h->normalGeometry();
-    if (!r.isValid() || r.width() < 100 || r.height() < 100)
-    {
-        if (!isMaximized())
-            r = geometry();
-        else
-            r = UserSettings::instance().windowRect();
-    }
-    if (r.width() < 760)
-        r.setWidth(760);
-    if (r.height() < 500)
-        r.setHeight(500);
-    UserSettings::instance().saveWindow(r, isMaximized());
-}
-
-void Client::markPeerRead(qint64 peerUid)
-{
-    if (peerUid <= 0)
-        return;
-    auto *item = findConvItem(peerUid);
-    if (!item || item->data(RoleUnread).toInt() != 0)
-        applyUnread(peerUid, 0);
-    ImClient::instance().setUnread(peerUid, 0, {});
-}
-
-void Client::saveLocalMessage(qint64 msgId, qint64 peerUid, qint64 fromUid, qint64 toUid,
-                              int msgType, const QString &content, qint64 ts, int status)
-{
-    if (msgId == 0 || peerUid <= 0)
-        return;
-    LocalStore::Msg m;
-    m.id = msgId;
-    m.peerUid = peerUid;
-    m.fromUid = fromUid;
-    m.toUid = toUid > 0 ? toUid : ((fromUid == m_uid) ? peerUid : m_uid);
-    m.msgType = msgType;
-    m.content = content;
-    m.ts = ts;
-    m.status = status;
-    LocalStore::instance().upsertMessage(m);
-}
-
-void Client::persistLocalConv(qint64 peerUid, const QString &name, const QString &preview,
-                              qint64 ts, int unread)
-{
-    LocalStore::Conv c;
-    c.peerUid = peerUid;
-    c.name = name;
-    c.preview = preview;
-    c.ts = ts;
-    c.unread = unread;
-    LocalStore::instance().upsertConversation(c);
-}
-
-void Client::loadLocalConvs()
-{
-    const auto rows = LocalStore::instance().listConversations();
-    if (rows.isEmpty())
-        return;
-    QJsonArray arr;
-    for (const auto &c : rows)
-    {
-        QJsonObject o;
-        o.insert(QStringLiteral("peer_uid"), c.peerUid);
-        o.insert(QStringLiteral("nickname"), c.name);
-        o.insert(QStringLiteral("preview"), c.preview);
-        o.insert(QStringLiteral("ts"), c.ts);
-        o.insert(QStringLiteral("unread"), c.unread);
-        arr.append(o);
-    }
-    applyConvList(arr);
-}
-
-bool Client::isViewingPeer(qint64 peerUid) const
-{
-    return peerUid > 0 && peerUid == m_currentPeerUid
-        && m_chatStack && m_chatStack->currentWidget() == m_chatBody;
-}
-
-void Client::sendCurrentMessage()
-{
-    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0)
-        return;
-    const QString text = m_input->toPlainText().trimmed();
-    if (text.isEmpty())
-        return;
-    const int contentMax = AppConfig::instance().contentMax();
-    if (contentMax > 0 && text.size() > contentMax)
-    {
-        UiHelpers::showToast(this, QStringLiteral("消息过长"), true);
-        return;
-    }
-
-    m_input->clear();
-    sendOutgoing(m_currentPeerUid, m_currentPeerName, im::MsgType::Text, text,
-                 previewOf(im::MsgType::Text, text));
-}
-
-void Client::clearBubbles()
-{
-    m_historyFp.clear();
-    m_historyPeerUid = 0;
-    exitMsgSelectMode();
-    if (!m_bubbleLay)
-        return;
-    QLayoutItem *tail = nullptr;
-    if (m_bubbleLay->count() > 0)
-        tail = m_bubbleLay->takeAt(m_bubbleLay->count() - 1);
-    QVector<QLayoutItem *> items;
-    items.reserve(m_bubbleLay->count());
-    while (m_bubbleLay->count() > 0)
-        items.push_back(m_bubbleLay->takeAt(0));
-    if (tail)
-        m_bubbleLay->addItem(tail);
-    for (QLayoutItem *it : items)
-    {
-        if (it->widget())
-            delete it->widget();
-        delete it;
-    }
-}
-
-void Client::enterMsgSelectMode(qint64 preselectId)
-{
-    if (!m_bubbleLay)
-        return;
-    m_msgSelectMode = true;
-    if (m_stickerPanel)
-        m_stickerPanel->hide();
-    if (m_inputWrap)
-        m_inputWrap->hide();
-    if (m_msgSelectBar)
-        m_msgSelectBar->show();
-    for (int i = 0; i < m_bubbleLay->count(); ++i)
-    {
-        QWidget *w = m_bubbleLay->itemAt(i)->widget();
-        if (!w || w->objectName() != QLatin1String("msgRow"))
-            continue;
-        w->setProperty("selecting", true);
-        if (auto *cb = w->findChild<QCheckBox *>(QStringLiteral("msgCheck")))
-        {
-            cb->show();
-            const qint64 id = w->property("msgId").toLongLong();
-            cb->setChecked(preselectId != 0 && id == preselectId);
-        }
-    }
-    updateMsgSelectCount();
-}
-
-void Client::exitMsgSelectMode()
-{
-    if (!m_msgSelectMode)
-        return;
-    m_msgSelectMode = false;
-    if (m_msgSelectBar)
-        m_msgSelectBar->hide();
-    if (m_inputWrap)
-        m_inputWrap->show();
-    if (!m_bubbleLay)
-        return;
-    for (int i = 0; i < m_bubbleLay->count(); ++i)
-    {
-        QWidget *w = m_bubbleLay->itemAt(i)->widget();
-        if (!w || w->objectName() != QLatin1String("msgRow"))
-            continue;
-        w->setProperty("selecting", false);
-        if (auto *cb = w->findChild<QCheckBox *>(QStringLiteral("msgCheck")))
-        {
-            cb->setChecked(false);
-            cb->hide();
-        }
-    }
-}
-
-void Client::updateMsgSelectCount()
-{
-    int n = 0;
-    if (m_bubbleLay)
-    {
-        for (int i = 0; i < m_bubbleLay->count(); ++i)
-        {
-            QWidget *w = m_bubbleLay->itemAt(i)->widget();
-            if (!w || w->objectName() != QLatin1String("msgRow"))
-                continue;
-            if (auto *cb = w->findChild<QCheckBox *>(QStringLiteral("msgCheck")))
-            {
-                if (cb->isChecked())
-                    ++n;
-            }
-        }
-    }
-    if (m_msgSelectHint)
-        m_msgSelectHint->setText(QStringLiteral("已选 %1 条").arg(n));
-}
-
-void Client::deleteChatMessages(const QVector<qint64> &ids)
-{
-    QVector<qint64> uniq;
-    for (qint64 id : ids)
-    {
-        if (id != 0 && !uniq.contains(id))
-            uniq.push_back(id);
-    }
-    if (uniq.isEmpty())
-        return;
-    const QString tip = uniq.size() == 1
-        ? QStringLiteral("删除后只从你的聊天记录中去掉,对方仍可看到")
-        : QStringLiteral("删除选中的 %1 条消息?仅从你这边去掉,对方仍可看到").arg(uniq.size());
-    if (!ConfirmDialog::ask(this, QStringLiteral("删除消息"), tip))
-        return;
-
-    QVector<qint64> remote;
-    QVector<qint64> localOnly;
-    for (qint64 id : uniq)
-    {
-        if (id > 0)
-            remote.push_back(id);
-        else
-            localOnly.push_back(id);
-    }
-
-    if (remote.isEmpty())
-    {
-        applyDeletedMessages(localOnly);
-        syncConvPreviewAfterDelete(m_currentPeerUid);
-        exitMsgSelectMode();
-        return;
-    }
-
-    QPointer<Client> self(this);
-    const qint64 peer = m_currentPeerUid;
-    ImClient::instance().deleteMsgs(peer, remote, [self, remote, localOnly, peer](const QJsonObject &reply) {
-        if (!self)
-            return;
-        if (!ImClient::ok(reply))
-        {
-            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
-            return;
-        }
-        QVector<qint64> all = remote;
-        for (qint64 id : localOnly)
-            all.push_back(id);
-        self->applyDeletedMessages(all);
-        const QString preview = reply.value(QStringLiteral("preview")).toString();
-        const qint64 ts = Proto::jsonI64(reply, QStringLiteral("ts"));
-        if (self->m_currentPeerUid == peer)
-        {
-            int unread = 0;
-            if (auto *item = self->findConvItem(peer))
-                unread = item->data(RoleUnread).toInt();
-            self->upsertConv(peer, self->m_currentPeerName, preview, ts, unread);
-        }
-        self->exitMsgSelectMode();
-    });
-}
-
-void Client::applyDeletedMessages(const QVector<qint64> &ids)
-{
-    QSet<qint64> want;
-    for (qint64 id : ids)
-    {
-        want.insert(id);
-        LocalStore::instance().removeMessage(id);
-    }
-    if (!m_bubbleLay)
-        return;
-    for (int i = m_bubbleLay->count() - 1; i >= 0; --i)
-    {
-        QWidget *w = m_bubbleLay->itemAt(i)->widget();
-        if (!w || w->objectName() != QLatin1String("msgRow"))
-            continue;
-        if (!want.contains(w->property("msgId").toLongLong()))
-            continue;
-        QLayoutItem *it = m_bubbleLay->takeAt(i);
-        if (it->widget())
-            it->widget()->deleteLater();
-        delete it;
-        if (i > 0)
-        {
-            QWidget *prev = m_bubbleLay->itemAt(i - 1) ? m_bubbleLay->itemAt(i - 1)->widget()
-                                                       : nullptr;
-            QWidget *next = (i < m_bubbleLay->count() && m_bubbleLay->itemAt(i))
-                                ? m_bubbleLay->itemAt(i)->widget()
-                                : nullptr;
-            auto *stamp = qobject_cast<QLabel *>(prev);
-            if (stamp && stamp->objectName() != QLatin1String("msgRow")
-                && !(next && next->objectName() == QLatin1String("msgRow")))
-            {
-                QLayoutItem *ps = m_bubbleLay->takeAt(i - 1);
-                if (ps->widget())
-                    ps->widget()->deleteLater();
-                delete ps;
-            }
-        }
-    }
-    m_historyFp.clear();
-    if (m_bubbleLay)
-    {
-        for (int i = 0; i < m_bubbleLay->count(); ++i)
-        {
-            QWidget *w = m_bubbleLay->itemAt(i)->widget();
-            if (!w || w->objectName() != QLatin1String("msgRow"))
-                continue;
-            const qint64 id = w->property("msgId").toLongLong();
-            if (id != 0)
-                m_historyFp += QString::number(id) + QLatin1Char(',');
-        }
-    }
-    m_historyPeerUid = m_currentPeerUid;
-    rememberChatPageState();
-}
-
-void Client::syncConvPreviewAfterDelete(qint64 peerUid)
-{
-    if (peerUid <= 0)
-        return;
-    const auto msgs = LocalStore::instance().listMessages(peerUid, 1);
-    QString preview;
-    qint64 ts = 0;
-    if (!msgs.isEmpty())
-    {
-        preview = previewOf(msgs.last().msgType, msgs.last().content);
-        ts = msgs.last().ts;
-    }
-    int unread = 0;
-    QString name = m_currentPeerName;
-    if (auto *item = findConvItem(peerUid))
-    {
-        unread = item->data(RoleUnread).toInt();
-        name = item->data(RoleName).toString();
-    }
-    upsertConv(peerUid, name, preview, ts, unread);
-}
-
-QWidget *Client::addBubble(bool mine, const QString &name, const QString &text, const QString &time,
-                           int msgType, int sendStatus, qint64 msgId, qint64 peerUid)
-{
-    if (!time.isEmpty())
-    {
-        auto *stamp = new QLabel(time, m_bubbleHost);
-        stamp->setAlignment(Qt::AlignCenter);
-        stamp->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:12px; background:transparent; padding:4px 0 8px 0;"));
-        if (m_buildingHistory)
-            m_bubbleLay->addWidget(stamp);
-        else
-            m_bubbleLay->insertWidget(m_bubbleLay->count() - 1, stamp);
-    }
-
-    auto *row = new QWidget(m_bubbleHost);
-    row->setProperty("sendStatus", sendStatus);
-    row->setProperty("msgId", QVariant::fromValue(msgId));
-    row->setProperty("mine", mine);
-    row->setObjectName(QStringLiteral("msgRow"));
-    row->setMinimumWidth(0);
-    row->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
-    auto *lay = new QHBoxLayout(row);
-    lay->setContentsMargins(0, 0, 0, 0);
-    lay->setSpacing(8);
-
-    auto *check = new QCheckBox(row);
-    check->setObjectName(QStringLiteral("msgCheck"));
-    check->setFixedSize(18, 18);
-    check->setCursor(Qt::PointingHandCursor);
-    check->setStyleSheet(QStringLiteral("QCheckBox { background:transparent; spacing:0; }"));
-    check->setVisible(m_msgSelectMode);
-    row->setProperty("selecting", m_msgSelectMode);
-    connect(check, &QCheckBox::toggled, this, [this]() { updateMsgSelectCount(); });
-
-    auto *avatar = new QLabel(row);
-    avatar->setFixedSize(36, 36);
-    const qint64 avatarUid = mine ? m_uid : m_currentPeerUid;
-    avatar->setPixmap(peerAvatar(avatarUid, name, 36));
-
-    auto *actionSlot = new QWidget(row);
-    actionSlot->setFixedSize(22, 22);
-    auto *sendMark = new QLabel(actionSlot);
-    sendMark->setObjectName(QStringLiteral("sendStatus"));
-    sendMark->setGeometry(3, 3, 16, 16);
-    sendMark->setAlignment(Qt::AlignCenter);
-    sendMark->hide();
-    sendMark->installEventFilter(this);
-
-    ClickWidget *bubble = nullptr;
-    FileBubble *fileBubble = nullptr;
-    if (msgType == im::MsgType::Sticker)
-    {
-        qint64 packId = 0;
-        QString code;
-        const QJsonObject o = QJsonDocument::fromJson(text.toUtf8()).object();
-        packId = Proto::jsonI64(o, QStringLiteral("pack_id"));
-        code = o.value(QStringLiteral("code")).toString();
-        auto *box = new ClickWidget(row);
-        box->setStyleSheet(QStringLiteral("background:transparent; border:none;"));
-        auto *img = new QLabel(box);
-        img->setFixedSize(72, 72);
-        img->setScaledContents(true);
-        const QPixmap pm = StickerCache::instance().pixmap(packId, code, 72);
-        if (pm.isNull())
-        {
-            img->setText(QStringLiteral("[表情]"));
-            img->setAlignment(Qt::AlignCenter);
-            img->setStyleSheet(QStringLiteral("color:#888; background:transparent;"));
-            StickerCache::instance().sync();
-        }
-        else
-        {
-            img->setPixmap(pm);
-        }
-        auto *stLay = new QVBoxLayout(box);
-        stLay->setContentsMargins(0, 0, 0, 0);
-        stLay->addWidget(img);
-        bubble = box;
-    }
-    else if (msgType == im::MsgType::File)
-    {
-        const QJsonObject o = QJsonDocument::fromJson(text.toUtf8()).object();
-        const QString fname = o.value(QStringLiteral("name")).toString(QStringLiteral("文件"));
-        const qint64 fsize = Proto::jsonI64(o, QStringLiteral("size"));
-        const qint64 fileId = Proto::jsonI64(o, QStringLiteral("file_id"));
-        const QString mime = o.value(QStringLiteral("mime")).toString();
-        const QString fileUrl = o.value(QStringLiteral("url")).toString();
-        if (FileCache::looksLikeImage(mime, fname))
-        {
-            auto *box = new ClickWidget(row);
-            box->setStyleSheet(QStringLiteral("background:transparent; border:none;"));
-            box->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
-            auto *img = new QLabel(box);
-            img->setAlignment(Qt::AlignCenter);
-            img->setStyleSheet(QStringLiteral(
-                "background:#F0F0F0; color:#888; border:none; border-radius:4px;"));
-            auto *layImg = new QVBoxLayout(box);
-            layImg->setContentsMargins(0, 0, 0, 0);
-            layImg->addWidget(img);
-            QPointer<QLabel> imgRef(img);
-            auto applyThumb = [this, imgRef](const QPixmap &got) {
-                if (!imgRef || got.isNull())
-                    return;
-                imgRef->setText(QString());
-                imgRef->setPixmap(got);
-                const qreal dpr = qMax(1.0, got.devicePixelRatio());
-                imgRef->setFixedSize(qMax(1, qRound(got.width() / dpr)),
-                                     qMax(1, qRound(got.height() / dpr)));
-                if (!m_buildingHistory && m_stickChatToBottom)
-                    scrollChatToBottom();
-            };
-            connect(&FileCache::instance(), &FileCache::ready, img,
-                    [imgRef, fileId, applyThumb](qint64 id) {
-                if (!imgRef || id != fileId)
-                    return;
-                applyThumb(FileCache::instance().thumbnail(fileId, 180));
-            });
-            const QPixmap pm = FileCache::instance().thumbnail(fileId, 180);
-            if (pm.isNull())
-            {
-                img->setFixedSize(120, 90);
-                img->setText(QStringLiteral("[图片]"));
-                FileCache::instance().ensure(fileId, fileUrl, mime, fname);
-            }
-            else
-            {
-                applyThumb(pm);
-            }
-            box->onClick = [this, fileId, fname, text]() { previewChatImage(fileId, fname, text); };
-            box->onForward = [this, text]() {
-                forwardPayload(im::MsgType::File, text, QStringLiteral("[图片]"));
-            };
-            box->onFavorite = [this, text]() {
-                favoritePayload(im::MsgType::File, text);
-            };
-            bubble = box;
-        }
-        else
-        {
-            fileBubble = new FileBubble(row, fileId, fname, fsize, fileUrl, mime, mine);
-            fileBubble->bindActionSlot(actionSlot, sendMark);
-            fileBubble->onForward = [this, text, fname]() {
-                forwardPayload(im::MsgType::File, text, QStringLiteral("[文件] %1").arg(fname));
-            };
-            fileBubble->onFavorite = [this, text]() {
-                favoritePayload(im::MsgType::File, text);
-            };
-            bubble = fileBubble;
-        }
-    }
-    else if (msgType == im::MsgType::Card)
-    {
-        const QJsonObject o = QJsonDocument::fromJson(text.toUtf8()).object();
-        const qint64 cardUid = Proto::jsonI64(o, QStringLiteral("uid"));
-        QString cardName = o.value(QStringLiteral("nickname")).toString();
-        const QString acc = o.value(QStringLiteral("account")).toString();
-        if (cardName.isEmpty())
-            cardName = acc;
-        const int ver = o.value(QStringLiteral("avatar_ver")).toInt();
-        auto *box = new ClickWidget(row);
-        box->setStyleSheet(QStringLiteral("background:#FFFFFF; border:none; border-radius:4px;"));
-        auto *h = new QHBoxLayout(box);
-        h->setContentsMargins(10, 8, 12, 8);
-        h->setSpacing(8);
-        auto *av = new QLabel(box);
-        av->setFixedSize(40, 40);
-        av->setPixmap(AvatarCache::instance().pixmap(cardUid, ver, cardName, 40));
-        auto *info = new QVBoxLayout();
-        info->setContentsMargins(0, 0, 0, 0);
-        info->setSpacing(2);
-        auto *n = new QLabel(cardName, box);
-        n->setStyleSheet(QStringLiteral("color:#191919; font-size:13px; background:transparent;"));
-        auto *a = new QLabel(QStringLiteral("账号  %1").arg(acc), box);
-        a->setStyleSheet(QStringLiteral("color:#888888; font-size:11px; background:transparent;"));
-        info->addWidget(n);
-        info->addWidget(a);
-        h->addWidget(av);
-        h->addLayout(info, 1);
-        box->setFixedWidth(220);
-        box->onClick = [this, cardUid]() {
-            if (cardUid > 0)
-                showProfile(cardUid);
-        };
-        box->onForward = [this, text, cardName]() {
-            forwardPayload(im::MsgType::Card, text, QStringLiteral("[名片] %1").arg(cardName));
-        };
-        box->onFavorite = [this, text]() {
-            favoritePayload(im::MsgType::Card, text);
-        };
-        bubble = box;
-    }
-    else
-    {
-        auto *box = new ClickWidget(row);
-        box->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
-        auto *lab = new BubbleText(text, box);
-        lab->installEventFilter(box);
-        auto *boxLay = new QVBoxLayout(box);
-        boxLay->setContentsMargins(10, 8, 10, 8);
-        boxLay->setSpacing(0);
-        boxLay->addWidget(lab);
-        applyTextBubbleWidth(lab, chatTextMaxWidth(mine));
-        box->setAttribute(Qt::WA_StyledBackground, true);
-        box->setStyleSheet(mine
-            ? QStringLiteral("background:#95EC69; border:none; border-radius:4px;")
-            : QStringLiteral("background:#FFFFFF; border:none; border-radius:4px;"));
-        box->setCursor(Qt::IBeamCursor);
-        box->onFavorite = [this, text]() { favoritePayload(im::MsgType::Text, text); };
-        box->onCopy = [lab, text]() {
-            QString s = lab->text();
-            if (s.isEmpty())
-                s = text;
-            QApplication::clipboard()->setText(s);
-        };
-        bubble = box;
-    }
-
-    if (bubble)
-    {
-        bubble->onDelete = [this, msgId]() { deleteChatMessages(QVector<qint64>() << msgId); };
-        bubble->onMultiSelect = [this, msgId]() {
-            if (m_msgSelectMode)
-                exitMsgSelectMode();
-            else
-                enterMsgSelectMode(msgId);
-        };
-    }
-
-    if (mine)
-    {
-        row->setProperty("outgoingId", QVariant::fromValue(msgId));
-        row->setProperty("outgoingPeer", QVariant::fromValue(peerUid > 0 ? peerUid : m_currentPeerUid));
-        row->setProperty("outgoingType", msgType);
-        row->setProperty("outgoingContent", text);
-        lay->addWidget(check, 0, Qt::AlignVCenter);
-        lay->addStretch();
-        lay->addWidget(actionSlot, 0, Qt::AlignVCenter);
-        lay->addWidget(bubble, 0, Qt::AlignTop);
-        lay->addWidget(avatar, 0, Qt::AlignTop);
-    }
-    else
-    {
-        lay->addWidget(check, 0, Qt::AlignVCenter);
-        lay->addWidget(avatar, 0, Qt::AlignTop);
-        lay->addWidget(bubble, 0, Qt::AlignTop);
-        if (fileBubble)
-            lay->addWidget(actionSlot, 0, Qt::AlignVCenter);
-        lay->addStretch();
-        if (!fileBubble)
-            actionSlot->hide();
-    }
-
-    if (m_buildingHistory)
-        m_bubbleLay->addWidget(row);
-    else
-        m_bubbleLay->insertWidget(m_bubbleLay->count() - 1, row);
-    row->installEventFilter(this);
-    if (bubble)
-    {
-        bubble->installEventFilter(this);
-        for (auto *child : bubble->findChildren<QWidget *>())
-            child->installEventFilter(this);
-    }
-    if (sendStatus == LocalMsg::Failed)
-        setBubbleSendState(row, true);
-    else if (sendStatus == LocalMsg::Sending)
-        setBubbleSendState(row, false, true);
-    if (!m_buildingHistory)
-    {
-        if (msgId != 0)
-        {
-            m_historyFp += QString::number(msgId) + QLatin1Char(',');
-            rememberChatPageState();
-        }
-        if (sendStatus == LocalMsg::Sending || m_stickChatToBottom)
-            scrollChatToBottom();
-    }
-    return row;
-}
-
-void Client::setBubbleSendState(QWidget *row, bool failed, bool sending)
-{
-    if (!row)
-        return;
-    auto *mark = row->findChild<QLabel *>(QStringLiteral("sendStatus"));
-    if (!mark)
-        return;
-    if (failed)
-    {
-        row->setProperty("sendStatus", LocalMsg::Failed);
-        mark->setText(QStringLiteral("!"));
-        mark->setCursor(Qt::PointingHandCursor);
-        mark->setStyleSheet(QStringLiteral(
-            "QLabel { color:#FFFFFF; background:#FA5151; border:none; border-radius:8px;"
-            " font-size:11px; font-weight:bold; }"));
-        mark->show();
-    }
-    else if (sending)
-    {
-        row->setProperty("sendStatus", LocalMsg::Sending);
-        mark->setText(QStringLiteral("…"));
-        mark->setCursor(Qt::ArrowCursor);
-        mark->setStyleSheet(QStringLiteral(
-            "QLabel { color:#888888; background:transparent; border:none; font-size:12px; }"));
-        mark->show();
-    }
-    else
-    {
-        row->setProperty("sendStatus", LocalMsg::Ok);
-        mark->setCursor(Qt::ArrowCursor);
-        mark->clear();
-        mark->hide();
-    }
-    if (QWidget *slot = mark->parentWidget())
-    {
-        if (!row->findChild<QWidget *>(QStringLiteral("fileBubble")))
-            slot->setVisible(failed || sending);
-    }
-    refreshRowFileAction(row);
-}
-
-QWidget *Client::findOutgoingRow(qint64 localId) const
-{
-    if (!m_bubbleLay || localId == 0)
-        return nullptr;
-    for (int i = 0; i < m_bubbleLay->count(); ++i)
-    {
-        QLayoutItem *it = m_bubbleLay->itemAt(i);
-        QWidget *w = it ? it->widget() : nullptr;
-        if (w && w->property("outgoingId").toLongLong() == localId)
-            return w;
-    }
-    return nullptr;
-}
-
-QWidget *Client::findMsgRow(qint64 msgId) const
-{
-    if (!m_bubbleLay || msgId == 0)
-        return nullptr;
-    for (int i = 0; i < m_bubbleLay->count(); ++i)
-    {
-        QLayoutItem *it = m_bubbleLay->itemAt(i);
-        QWidget *w = it ? it->widget() : nullptr;
-        if (w && w->objectName() == QLatin1String("msgRow")
-            && w->property("msgId").toLongLong() == msgId)
-            return w;
-    }
-    return nullptr;
-}
-
-int Client::chatTextMaxWidth(bool mine) const
-{
-    Q_UNUSED(mine);
-    int viewW = 480;
-    if (m_scroll && m_scroll->viewport())
-    {
-        viewW = m_scroll->viewport()->width();
-        QScrollBar *bar = m_scroll->verticalScrollBar();
-        const int sbw = m_scroll->style()->pixelMetric(QStyle::PM_ScrollBarExtent, nullptr, bar);
-        if (bar && !bar->isVisible())
-            viewW -= sbw;
-    }
-    const int opposite = 120;
-    const int avatarAndGap = 36 + 8;
-    const int hostPad = 32;
-    return qMax(120, viewW - opposite - avatarAndGap - hostPad);
-}
-
-void Client::relayoutTextBubbles()
-{
-    if (!m_bubbleLay)
-        return;
-    int viewW = 0;
-    if (m_scroll && m_scroll->viewport())
-        viewW = m_scroll->viewport()->width();
-    m_lastTextBubbleMaxW = viewW;
-    for (int i = 0; i < m_bubbleLay->count(); ++i)
-    {
-        QWidget *w = m_bubbleLay->itemAt(i)->widget();
-        if (!w || w->objectName() != QLatin1String("msgRow"))
-            continue;
-        if (auto *lab = w->findChild<BubbleText *>(QStringLiteral("chatText")))
-            applyTextBubbleWidth(lab, chatTextMaxWidth(w->property("mine").toBool()));
-    }
-}
-
-void Client::scrollChatToBottom()
-{
-    m_stickChatToBottom = true;
-    if (!m_scroll)
-        return;
-    QScrollBar *bar = m_scroll->verticalScrollBar();
-    bar->setValue(bar->maximum());
-    QTimer::singleShot(0, this, [this]() {
-        if (!m_scroll || !m_stickChatToBottom)
-            return;
-        QScrollBar *bar = m_scroll->verticalScrollBar();
-        bar->setValue(bar->maximum());
-    });
-}
-
-void Client::onChangePassword()
-{
-    ChangePasswordDialog dlg(this);
-    if (dlg.exec() == QDialog::Accepted)
-        UiHelpers::showToast(this, QStringLiteral("密码已修改"));
-}
-
-void Client::onLogout()
-{
-    if (m_loggingOut)
-        return;
-    m_loggingOut = true;
-    hideReconnectOverlay();
-    if (m_tray)
-        m_tray->hide();
-    if (m_trayPreview)
-        m_trayPreview->hide();
-    stopUnreadAlert();
-    QPointer<Client> self(this);
-    ImClient::instance().logout([self](const QJsonObject &) {
-        if (!self)
-            return;
-        ImClient::instance().disconnectFromServer();
-        emit self->loggedOut();
-        self->close();
-    });
-}
-
-void Client::onKicked()
-{
-    if (m_loggingOut)
-        return;
-    m_loggingOut = true;
-    hideReconnectOverlay();
-    if (m_tray)
-        m_tray->hide();
-    if (m_trayPreview)
-        m_trayPreview->hide();
-    stopUnreadAlert();
-    UiHelpers::showToast(this, QStringLiteral("账号已在其他设备登录"));
-    ImClient::instance().disconnectFromServer();
-    emit loggedOut();
-    close();
-}
-
-void Client::onDisconnected()
-{
-    if (m_loggingOut || ImClient::instance().isIntentionalClose())
-        return;
-    if (m_reconnecting)
-    {
-        finishReconnectAttempt(false);
-        return;
-    }
-    startReconnect();
-}
-
-void Client::onConnected()
-{
-    if (!m_reconnecting || !m_reconnectInFlight)
-        return;
-    const QString password = reconnectPassword();
-    const QString account = m_account.isEmpty() ? ImClient::instance().account() : m_account;
-    if (account.isEmpty() || password.isEmpty())
-    {
-        finishReconnectAttempt(false);
-        return;
-    }
-    QPointer<Client> self(this);
-    ImClient::instance().login(account, password, [self](const QJsonObject &reply) {
-        if (!self || !self->m_reconnecting)
-            return;
-        if (ImClient::ok(reply))
-        {
-            self->finishReconnectOk();
-            return;
-        }
-        const int code = reply.value(QStringLiteral("code")).toInt();
-        if (code == Proto::Err::AuthFail || code == Proto::Err::BadAccount
-            || code == Proto::Err::Disabled)
-        {
-            self->m_loggingOut = true;
-            self->hideReconnectOverlay();
-            UiHelpers::showToast(self, QStringLiteral("登录已失效,请重新登录"), true);
-            emit self->loggedOut();
-            self->close();
-            return;
-        }
-        self->finishReconnectAttempt(false);
-    });
-}
-
-void Client::onConnectFailed(const QString &)
-{
-    if (!m_reconnecting)
-        return;
-    finishReconnectAttempt(false);
-}
-
-void Client::setupReconnectOverlay(QWidget *parent)
-{
-    m_reconnectMask = new QWidget(parent);
-    m_reconnectMask->setObjectName(QStringLiteral("ReconnectMask"));
-    m_reconnectMask->setAttribute(Qt::WA_StyledBackground, true);
-    m_reconnectMask->setStyleSheet(QStringLiteral(
-        "QWidget#ReconnectMask { background:rgba(0,0,0,88); }"));
-    m_reconnectMask->hide();
-
-    auto *lay = new QVBoxLayout(m_reconnectMask);
-    lay->setContentsMargins(24, 24, 24, 24);
-    lay->addStretch();
-
-    auto *card = new QWidget(m_reconnectMask);
-    card->setObjectName(QStringLiteral("ReconnectCard"));
-    card->setAttribute(Qt::WA_StyledBackground, true);
-    card->setFixedWidth(280);
-    card->setStyleSheet(QStringLiteral(
-        "QWidget#ReconnectCard { background:#FFFFFF; border:none; border-radius:8px; }"));
-    auto *cLay = new QVBoxLayout(card);
-    cLay->setContentsMargins(28, 28, 28, 24);
-    cLay->setSpacing(8);
-    cLay->setAlignment(Qt::AlignHCenter);
-
-    auto *spin = new SpinRing(card);
-    m_reconnectSpin = spin;
-    m_reconnectTitle = new QLabel(QStringLiteral("正在重连中…"), card);
-    m_reconnectTitle->setAlignment(Qt::AlignCenter);
-    m_reconnectTitle->setStyleSheet(QStringLiteral(
-        "font-size:16px; color:#1A1A1A; background:transparent; border:none; font-weight:bold;"));
-    m_reconnectHint = new QLabel(card);
-    m_reconnectHint->setAlignment(Qt::AlignCenter);
-    m_reconnectHint->setWordWrap(true);
-    m_reconnectHint->setStyleSheet(QStringLiteral(
-        "font-size:12px; color:#888888; background:transparent; border:none;"));
-    m_reconnectBtn = UiHelpers::primaryButton(QStringLiteral("重试"), card);
-    m_reconnectBtn->setFixedWidth(120);
-    m_reconnectBtn->hide();
-
-    cLay->addWidget(spin, 0, Qt::AlignHCenter);
-    cLay->addSpacing(6);
-    cLay->addWidget(m_reconnectTitle);
-    cLay->addWidget(m_reconnectHint);
-    cLay->addSpacing(8);
-    cLay->addWidget(m_reconnectBtn, 0, Qt::AlignHCenter);
-
-    lay->addWidget(card, 0, Qt::AlignHCenter);
-    lay->addStretch();
-
-    m_reconnectTimer = new QTimer(this);
-    m_reconnectTimer->setInterval(1000);
-    connect(m_reconnectTimer, &QTimer::timeout, this, &Client::onReconnectTick);
-    connect(m_reconnectBtn, &QPushButton::clicked, this, [this]() { startReconnect(); });
-}
-
-QString Client::reconnectPassword() const
-{
-    const QString session = ImClient::instance().sessionPassword();
-    if (!session.isEmpty())
-        return session;
-    return UserSettings::instance().lastPassword();
-}
-
-void Client::updateReconnectUi()
-{
-    if (!m_reconnectMask)
-        return;
-    m_reconnectMask->show();
-    m_reconnectMask->raise();
-    if (m_reconnectInFlight)
-    {
-        if (m_reconnectSpin)
-            m_reconnectSpin->show();
-        if (m_reconnectBtn)
-            m_reconnectBtn->hide();
-        m_reconnectTitle->setText(QStringLiteral("正在重连中…"));
-        m_reconnectHint->setText(QStringLiteral("第 %1 / %2 次")
-                                     .arg(m_reconnectAttempt)
-                                     .arg(kReconnectMaxTries));
-        return;
-    }
-    if (m_reconnectWaitLeft > 0)
-    {
-        if (m_reconnectSpin)
-            m_reconnectSpin->show();
-        if (m_reconnectBtn)
-            m_reconnectBtn->hide();
-        m_reconnectTitle->setText(QStringLiteral("正在重连中…"));
-        m_reconnectHint->setText(QStringLiteral("%1 秒后进行第 %2 / %3 次")
-                                     .arg(m_reconnectWaitLeft)
-                                     .arg(m_reconnectAttempt + 1)
-                                     .arg(kReconnectMaxTries));
-        return;
-    }
-}
-
-void Client::hideReconnectOverlay()
-{
-    m_reconnecting = false;
-    m_reconnectInFlight = false;
-    m_reconnectAttempt = 0;
-    m_reconnectWaitLeft = 0;
-    if (m_reconnectTimer)
-        m_reconnectTimer->stop();
-    if (m_reconnectMask)
-        m_reconnectMask->hide();
-}
-
-void Client::startReconnect()
-{
-    if (m_loggingOut)
-        return;
-    m_reconnecting = true;
-    m_reconnectInFlight = false;
-    m_reconnectAttempt = 0;
-    m_reconnectWaitLeft = 0;
-    if (m_reconnectTimer)
-        m_reconnectTimer->stop();
-    if (m_reconnectMask)
-    {
-        m_reconnectMask->show();
-        m_reconnectMask->raise();
-    }
-    attemptReconnect();
-}
-
-void Client::attemptReconnect()
-{
-    if (m_loggingOut || !m_reconnecting)
-        return;
-    if (m_reconnectAttempt >= kReconnectMaxTries)
-    {
-        showReconnectFailed();
-        return;
-    }
-    m_reconnectWaitLeft = 0;
-    if (m_reconnectTimer)
-        m_reconnectTimer->stop();
-    ++m_reconnectAttempt;
-    m_reconnectInFlight = true;
-    updateReconnectUi();
-
-    if (ImClient::instance().isConnected())
-    {
-        onConnected();
-        return;
-    }
-    ImClient::instance().connectToServer();
-}
-
-void Client::scheduleReconnectRetry()
-{
-    if (m_reconnectAttempt >= kReconnectMaxTries)
-    {
-        showReconnectFailed();
-        return;
-    }
-    m_reconnectInFlight = false;
-    m_reconnectWaitLeft = kReconnectIntervalSec;
-    if (m_reconnectTimer)
-        m_reconnectTimer->start();
-    updateReconnectUi();
-}
-
-void Client::onReconnectTick()
-{
-    if (!m_reconnecting || m_reconnectInFlight)
-        return;
-    if (m_reconnectWaitLeft > 0)
-        --m_reconnectWaitLeft;
-    if (m_reconnectWaitLeft <= 0)
-    {
-        if (m_reconnectTimer)
-            m_reconnectTimer->stop();
-        attemptReconnect();
-        return;
-    }
-    updateReconnectUi();
-}
-
-void Client::finishReconnectAttempt(bool ok)
-{
-    if (!m_reconnecting || !m_reconnectInFlight)
-        return;
-    m_reconnectInFlight = false;
-    if (ok)
-    {
-        finishReconnectOk();
-        return;
-    }
-    scheduleReconnectRetry();
-}
-
-void Client::finishReconnectOk()
-{
-    const QString nick = ImClient::instance().nickname();
-    if (!nick.isEmpty())
-        m_nickname = nick;
-    hideReconnectOverlay();
-    refreshConvList();
-    refreshFriendList();
-}
-
-void Client::showReconnectFailed()
-{
-    m_reconnecting = true;
-    m_reconnectInFlight = false;
-    m_reconnectWaitLeft = 0;
-    if (m_reconnectTimer)
-        m_reconnectTimer->stop();
-    if (m_reconnectSpin)
-        m_reconnectSpin->hide();
-    if (m_reconnectBtn)
-        m_reconnectBtn->show();
-    if (m_reconnectTitle)
-        m_reconnectTitle->setText(QStringLiteral("连接已断开"));
-    if (m_reconnectHint)
-        m_reconnectHint->setText(QStringLiteral("已重试 %1 次,请检查网络后重试")
-                                     .arg(kReconnectMaxTries));
-    if (m_reconnectMask)
-    {
-        m_reconnectMask->show();
-        m_reconnectMask->raise();
-    }
-}
-
-void Client::onChatNotify(const QJsonObject &msg)
-{
-    const qint64 from = Proto::jsonI64(msg, QStringLiteral("from_uid"));
-    const qint64 to = Proto::jsonI64(msg, QStringLiteral("to_uid"));
-    const qint64 peer = (from == m_uid) ? to : from;
-    QString name = msg.value(QStringLiteral("from_nickname")).toString();
-    if (name.isEmpty())
-        name = msg.value(QStringLiteral("from_account")).toString();
-    if (from == m_uid)
-    {
-        if (auto *item = findConvItem(peer))
-            name = item->data(RoleName).toString();
-    }
-    const QString text = msg.value(QStringLiteral("content")).toString();
-    int msgType = static_cast<int>(Proto::jsonI64(msg, QStringLiteral("msg_type")));
-    if (msgType <= 0)
-        msgType = msg.value(QStringLiteral("msg_type")).toInt(1);
-    const qint64 ts = Proto::jsonI64(msg, QStringLiteral("ts"));
-    qint64 msgId = Proto::jsonI64(msg, QStringLiteral("msg_id"));
-    if (msgId <= 0)
-        msgId = Proto::jsonI64(msg, QStringLiteral("id"));
-    saveLocalMessage(msgId, peer, from, to, msgType, text, ts);
-
-    const bool viewing = isViewingPeer(peer) && isVisible() && !isMinimized();
-    int unread = 0;
-    if (!viewing)
-    {
-        unread = 1;
-        if (auto *item = findConvItem(peer))
-            unread = item->data(RoleUnread).toInt() + 1;
-    }
-    upsertConv(peer, name, previewOf(msgType, text), ts, unread);
-    if (peer != m_currentPeerUid)
-        dropChatPage(peer);
-    if (viewing && from != m_uid)
-    {
-        if (msgId <= 0 || !findMsgRow(msgId))
-            addBubble(false, name, text, formatTs(ts), msgType, 0, msgId, peer);
-        markPeerRead(peer);
-    }
-    if (from != m_uid)
-    {
-        const bool quiet = viewing && isVisible() && !isMinimized() && isActiveWindow();
-        if (!quiet)
-            UiHelpers::playNotifySound();
-    }
-}
-
-bool Client::eventFilter(QObject *watched, QEvent *event)
-{
-    if (event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseButtonRelease)
-    {
-        auto *me = static_cast<QMouseEvent *>(event);
-        if (me->button() == Qt::LeftButton && !qobject_cast<QCheckBox *>(watched))
-        {
-            QWidget *row = qobject_cast<QWidget *>(watched);
-            while (row && row->objectName() != QLatin1String("msgRow"))
-                row = row->parentWidget();
-            if (row && row->property("selecting").toBool())
-            {
-                if (event->type() == QEvent::MouseButtonRelease)
-                {
-                    if (auto *cb = row->findChild<QCheckBox *>(QStringLiteral("msgCheck")))
-                        cb->toggle();
-                }
-                return true;
-            }
-        }
-    }
-    if (event->type() == QEvent::MouseButtonRelease)
-    {
-        if (auto *lab = qobject_cast<QLabel *>(watched))
-        {
-            if (lab->objectName() == QStringLiteral("sendStatus"))
-            {
-                auto *me = static_cast<QMouseEvent *>(event);
-                if (me->button() == Qt::LeftButton)
-                    resendFailed(lab->parentWidget());
-                return true;
-            }
-        }
-    }
-    if (watched == m_navAvatar && event->type() == QEvent::MouseButtonRelease)
-    {
-        auto *me = static_cast<QMouseEvent *>(event);
-        if (me->button() == Qt::LeftButton)
-            showSettings(0);
-        return true;
-    }
-    if (watched == m_chatTitle && event->type() == QEvent::MouseButtonRelease)
-    {
-        auto *me = static_cast<QMouseEvent *>(event);
-        if (me->button() == Qt::LeftButton && m_currentPeerUid > 0
-            && m_chatStack && m_chatStack->currentWidget() == m_chatBody)
-            showProfile(m_currentPeerUid);
-        return true;
-    }
-    if (watched == m_input && event->type() == QEvent::KeyPress)
-    {
-        auto *key = static_cast<QKeyEvent *>(event);
-        if (key->key() == Qt::Key_Return || key->key() == Qt::Key_Enter)
-        {
-            if (key->modifiers() & Qt::ShiftModifier)
-                return QMainWindow::eventFilter(watched, event);
-            sendCurrentMessage();
-            return true;
-        }
-    }
-    return QMainWindow::eventFilter(watched, event);
-}
-
-void Client::showSettings()
-{
-    showSettings(1);
-}
-
-void Client::showSettings(int page)
-{
-    SettingsDialog dlg(m_account, m_nickname, ImClient::instance().signature(), this, page);
-    dlg.exec();
-    m_nickname = ImClient::instance().nickname();
-    refreshAvatar(m_uid);
-    if (dlg.wantsLogout())
-        onLogout();
-    else if (dlg.wantsChangePassword())
-        onChangePassword();
-}
-
-void Client::applyListFilter()
-{
-    const QString key = m_search ? m_search->text().trimmed() : QString();
-    const bool friendsTab = m_sideStack && m_sideStack->currentIndex() == 1;
-    const bool favTab = m_sideStack && m_sideStack->currentIndex() == 2;
-
-    auto applyOne = [&](QListWidget *list, QStackedWidget *stack, QLabel *empty, bool useKey,
-                        const QString &miss, const QString &none) {
-        if (!list || !stack || !empty)
-            return;
-        int visible = 0;
-        for (int i = 0; i < list->count(); ++i)
-        {
-            auto *item = list->item(i);
-            const bool hide = useKey && !key.isEmpty() &&
-                !item->data(RoleName).toString().contains(key, Qt::CaseInsensitive);
-            item->setHidden(hide);
-            if (!hide)
-                ++visible;
-        }
-        if (visible == 0)
-        {
-            empty->setText((useKey && !key.isEmpty()) ? miss : none);
-            stack->setCurrentWidget(empty);
-        }
-        else
-        {
-            stack->setCurrentWidget(list);
-        }
-    };
-
-    applyOne(m_convList, m_convStack, m_convEmpty, !friendsTab && !favTab,
-             QStringLiteral("未找到相关会话"), QStringLiteral("暂无消息"));
-    applyOne(m_friendList, m_friendStack, m_friendEmpty, friendsTab,
-             QStringLiteral("未找到相关好友"), QStringLiteral("暂无好友,点击右上角 + 添加"));
-    if (m_favPanel)
-        m_favPanel->applyFilter(favTab ? key : QString());
-}
+    m_chatEmpty = new QWidget(m_chatStack);
+    m_chatEmpty->setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    auto *emptyLay = new QVBoxLayout(m_chatEmpty);
+    auto *hint = new QLabel(QStringLiteral("选择一个会话开始聊天"), m_chatEmpty);
+    hint->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:14px; background:transparent;"));
+    hint->setAlignment(Qt::AlignCenter);
+    emptyLay->addStretch();
+    emptyLay->addWidget(hint);
+    emptyLay->addStretch();
 
-QString Client::previewOf(int msgType, const QString &content) const
-{
-    if (msgType == im::MsgType::Sticker)
-        return QStringLiteral("[表情]");
-    const QJsonObject o = QJsonDocument::fromJson(content.toUtf8()).object();
-    if (msgType == im::MsgType::File)
-    {
-        const QString name = o.value(QStringLiteral("name")).toString();
-        const QString mime = o.value(QStringLiteral("mime")).toString();
-        if (FileCache::looksLikeImage(mime, name))
-            return QStringLiteral("[图片]");
-        return name.isEmpty() ? QStringLiteral("[文件]") : QStringLiteral("[文件] %1").arg(name);
-    }
-    if (msgType == im::MsgType::Card)
-    {
-        QString name = o.value(QStringLiteral("nickname")).toString();
-        if (name.isEmpty())
-            name = o.value(QStringLiteral("account")).toString();
-        return name.isEmpty() ? QStringLiteral("[名片]") : QStringLiteral("[名片] %1").arg(name);
-    }
-    return content;
-}
+    m_friendHintPage = new QWidget(m_chatStack);
+    m_friendHintPage->setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    auto *friendHintLay = new QVBoxLayout(m_friendHintPage);
+    auto *friendHint = new QLabel(QStringLiteral("选择一位好友查看资料"), m_friendHintPage);
+    friendHint->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:14px; background:transparent;"));
+    friendHint->setAlignment(Qt::AlignCenter);
+    friendHintLay->addStretch();
+    friendHintLay->addWidget(friendHint);
+    friendHintLay->addStretch();
 
-void Client::sendSticker(qint64 packId, const QString &code)
-{
-    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0 || code.isEmpty())
-        return;
-    QJsonObject body;
-    body.insert(QStringLiteral("pack_id"), packId);
-    body.insert(QStringLiteral("code"), code);
-    const QString content = QString::fromUtf8(QJsonDocument(body).toJson(QJsonDocument::Compact));
-    sendOutgoing(m_currentPeerUid, m_currentPeerName, im::MsgType::Sticker, content,
-                 QStringLiteral("[表情]"));
-}
+    m_chatBody = new QWidget(m_chatStack);
+    auto *body = new QVBoxLayout(m_chatBody);
+    body->setContentsMargins(0, 0, 0, 0);
+    body->setSpacing(0);
 
-int Client::avatarVerOf(qint64 uid) const
-{
-    if (uid <= 0)
-        return 0;
-    if (uid == m_uid)
-        return ImClient::instance().avatarVer();
-    int ver = 0;
-    if (m_friendList)
-    {
-        for (int i = 0; i < m_friendList->count(); ++i)
-        {
-            auto *item = m_friendList->item(i);
-            if (item && item->data(RolePeerUid).toLongLong() == uid)
-            {
-                ver = item->data(RoleAvatarVer).toInt();
-                break;
-            }
-        }
-    }
-    if (ver <= 0 && uid == m_profileUid)
-        ver = m_profileAvatarVer;
-    if (ver <= 0)
-    {
-        if (auto *item = findConvItem(uid))
-            ver = item->data(RoleAvatarVer).toInt();
-    }
-    return ver;
-}
+    auto *header = new QWidget(m_chatBody);
+    header->setFixedHeight(56);
+    header->setStyleSheet(QStringLiteral("background:#F7F7F7; border-bottom:1px solid #E5E5E5;"));
+    auto *hLay = new QHBoxLayout(header);
+    hLay->setContentsMargins(16, 0, 16, 0);
+    m_chatTitle = new QLabel(header);
+    m_chatTitle->setCursor(Qt::PointingHandCursor);
+    m_chatTitle->setStyleSheet(QStringLiteral("font-size:15px; font-weight:bold; background:transparent;"));
+    m_chatTitle->installEventFilter(this);
+    hLay->addWidget(m_chatTitle);
+    hLay->addStretch();
 
-QPixmap Client::peerAvatar(qint64 uid, const QString &name, int size) const
-{
-    return AvatarCache::instance().pixmap(uid, avatarVerOf(uid), name, size);
-}
+    m_chatPageStack = new QStackedWidget(m_chatBody);
+    m_chatPageStack->setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    ChatPage first = createChatPage();
+    m_scroll = first.scroll;
+    m_bubbleHost = first.host;
+    m_bubbleLay = first.lay;
 
-void Client::refreshAvatar(qint64 uid)
-{
-    const int known = AvatarCache::instance().knownVer(uid);
-    if (uid == m_uid && m_navAvatar)
-    {
-        const int selfVer = qMax(ImClient::instance().avatarVer(), known);
-        m_navAvatar->setProperty("avatarVer", selfVer);
-        m_navAvatar->setPixmap(peerAvatar(m_uid, m_nickname, 36));
-    }
-    if (auto *item = findConvItem(uid))
-    {
-        Conv c = convFromItem(item);
-        c.avatarVer = qMax(item->data(RoleAvatarVer).toInt(), known);
-        item->setData(RoleAvatarVer, c.avatarVer);
-        if (QWidget *old = m_convList->itemWidget(item))
-            old->deleteLater();
-        m_convList->setItemWidget(item, makeConvRow(c, m_convList));
-    }
-    if (m_friendList)
-    {
-        for (int i = 0; i < m_friendList->count(); ++i)
-        {
-            auto *item = m_friendList->item(i);
-            if (!item || item->data(RolePeerUid).toLongLong() != uid)
-                continue;
-            const QString name = item->data(RoleName).toString();
-            const bool online = item->data(RoleOnline).toInt() == 1;
-            const int ver = qMax(item->data(RoleAvatarVer).toInt(), known);
-            item->setData(RoleAvatarVer, ver);
-            if (QWidget *old = m_friendList->itemWidget(item))
-                old->deleteLater();
-            m_friendList->setItemWidget(item, makeFriendRow(name, online, uid, ver, m_friendList));
-            break;
-        }
-    }
-    if (uid == m_profileUid)
-    {
-        if (known > m_profileAvatarVer)
-            m_profileAvatarVer = known;
-        if (m_profileAvatar)
-            m_profileAvatar->setPixmap(peerAvatar(uid, m_profileDisplayName, 72));
-    }
-}
+    auto *inputWrap = new QWidget(m_chatBody);
+    m_inputWrap = inputWrap;
+    inputWrap->setFixedHeight(148);
+    inputWrap->setStyleSheet(QStringLiteral("background:#FFFFFF; border-top:1px solid #E5E5E5;"));
+    auto *iLay = new QVBoxLayout(inputWrap);
+    iLay->setContentsMargins(0, 0, 0, 0);
+    iLay->setSpacing(0);
 
-void Client::sendChatPayload(int msgType, const QString &content, const QString &preview)
-{
-    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0 || content.isEmpty())
-        return;
-    sendChatPayloadTo(m_currentPeerUid, m_currentPeerName, msgType, content, preview);
-}
+    auto *toolbar = new QWidget(inputWrap);
+    toolbar->setFixedHeight(36);
+    toolbar->setStyleSheet(QStringLiteral("background:#FFFFFF;"));
+    auto *tLay = new QHBoxLayout(toolbar);
+    tLay->setContentsMargins(12, 0, 12, 0);
+    auto makeChatTool = [toolbar](int kind, const QString &tip) {
+        auto *btn = new QPushButton(toolbar);
+        btn->setCursor(Qt::PointingHandCursor);
+        btn->setFlat(true);
+        btn->setFixedSize(28, 28);
+        btn->setIconSize(QSize(20, 20));
+        btn->setIcon(QIcon(chatToolIcon(kind, QColor("#07C160"), 20)));
+        btn->setStyleSheet(QStringLiteral(
+            "QPushButton { background:transparent; border:none; padding:0; }"
+            "QPushButton:hover { background:#F5F5F5; border-radius:4px; }"));
+        return btn;
+    };
+    auto *emoji = makeChatTool(0, QStringLiteral("表情"));
+    m_emojiBtn = emoji;
+    auto *fileBtn = makeChatTool(2, QStringLiteral("文件"));
+    auto *imgBtn = makeChatTool(1, QStringLiteral("图片"));
+    auto *tip = new QLabel(QStringLiteral("Enter 发送,Shift+Enter 换行"), toolbar);
+    tip->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:11px; background:transparent;"));
+    tLay->addWidget(emoji);
+    tLay->addWidget(fileBtn);
+    tLay->addWidget(imgBtn);
+    tLay->addWidget(tip);
+    tLay->addStretch();
 
-void Client::sendChatPayloadTo(qint64 peer, const QString &peerName, int msgType,
-                               const QString &content, const QString &preview)
-{
-    sendOutgoing(peer, peerName, msgType, content, preview);
-}
+    m_input = new QTextEdit(inputWrap);
+    m_input->setPlaceholderText(QStringLiteral("输入消息"));
+    m_input->setAcceptRichText(false);
+    m_input->installEventFilter(this);
 
-void Client::sendOutgoing(qint64 peer, const QString &peerName, int msgType,
-                          const QString &content, const QString &preview,
-                          QWidget *existingRow, qint64 localId)
-{
-    if (peer <= 0 || content.isEmpty())
-        return;
-    if (localId == 0)
-        localId = LocalStore::instance().allocLocalId();
-    const qint64 ts = QDateTime::currentMSecsSinceEpoch();
-    saveLocalMessage(localId, peer, m_uid, peer, msgType, content, ts, LocalMsg::Sending);
-    m_inflightLocalIds.insert(localId);
+    auto *sendRow = new QHBoxLayout();
+    sendRow->setContentsMargins(12, 0, 12, 10);
+    auto *send = UiHelpers::primaryButton(QStringLiteral("发送"), inputWrap);
+    send->setFixedSize(88, 32);
+    send->setStyleSheet(QStringLiteral(
+        "QPushButton { background-color:#07C160; color:#FFFFFF; border:1px solid #07C160; font-size:13px; border-radius:2px; }"
+        "QPushButton:hover { background-color:#06AD56; border-color:#06AD56; }"
+        "QPushButton:pressed { background-color:#038D4A; border-color:#038D4A; }"));
+    sendRow->addStretch();
+    sendRow->addWidget(send);
 
-    QPointer<QWidget> row = existingRow;
-    if (row)
-        setBubbleSendState(row, false, true);
-    else if (isViewingPeer(peer))
-        row = addBubble(true, m_nickname, content, QStringLiteral("刚刚"), msgType,
-                        LocalMsg::Sending, localId, peer);
-    upsertConv(peer, peerName, preview, ts, 0);
+    iLay->addWidget(toolbar);
+    iLay->addWidget(m_input, 1);
+    iLay->addLayout(sendRow);
 
-    QPointer<Client> self(this);
-    ImClient::instance().chat(peer, msgType, content,
-        [self, row, peer, peerName, content, msgType, preview, localId, ts](const QJsonObject &reply) {
-            if (!self)
-                return;
-            self->m_inflightLocalIds.remove(localId);
-            QWidget *markRow = row;
-            if (!markRow)
-                markRow = self->findOutgoingRow(localId);
-            if (!ImClient::ok(reply))
-            {
-                self->saveLocalMessage(localId, peer, self->m_uid, peer, msgType, content,
-                                       ts, LocalMsg::Failed);
-                if (markRow)
-                    self->setBubbleSendState(markRow, true);
-                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
-                return;
-            }
-            LocalStore::instance().removeMessage(localId);
-            const qint64 serverId = Proto::jsonI64(reply, QStringLiteral("msg_id"));
-            const qint64 serverTs = Proto::jsonI64(reply, QStringLiteral("ts"));
-            self->saveLocalMessage(serverId, peer, self->m_uid, peer, msgType, content,
-                                   serverTs > 0 ? serverTs : ts, LocalMsg::Ok);
-            if (markRow)
+    m_msgSelectBar = new QWidget(m_chatBody);
+    m_msgSelectBar->setFixedHeight(48);
+    m_msgSelectBar->setStyleSheet(QStringLiteral(
+        "background:#FFFFFF; border-top:1px solid #E5E5E5;"));
+    auto *selLay = new QHBoxLayout(m_msgSelectBar);
+    selLay->setContentsMargins(16, 8, 16, 8);
+    m_msgSelectHint = new QLabel(QStringLiteral("已选 0 条"), m_msgSelectBar);
+    m_msgSelectHint->setStyleSheet(QStringLiteral(
+        "color:#191919; font-size:13px; background:transparent;"));
+    auto *selDel = new QPushButton(QStringLiteral("删除"), m_msgSelectBar);
+    selDel->setCursor(Qt::PointingHandCursor);
+    selDel->setFixedSize(72, 28);
+    selDel->setStyleSheet(QStringLiteral(
+        "QPushButton { background:#FA5151; color:#FFFFFF; border:none; border-radius:2px; font-size:13px; }"
+        "QPushButton:hover { background:#E44545; }"));
+    auto *selCancel = UiHelpers::ghostButton(QStringLiteral("取消"), m_msgSelectBar);
+    selCancel->setFixedSize(72, 28);
+    selLay->addWidget(m_msgSelectHint);
+    selLay->addStretch();
+    selLay->addWidget(selDel);
+    selLay->addWidget(selCancel);
+    m_msgSelectBar->hide();
+    connect(selDel, &QPushButton::clicked, this, [this]() {
+        QVector<qint64> ids;
+        for (int i = 0; i < m_bubbleLay->count(); ++i)
+        {
+            QWidget *w = m_bubbleLay->itemAt(i)->widget();
+            if (!w || w->objectName() != QLatin1String("msgRow"))
+                continue;
+            if (auto *cb = w->findChild<QCheckBox *>(QStringLiteral("msgCheck")))
             {
-                markRow->setProperty("outgoingId", QVariant::fromValue(serverId));
-                markRow->setProperty("msgId", QVariant::fromValue(serverId));
-                self->setBubbleSendState(markRow, false);
+                if (cb->isChecked())
+                    ids.push_back(w->property("msgId").toLongLong());
             }
-            self->upsertConv(peer, peerName, preview, serverTs > 0 ? serverTs : ts, 0);
-        });
-}
-
-void Client::resendFailed(QWidget *row)
-{
-    if (!row || row->property("sendStatus").toInt() != LocalMsg::Failed)
-        return;
-    const qint64 localId = row->property("outgoingId").toLongLong();
-    const qint64 peer = row->property("outgoingPeer").toLongLong();
-    const int msgType = row->property("outgoingType").toInt();
-    const QString content = row->property("outgoingContent").toString();
-    if (localId == 0 || peer <= 0 || content.isEmpty())
-        return;
-    QString peerName = m_currentPeerName;
-    if (auto *item = findConvItem(peer))
-        peerName = item->data(RoleName).toString();
-    sendOutgoing(peer, peerName, msgType > 0 ? msgType : im::MsgType::Text, content,
-                 previewOf(msgType > 0 ? msgType : im::MsgType::Text, content), row, localId);
-}
-
-void Client::sendFile(bool imageOnly)
-{
-    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0)
-        return;
-    const QString filter = imageOnly
-        ? QStringLiteral("图片 (*.jpg *.jpeg *.png *.gif *.bmp *.webp)")
-        : QString();
-    const QString path = QFileDialog::getOpenFileName(
-        this, imageOnly ? QStringLiteral("选择图片") : QStringLiteral("选择文件"), QString(), filter);
-    if (path.isEmpty())
-        return;
-    QFileInfo info(path);
-    if (info.size() <= 0)
-    {
-        UiHelpers::showToast(this, QStringLiteral("文件无效"), true);
-        return;
-    }
-    const qint64 fileMax = AppConfig::instance().fileMax();
-    if (fileMax > 0 && info.size() > fileMax)
-    {
-        UiHelpers::showToast(this, QStringLiteral("文件不能超过 %1")
-                                       .arg(FileCache::formatSize(fileMax)), true);
-        return;
-    }
-    const QString mime = QMimeDatabase().mimeTypeForFile(path).name();
-    const QString name = info.fileName();
-    const qint64 size = info.size();
-    if (imageOnly && !FileCache::looksLikeImage(mime, name))
-    {
-        UiHelpers::showToast(this, QStringLiteral("请选择图片文件"), true);
-        return;
-    }
-
-    QPointer<Client> self(this);
-    const qint64 peer = m_currentPeerUid;
-    const QString peerName = m_currentPeerName;
-    ImClient::instance().putFile(name, mime, size, [self, path, name, mime, size, peer, peerName](const QJsonObject &reply) {
-        if (!self)
-            return;
-        if (!ImClient::ok(reply))
-        {
-            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
-            return;
-        }
-        const qint64 fileId = Proto::jsonI64(reply, QStringLiteral("file_id"));
-        const QString gotMime = reply.value(QStringLiteral("mime")).toString(mime);
-        const QString gotName = reply.value(QStringLiteral("name")).toString(name);
-        const QString url = reply.value(QStringLiteral("url")).toString();
-        if (fileId <= 0 || url.isEmpty())
-        {
-            UiHelpers::showToast(self, QStringLiteral("上传凭证无效"), true);
-            return;
-        }
-        if (!FileCache::instance().rememberPath(fileId, path, gotMime, gotName))
-        {
-            UiHelpers::showToast(self, QStringLiteral("无法保存本地文件"), true);
-            return;
         }
-        FileCache::instance().bindMeta(fileId, url, gotMime, gotName, size);
-
-        QJsonObject content;
-        content.insert(QStringLiteral("file_id"), fileId);
-        content.insert(QStringLiteral("name"), gotName);
-        content.insert(QStringLiteral("size"), size);
-        content.insert(QStringLiteral("mime"), gotMime);
-        content.insert(QStringLiteral("url"), url);
-        const QString contentText = QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Compact));
-        const QString preview = FileCache::looksLikeImage(gotMime, gotName)
-            ? QStringLiteral("[图片]")
-            : QStringLiteral("[文件] %1").arg(gotName);
+        deleteChatMessages(ids);
+    });
+    connect(selCancel, &QPushButton::clicked, this, [this]() { exitMsgSelectMode(); });
 
-        const qint64 localId = LocalStore::instance().allocLocalId();
-        const qint64 ts = QDateTime::currentMSecsSinceEpoch();
-        self->saveLocalMessage(localId, peer, self->m_uid, peer, im::MsgType::File, contentText, ts,
-                               LocalMsg::Sending);
-        self->m_inflightLocalIds.insert(localId);
+    body->addWidget(header);
+    body->addWidget(m_chatPageStack, 1);
+    body->addWidget(m_msgSelectBar);
+    body->addWidget(inputWrap);
 
-        auto *once = new QObject(self);
-        QObject::connect(&FileCache::instance(), &FileCache::uploaded, once,
-                         [self, once, fileId, peer, peerName, contentText, preview, localId](qint64 id) {
-            if (id != fileId)
-                return;
-            once->deleteLater();
-            if (!self)
-                return;
-            QWidget *row = self->findOutgoingRow(localId);
-            self->sendOutgoing(peer, peerName, im::MsgType::File, contentText, preview, row, localId);
-        });
-        QObject::connect(&FileCache::instance(), &FileCache::failed, once,
-                         [self, fileId](qint64 id, const QString &error) {
-            if (id != fileId || !self)
-                return;
-            UiHelpers::showToast(self, error.isEmpty() ? QStringLiteral("上传失败") : error, true);
-        });
+    m_chatStack->addWidget(m_chatEmpty);
+    m_chatStack->addWidget(m_friendHintPage);
+    m_chatStack->addWidget(m_chatBody);
 
-        FileCache::instance().startUpload(fileId, reply.value(QStringLiteral("token")).toObject());
-        if (self->isViewingPeer(peer))
-            self->addBubble(true, self->m_nickname, contentText, QStringLiteral("刚刚"),
-                            im::MsgType::File, LocalMsg::Sending, localId, peer);
-        self->upsertConv(peer, peerName, preview, ts, 0);
+    m_profilePage = new QWidget(m_chatStack);
+    m_profilePage->setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    auto *pLay = new QVBoxLayout(m_profilePage);
+    pLay->setContentsMargins(40, 48, 40, 32);
+    pLay->setSpacing(10);
+    pLay->setAlignment(Qt::AlignHCenter | Qt::AlignTop);
+    m_profileAvatar = new QLabel(m_profilePage);
+    m_profileAvatar->setFixedSize(72, 72);
+    m_profileTitle = new QLabel(m_profilePage);
+    m_profileTitle->setAlignment(Qt::AlignCenter);
+    m_profileTitle->setStyleSheet(QStringLiteral("font-size:18px; color:#1A1A1A; background:transparent; border:none;"));
+    auto metaStyle = QStringLiteral("font-size:13px; color:#6A6A6A; background:transparent; border:none;");
+    m_profileRemark = new QLabel(m_profilePage);
+    m_profileRemark->setAlignment(Qt::AlignCenter);
+    m_profileRemark->setStyleSheet(metaStyle);
+    m_profileNick = new QLabel(m_profilePage);
+    m_profileNick->setAlignment(Qt::AlignCenter);
+    m_profileNick->setStyleSheet(metaStyle);
+    m_profileAccount = new QLabel(m_profilePage);
+    m_profileAccount->setAlignment(Qt::AlignCenter);
+    m_profileAccount->setStyleSheet(metaStyle);
+    m_profileSign = new QLabel(m_profilePage);
+    m_profileSign->setAlignment(Qt::AlignCenter);
+    m_profileSign->setWordWrap(true);
+    m_profileSign->setStyleSheet(QStringLiteral("font-size:13px; color:#8A8A8A; background:transparent; border:none;"));
+    auto *sendMsg = UiHelpers::compactButton(QStringLiteral("发消息"), m_profilePage, true);
+    sendMsg->setFixedWidth(120);
+    auto *shareMsg = UiHelpers::compactButton(QStringLiteral("分享"), m_profilePage);
+    shareMsg->setFixedWidth(120);
+    pLay->addWidget(m_profileAvatar, 0, Qt::AlignHCenter);
+    pLay->addSpacing(8);
+    pLay->addWidget(m_profileTitle);
+    pLay->addWidget(m_profileRemark);
+    pLay->addWidget(m_profileNick);
+    pLay->addWidget(m_profileAccount);
+    pLay->addWidget(m_profileSign);
+    pLay->addSpacing(16);
+    pLay->addWidget(sendMsg, 0, Qt::AlignHCenter);
+    pLay->addWidget(shareMsg, 0, Qt::AlignHCenter);
+    pLay->addStretch();
+    m_chatStack->addWidget(m_profilePage);
+    m_favDetail = new FavoriteDetailPage(m_chatStack);
+    m_chatStack->addWidget(m_favDetail);
+    connect(m_favDetail, &FavoriteDetailPage::changed, this, [this]() {
+        if (m_favPanel)
+            m_favPanel->refresh();
     });
-}
-
-void Client::previewChatImage(qint64 fileId, const QString &name, const QString &content)
-{
-    if (fileId <= 0)
-        return;
-    auto show = [this, fileId, name, content]() {
-        const QByteArray data = FileCache::instance().bytes(fileId);
-        if (data.isEmpty())
-            return false;
-        QPixmap pm;
-        if (!pm.loadFromData(data))
-        {
-            UiHelpers::showToast(this, QStringLiteral("无法预览该图片"), true);
-            return true;
-        }
-        ImagePreviewDialog dlg(pm, name, data, this, !content.isEmpty(), !content.isEmpty());
-        dlg.exec();
-        if (dlg.wantsForward())
-            forwardPayload(im::MsgType::File, content, QStringLiteral("[图片]"));
-        if (dlg.wantsFavorite())
-            favoritePayload(im::MsgType::File, content);
-        return true;
-    };
-    if (show())
-        return;
-    UiHelpers::showToast(this, QStringLiteral("正在加载图片"));
-    QPointer<Client> self(this);
-    auto *once = new QObject(this);
-    connect(&FileCache::instance(), &FileCache::ready, once, [self, once, fileId, name, content](qint64 id) {
-        if (id != fileId)
-            return;
-        once->deleteLater();
-        if (!self)
-            return;
-        const QByteArray data = FileCache::instance().bytes(fileId);
-        QPixmap pm;
-        if (data.isEmpty() || !pm.loadFromData(data))
-            return;
-        ImagePreviewDialog dlg(pm, name, data, self, !content.isEmpty(), !content.isEmpty());
-        dlg.exec();
-        if (dlg.wantsForward())
-            self->forwardPayload(im::MsgType::File, content, QStringLiteral("[图片]"));
-        if (dlg.wantsFavorite())
-            self->favoritePayload(im::MsgType::File, content);
+    connect(m_favDetail, &FavoriteDetailPage::openCard, this, [this](qint64 uid) {
+        if (uid > 0)
+            showProfile(uid);
+    });
+    connect(m_favDetail, &FavoriteDetailPage::previewImage, this,
+            [this](qint64 fileId, const QString &name, const QString &content) {
+        previewChatImage(fileId, name, content);
+    });
+    m_profileChatBtn = sendMsg;
+    m_profileShareBtn = shareMsg;
+    connect(sendMsg, &QPushButton::clicked, this, [this]() {
+        if (m_profileUid > 0)
+            openChat(m_profileUid, m_profileDisplayName);
+    });
+    connect(shareMsg, &QPushButton::clicked, this, [this]() {
+        shareUserCard(m_profileUser);
     });
-    FileCache::instance().ensure(fileId, QJsonDocument::fromJson(content.toUtf8()).object()
-                                             .value(QStringLiteral("url")).toString());
-}
-
-void Client::favoritePayload(int msgType, const QString &content)
-{
-    if (content.isEmpty())
-        return;
-    QJsonObject body;
-    body.insert(QStringLiteral("msg_type"), msgType);
-    body.insert(QStringLiteral("content"), content);
-    if (m_currentPeerUid > 0)
-        body.insert(QStringLiteral("src_peer_uid"), m_currentPeerUid);
-
-    const bool waitFile = (msgType == im::MsgType::File);
-    const int timeoutMs = waitFile ? 30000 : 15000;
-    QPointer<Client> self(this);
-
-    if (!waitFile)
-    {
-        ImClient::instance().favoriteAdd(body, [self](const QJsonObject &reply) {
-            if (!self)
-                return;
-            if (!ImClient::ok(reply))
-            {
-                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
-                return;
-            }
-            UiHelpers::showToast(self, QStringLiteral("已收藏"));
-            if (self->m_favPanel)
-                self->m_favPanel->refresh();
-        }, timeoutMs);
-        return;
-    }
 
-    WaitDialog wait(this, QStringLiteral("收藏文件"),
-                    QStringLiteral("正在收藏文件,请稍候…"));
-    QJsonObject result;
-    ImClient::instance().favoriteAdd(body, [&wait, &result](const QJsonObject &reply) {
-        result = reply;
-        wait.finish();
-    }, timeoutMs);
-    const QPoint c = mapToGlobal(rect().center());
-    wait.move(c.x() - wait.width() / 2, c.y() - wait.height() / 2);
-    wait.exec();
-    if (!self)
-        return;
-    if (!ImClient::ok(result))
-    {
-        UiHelpers::showToast(self, ImClient::messageOf(result), true);
-        return;
-    }
-    UiHelpers::showToast(self, QStringLiteral("已收藏"));
-    if (self->m_favPanel)
-        self->m_favPanel->refresh();
-}
+    m_chatStack->setCurrentWidget(m_chatEmpty);
 
-void Client::forwardPayload(int msgType, const QString &content, const QString &preview,
-                            QWidget *host)
-{
-    Q_UNUSED(host);
-    if (content.isEmpty())
-        return;
-    QPointer<Client> self(this);
-    ImClient::instance().friendList([self, msgType, content, preview](const QJsonObject &reply) {
-        if (!self)
-            return;
-        if (!ImClient::ok(reply))
+    m_stickerPanel = new StickerPanel(this);
+    m_stickerPanel->setWindowFlags(Qt::Popup | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint);
+    m_stickerPanel->hide();
+    connect(emoji, &QPushButton::clicked, this, [this, inputWrap]() {
+        if (m_stickerPanel->isVisible())
         {
-            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            m_stickerPanel->hide();
             return;
         }
-        const QJsonArray list = reply.value(QStringLiteral("list")).toArray();
-        // 不能在 socket readyRead / 其它模态 exec 里直接再 exec,否则转发框会挂起直到下一次点击。
-        QTimer::singleShot(0, self.data(), [self, msgType, content, preview, list]() {
-            if (!self)
-                return;
-            PickFriendDialog dlg(list, self->m_uid, self, QStringLiteral("转发给"), true);
-            dlg.setWindowModality(Qt::ApplicationModal);
-            dlg.raise();
-            dlg.activateWindow();
-            if (dlg.exec() != QDialog::Accepted)
-                return;
-            const QJsonObject o = dlg.selected();
-            const qint64 uid = Proto::jsonI64(o, QStringLiteral("uid"));
-            if (uid <= 0)
-                return;
-            QString name = o.value(QStringLiteral("remark")).toString();
-            if (name.isEmpty())
-                name = o.value(QStringLiteral("nickname")).toString();
-            if (name.isEmpty())
-                name = o.value(QStringLiteral("account")).toString();
-            self->sendChatPayloadTo(uid, name, msgType, content, preview);
-            UiHelpers::showToast(self, QStringLiteral("已转发"));
-        });
+        if (QDateTime::currentMSecsSinceEpoch() - m_stickerDismissedAt < 250)
+            return;
+        const QPoint pos = inputWrap->mapToGlobal(QPoint(8, -m_stickerPanel->height() + 12));
+        m_stickerPanel->move(pos);
+        m_stickerPanel->show();
     });
-}
-
-void Client::shareUserCard(const QJsonObject &user, QWidget *host)
-{
-    const qint64 uid = Proto::jsonI64(user, QStringLiteral("uid"));
-    if (uid <= 0)
-        return;
-    QJsonObject content;
-    content.insert(QStringLiteral("uid"), uid);
-    content.insert(QStringLiteral("account"), user.value(QStringLiteral("account")).toString());
-    QString nick = user.value(QStringLiteral("nickname")).toString();
-    if (nick.isEmpty())
-        nick = user.value(QStringLiteral("account")).toString();
-    content.insert(QStringLiteral("nickname"), nick);
-    content.insert(QStringLiteral("signature"), user.value(QStringLiteral("signature")).toString());
-    content.insert(QStringLiteral("avatar_ver"), user.value(QStringLiteral("avatar_ver")).toInt());
-    forwardPayload(im::MsgType::Card,
-                   QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Compact)),
-                   QStringLiteral("[名片] %1").arg(nick), host);
-}
-
-void Client::openAddFriend()
-{
-    AddFriendDialog dlg(this);
-    dlg.exec();
-    refreshFriendList();
-}
+    connect(m_stickerPanel, &StickerPanel::dismissed, this, [this]() {
+        m_stickerDismissedAt = QDateTime::currentMSecsSinceEpoch();
+    });
+    connect(m_stickerPanel, &StickerPanel::stickerPicked, this, [this](qint64 packId, const QString &code) {
+        m_stickerPanel->hide();
+        sendSticker(packId, code);
+    });
+    connect(fileBtn, &QPushButton::clicked, this, [this]() { sendFile(false); });
+    connect(imgBtn, &QPushButton::clicked, this, [this]() { sendFile(true); });
 
-void Client::openFriendRequests()
-{
-    FriendRequestDialog dlg(this);
-    connect(&dlg, &FriendRequestDialog::acceptedFriend, this, [this]() { refreshFriendList(); });
-    dlg.exec();
-    refreshFriendList();
+    connect(send, &QPushButton::clicked, this, &Client::sendCurrentMessage);
+    return pane;
 }
 
-void Client::setPendingCount(int n)
+void Client::persistWindow()
 {
-    m_pendingFriends = n;
-    if (!m_pendingBadge)
-        return;
-    if (n <= 0)
+    QRect r;
+    if (auto *h = m_titleBar ? m_titleBar->framelessHelper() : nullptr)
+        r = h->normalGeometry();
+    if (!r.isValid() || r.width() < 100 || r.height() < 100)
     {
-        m_pendingBadge->hide();
-        updateTray();
-        return;
+        if (!isMaximized())
+            r = geometry();
+        else
+            r = UserSettings::instance().windowRect();
     }
-    m_pendingBadge->setText(n > 99 ? QStringLiteral("99") : QString::number(n));
-    m_pendingBadge->setFixedSize(18, 18);
-    m_pendingBadge->show();
-    updateTray();
-}
-
-void Client::onFriendRequestNotify(const QJsonObject &msg)
-{
-    QString name = msg.value(QStringLiteral("nickname")).toString();
-    if (name.isEmpty())
-        name = msg.value(QStringLiteral("account")).toString();
-    UiHelpers::showToast(this, QStringLiteral("%1 请求添加你为好友").arg(name));
-    refreshFriendList();
-}
-
-void Client::onFriendAcceptedNotify(const QJsonObject &msg)
-{
-    QString name = msg.value(QStringLiteral("nickname")).toString();
-    if (name.isEmpty())
-        name = msg.value(QStringLiteral("account")).toString();
-    UiHelpers::showToast(this, QStringLiteral("%1 已同意你的好友申请").arg(name));
-    refreshFriendList();
+    if (r.width() < 760)
+        r.setWidth(760);
+    if (r.height() < 500)
+        r.setHeight(500);
+    UserSettings::instance().saveWindow(r, isMaximized());
 }
 
-int Client::unreadTotal() const
+void Client::onChangePassword()
 {
-    int n = 0;
-    if (!m_convList)
-        return 0;
-    for (int i = 0; i < m_convList->count(); ++i)
-        n += m_convList->item(i)->data(RoleUnread).toInt();
-    return n;
+    ChangePasswordDialog dlg(this);
+    if (dlg.exec() == QDialog::Accepted)
+        UiHelpers::showToast(this, QStringLiteral("密码已修改"));
 }
 
-void Client::setupTray()
+void Client::onLogout()
 {
-    if (!QSystemTrayIcon::isSystemTrayAvailable())
+    if (m_loggingOut)
         return;
-
-    auto *menu = new QMenu(this);
-    auto *openAct = menu->addAction(QStringLiteral("打开主面板"));
-    menu->addSeparator();
-    auto *quitAct = menu->addAction(QStringLiteral("退出"));
-    connect(openAct, &QAction::triggered, this, &Client::showFromTray);
-    connect(quitAct, &QAction::triggered, this, &Client::quitFromTray);
-
-    m_tray = new QSystemTrayIcon(this);
-    m_tray->setContextMenu(menu);
-    m_tray->setIcon(UiHelpers::appIcon());
-    m_tray->show();
-    connect(m_tray, &QSystemTrayIcon::activated, this, [this](QSystemTrayIcon::ActivationReason reason) {
-        if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick)
-            showFromTray();
+    m_loggingOut = true;
+    hideReconnectOverlay();
+    if (m_tray)
+        m_tray->hide();
+    if (m_trayPreview)
+        m_trayPreview->hide();
+    stopUnreadAlert();
+    QPointer<Client> self(this);
+    ImClient::instance().logout([self](const QJsonObject &) {
+        if (!self)
+            return;
+        ImClient::instance().disconnectFromServer();
+        emit self->loggedOut();
+        self->close();
     });
-
-    m_trayPreview = new QWidget(this, Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
-    m_trayPreview->setAttribute(Qt::WA_ShowWithoutActivating);
-    m_trayPreview->setAttribute(Qt::WA_TranslucentBackground, false);
-    m_trayPreview->setObjectName(QStringLiteral("TrayPreview"));
-    m_trayPreview->setFixedWidth(280);
-    m_trayPreview->setStyleSheet(QStringLiteral(
-        "QWidget#TrayPreview { background:#FFFFFF; border:1px solid #E5E5E5; }"));
-    auto *lay = new QVBoxLayout(m_trayPreview);
-    lay->setContentsMargins(6, 8, 6, 8);
-    lay->setSpacing(2);
-    m_trayPreview->hide();
-
-    m_trayHover = new QTimer(this);
-    m_trayHover->setInterval(160);
-    connect(m_trayHover, &QTimer::timeout, this, &Client::pollTrayHover);
-    m_trayHover->start();
-    updateTray();
 }
 
-void Client::updateTray()
+bool Client::eventFilter(QObject *watched, QEvent *event)
 {
-    const int unread = unreadTotal();
-    if (unread <= 0)
-        stopUnreadAlert();
-    else
-        startUnreadAlert();
-    if (!m_tray)
-        return;
-    if (!m_trayFlash || !m_trayFlash->isActive())
-        m_tray->setIcon(UiHelpers::appIcon());
-
-    auto *lay = qobject_cast<QVBoxLayout *>(m_trayPreview ? m_trayPreview->layout() : nullptr);
-    if (!lay)
-        return;
-    while (QLayoutItem *it = lay->takeAt(0))
+    if (event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseButtonRelease)
     {
-        if (it->widget())
-            it->widget()->deleteLater();
-        delete it;
-    }
-
-    auto addPreviewRow = [&](const QString &name, const QString &preview, int unread,
-                             qint64 peerUid, bool friends) {
-        auto *row = new TrayPreviewRow(m_trayPreview);
-        const QString peerName = name;
-        row->onActivate = [this, peerUid, peerName, friends]() {
-            showFromTray();
-            if (friends)
-                QTimer::singleShot(0, this, &Client::openFriendRequests);
-            else
-                openChat(peerUid, peerName);
-        };
-        auto *h = new QHBoxLayout(row);
-        h->setContentsMargins(8, 6, 8, 6);
-        h->setSpacing(8);
-
-        auto passthrough = [](QWidget *w) {
-            w->setAttribute(Qt::WA_TransparentForMouseEvents);
-        };
-
-        auto *avatar = new QLabel(row);
-        avatar->setFixedSize(32, 32);
-        avatar->setPixmap(UiHelpers::avatarPixmap(name, 32));
-        if (!friends)
-            avatar->setPixmap(peerAvatar(peerUid, name, 32));
-        passthrough(avatar);
-
-        auto *info = new QVBoxLayout();
-        info->setContentsMargins(0, 0, 0, 0);
-        info->setSpacing(1);
-        auto *nameLab = new ElideLabel(name, row);
-        nameLab->setStyleSheet(QStringLiteral("font-size:13px; color:#1A1A1A; background:transparent;"));
-        passthrough(nameLab);
-        auto *prev = new ElideLabel(preview, row);
-        prev->setStyleSheet(QStringLiteral("font-size:12px; color:#9A9A9A; background:transparent;"));
-        passthrough(prev);
-        info->addWidget(nameLab);
-        info->addWidget(prev);
-
-        auto *badge = new QLabel(row);
-        passthrough(badge);
-        badge->setAlignment(Qt::AlignCenter);
-        if (unread > 0)
-        {
-            badge->setObjectName(QStringLiteral("trayUnreadBadge"));
-            badge->setText(unread > 99 ? QStringLiteral("99") : QString::number(unread));
-            badge->setFixedSize(18, 18);
-            badge->setStyleSheet(QStringLiteral(
-                "QLabel#trayUnreadBadge { background:#FA5151; color:#FFFFFF; font-size:10px;"
-                " border:none; border-radius:9px; }"));
-        }
-        else
+        auto *me = static_cast<QMouseEvent *>(event);
+        if (me->button() == Qt::LeftButton && !qobject_cast<QCheckBox *>(watched))
         {
-            badge->hide();
+            QWidget *row = qobject_cast<QWidget *>(watched);
+            while (row && row->objectName() != QLatin1String("msgRow"))
+                row = row->parentWidget();
+            if (row && row->property("selecting").toBool())
+            {
+                if (event->type() == QEvent::MouseButtonRelease)
+                {
+                    if (auto *cb = row->findChild<QCheckBox *>(QStringLiteral("msgCheck")))
+                        cb->toggle();
+                }
+                return true;
+            }
         }
-
-        h->addWidget(avatar, 0, Qt::AlignVCenter);
-        h->addLayout(info, 1);
-        h->addWidget(badge, 0, Qt::AlignVCenter);
-        lay->addWidget(row);
-    };
-
-    auto *title = new QLabel(m_trayPreview);
-    title->setStyleSheet(QStringLiteral("font-size:12px; color:#9A9A9A; padding:2px 8px 6px 8px;"));
-    if (unread <= 0 && m_pendingFriends <= 0)
-    {
-        title->setText(QStringLiteral("暂无未读消息"));
-        lay->addWidget(title);
-        m_trayPreview->adjustSize();
-        return;
-    }
-    if (unread > 0)
-    {
-        title->setText(QStringLiteral("%1 条未读消息").arg(unread));
-        lay->addWidget(title);
-    }
-    else
-    {
-        delete title;
-        title = nullptr;
     }
-
-    int shown = 0;
-    if (m_convList)
+    if (event->type() == QEvent::MouseButtonRelease)
     {
-        for (int i = 0; i < m_convList->count() && shown < 5; ++i)
+        if (auto *lab = qobject_cast<QLabel *>(watched))
         {
-            auto *item = m_convList->item(i);
-            const int n = item->data(RoleUnread).toInt();
-            if (n <= 0)
-                continue;
-            addPreviewRow(item->data(RoleName).toString(),
-                          item->data(RolePreview).toString(),
-                          n,
-                          item->data(RolePeerUid).toLongLong(),
-                          false);
-            ++shown;
+            if (lab->objectName() == QStringLiteral("sendStatus"))
+            {
+                auto *me = static_cast<QMouseEvent *>(event);
+                if (me->button() == Qt::LeftButton)
+                    resendFailed(lab->parentWidget());
+                return true;
+            }
         }
     }
-    if (unread > shown)
-    {
-        auto *more = new TrayPreviewRow(m_trayPreview);
-        more->setMinimumHeight(28);
-        more->onActivate = [this]() { showFromTray(); };
-        auto *moreLab = new QLabel(QStringLiteral("还有 %1 条未读").arg(unread - shown), more);
-        moreLab->setStyleSheet(QStringLiteral("font-size:12px; color:#B0B0B0; background:transparent;"));
-        moreLab->setAttribute(Qt::WA_TransparentForMouseEvents);
-        auto *moreLay = new QHBoxLayout(more);
-        moreLay->setContentsMargins(8, 4, 8, 4);
-        moreLay->addWidget(moreLab);
-        lay->addWidget(more);
-    }
-    if (m_pendingFriends > 0)
-        addPreviewRow(QStringLiteral("新的朋友"),
-                      QStringLiteral("%1 条好友申请").arg(m_pendingFriends),
-                      m_pendingFriends,
-                      0,
-                      true);
-    m_trayPreview->adjustSize();
-}
-
-void Client::pollTrayHover()
-{
-    if (!m_tray || !m_tray->isVisible() || !m_trayPreview)
-        return;
-    const QRect iconRect = m_tray->geometry();
-    if (!iconRect.isValid() || iconRect.isEmpty())
-        return;
-    const QPoint pos = QCursor::pos();
-    const bool overIcon = iconRect.adjusted(-4, -4, 4, 4).contains(pos);
-    const bool overPreview = m_trayPreview->isVisible() && m_trayPreview->geometry().adjusted(-6, -6, 6, 6).contains(pos);
-    const int unread = unreadTotal();
-    if ((overIcon || overPreview) && (unread > 0 || m_pendingFriends > 0))
-    {
-        QPoint p(iconRect.center().x() - m_trayPreview->width() / 2,
-                 iconRect.top() - m_trayPreview->height() - 8);
-        if (p.y() < 0)
-            p.setY(iconRect.bottom() + 8);
-        if (p.x() < 8)
-            p.setX(8);
-        m_trayPreview->move(p);
-        m_trayPreview->show();
-        m_trayPreview->raise();
-    }
-    else if (m_trayPreview->isVisible())
-    {
-        m_trayPreview->hide();
-    }
-}
-
-void Client::startUnreadAlert()
-{
-    const bool already = m_trayFlash && m_trayFlash->isActive();
-#ifdef Q_OS_WIN
-    if (!already)
+    if (watched == m_navAvatar && event->type() == QEvent::MouseButtonRelease)
     {
-        FLASHWINFO info;
-        ZeroMemory(&info, sizeof(info));
-        info.cbSize = sizeof(info);
-        info.hwnd = reinterpret_cast<HWND>(winId());
-        info.dwFlags = FLASHW_TRAY | FLASHW_TIMER;
-        info.uCount = 0;
-        info.dwTimeout = 0;
-        FlashWindowEx(&info);
+        auto *me = static_cast<QMouseEvent *>(event);
+        if (me->button() == Qt::LeftButton)
+            showSettings(0);
+        return true;
     }
-#else
-    if (!already)
-        QApplication::alert(this, 0);
-#endif
-    if (!m_tray)
-        return;
-    if (!m_trayFlash)
+    if (watched == m_chatTitle && event->type() == QEvent::MouseButtonRelease)
     {
-        m_trayFlash = new QTimer(this);
-        m_trayFlash->setInterval(450);
-        connect(m_trayFlash, &QTimer::timeout, this, [this]() {
-            m_trayFlashLit = !m_trayFlashLit;
-            m_tray->setIcon(m_trayFlashLit ? UiHelpers::appIcon() : blankTrayIcon());
-        });
+        auto *me = static_cast<QMouseEvent *>(event);
+        if (me->button() == Qt::LeftButton && m_currentPeerUid > 0
+            && m_chatStack && m_chatStack->currentWidget() == m_chatBody)
+            showProfile(m_currentPeerUid);
+        return true;
     }
-    if (!m_trayFlash->isActive())
+    if (watched == m_input && event->type() == QEvent::KeyPress)
     {
-        m_trayFlashLit = true;
-        m_trayFlash->start();
+        auto *key = static_cast<QKeyEvent *>(event);
+        if (key->key() == Qt::Key_Return || key->key() == Qt::Key_Enter)
+        {
+            if (key->modifiers() & Qt::ShiftModifier)
+                return QMainWindow::eventFilter(watched, event);
+            sendCurrentMessage();
+            return true;
+        }
     }
+    return QMainWindow::eventFilter(watched, event);
 }
 
-void Client::stopUnreadAlert()
-{
-#ifdef Q_OS_WIN
-    FLASHWINFO info;
-    ZeroMemory(&info, sizeof(info));
-    info.cbSize = sizeof(info);
-    info.hwnd = reinterpret_cast<HWND>(winId());
-    info.dwFlags = FLASHW_STOP;
-    FlashWindowEx(&info);
-#endif
-    if (m_trayFlash)
-        m_trayFlash->stop();
-    m_trayFlashLit = true;
-    if (m_tray)
-        m_tray->setIcon(UiHelpers::appIcon());
-}
-
-void Client::showFromTray()
+void Client::showSettings()
 {
-    if (m_trayPreview)
-        m_trayPreview->hide();
-    show();
-    raise();
-    activateWindow();
-    if (unreadTotal() <= 0)
-        stopUnreadAlert();
-    else
-        startUnreadAlert();
+    showSettings(1);
 }
 
-void Client::quitFromTray()
+void Client::showSettings(int page)
 {
-    m_forceQuit = true;
-    if (m_tray)
-        m_tray->hide();
-    QApplication::quit();
+    SettingsDialog dlg(m_account, m_nickname, ImClient::instance().signature(), this, page);
+    dlg.exec();
+    m_nickname = ImClient::instance().nickname();
+    refreshAvatar(m_uid);
+    if (dlg.wantsLogout())
+        onLogout();
+    else if (dlg.wantsChangePassword())
+        onChangePassword();
 }
 
 #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)

+ 6 - 0
Client/src/mainframe/Client.h

@@ -34,6 +34,9 @@ class Client : public QMainWindow
 {
     Q_OBJECT
 
+    // 实现拆在 Client.cpp / ClientChat.cpp / ClientConv.cpp / ClientSend.cpp / ClientSession.cpp
+    // 内部控件见 ClientInternal.h
+
 public:
     explicit Client(const QString &account, const QString &nickname, QWidget *parent = nullptr);
     ~Client();
@@ -160,6 +163,8 @@ private:
     void onChatNotify(const QJsonObject &msg);
     void onFriendRequestNotify(const QJsonObject &msg);
     void onFriendAcceptedNotify(const QJsonObject &msg);
+    void onPresenceNotify(const QJsonObject &msg);
+    void applyPresence(qint64 uid, bool online);
     void applyNavSelection(int index);
     void setPendingCount(int n);
     void setupTray();
@@ -171,6 +176,7 @@ private:
     void stopUnreadAlert();
     int unreadTotal() const;
     QListWidgetItem *findConvItem(qint64 peerUid) const;
+    QListWidgetItem *findFriendItem(qint64 uid) const;
     QString previewOf(int msgType, const QString &content) const;
 
     QString m_account;

+ 1033 - 0
Client/src/mainframe/ClientChat.cpp

@@ -0,0 +1,1033 @@
+// ClientChat.cpp — 会话页缓存、历史气泡、多选删除
+#include "ClientImpl.h"
+
+Client::ChatPage Client::createChatPage()
+{
+    ChatPage p;
+    p.scroll = new QScrollArea(m_chatPageStack);
+    p.scroll->setWidgetResizable(true);
+    p.scroll->setFrameShape(QFrame::NoFrame);
+    p.scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+    p.scroll->setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    p.host = new QWidget(p.scroll);
+    p.host->setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    p.lay = new QVBoxLayout(p.host);
+    p.lay->setContentsMargins(16, 16, 16, 16);
+    p.lay->setSpacing(12);
+    p.lay->addStretch();
+    p.scroll->setWidget(p.host);
+    bindChatScroll(p.scroll);
+    if (m_chatPageStack)
+        m_chatPageStack->addWidget(p.scroll);
+    return p;
+}
+
+void Client::bindChatScroll(QScrollArea *scroll)
+{
+    if (!scroll)
+        return;
+    connect(scroll->verticalScrollBar(), &QScrollBar::rangeChanged, this, [this, scroll](int, int max) {
+        if (m_scroll != scroll)
+            return;
+        if (m_stickChatToBottom)
+            scroll->verticalScrollBar()->setValue(max);
+    });
+    connect(scroll->verticalScrollBar(), &QScrollBar::valueChanged, this, [this, scroll](int v) {
+        if (m_scroll != scroll)
+            return;
+        const int max = scroll->verticalScrollBar()->maximum();
+        m_stickChatToBottom = (max - v) <= 24;
+    });
+}
+
+void Client::stashChatPage(qint64 peerUid)
+{
+    if (peerUid <= 0)
+        return;
+    auto it = m_chatPages.find(peerUid);
+    if (it == m_chatPages.end())
+        return;
+    it->fp = m_historyFp;
+    it->stickBottom = m_stickChatToBottom;
+    if (it->scroll && it->scroll->viewport())
+        it->laidOutW = it->scroll->viewport()->width();
+    if (it->scroll)
+        it->scrollValue = it->scroll->verticalScrollBar()->value();
+}
+
+void Client::rememberChatPageState()
+{
+    stashChatPage(m_currentPeerUid);
+}
+
+bool Client::activateChatPage(qint64 peerUid)
+{
+    if (peerUid <= 0 || !m_chatPageStack)
+        return false;
+    const bool cached = m_chatPages.contains(peerUid);
+
+    m_chatPageOrder.removeAll(peerUid);
+    m_chatPageOrder.append(peerUid);
+
+    auto it = m_chatPages.find(peerUid);
+    if (it == m_chatPages.end())
+    {
+        ChatPage p;
+        bool owned = false;
+        for (auto pit = m_chatPages.cbegin(); pit != m_chatPages.cend(); ++pit)
+        {
+            if (pit->scroll == m_scroll)
+            {
+                owned = true;
+                break;
+            }
+        }
+        if (m_scroll && m_bubbleHost && m_bubbleLay && !owned)
+        {
+            p.scroll = m_scroll;
+            p.host = m_bubbleHost;
+            p.lay = m_bubbleLay;
+        }
+        else
+        {
+            p = createChatPage();
+        }
+        it = m_chatPages.insert(peerUid, p);
+        m_historyFp.clear();
+        m_historyPeerUid = peerUid;
+        m_stickChatToBottom = true;
+        trimChatPages();
+        it = m_chatPages.find(peerUid);
+        if (it == m_chatPages.end())
+            return false;
+    }
+    else
+    {
+        m_historyFp = it->fp;
+        m_historyPeerUid = peerUid;
+        m_stickChatToBottom = it->stickBottom;
+    }
+
+    m_scroll = it->scroll;
+    m_bubbleHost = it->host;
+    m_bubbleLay = it->lay;
+    if (m_scroll && m_chatPageStack->currentWidget() != m_scroll)
+        m_chatPageStack->setCurrentWidget(m_scroll);
+
+    int viewW = 0;
+    if (m_scroll && m_scroll->viewport())
+        viewW = m_scroll->viewport()->width();
+    if (it->laidOutW > 0 && viewW > 0 && it->laidOutW != viewW)
+        relayoutTextBubbles();
+    return cached;
+}
+
+bool Client::chatPageHasAll(const QJsonArray &list) const
+{
+    if (!m_bubbleLay || m_bubbleLay->count() <= 1)
+        return false;
+    for (const QJsonValue &v : list)
+    {
+        const QJsonObject o = v.toObject();
+        qint64 msgId = Proto::jsonI64(o, QStringLiteral("msg_id"));
+        if (msgId <= 0)
+            msgId = Proto::jsonI64(o, QStringLiteral("id"));
+        if (msgId > 0 && !findMsgRow(msgId))
+            return false;
+    }
+    return true;
+}
+
+void Client::dropChatPage(qint64 uid)
+{
+    auto it = m_chatPages.find(uid);
+    if (it == m_chatPages.end())
+        return;
+    QScrollArea *scroll = it->scroll;
+    m_chatPages.erase(it);
+    m_chatPageOrder.removeAll(uid);
+    const bool droppingCurrent = (m_scroll == scroll);
+    if (scroll)
+    {
+        if (m_chatPageStack)
+            m_chatPageStack->removeWidget(scroll);
+        scroll->deleteLater();
+    }
+    if (!droppingCurrent)
+        return;
+    ChatPage fresh = createChatPage();
+    m_scroll = fresh.scroll;
+    m_bubbleHost = fresh.host;
+    m_bubbleLay = fresh.lay;
+    m_historyFp.clear();
+    m_historyPeerUid = 0;
+    if (m_chatPageStack && m_scroll)
+        m_chatPageStack->setCurrentWidget(m_scroll);
+}
+
+void Client::trimChatPages()
+{
+    constexpr int kMaxPages = 8;
+    while (m_chatPages.size() > kMaxPages)
+    {
+        qint64 victim = 0;
+        for (qint64 id : m_chatPageOrder)
+        {
+            if (id != m_currentPeerUid)
+            {
+                victim = id;
+                break;
+            }
+        }
+        if (victim == 0)
+            break;
+        dropChatPage(victim);
+    }
+}
+
+void Client::applyHistory(const QJsonArray &list)
+{
+    QElapsedTimer clock;
+    clock.start();
+    int histLimit = AppConfig::instance().historyDefault();
+    if (histLimit <= 0)
+        histLimit = 50;
+    QMap<qint64, LocalStore::Msg> byId;
+    int fallbackId = 0;
+    for (const auto &m : LocalStore::instance().listMessages(m_currentPeerUid, histLimit))
+    {
+        if (m.id != 0)
+            byId.insert(m.id, m);
+    }
+    const qint64 sqliteNs = clock.nsecsElapsed();
+    for (const QJsonValue &v : list)
+    {
+        const QJsonObject o = v.toObject();
+        const qint64 from = Proto::jsonI64(o, QStringLiteral("from_uid"));
+        const qint64 to = Proto::jsonI64(o, QStringLiteral("to_uid"));
+        qint64 msgId = Proto::jsonI64(o, QStringLiteral("msg_id"));
+        if (msgId <= 0)
+            msgId = Proto::jsonI64(o, QStringLiteral("id"));
+        const qint64 peer = m_currentPeerUid > 0
+            ? m_currentPeerUid
+            : ((from == m_uid) ? to : from);
+        const int msgType = static_cast<int>(Proto::jsonI64(o, QStringLiteral("msg_type")));
+        const QString text = o.value(QStringLiteral("content")).toString();
+        const qint64 ts = Proto::jsonI64(o, QStringLiteral("ts"));
+        saveLocalMessage(msgId, peer, from, to, msgType > 0 ? msgType : 1, text, ts);
+
+        LocalStore::Msg m;
+        m.id = msgId > 0 ? msgId : --fallbackId;
+        m.peerUid = peer;
+        m.fromUid = from;
+        m.toUid = to;
+        m.msgType = msgType > 0 ? msgType : 1;
+        m.content = text;
+        m.ts = ts;
+        byId.insert(m.id, m);
+    }
+
+    QVector<LocalStore::Msg> rows;
+    rows.reserve(byId.size());
+    for (auto it = byId.constBegin(); it != byId.constEnd(); ++it)
+        rows.push_back(it.value());
+    std::sort(rows.begin(), rows.end(), [](const LocalStore::Msg &a, const LocalStore::Msg &b) {
+        if (a.ts != b.ts)
+            return a.ts < b.ts;
+        return a.id < b.id;
+    });
+    if (rows.size() > histLimit)
+        rows = rows.mid(rows.size() - histLimit);
+
+    QString fp;
+    fp.reserve(rows.size() * 16);
+    for (const LocalStore::Msg &m : rows)
+        fp += QString::number(m.id) + QLatin1Char(',');
+    const qint64 mergeNs = clock.nsecsElapsed();
+    if (m_historyPeerUid == m_currentPeerUid && fp == m_historyFp)
+    {
+        if (m_stickChatToBottom)
+            scrollChatToBottom();
+        LOG_INFO(QStringLiteral("applyHistory peer=%1 src=%2 rows=%3 skip sqlite=%4ms merge=%5ms total=%6ms")
+                     .arg(QString::number(m_currentPeerUid),
+                          list.isEmpty() ? QStringLiteral("local") : QStringLiteral("server"),
+                          QString::number(rows.size()),
+                          fmtMs(sqliteNs), fmtMs(mergeNs - sqliteNs),
+                          fmtMs(clock.nsecsElapsed())));
+        return;
+    }
+
+    if (m_scroll)
+        m_scroll->setUpdatesEnabled(false);
+    if (m_bubbleHost)
+        m_bubbleHost->setUpdatesEnabled(false);
+    clearBubbles();
+    m_buildingHistory = true;
+    QLayoutItem *tail = nullptr;
+    if (m_bubbleLay && m_bubbleLay->count() > 0)
+        tail = m_bubbleLay->takeAt(m_bubbleLay->count() - 1);
+    qint64 lastTs = 0;
+    for (const LocalStore::Msg &m : rows)
+    {
+        const bool mine = (m.fromUid == m_uid);
+        const QString name = mine ? m_nickname : m_currentPeerName;
+        QString time;
+        if (lastTs == 0 || qAbs(m.ts - lastTs) > 5 * 60 * 1000)
+            time = formatTs(m.ts);
+        lastTs = m.ts;
+        int status = m.status;
+        if (status == LocalMsg::Sending && !m_inflightLocalIds.contains(m.id))
+        {
+            status = LocalMsg::Failed;
+            LocalStore::Msg stale = m;
+            stale.status = LocalMsg::Failed;
+            LocalStore::instance().upsertMessage(stale);
+        }
+        addBubble(mine, name, m.content, time, m.msgType, status, m.id, m.peerUid);
+    }
+    if (m_bubbleLay && tail)
+        m_bubbleLay->addItem(tail);
+    m_buildingHistory = false;
+    m_historyPeerUid = m_currentPeerUid;
+    m_historyFp = fp;
+    rememberChatPageState();
+    if (m_bubbleHost)
+        m_bubbleHost->setUpdatesEnabled(true);
+    if (m_scroll)
+        m_scroll->setUpdatesEnabled(true);
+    scrollChatToBottom();
+    LOG_INFO(QStringLiteral("applyHistory peer=%1 src=%2 rows=%3 rebuild sqlite=%4ms merge=%5ms build=%6ms total=%7ms")
+                 .arg(QString::number(m_currentPeerUid),
+                      list.isEmpty() ? QStringLiteral("local") : QStringLiteral("server"),
+                      QString::number(rows.size()),
+                      fmtMs(sqliteNs), fmtMs(mergeNs - sqliteNs),
+                      fmtMs(clock.nsecsElapsed() - mergeNs), fmtMs(clock.nsecsElapsed())));
+}
+
+void Client::openChat(qint64 peerUid, const QString &name)
+{
+    if (peerUid <= 0)
+        return;
+    QElapsedTimer clock;
+    clock.start();
+    const bool sameChat = (peerUid == m_currentPeerUid)
+        && m_chatStack && m_chatStack->currentWidget() == m_chatBody;
+    exitMsgSelectMode();
+    qint64 stashNs = 0;
+    if (!sameChat)
+    {
+        const qint64 t0 = clock.nsecsElapsed();
+        stashChatPage(m_currentPeerUid);
+        stashNs = clock.nsecsElapsed() - t0;
+    }
+    m_currentPeerUid = peerUid;
+    m_currentPeerName = name;
+    m_chatTitle->setText(name);
+    m_chatStack->setCurrentWidget(m_chatBody);
+    applyNavSelection(0);
+    markPeerRead(peerUid);
+    if (auto *item = findConvItem(peerUid))
+    {
+        m_convList->blockSignals(true);
+        m_convList->setCurrentItem(item);
+        m_convList->blockSignals(false);
+    }
+    else
+    {
+        upsertConv(peerUid, name, QString(), QDateTime::currentMSecsSinceEpoch(), 0);
+        if (auto *item = findConvItem(peerUid))
+        {
+            m_convList->blockSignals(true);
+            m_convList->setCurrentItem(item);
+            m_convList->blockSignals(false);
+        }
+    }
+    const qint64 uiNs = clock.nsecsElapsed() - stashNs;
+    if (sameChat)
+    {
+        LOG_INFO(QStringLiteral("openChat peer=%1 same=1 ui=%2ms total=%3ms")
+                     .arg(QString::number(peerUid), fmtMs(uiNs), fmtMs(clock.nsecsElapsed())));
+        return;
+    }
+
+    const qint64 pageT0 = clock.nsecsElapsed();
+    const bool cached = activateChatPage(peerUid);
+    const qint64 pageNs = clock.nsecsElapsed() - pageT0;
+
+    QPointer<Client> self(this);
+    qint64 localNs = 0;
+    if (!cached)
+    {
+        const qint64 localT0 = clock.nsecsElapsed();
+        applyHistory(QJsonArray());
+        localNs = clock.nsecsElapsed() - localT0;
+    }
+    const int limit = AppConfig::instance().historyDefault();
+    QElapsedTimer net;
+    net.start();
+    ImClient::instance().msgHistory(peerUid, 0, limit, [self, peerUid, net](const QJsonObject &reply) {
+        const qint64 waitNs = net.nsecsElapsed();
+        if (!self || self->m_currentPeerUid != peerUid)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            LOG_INFO(QStringLiteral("openChat histReply peer=%1 wait=%2ms error")
+                         .arg(QString::number(peerUid), fmtMs(waitNs)));
+            return;
+        }
+        const QJsonArray list = reply.value(QStringLiteral("list")).toArray();
+        QElapsedTimer apply;
+        apply.start();
+        const bool hasAll = self->chatPageHasAll(list);
+        if (!hasAll)
+            self->applyHistory(list);
+        LOG_INFO(QStringLiteral("openChat histReply peer=%1 wait=%2ms n=%3 hasAll=%4 apply=%5ms")
+                     .arg(QString::number(peerUid), fmtMs(waitNs), QString::number(list.size()),
+                          hasAll ? QStringLiteral("1") : QStringLiteral("0"),
+                          fmtMs(apply.nsecsElapsed())));
+    });
+    LOG_INFO(QStringLiteral("openChat peer=%1 cached=%2 stash=%3ms ui=%4ms page=%5ms local=%6ms sync=%7ms")
+                 .arg(QString::number(peerUid),
+                      cached ? QStringLiteral("1") : QStringLiteral("0"),
+                      fmtMs(stashNs), fmtMs(uiNs), fmtMs(pageNs), fmtMs(localNs),
+                      fmtMs(clock.nsecsElapsed())));
+}
+
+void Client::clearBubbles()
+{
+    m_historyFp.clear();
+    m_historyPeerUid = 0;
+    exitMsgSelectMode();
+    if (!m_bubbleLay)
+        return;
+    QLayoutItem *tail = nullptr;
+    if (m_bubbleLay->count() > 0)
+        tail = m_bubbleLay->takeAt(m_bubbleLay->count() - 1);
+    QVector<QLayoutItem *> items;
+    items.reserve(m_bubbleLay->count());
+    while (m_bubbleLay->count() > 0)
+        items.push_back(m_bubbleLay->takeAt(0));
+    if (tail)
+        m_bubbleLay->addItem(tail);
+    for (QLayoutItem *it : items)
+    {
+        if (it->widget())
+            delete it->widget();
+        delete it;
+    }
+}
+
+void Client::enterMsgSelectMode(qint64 preselectId)
+{
+    if (!m_bubbleLay)
+        return;
+    m_msgSelectMode = true;
+    if (m_stickerPanel)
+        m_stickerPanel->hide();
+    if (m_inputWrap)
+        m_inputWrap->hide();
+    if (m_msgSelectBar)
+        m_msgSelectBar->show();
+    for (int i = 0; i < m_bubbleLay->count(); ++i)
+    {
+        QWidget *w = m_bubbleLay->itemAt(i)->widget();
+        if (!w || w->objectName() != QLatin1String("msgRow"))
+            continue;
+        w->setProperty("selecting", true);
+        if (auto *cb = w->findChild<QCheckBox *>(QStringLiteral("msgCheck")))
+        {
+            cb->show();
+            const qint64 id = w->property("msgId").toLongLong();
+            cb->setChecked(preselectId != 0 && id == preselectId);
+        }
+    }
+    updateMsgSelectCount();
+}
+
+void Client::exitMsgSelectMode()
+{
+    if (!m_msgSelectMode)
+        return;
+    m_msgSelectMode = false;
+    if (m_msgSelectBar)
+        m_msgSelectBar->hide();
+    if (m_inputWrap)
+        m_inputWrap->show();
+    if (!m_bubbleLay)
+        return;
+    for (int i = 0; i < m_bubbleLay->count(); ++i)
+    {
+        QWidget *w = m_bubbleLay->itemAt(i)->widget();
+        if (!w || w->objectName() != QLatin1String("msgRow"))
+            continue;
+        w->setProperty("selecting", false);
+        if (auto *cb = w->findChild<QCheckBox *>(QStringLiteral("msgCheck")))
+        {
+            cb->setChecked(false);
+            cb->hide();
+        }
+    }
+}
+
+void Client::updateMsgSelectCount()
+{
+    int n = 0;
+    if (m_bubbleLay)
+    {
+        for (int i = 0; i < m_bubbleLay->count(); ++i)
+        {
+            QWidget *w = m_bubbleLay->itemAt(i)->widget();
+            if (!w || w->objectName() != QLatin1String("msgRow"))
+                continue;
+            if (auto *cb = w->findChild<QCheckBox *>(QStringLiteral("msgCheck")))
+            {
+                if (cb->isChecked())
+                    ++n;
+            }
+        }
+    }
+    if (m_msgSelectHint)
+        m_msgSelectHint->setText(QStringLiteral("已选 %1 条").arg(n));
+}
+
+void Client::deleteChatMessages(const QVector<qint64> &ids)
+{
+    QVector<qint64> uniq;
+    for (qint64 id : ids)
+    {
+        if (id != 0 && !uniq.contains(id))
+            uniq.push_back(id);
+    }
+    if (uniq.isEmpty())
+        return;
+    const QString tip = uniq.size() == 1
+        ? QStringLiteral("删除后只从你的聊天记录中去掉,对方仍可看到")
+        : QStringLiteral("删除选中的 %1 条消息?仅从你这边去掉,对方仍可看到").arg(uniq.size());
+    if (!ConfirmDialog::ask(this, QStringLiteral("删除消息"), tip))
+        return;
+
+    QVector<qint64> remote;
+    QVector<qint64> localOnly;
+    for (qint64 id : uniq)
+    {
+        if (id > 0)
+            remote.push_back(id);
+        else
+            localOnly.push_back(id);
+    }
+
+    if (remote.isEmpty())
+    {
+        applyDeletedMessages(localOnly);
+        syncConvPreviewAfterDelete(m_currentPeerUid);
+        exitMsgSelectMode();
+        return;
+    }
+
+    QPointer<Client> self(this);
+    const qint64 peer = m_currentPeerUid;
+    ImClient::instance().deleteMsgs(peer, remote, [self, remote, localOnly, peer](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        QVector<qint64> all = remote;
+        for (qint64 id : localOnly)
+            all.push_back(id);
+        self->applyDeletedMessages(all);
+        const QString preview = reply.value(QStringLiteral("preview")).toString();
+        const qint64 ts = Proto::jsonI64(reply, QStringLiteral("ts"));
+        if (self->m_currentPeerUid == peer)
+        {
+            int unread = 0;
+            if (auto *item = self->findConvItem(peer))
+                unread = item->data(RoleUnread).toInt();
+            self->upsertConv(peer, self->m_currentPeerName, preview, ts, unread);
+        }
+        self->exitMsgSelectMode();
+    });
+}
+
+void Client::applyDeletedMessages(const QVector<qint64> &ids)
+{
+    QSet<qint64> want;
+    for (qint64 id : ids)
+    {
+        want.insert(id);
+        LocalStore::instance().removeMessage(id);
+    }
+    if (!m_bubbleLay)
+        return;
+    for (int i = m_bubbleLay->count() - 1; i >= 0; --i)
+    {
+        QWidget *w = m_bubbleLay->itemAt(i)->widget();
+        if (!w || w->objectName() != QLatin1String("msgRow"))
+            continue;
+        if (!want.contains(w->property("msgId").toLongLong()))
+            continue;
+        QLayoutItem *it = m_bubbleLay->takeAt(i);
+        if (it->widget())
+            it->widget()->deleteLater();
+        delete it;
+        if (i > 0)
+        {
+            QWidget *prev = m_bubbleLay->itemAt(i - 1) ? m_bubbleLay->itemAt(i - 1)->widget()
+                                                       : nullptr;
+            QWidget *next = (i < m_bubbleLay->count() && m_bubbleLay->itemAt(i))
+                                ? m_bubbleLay->itemAt(i)->widget()
+                                : nullptr;
+            auto *stamp = qobject_cast<QLabel *>(prev);
+            if (stamp && stamp->objectName() != QLatin1String("msgRow")
+                && !(next && next->objectName() == QLatin1String("msgRow")))
+            {
+                QLayoutItem *ps = m_bubbleLay->takeAt(i - 1);
+                if (ps->widget())
+                    ps->widget()->deleteLater();
+                delete ps;
+            }
+        }
+    }
+    m_historyFp.clear();
+    if (m_bubbleLay)
+    {
+        for (int i = 0; i < m_bubbleLay->count(); ++i)
+        {
+            QWidget *w = m_bubbleLay->itemAt(i)->widget();
+            if (!w || w->objectName() != QLatin1String("msgRow"))
+                continue;
+            const qint64 id = w->property("msgId").toLongLong();
+            if (id != 0)
+                m_historyFp += QString::number(id) + QLatin1Char(',');
+        }
+    }
+    m_historyPeerUid = m_currentPeerUid;
+    rememberChatPageState();
+}
+
+void Client::syncConvPreviewAfterDelete(qint64 peerUid)
+{
+    if (peerUid <= 0)
+        return;
+    const auto msgs = LocalStore::instance().listMessages(peerUid, 1);
+    QString preview;
+    qint64 ts = 0;
+    if (!msgs.isEmpty())
+    {
+        preview = previewOf(msgs.last().msgType, msgs.last().content);
+        ts = msgs.last().ts;
+    }
+    int unread = 0;
+    QString name = m_currentPeerName;
+    if (auto *item = findConvItem(peerUid))
+    {
+        unread = item->data(RoleUnread).toInt();
+        name = item->data(RoleName).toString();
+    }
+    upsertConv(peerUid, name, preview, ts, unread);
+}
+
+QWidget *Client::addBubble(bool mine, const QString &name, const QString &text, const QString &time,
+                           int msgType, int sendStatus, qint64 msgId, qint64 peerUid)
+{
+    if (!time.isEmpty())
+    {
+        auto *stamp = new QLabel(time, m_bubbleHost);
+        stamp->setAlignment(Qt::AlignCenter);
+        stamp->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:12px; background:transparent; padding:4px 0 8px 0;"));
+        if (m_buildingHistory)
+            m_bubbleLay->addWidget(stamp);
+        else
+            m_bubbleLay->insertWidget(m_bubbleLay->count() - 1, stamp);
+    }
+
+    auto *row = new QWidget(m_bubbleHost);
+    row->setProperty("sendStatus", sendStatus);
+    row->setProperty("msgId", QVariant::fromValue(msgId));
+    row->setProperty("mine", mine);
+    row->setObjectName(QStringLiteral("msgRow"));
+    row->setMinimumWidth(0);
+    row->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
+    auto *lay = new QHBoxLayout(row);
+    lay->setContentsMargins(0, 0, 0, 0);
+    lay->setSpacing(8);
+
+    auto *check = new QCheckBox(row);
+    check->setObjectName(QStringLiteral("msgCheck"));
+    check->setFixedSize(18, 18);
+    check->setCursor(Qt::PointingHandCursor);
+    check->setStyleSheet(QStringLiteral("QCheckBox { background:transparent; spacing:0; }"));
+    check->setVisible(m_msgSelectMode);
+    row->setProperty("selecting", m_msgSelectMode);
+    connect(check, &QCheckBox::toggled, this, [this]() { updateMsgSelectCount(); });
+
+    auto *avatar = new QLabel(row);
+    avatar->setFixedSize(36, 36);
+    const qint64 avatarUid = mine ? m_uid : m_currentPeerUid;
+    avatar->setPixmap(peerAvatar(avatarUid, name, 36));
+
+    auto *actionSlot = new QWidget(row);
+    actionSlot->setFixedSize(22, 22);
+    auto *sendMark = new QLabel(actionSlot);
+    sendMark->setObjectName(QStringLiteral("sendStatus"));
+    sendMark->setGeometry(3, 3, 16, 16);
+    sendMark->setAlignment(Qt::AlignCenter);
+    sendMark->hide();
+    sendMark->installEventFilter(this);
+
+    ClickWidget *bubble = nullptr;
+    FileBubble *fileBubble = nullptr;
+    if (msgType == im::MsgType::Sticker)
+    {
+        qint64 packId = 0;
+        QString code;
+        const QJsonObject o = QJsonDocument::fromJson(text.toUtf8()).object();
+        packId = Proto::jsonI64(o, QStringLiteral("pack_id"));
+        code = o.value(QStringLiteral("code")).toString();
+        auto *box = new ClickWidget(row);
+        box->setStyleSheet(QStringLiteral("background:transparent; border:none;"));
+        auto *img = new QLabel(box);
+        img->setFixedSize(72, 72);
+        img->setScaledContents(true);
+        const QPixmap pm = StickerCache::instance().pixmap(packId, code, 72);
+        if (pm.isNull())
+        {
+            img->setText(QStringLiteral("[表情]"));
+            img->setAlignment(Qt::AlignCenter);
+            img->setStyleSheet(QStringLiteral("color:#888; background:transparent;"));
+            StickerCache::instance().sync();
+        }
+        else
+        {
+            img->setPixmap(pm);
+        }
+        auto *stLay = new QVBoxLayout(box);
+        stLay->setContentsMargins(0, 0, 0, 0);
+        stLay->addWidget(img);
+        bubble = box;
+    }
+    else if (msgType == im::MsgType::File)
+    {
+        const QJsonObject o = QJsonDocument::fromJson(text.toUtf8()).object();
+        const QString fname = o.value(QStringLiteral("name")).toString(QStringLiteral("文件"));
+        const qint64 fsize = Proto::jsonI64(o, QStringLiteral("size"));
+        const qint64 fileId = Proto::jsonI64(o, QStringLiteral("file_id"));
+        const QString mime = o.value(QStringLiteral("mime")).toString();
+        const QString fileUrl = o.value(QStringLiteral("url")).toString();
+        if (FileCache::looksLikeImage(mime, fname))
+        {
+            auto *box = new ClickWidget(row);
+            box->setStyleSheet(QStringLiteral("background:transparent; border:none;"));
+            box->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
+            auto *img = new QLabel(box);
+            img->setAlignment(Qt::AlignCenter);
+            img->setStyleSheet(QStringLiteral(
+                "background:#F0F0F0; color:#888; border:none; border-radius:4px;"));
+            auto *layImg = new QVBoxLayout(box);
+            layImg->setContentsMargins(0, 0, 0, 0);
+            layImg->addWidget(img);
+            QPointer<QLabel> imgRef(img);
+            auto applyThumb = [this, imgRef](const QPixmap &got) {
+                if (!imgRef || got.isNull())
+                    return;
+                imgRef->setText(QString());
+                imgRef->setPixmap(got);
+                const qreal dpr = qMax(1.0, got.devicePixelRatio());
+                imgRef->setFixedSize(qMax(1, qRound(got.width() / dpr)),
+                                     qMax(1, qRound(got.height() / dpr)));
+                if (!m_buildingHistory && m_stickChatToBottom)
+                    scrollChatToBottom();
+            };
+            connect(&FileCache::instance(), &FileCache::ready, img,
+                    [imgRef, fileId, applyThumb](qint64 id) {
+                if (!imgRef || id != fileId)
+                    return;
+                applyThumb(FileCache::instance().thumbnail(fileId, 180));
+            });
+            const QPixmap pm = FileCache::instance().thumbnail(fileId, 180);
+            if (pm.isNull())
+            {
+                img->setFixedSize(120, 90);
+                img->setText(QStringLiteral("[图片]"));
+                FileCache::instance().ensure(fileId, fileUrl, mime, fname);
+            }
+            else
+            {
+                applyThumb(pm);
+            }
+            box->onClick = [this, fileId, fname, text]() { previewChatImage(fileId, fname, text); };
+            box->onForward = [this, text]() {
+                forwardPayload(im::MsgType::File, text, QStringLiteral("[图片]"));
+            };
+            box->onFavorite = [this, text]() {
+                favoritePayload(im::MsgType::File, text);
+            };
+            bubble = box;
+        }
+        else
+        {
+            fileBubble = new FileBubble(row, fileId, fname, fsize, fileUrl, mime, mine);
+            fileBubble->bindActionSlot(actionSlot, sendMark);
+            fileBubble->onForward = [this, text, fname]() {
+                forwardPayload(im::MsgType::File, text, QStringLiteral("[文件] %1").arg(fname));
+            };
+            fileBubble->onFavorite = [this, text]() {
+                favoritePayload(im::MsgType::File, text);
+            };
+            bubble = fileBubble;
+        }
+    }
+    else if (msgType == im::MsgType::Card)
+    {
+        const QJsonObject o = QJsonDocument::fromJson(text.toUtf8()).object();
+        const qint64 cardUid = Proto::jsonI64(o, QStringLiteral("uid"));
+        QString cardName = o.value(QStringLiteral("nickname")).toString();
+        const QString acc = o.value(QStringLiteral("account")).toString();
+        if (cardName.isEmpty())
+            cardName = acc;
+        const int ver = o.value(QStringLiteral("avatar_ver")).toInt();
+        auto *box = new ClickWidget(row);
+        box->setStyleSheet(QStringLiteral("background:#FFFFFF; border:none; border-radius:4px;"));
+        auto *h = new QHBoxLayout(box);
+        h->setContentsMargins(10, 8, 12, 8);
+        h->setSpacing(8);
+        auto *av = new QLabel(box);
+        av->setFixedSize(40, 40);
+        av->setPixmap(AvatarCache::instance().pixmap(cardUid, ver, cardName, 40));
+        auto *info = new QVBoxLayout();
+        info->setContentsMargins(0, 0, 0, 0);
+        info->setSpacing(2);
+        auto *n = new QLabel(cardName, box);
+        n->setStyleSheet(QStringLiteral("color:#191919; font-size:13px; background:transparent;"));
+        auto *a = new QLabel(QStringLiteral("账号  %1").arg(acc), box);
+        a->setStyleSheet(QStringLiteral("color:#888888; font-size:11px; background:transparent;"));
+        info->addWidget(n);
+        info->addWidget(a);
+        h->addWidget(av);
+        h->addLayout(info, 1);
+        box->setFixedWidth(220);
+        box->onClick = [this, cardUid]() {
+            if (cardUid > 0)
+                showProfile(cardUid);
+        };
+        box->onForward = [this, text, cardName]() {
+            forwardPayload(im::MsgType::Card, text, QStringLiteral("[名片] %1").arg(cardName));
+        };
+        box->onFavorite = [this, text]() {
+            favoritePayload(im::MsgType::Card, text);
+        };
+        bubble = box;
+    }
+    else
+    {
+        auto *box = new ClickWidget(row);
+        box->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
+        auto *lab = new BubbleText(text, box);
+        lab->installEventFilter(box);
+        auto *boxLay = new QVBoxLayout(box);
+        boxLay->setContentsMargins(10, 8, 10, 8);
+        boxLay->setSpacing(0);
+        boxLay->addWidget(lab);
+        applyTextBubbleWidth(lab, chatTextMaxWidth(mine));
+        box->setAttribute(Qt::WA_StyledBackground, true);
+        box->setStyleSheet(mine
+            ? QStringLiteral("background:#95EC69; border:none; border-radius:4px;")
+            : QStringLiteral("background:#FFFFFF; border:none; border-radius:4px;"));
+        box->setCursor(Qt::IBeamCursor);
+        box->onFavorite = [this, text]() { favoritePayload(im::MsgType::Text, text); };
+        box->onCopy = [lab, text]() {
+            QString s = lab->text();
+            if (s.isEmpty())
+                s = text;
+            QApplication::clipboard()->setText(s);
+        };
+        bubble = box;
+    }
+
+    if (bubble)
+    {
+        bubble->onDelete = [this, msgId]() { deleteChatMessages(QVector<qint64>() << msgId); };
+        bubble->onMultiSelect = [this, msgId]() {
+            if (m_msgSelectMode)
+                exitMsgSelectMode();
+            else
+                enterMsgSelectMode(msgId);
+        };
+    }
+
+    if (mine)
+    {
+        row->setProperty("outgoingId", QVariant::fromValue(msgId));
+        row->setProperty("outgoingPeer", QVariant::fromValue(peerUid > 0 ? peerUid : m_currentPeerUid));
+        row->setProperty("outgoingType", msgType);
+        row->setProperty("outgoingContent", text);
+        lay->addWidget(check, 0, Qt::AlignVCenter);
+        lay->addStretch();
+        lay->addWidget(actionSlot, 0, Qt::AlignVCenter);
+        lay->addWidget(bubble, 0, Qt::AlignTop);
+        lay->addWidget(avatar, 0, Qt::AlignTop);
+    }
+    else
+    {
+        lay->addWidget(check, 0, Qt::AlignVCenter);
+        lay->addWidget(avatar, 0, Qt::AlignTop);
+        lay->addWidget(bubble, 0, Qt::AlignTop);
+        if (fileBubble)
+            lay->addWidget(actionSlot, 0, Qt::AlignVCenter);
+        lay->addStretch();
+        if (!fileBubble)
+            actionSlot->hide();
+    }
+
+    if (m_buildingHistory)
+        m_bubbleLay->addWidget(row);
+    else
+        m_bubbleLay->insertWidget(m_bubbleLay->count() - 1, row);
+    row->installEventFilter(this);
+    if (bubble)
+    {
+        bubble->installEventFilter(this);
+        for (auto *child : bubble->findChildren<QWidget *>())
+            child->installEventFilter(this);
+    }
+    if (sendStatus == LocalMsg::Failed)
+        setBubbleSendState(row, true);
+    else if (sendStatus == LocalMsg::Sending)
+        setBubbleSendState(row, false, true);
+    if (!m_buildingHistory)
+    {
+        if (msgId != 0)
+        {
+            m_historyFp += QString::number(msgId) + QLatin1Char(',');
+            rememberChatPageState();
+        }
+        if (sendStatus == LocalMsg::Sending || m_stickChatToBottom)
+            scrollChatToBottom();
+    }
+    return row;
+}
+
+void Client::setBubbleSendState(QWidget *row, bool failed, bool sending)
+{
+    if (!row)
+        return;
+    auto *mark = row->findChild<QLabel *>(QStringLiteral("sendStatus"));
+    if (!mark)
+        return;
+    if (failed)
+    {
+        row->setProperty("sendStatus", LocalMsg::Failed);
+        mark->setText(QStringLiteral("!"));
+        mark->setCursor(Qt::PointingHandCursor);
+        mark->setStyleSheet(QStringLiteral(
+            "QLabel { color:#FFFFFF; background:#FA5151; border:none; border-radius:8px;"
+            " font-size:11px; font-weight:bold; }"));
+        mark->show();
+    }
+    else if (sending)
+    {
+        row->setProperty("sendStatus", LocalMsg::Sending);
+        mark->setText(QStringLiteral("…"));
+        mark->setCursor(Qt::ArrowCursor);
+        mark->setStyleSheet(QStringLiteral(
+            "QLabel { color:#888888; background:transparent; border:none; font-size:12px; }"));
+        mark->show();
+    }
+    else
+    {
+        row->setProperty("sendStatus", LocalMsg::Ok);
+        mark->setCursor(Qt::ArrowCursor);
+        mark->clear();
+        mark->hide();
+    }
+    if (QWidget *slot = mark->parentWidget())
+    {
+        if (!row->findChild<QWidget *>(QStringLiteral("fileBubble")))
+            slot->setVisible(failed || sending);
+    }
+    refreshRowFileAction(row);
+}
+
+QWidget *Client::findOutgoingRow(qint64 localId) const
+{
+    if (!m_bubbleLay || localId == 0)
+        return nullptr;
+    for (int i = 0; i < m_bubbleLay->count(); ++i)
+    {
+        QLayoutItem *it = m_bubbleLay->itemAt(i);
+        QWidget *w = it ? it->widget() : nullptr;
+        if (w && w->property("outgoingId").toLongLong() == localId)
+            return w;
+    }
+    return nullptr;
+}
+
+QWidget *Client::findMsgRow(qint64 msgId) const
+{
+    if (!m_bubbleLay || msgId == 0)
+        return nullptr;
+    for (int i = 0; i < m_bubbleLay->count(); ++i)
+    {
+        QLayoutItem *it = m_bubbleLay->itemAt(i);
+        QWidget *w = it ? it->widget() : nullptr;
+        if (w && w->objectName() == QLatin1String("msgRow")
+            && w->property("msgId").toLongLong() == msgId)
+            return w;
+    }
+    return nullptr;
+}
+
+int Client::chatTextMaxWidth(bool mine) const
+{
+    Q_UNUSED(mine);
+    int viewW = 480;
+    if (m_scroll && m_scroll->viewport())
+    {
+        viewW = m_scroll->viewport()->width();
+        QScrollBar *bar = m_scroll->verticalScrollBar();
+        const int sbw = m_scroll->style()->pixelMetric(QStyle::PM_ScrollBarExtent, nullptr, bar);
+        if (bar && !bar->isVisible())
+            viewW -= sbw;
+    }
+    const int opposite = 120;
+    const int avatarAndGap = 36 + 8;
+    const int hostPad = 32;
+    return qMax(120, viewW - opposite - avatarAndGap - hostPad);
+}
+
+void Client::relayoutTextBubbles()
+{
+    if (!m_bubbleLay)
+        return;
+    int viewW = 0;
+    if (m_scroll && m_scroll->viewport())
+        viewW = m_scroll->viewport()->width();
+    m_lastTextBubbleMaxW = viewW;
+    for (int i = 0; i < m_bubbleLay->count(); ++i)
+    {
+        QWidget *w = m_bubbleLay->itemAt(i)->widget();
+        if (!w || w->objectName() != QLatin1String("msgRow"))
+            continue;
+        if (auto *lab = w->findChild<BubbleText *>(QStringLiteral("chatText")))
+            applyTextBubbleWidth(lab, chatTextMaxWidth(w->property("mine").toBool()));
+    }
+}
+
+void Client::scrollChatToBottom()
+{
+    m_stickChatToBottom = true;
+    if (!m_scroll)
+        return;
+    QScrollBar *bar = m_scroll->verticalScrollBar();
+    bar->setValue(bar->maximum());
+    QTimer::singleShot(0, this, [this]() {
+        if (!m_scroll || !m_stickChatToBottom)
+            return;
+        QScrollBar *bar = m_scroll->verticalScrollBar();
+        bar->setValue(bar->maximum());
+    });
+}
+

+ 728 - 0
Client/src/mainframe/ClientConv.cpp

@@ -0,0 +1,728 @@
+// ClientConv.cpp — 会话/好友列表、资料页、本地会话存储
+#include "ClientImpl.h"
+
+QListWidgetItem *Client::findConvItem(qint64 peerUid) const
+{
+    for (int i = 0; i < m_convList->count(); ++i)
+    {
+        auto *item = m_convList->item(i);
+        if (item->data(RolePeerUid).toLongLong() == peerUid)
+            return item;
+    }
+    return nullptr;
+}
+
+QListWidgetItem *Client::findFriendItem(qint64 uid) const
+{
+    if (!m_friendList)
+        return nullptr;
+    for (int i = 0; i < m_friendList->count(); ++i)
+    {
+        auto *item = m_friendList->item(i);
+        if (item && item->data(RolePeerUid).toLongLong() == uid)
+            return item;
+    }
+    return nullptr;
+}
+
+void Client::refreshConvList()
+{
+    QPointer<Client> self(this);
+    ImClient::instance().convList([self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+            return;
+        self->applyConvList(reply.value(QStringLiteral("list")).toArray());
+    });
+}
+
+void Client::refreshFriendList()
+{
+    QPointer<Client> self(this);
+    ImClient::instance().friendList([self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+            return;
+        self->setPendingCount(reply.value(QStringLiteral("pending")).toInt());
+        self->applyFriendList(reply.value(QStringLiteral("list")).toArray());
+    });
+}
+
+void Client::applyConvList(const QJsonArray &list)
+{
+    const qint64 keep = m_currentPeerUid;
+    m_convList->clear();
+    for (const QJsonValue &v : list)
+    {
+        const QJsonObject o = v.toObject();
+        Conv c;
+        c.uid = Proto::jsonI64(o, QStringLiteral("peer_uid"));
+        c.name = o.value(QStringLiteral("nickname")).toString();
+        if (c.name.isEmpty())
+            c.name = o.value(QStringLiteral("account")).toString();
+        c.preview = o.value(QStringLiteral("preview")).toString();
+        c.time = formatTs(Proto::jsonI64(o, QStringLiteral("ts")));
+        c.unread = o.value(QStringLiteral("unread")).toInt();
+        c.avatarVer = o.value(QStringLiteral("avatar_ver")).toInt();
+        c.online = o.value(QStringLiteral("online")).toInt() == 1;
+        auto *item = new QListWidgetItem(m_convList);
+        item->setData(RolePeerUid, c.uid);
+        item->setData(RoleName, c.name);
+        item->setData(RoleUnread, c.unread);
+        item->setData(RolePreview, c.preview);
+        item->setData(RoleTs, Proto::jsonI64(o, QStringLiteral("ts")));
+        item->setData(RoleAvatarVer, c.avatarVer);
+        item->setData(RoleOnline, c.online ? 1 : 0);
+        item->setSizeHint(QSize(10, 68));
+        m_convList->setItemWidget(item, makeConvRow(c, m_convList));
+        persistLocalConv(c.uid, c.name, c.preview, Proto::jsonI64(o, QStringLiteral("ts")), c.unread);
+    }
+    applyListFilter();
+    if (keep > 0)
+    {
+        if (auto *item = findConvItem(keep))
+        {
+            m_convList->blockSignals(true);
+            m_convList->setCurrentItem(item);
+            m_convList->blockSignals(false);
+        }
+    }
+    updateTray();
+}
+
+void Client::applyFriendList(const QJsonArray &list)
+{
+    m_friendList->clear();
+    for (const QJsonValue &v : list)
+    {
+        const QJsonObject o = v.toObject();
+        const qint64 uid = Proto::jsonI64(o, QStringLiteral("uid"));
+        const QString account = o.value(QStringLiteral("account")).toString();
+        const QString nick = o.value(QStringLiteral("nickname")).toString();
+        const QString remark = o.value(QStringLiteral("remark")).toString();
+        QString name = remark.isEmpty() ? nick : remark;
+        if (name.isEmpty())
+            name = account;
+        const bool online = o.value(QStringLiteral("online")).toInt() == 1;
+        const int avatarVer = o.value(QStringLiteral("avatar_ver")).toInt();
+        auto *item = new QListWidgetItem(m_friendList);
+        item->setData(RolePeerUid, uid);
+        item->setData(RoleName, name);
+        item->setData(RoleNick, nick);
+        item->setData(RoleAccount, account);
+        item->setData(RoleSignature, o.value(QStringLiteral("signature")).toString());
+        item->setData(RoleRemark, remark);
+        item->setData(RoleAvatarVer, avatarVer);
+        item->setData(RoleOnline, online ? 1 : 0);
+        item->setSizeHint(QSize(10, 48));
+        m_friendList->setItemWidget(item, makeFriendRow(name, online, uid, avatarVer, m_friendList));
+        if (auto *conv = findConvItem(uid))
+        {
+            const int oldVer = conv->data(RoleAvatarVer).toInt();
+            const bool oldOnline = conv->data(RoleOnline).toInt() == 1;
+            const int ver = qMax(oldVer, avatarVer);
+            conv->setData(RoleAvatarVer, ver);
+            conv->setData(RoleOnline, online ? 1 : 0);
+            if (ver != oldVer || oldOnline != online)
+            {
+                Conv c = convFromItem(conv);
+                c.avatarVer = ver;
+                c.online = online;
+                if (QWidget *old = m_convList->itemWidget(conv))
+                    old->deleteLater();
+                m_convList->setItemWidget(conv, makeConvRow(c, m_convList));
+            }
+        }
+    }
+    applyListFilter();
+}
+
+void Client::upsertConv(qint64 peerUid, const QString &name, const QString &preview,
+                        qint64 ts, int unread)
+{
+    Conv c;
+    c.uid = peerUid;
+    c.name = name;
+    c.preview = preview;
+    c.time = formatTs(ts);
+    c.unread = unread;
+    if (auto *exist = findConvItem(peerUid))
+    {
+        c.avatarVer = exist->data(RoleAvatarVer).toInt();
+        c.online = exist->data(RoleOnline).toInt() == 1;
+    }
+    if (c.avatarVer <= 0)
+        c.avatarVer = avatarVerOf(peerUid);
+    if (!c.online)
+        c.online = listOnlineOf(m_friendList, peerUid);
+    const qint64 keepUid = m_currentPeerUid;
+    m_convList->blockSignals(true);
+    if (auto *item = findConvItem(peerUid))
+    {
+        item->setData(RoleName, name);
+        item->setData(RoleUnread, unread);
+        item->setData(RolePreview, preview);
+        item->setData(RoleTs, ts);
+        item->setData(RoleAvatarVer, c.avatarVer);
+        item->setData(RoleOnline, c.online ? 1 : 0);
+        const int row = m_convList->row(item);
+        if (row > 0)
+        {
+            m_convList->takeItem(row);
+            m_convList->insertItem(0, item);
+        }
+        if (QWidget *old = m_convList->itemWidget(item))
+            old->deleteLater();
+        m_convList->setItemWidget(item, makeConvRow(c, m_convList));
+    }
+    else
+    {
+        auto *created = new QListWidgetItem;
+        created->setData(RolePeerUid, peerUid);
+        created->setData(RoleName, name);
+        created->setData(RoleUnread, unread);
+        created->setData(RolePreview, preview);
+        created->setData(RoleTs, ts);
+        created->setData(RoleAvatarVer, c.avatarVer);
+        created->setData(RoleOnline, c.online ? 1 : 0);
+        created->setSizeHint(QSize(10, 68));
+        m_convList->insertItem(0, created);
+        m_convList->setItemWidget(created, makeConvRow(c, m_convList));
+    }
+    if (keepUid > 0)
+    {
+        if (auto *keep = findConvItem(keepUid))
+            m_convList->setCurrentItem(keep);
+    }
+    m_convList->blockSignals(false);
+    persistLocalConv(peerUid, name, preview, ts, unread);
+    applyListFilter();
+    updateTray();
+}
+
+void Client::selectConversation(int row)
+{
+    if (row < 0)
+        return;
+    auto *item = m_convList->item(row);
+    if (!item)
+        return;
+    openChat(item->data(RolePeerUid).toLongLong(), item->data(RoleName).toString());
+}
+
+void Client::applyUnread(qint64 peerUid, int unread)
+{
+    auto *item = findConvItem(peerUid);
+    if (!item)
+        return;
+    item->setData(RoleUnread, unread);
+    Conv c = convFromItem(item);
+    c.unread = unread;
+    if (QWidget *old = m_convList->itemWidget(item))
+        old->deleteLater();
+    m_convList->setItemWidget(item, makeConvRow(c, m_convList));
+    LocalStore::instance().setUnread(peerUid, unread);
+    updateTray();
+}
+
+void Client::onConvMenu(const QPoint &pos)
+{
+    auto *item = m_convList->itemAt(pos);
+    if (!item)
+        return;
+    const qint64 uid = item->data(RolePeerUid).toLongLong();
+    const QString name = item->data(RoleName).toString();
+    const int unread = item->data(RoleUnread).toInt();
+    QMenu menu(this);
+    QAction *del = menu.addAction(QStringLiteral("删除"));
+    QAction *read = menu.addAction(QStringLiteral("标记已读"));
+    QAction *unreadAct = menu.addAction(QStringLiteral("标记未读"));
+    read->setEnabled(unread > 0);
+    unreadAct->setEnabled(unread <= 0);
+    QAction *picked = menu.exec(m_convList->viewport()->mapToGlobal(pos));
+    if (!picked)
+        return;
+    QPointer<Client> self(this);
+    if (picked == del)
+    {
+        if (!ConfirmDialog::ask(this, QStringLiteral("删除聊天"),
+                                QStringLiteral("删除后只清空你这边与「%1」的聊天记录,对方仍可看到")
+                                    .arg(name)))
+            return;
+        ImClient::instance().deleteConv(uid, [self, uid](const QJsonObject &reply) {
+            if (!self)
+                return;
+            if (!ImClient::ok(reply))
+            {
+                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                return;
+            }
+            self->removeLocalConv(uid);
+        });
+        return;
+    }
+    const int next = (picked == read) ? 0 : 1;
+    ImClient::instance().setUnread(uid, next, [self, uid, next](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        self->applyUnread(uid, next);
+    });
+}
+
+void Client::onFriendMenu(const QPoint &pos)
+{
+    auto *item = m_friendList->itemAt(pos);
+    if (!item)
+        return;
+    const qint64 uid = item->data(RolePeerUid).toLongLong();
+    const QString name = item->data(RoleName).toString();
+    const QString remark = item->data(RoleRemark).toString();
+    QMenu menu(this);
+    QAction *del = nullptr;
+    QAction *mark = nullptr;
+    if (uid != im::kFileHelperUid)
+    {
+        del = menu.addAction(QStringLiteral("删除"));
+        mark = menu.addAction(QStringLiteral("备注"));
+    }
+    QAction *card = menu.addAction(QStringLiteral("查看名片"));
+    QAction *picked = menu.exec(m_friendList->viewport()->mapToGlobal(pos));
+    if (!picked)
+        return;
+    if (picked == card)
+    {
+        showProfile(uid);
+        return;
+    }
+    QPointer<Client> self(this);
+    if (picked == mark)
+    {
+        QString next = remark;
+        if (!PromptDialog::ask(this, QStringLiteral("设置备注"),
+                               QStringLiteral("不填则显示昵称"), &next, 32))
+            return;
+        ImClient::instance().setRemark(uid, next, [self, uid, next](const QJsonObject &reply) {
+            if (!self)
+                return;
+            if (!ImClient::ok(reply))
+            {
+                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                return;
+            }
+            const QString shown = next.isEmpty()
+                ? reply.value(QStringLiteral("nickname")).toString()
+                : next;
+            self->refreshFriendList();
+            if (auto *it = self->findConvItem(uid))
+            {
+                const qint64 ts = it->data(RoleTs).toLongLong();
+                self->upsertConv(uid, shown.isEmpty() ? it->data(RoleName).toString() : shown,
+                                 it->data(RolePreview).toString(), ts,
+                                 it->data(RoleUnread).toInt());
+            }
+            if (self->m_currentPeerUid == uid && !shown.isEmpty())
+            {
+                self->m_currentPeerName = shown;
+                if (self->m_chatTitle)
+                    self->m_chatTitle->setText(shown);
+            }
+        });
+        return;
+    }
+    if (picked == del)
+    {
+        if (!ConfirmDialog::ask(
+                this, QStringLiteral("删除好友"),
+                QStringLiteral("将好友「%1」删除,并清空你这边的聊天记录?对方记录不受影响。")
+                    .arg(name)))
+            return;
+        ImClient::instance().deleteFriend(uid, [self, uid](const QJsonObject &reply) {
+            if (!self)
+                return;
+            if (!ImClient::ok(reply))
+            {
+                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                return;
+            }
+            self->removeLocalConv(uid);
+            self->refreshFriendList();
+        });
+    }
+}
+
+void Client::showProfile(qint64 uid)
+{
+    QPointer<Client> self(this);
+    ImClient::instance().getProfile(uid, [self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        const qint64 peer = Proto::jsonI64(reply, QStringLiteral("uid"));
+        QString nick = reply.value(QStringLiteral("nickname")).toString().trimmed();
+        const QString account = reply.value(QStringLiteral("account")).toString();
+        const QString remark = reply.value(QStringLiteral("remark")).toString().trimmed();
+        const QString sign = reply.value(QStringLiteral("signature")).toString().trimmed();
+        if (nick.isEmpty())
+            nick = account;
+        const QString shown = remark.isEmpty() ? nick : remark;
+
+        QDialog card(self);
+        card.setObjectName(QStringLiteral("ChromeWindow"));
+        card.setWindowTitle(QStringLiteral("名片"));
+        card.setFixedSize(360, 400);
+        UiHelpers::setupFramelessDialog(&card);
+
+        auto *root = new QVBoxLayout(&card);
+        root->setContentsMargins(0, 0, 0, 0);
+        root->setSpacing(0);
+        auto *bar = new TitleBar(&card);
+        bar->setTitle(QStringLiteral("名片"));
+        bar->setTheme(TitleBar::Light);
+        bar->setMaximizable(false);
+        bar->bind(&card, false);
+        if (peer != im::kFileHelperUid)
+            bar->setActionText(QStringLiteral("分享"));
+        root->addWidget(bar);
+
+        auto *page = new QWidget(&card);
+        auto *lay = new QVBoxLayout(page);
+        lay->setContentsMargins(28, 20, 28, 22);
+        lay->setSpacing(8);
+        auto *avatar = new QLabel(page);
+        avatar->setFixedSize(64, 64);
+        avatar->setPixmap(AvatarCache::instance().pixmap(
+            peer, reply.value(QStringLiteral("avatar_ver")).toInt(), shown, 64));
+        auto *nameLab = new QLabel(shown, page);
+        nameLab->setAlignment(Qt::AlignCenter);
+        nameLab->setStyleSheet(QStringLiteral("font-size:16px; color:#1A1A1A; background:transparent; border:none;"));
+        const QString meta = QStringLiteral("font-size:13px; color:#6A6A6A; background:transparent; border:none;");
+        auto *remarkLab = new QLabel(QStringLiteral("备注  %1").arg(remark.isEmpty() ? QStringLiteral("未设置") : remark), page);
+        remarkLab->setAlignment(Qt::AlignCenter);
+        remarkLab->setStyleSheet(meta);
+        auto *nickLab = new QLabel(QStringLiteral("昵称  %1").arg(nick), page);
+        nickLab->setAlignment(Qt::AlignCenter);
+        nickLab->setStyleSheet(meta);
+        auto *accLab = new QLabel(QStringLiteral("账号  %1").arg(account), page);
+        accLab->setAlignment(Qt::AlignCenter);
+        accLab->setStyleSheet(meta);
+        auto *signLab = new QLabel(sign.isEmpty() ? QStringLiteral("这个人很懒,什么都没写") : sign, page);
+        signLab->setAlignment(Qt::AlignCenter);
+        signLab->setWordWrap(true);
+        signLab->setStyleSheet(QStringLiteral("font-size:13px; color:#8A8A8A; background:transparent; border:none;"));
+        auto *chatBtn = UiHelpers::compactButton(QStringLiteral("发消息"), page, true);
+        const int rel = reply.value(QStringLiteral("relation")).toInt();
+        const bool canChat = peer != self->m_uid
+            && (peer == im::kFileHelperUid || rel == im::FriendRel::Friend);
+        chatBtn->setVisible(canChat);
+        lay->addWidget(avatar, 0, Qt::AlignHCenter);
+        lay->addWidget(nameLab);
+        lay->addWidget(remarkLab);
+        lay->addWidget(nickLab);
+        lay->addWidget(accLab);
+        lay->addWidget(signLab);
+        lay->addStretch();
+        lay->addWidget(chatBtn, 0, Qt::AlignHCenter);
+        root->addWidget(page, 1);
+        QObject::connect(chatBtn, &QPushButton::clicked, &card, [&card, self, peer, shown]() {
+            card.accept();
+            if (self)
+                self->openChat(peer, shown);
+        });
+        QObject::connect(bar, &TitleBar::actionClicked, &card, [self, reply, &card]() {
+            // 先关掉名片,避免在已有 exec() 里再套一层转发框(Windows 上经常不显示)。
+            card.accept();
+            if (self)
+                self->shareUserCard(reply);
+        });
+        card.exec();
+    });
+}
+
+void Client::showFriendCard(qint64 uid)
+{
+    if (uid <= 0)
+        return;
+    QPointer<Client> self(this);
+    ImClient::instance().getProfile(uid, [self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        self->fillProfilePage(reply);
+        if (self->m_chatStack && self->m_profilePage)
+            self->m_chatStack->setCurrentWidget(self->m_profilePage);
+    });
+}
+
+void Client::fillProfilePage(const QJsonObject &user)
+{
+    m_profileUser = user;
+    m_profileUid = Proto::jsonI64(user, QStringLiteral("uid"));
+    m_profileAvatarVer = user.value(QStringLiteral("avatar_ver")).toInt();
+    QString nick = user.value(QStringLiteral("nickname")).toString().trimmed();
+    const QString account = user.value(QStringLiteral("account")).toString();
+    const QString remark = user.value(QStringLiteral("remark")).toString().trimmed();
+    const QString sign = user.value(QStringLiteral("signature")).toString().trimmed();
+    if (nick.isEmpty())
+        nick = account;
+    m_profileDisplayName = remark.isEmpty() ? nick : remark;
+    if (m_profileAvatar)
+        m_profileAvatar->setPixmap(peerAvatar(m_profileUid, m_profileDisplayName, 72));
+    if (m_profileTitle)
+        m_profileTitle->setText(m_profileDisplayName);
+    if (m_profileRemark)
+        m_profileRemark->setText(QStringLiteral("备注  %1").arg(remark.isEmpty() ? QStringLiteral("未设置") : remark));
+    if (m_profileNick)
+        m_profileNick->setText(QStringLiteral("昵称  %1").arg(nick));
+    if (m_profileAccount)
+        m_profileAccount->setText(QStringLiteral("账号  %1").arg(account));
+    if (m_profileSign)
+        m_profileSign->setText(sign.isEmpty() ? QStringLiteral("这个人很懒,什么都没写") : sign);
+    const int rel = user.value(QStringLiteral("relation")).toInt();
+    const bool canChat = m_profileUid != m_uid
+        && (m_profileUid == im::kFileHelperUid || rel == im::FriendRel::Friend);
+    if (m_profileChatBtn)
+        m_profileChatBtn->setVisible(canChat);
+    if (m_profileShareBtn)
+        m_profileShareBtn->setVisible(m_profileUid > 0 && m_profileUid != im::kFileHelperUid);
+}
+
+void Client::removeLocalConv(qint64 uid)
+{
+    if (m_currentPeerUid == uid)
+    {
+        m_currentPeerUid = 0;
+        m_currentPeerName.clear();
+        dropChatPage(uid);
+        if (m_chatStack && m_chatEmpty)
+            m_chatStack->setCurrentWidget(m_chatEmpty);
+    }
+    else
+    {
+        dropChatPage(uid);
+    }
+    if (m_profileUid == uid)
+    {
+        m_profileUid = 0;
+        if (m_chatStack && m_chatStack->currentWidget() == m_profilePage)
+        {
+            if (m_currentPeerUid > 0 && m_chatBody)
+                m_chatStack->setCurrentWidget(m_chatBody);
+            else if (m_chatEmpty)
+                m_chatStack->setCurrentWidget(m_chatEmpty);
+        }
+    }
+    if (auto *it = findConvItem(uid))
+        delete it;
+    LocalStore::instance().deletePeer(uid);
+    applyListFilter();
+    updateTray();
+}
+
+void Client::markPeerRead(qint64 peerUid)
+{
+    if (peerUid <= 0)
+        return;
+    auto *item = findConvItem(peerUid);
+    if (!item || item->data(RoleUnread).toInt() != 0)
+        applyUnread(peerUid, 0);
+    ImClient::instance().setUnread(peerUid, 0, {});
+}
+
+void Client::saveLocalMessage(qint64 msgId, qint64 peerUid, qint64 fromUid, qint64 toUid,
+                              int msgType, const QString &content, qint64 ts, int status)
+{
+    if (msgId == 0 || peerUid <= 0)
+        return;
+    LocalStore::Msg m;
+    m.id = msgId;
+    m.peerUid = peerUid;
+    m.fromUid = fromUid;
+    m.toUid = toUid > 0 ? toUid : ((fromUid == m_uid) ? peerUid : m_uid);
+    m.msgType = msgType;
+    m.content = content;
+    m.ts = ts;
+    m.status = status;
+    LocalStore::instance().upsertMessage(m);
+}
+
+void Client::persistLocalConv(qint64 peerUid, const QString &name, const QString &preview,
+                              qint64 ts, int unread)
+{
+    LocalStore::Conv c;
+    c.peerUid = peerUid;
+    c.name = name;
+    c.preview = preview;
+    c.ts = ts;
+    c.unread = unread;
+    LocalStore::instance().upsertConversation(c);
+}
+
+void Client::loadLocalConvs()
+{
+    const auto rows = LocalStore::instance().listConversations();
+    if (rows.isEmpty())
+        return;
+    QJsonArray arr;
+    for (const auto &c : rows)
+    {
+        QJsonObject o;
+        o.insert(QStringLiteral("peer_uid"), c.peerUid);
+        o.insert(QStringLiteral("nickname"), c.name);
+        o.insert(QStringLiteral("preview"), c.preview);
+        o.insert(QStringLiteral("ts"), c.ts);
+        o.insert(QStringLiteral("unread"), c.unread);
+        arr.append(o);
+    }
+    applyConvList(arr);
+}
+
+bool Client::isViewingPeer(qint64 peerUid) const
+{
+    return peerUid > 0 && peerUid == m_currentPeerUid
+        && m_chatStack && m_chatStack->currentWidget() == m_chatBody;
+}
+
+void Client::applyListFilter()
+{
+    const QString key = m_search ? m_search->text().trimmed() : QString();
+    const bool friendsTab = m_sideStack && m_sideStack->currentIndex() == 1;
+    const bool favTab = m_sideStack && m_sideStack->currentIndex() == 2;
+
+    auto applyOne = [&](QListWidget *list, QStackedWidget *stack, QLabel *empty, bool useKey,
+                        const QString &miss, const QString &none) {
+        if (!list || !stack || !empty)
+            return;
+        int visible = 0;
+        for (int i = 0; i < list->count(); ++i)
+        {
+            auto *item = list->item(i);
+            const bool hide = useKey && !key.isEmpty() &&
+                !item->data(RoleName).toString().contains(key, Qt::CaseInsensitive);
+            item->setHidden(hide);
+            if (!hide)
+                ++visible;
+        }
+        if (visible == 0)
+        {
+            empty->setText((useKey && !key.isEmpty()) ? miss : none);
+            stack->setCurrentWidget(empty);
+        }
+        else
+        {
+            stack->setCurrentWidget(list);
+        }
+    };
+
+    applyOne(m_convList, m_convStack, m_convEmpty, !friendsTab && !favTab,
+             QStringLiteral("未找到相关会话"), QStringLiteral("暂无消息"));
+    applyOne(m_friendList, m_friendStack, m_friendEmpty, friendsTab,
+             QStringLiteral("未找到相关好友"), QStringLiteral("暂无好友,点击右上角 + 添加"));
+    if (m_favPanel)
+        m_favPanel->applyFilter(favTab ? key : QString());
+}
+
+void Client::openAddFriend()
+{
+    AddFriendDialog dlg(this);
+    dlg.exec();
+    refreshFriendList();
+}
+
+void Client::openFriendRequests()
+{
+    FriendRequestDialog dlg(this);
+    connect(&dlg, &FriendRequestDialog::acceptedFriend, this, [this]() { refreshFriendList(); });
+    dlg.exec();
+    refreshFriendList();
+}
+
+void Client::setPendingCount(int n)
+{
+    m_pendingFriends = n;
+    if (!m_pendingBadge)
+        return;
+    if (n <= 0)
+    {
+        m_pendingBadge->hide();
+        updateTray();
+        return;
+    }
+    m_pendingBadge->setText(n > 99 ? QStringLiteral("99") : QString::number(n));
+    m_pendingBadge->setFixedSize(18, 18);
+    m_pendingBadge->show();
+    updateTray();
+}
+
+void Client::onFriendRequestNotify(const QJsonObject &msg)
+{
+    QString name = msg.value(QStringLiteral("nickname")).toString();
+    if (name.isEmpty())
+        name = msg.value(QStringLiteral("account")).toString();
+    UiHelpers::showToast(this, QStringLiteral("%1 请求添加你为好友").arg(name));
+    refreshFriendList();
+}
+
+void Client::onFriendAcceptedNotify(const QJsonObject &msg)
+{
+    QString name = msg.value(QStringLiteral("nickname")).toString();
+    if (name.isEmpty())
+        name = msg.value(QStringLiteral("account")).toString();
+    UiHelpers::showToast(this, QStringLiteral("%1 已同意你的好友申请").arg(name));
+    refreshFriendList();
+}
+
+void Client::onPresenceNotify(const QJsonObject &msg)
+{
+    const qint64 uid = Proto::jsonI64(msg, QStringLiteral("uid"));
+    const bool online = msg.value(QStringLiteral("online")).toInt() == 1
+                        || Proto::jsonI64(msg, QStringLiteral("online")) == 1;
+    applyPresence(uid, online);
+}
+
+void Client::applyPresence(qint64 uid, bool online)
+{
+    if (uid <= 0 || uid == m_uid)
+        return;
+    if (auto *item = findFriendItem(uid))
+    {
+        const bool old = item->data(RoleOnline).toInt() == 1;
+        if (old != online)
+        {
+            item->setData(RoleOnline, online ? 1 : 0);
+            if (QWidget *oldw = m_friendList->itemWidget(item))
+                oldw->deleteLater();
+            m_friendList->setItemWidget(
+                item, makeFriendRow(item->data(RoleName).toString(), online, uid,
+                                    item->data(RoleAvatarVer).toInt(), m_friendList));
+        }
+    }
+    if (auto *conv = findConvItem(uid))
+    {
+        const bool old = conv->data(RoleOnline).toInt() == 1;
+        if (old != online)
+        {
+            conv->setData(RoleOnline, online ? 1 : 0);
+            Conv c = convFromItem(conv);
+            c.online = online;
+            if (QWidget *oldw = m_convList->itemWidget(conv))
+                oldw->deleteLater();
+            m_convList->setItemWidget(conv, makeConvRow(c, m_convList));
+        }
+    }
+}
+

+ 107 - 0
Client/src/mainframe/ClientImpl.h

@@ -0,0 +1,107 @@
+#pragma once
+
+#include "Client.h"
+#include "ClientInternal.h"
+
+#include "AddFriendDialog.h"
+#include "FriendRequestDialog.h"
+#include "ChangePasswordDialog.h"
+#include "SettingsDialog.h"
+#include "FavoritePanel.h"
+#include "PickFriendDialog.h"
+#include "StickerCache.h"
+#include "AvatarCache.h"
+#include "FileCache.h"
+#include "FileTypeIcon.h"
+#include "ImagePreviewDialog.h"
+#include "StickerPanel.h"
+#include "UiHelpers.h"
+#include "AppTheme.h"
+#include "AppConfig.h"
+#include "TitleBar.h"
+#include "FramelessHelper.h"
+#include "ConfirmDialog.h"
+#include "PromptDialog.h"
+#include "UserSettings.h"
+#include "LocalStore.h"
+#include "ImClient.h"
+#include "Protocol.h"
+#include "Updater.h"
+#include "Logger.h"
+
+#include <algorithm>
+#include <functional>
+
+#include <QAbstractItemView>
+#include <QAction>
+#include <QApplication>
+#include <QCheckBox>
+#include <QClipboard>
+#include <QCloseEvent>
+#include <QCursor>
+#include <QDate>
+#include <QDateTime>
+#include <QDesktopServices>
+#include <QDialog>
+#include <QElapsedTimer>
+#include <QEvent>
+#include <QFile>
+#include <QFileDialog>
+#include <QFileInfo>
+#include <QFontMetrics>
+#include <QFrame>
+#include <QGridLayout>
+#include <QHBoxLayout>
+#include <QIcon>
+#include <QImage>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QKeyEvent>
+#include <QLabel>
+#include <QLayout>
+#include <QLayoutItem>
+#include <QLineEdit>
+#include <QListView>
+#include <QListWidget>
+#include <QListWidgetItem>
+#include <QMap>
+#include <QMenu>
+#include <QMenuBar>
+#include <QMimeDatabase>
+#include <QMouseEvent>
+#include <QObject>
+#include <QPainter>
+#include <QPainterPath>
+#include <QPalette>
+#include <QPen>
+#include <QPointer>
+#include <QProgressBar>
+#include <QPushButton>
+#include <QRect>
+#include <QResizeEvent>
+#include <QScrollArea>
+#include <QScrollBar>
+#include <QShowEvent>
+#include <QSizePolicy>
+#include <QSplitter>
+#include <QStackedWidget>
+#include <QStandardPaths>
+#include <QStatusBar>
+#include <QStyle>
+#include <QSystemTrayIcon>
+#include <QTextEdit>
+#include <QTimer>
+#include <QUrl>
+#include <QVariant>
+#include <QVBoxLayout>
+#include <QtMath>
+
+#ifdef Q_OS_WIN
+#ifndef WIN32_LEAN_AND_MEAN
+#define WIN32_LEAN_AND_MEAN
+#endif
+#include <windows.h>
+#endif
+
+using namespace ClientInternal;

+ 1026 - 0
Client/src/mainframe/ClientInternal.h

@@ -0,0 +1,1026 @@
+#pragma once
+
+// Shared widgets/helpers used by Client*.cpp. Not part of the public Client API.
+
+#include "AvatarCache.h"
+#include "FileCache.h"
+#include "FileTypeIcon.h"
+#include "LocalStore.h"
+#include "UiHelpers.h"
+
+#include <functional>
+
+#include <QAction>
+#include <QApplication>
+#include <QCheckBox>
+#include <QColor>
+#include <QContextMenuEvent>
+#include <QDate>
+#include <QDateTime>
+#include <QDesktopServices>
+#include <QEvent>
+#include <QFile>
+#include <QFileDialog>
+#include <QFontMetrics>
+#include <QFrame>
+#include <QGridLayout>
+#include <QHBoxLayout>
+#include <QIcon>
+#include <QImage>
+#include <QLabel>
+#include <QListWidget>
+#include <QListWidgetItem>
+#include <QMenu>
+#include <QMouseEvent>
+#include <QObject>
+#include <QPaintEvent>
+#include <QPainter>
+#include <QPainterPath>
+#include <QPalette>
+#include <QPen>
+#include <QPixmap>
+#include <QPolygonF>
+#include <QProgressBar>
+#include <QPushButton>
+#include <QSizePolicy>
+#include <QTextLayout>
+#include <QTextOption>
+#include <QTimer>
+#include <QUrl>
+#include <QVBoxLayout>
+#include <QWidget>
+#include <QtMath>
+
+namespace ClientInternal {
+    enum {
+        RolePeerUid = Qt::UserRole,
+        RoleName = Qt::UserRole + 1,
+        RoleUnread = Qt::UserRole + 2,
+        RolePreview = Qt::UserRole + 3,
+        RoleTs = Qt::UserRole + 4,
+        RoleAccount = Qt::UserRole + 5,
+        RoleSignature = Qt::UserRole + 6,
+        RoleRemark = Qt::UserRole + 7,
+        RoleNick = Qt::UserRole + 8,
+        RoleAvatarVer = Qt::UserRole + 9,
+        RoleOnline = Qt::UserRole + 10
+    };
+
+    constexpr int kReconnectMaxTries = 3;
+    constexpr int kReconnectIntervalSec = 10;
+
+    inline QString fmtMs(qint64 nsecs)
+    {
+        return QString::number(nsecs / 1000000.0, 'f', 1);
+    }
+
+    class SpinRing : public QWidget
+    {
+    public:
+        explicit SpinRing(QWidget *parent = nullptr)
+            : QWidget(parent)
+        {
+            setFixedSize(36, 36);
+            auto *t = new QTimer(this);
+            connect(t, &QTimer::timeout, this, [this]() {
+                m_angle = (m_angle + 30) % 360;
+                update();
+            });
+            t->start(80);
+        }
+
+    protected:
+        void paintEvent(QPaintEvent *) override
+        {
+            QPainter p(this);
+            p.setRenderHint(QPainter::Antialiasing, true);
+            const QRectF r = QRectF(rect()).adjusted(3.5, 3.5, -3.5, -3.5);
+            QPen back(QColor(7, 193, 96, 40), 3, Qt::SolidLine, Qt::RoundCap);
+            p.setPen(back);
+            p.drawEllipse(r);
+            QPen pen(QColor("#07C160"), 3, Qt::SolidLine, Qt::RoundCap);
+            p.setPen(pen);
+            p.drawArc(r, -m_angle * 16, 220 * 16);
+        }
+
+    private:
+        int m_angle = 0;
+    };
+
+    struct Conv
+    {
+        qint64 uid = 0;
+        QString name;
+        QString preview;
+        QString time;
+        int unread = 0;
+        int avatarVer = 0;
+        bool online = false;
+    };
+
+    inline QString formatTs(qint64 ms)
+    {
+        if (ms <= 0)
+            return QString();
+        const QDateTime dt = QDateTime::fromMSecsSinceEpoch(ms);
+        const QDate today = QDate::currentDate();
+        if (dt.date() == today)
+            return dt.toString(QStringLiteral("HH:mm"));
+        if (dt.date() == today.addDays(-1))
+            return QStringLiteral("昨天");
+        if (dt.date().year() == today.year())
+            return dt.toString(QStringLiteral("MM-dd"));
+        return dt.toString(QStringLiteral("yyyy-MM-dd"));
+    }
+
+    class BubbleText : public QWidget
+    {
+    public:
+        explicit BubbleText(const QString &text, QWidget *parent = nullptr)
+            : QWidget(parent)
+            , m_text(text)
+        {
+            setObjectName(QStringLiteral("chatText"));
+            setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
+            setCursor(Qt::IBeamCursor);
+        }
+
+        QString text() const { return m_text; }
+
+        void applyMaxWidth(int maxOuter)
+        {
+            const int ml = 10;
+            const int mr = 10;
+            const int mt = 8;
+            const int mb = 8;
+            const int innerMax = qMax(1, maxOuter - ml - mr);
+            rebuild(innerMax);
+            setFixedSize(m_size);
+            if (QWidget *box = parentWidget())
+            {
+                if (auto *lay = qobject_cast<QVBoxLayout *>(box->layout()))
+                    lay->setContentsMargins(ml, mt, mr, mb);
+                box->setFixedSize(m_size.width() + ml + mr, m_size.height() + mt + mb);
+            }
+            update();
+        }
+
+    protected:
+        void paintEvent(QPaintEvent *) override
+        {
+            QPainter p(this);
+            p.setRenderHint(QPainter::TextAntialiasing, true);
+            p.setPen(QColor(QStringLiteral("#191919")));
+            m_layout.draw(&p, QPointF(0, 0));
+        }
+
+    private:
+        void rebuild(int innerMax)
+        {
+            m_layout.setText(m_text);
+            m_layout.setFont(font());
+            QTextOption opt;
+            opt.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere);
+            m_layout.setTextOption(opt);
+            m_layout.setCacheEnabled(true);
+            qreal y = 0;
+            qreal maxLine = 0;
+            m_layout.beginLayout();
+            for (;;)
+            {
+                QTextLine line = m_layout.createLine();
+                if (!line.isValid())
+                    break;
+                line.setLineWidth(innerMax);
+                line.setPosition(QPointF(0, y));
+                y += line.height();
+                maxLine = qMax(maxLine, line.naturalTextWidth());
+            }
+            m_layout.endLayout();
+            if (y < 1)
+                y = QFontMetrics(font()).height();
+            m_size = QSize(qBound(1, qCeil(maxLine), innerMax), qCeil(y));
+        }
+
+        QString m_text;
+        QTextLayout m_layout;
+        QSize m_size;
+    };
+
+    inline void applyTextBubbleWidth(BubbleText *lab, int maxW)
+    {
+        if (lab)
+            lab->applyMaxWidth(maxW);
+    }
+
+    class ClickWidget : public QFrame
+    {
+    public:
+        std::function<void()> onClick;
+        std::function<void()> onForward;
+        std::function<void()> onSaveAs;
+        std::function<void()> onFavorite;
+        std::function<void()> onCopy;
+        std::function<void()> onDelete;
+        std::function<void()> onMultiSelect;
+        explicit ClickWidget(QWidget *parent = nullptr) : QFrame(parent)
+        {
+            setCursor(Qt::PointingHandCursor);
+        }
+
+    protected:
+        bool eventFilter(QObject *watched, QEvent *event) override
+        {
+            if (event->type() == QEvent::ContextMenu)
+            {
+                contextMenuEvent(static_cast<QContextMenuEvent *>(event));
+                return true;
+            }
+            return QFrame::eventFilter(watched, event);
+        }
+
+        void mouseReleaseEvent(QMouseEvent *e) override
+        {
+            if (e->button() != Qt::LeftButton)
+                return;
+            QWidget *row = parentWidget();
+            if (row && row->property("selecting").toBool())
+            {
+                if (auto *cb = row->findChild<QCheckBox *>(QStringLiteral("msgCheck")))
+                    cb->toggle();
+                return;
+            }
+            if (onClick)
+                onClick();
+        }
+
+        void contextMenuEvent(QContextMenuEvent *e) override
+        {
+            QWidget *row = parentWidget();
+            const bool selecting = row && row->property("selecting").toBool();
+            if (!onCopy && !onForward && !onSaveAs && !onFavorite && !onDelete && !onMultiSelect)
+            {
+                QFrame::contextMenuEvent(e);
+                return;
+            }
+            QMenu menu;
+            QAction *copy = onCopy ? menu.addAction(QStringLiteral("复制")) : nullptr;
+            QAction *fav = onFavorite ? menu.addAction(QStringLiteral("收藏")) : nullptr;
+            QAction *fwd = onForward ? menu.addAction(QStringLiteral("转发")) : nullptr;
+            QAction *save = onSaveAs ? menu.addAction(QStringLiteral("另存为")) : nullptr;
+            if ((copy || fav || fwd || save) && (onDelete || onMultiSelect))
+                menu.addSeparator();
+            QAction *del = onDelete ? menu.addAction(QStringLiteral("删除")) : nullptr;
+            QAction *multi = nullptr;
+            if (onMultiSelect)
+                multi = menu.addAction(selecting ? QStringLiteral("取消多选")
+                                                 : QStringLiteral("多选"));
+            QAction *picked = menu.exec(e->globalPos());
+            if (picked && picked == copy && onCopy)
+                onCopy();
+            else if (picked && picked == fav && onFavorite)
+                onFavorite();
+            else if (picked && picked == fwd && onForward)
+                onForward();
+            else if (picked && picked == save && onSaveAs)
+                onSaveAs();
+            else if (picked && picked == del && onDelete)
+                onDelete();
+            else if (picked && picked == multi && onMultiSelect)
+                onMultiSelect();
+        }
+    };
+
+    inline QPixmap fileCtlIcon(bool pause, bool upload = false)
+    {
+        const int size = 22;
+        const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
+        QPixmap pm(qRound(size * dpr), qRound(size * dpr));
+        pm.fill(Qt::transparent);
+        pm.setDevicePixelRatio(dpr);
+        QPainter p(&pm);
+        p.setRenderHint(QPainter::Antialiasing);
+        const QColor fg(QStringLiteral("#576B95"));
+        p.setPen(QPen(fg, 1.4));
+        p.setBrush(Qt::NoBrush);
+        p.drawEllipse(QRectF(1.2, 1.2, size - 2.4, size - 2.4));
+        if (pause)
+        {
+            p.setPen(Qt::NoPen);
+            p.setBrush(fg);
+            p.drawRoundedRect(QRectF(7.2, 6.4, 2.6, 9.2), 0.8, 0.8);
+            p.drawRoundedRect(QRectF(12.2, 6.4, 2.6, 9.2), 0.8, 0.8);
+        }
+        else
+        {
+            p.setPen(QPen(fg, 1.6, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
+            if (upload)
+            {
+                p.drawLine(QPointF(11.0, 14.4), QPointF(11.0, 8.0));
+                p.drawLine(QPointF(7.8, 10.4), QPointF(11.0, 6.4));
+                p.drawLine(QPointF(14.2, 10.4), QPointF(11.0, 6.4));
+            }
+            else
+            {
+                p.drawLine(QPointF(11.0, 6.2), QPointF(11.0, 12.6));
+                p.drawLine(QPointF(7.8, 10.4), QPointF(11.0, 14.4));
+                p.drawLine(QPointF(14.2, 10.4), QPointF(11.0, 14.4));
+            }
+        }
+        return pm;
+    }
+
+    inline QPushButton *fileCtlBtn(QWidget *parent, bool pause, bool upload = false)
+    {
+        auto *btn = new QPushButton(parent);
+        btn->setFixedSize(22, 22);
+        btn->setCursor(Qt::PointingHandCursor);
+        btn->setFocusPolicy(Qt::NoFocus);
+        btn->setFlat(true);
+        btn->setIcon(QIcon(fileCtlIcon(pause, upload)));
+        btn->setIconSize(QSize(22, 22));
+        btn->setStyleSheet(QStringLiteral(
+            "QPushButton { background:transparent; border:none; padding:0; }"
+            "QPushButton:disabled { opacity:0.35; }"));
+        return btn;
+    }
+
+    class FileBubble : public ClickWidget
+    {
+    public:
+        FileBubble(QWidget *parent, qint64 fileId, const QString &name, qint64 size,
+                   const QString &url, const QString &mime, bool mine)
+            : ClickWidget(parent)
+            , m_fileId(fileId)
+            , m_size(size)
+            , m_url(url)
+            , m_mime(mime)
+            , m_name(name)
+            , m_mine(mine)
+        {
+            setObjectName(QStringLiteral("fileBubble"));
+            setStyleSheet(QStringLiteral("background:#FFFFFF; border:none; border-radius:4px;"));
+            setFixedWidth(252);
+
+            FileCache::instance().bindMeta(fileId, url, mime, name, size);
+
+            auto *root = new QVBoxLayout(this);
+            root->setContentsMargins(8, 8, 10, 8);
+            root->setSpacing(6);
+
+            auto *top = new QHBoxLayout();
+            top->setContentsMargins(0, 0, 0, 0);
+            top->setSpacing(8);
+
+            auto *icon = new QLabel(this);
+            icon->setFixedSize(40, 40);
+            icon->setScaledContents(true);
+            icon->setStyleSheet(QStringLiteral("background:transparent; border:none;"));
+            if (FileCache::looksLikeImage(mime, name))
+            {
+                const QPixmap pm = FileCache::instance().thumbnail(fileId, 40);
+                icon->setPixmap(pm.isNull() ? FileTypeIcon::pixmap(name, 40) : pm);
+            }
+            else
+            {
+                icon->setPixmap(FileTypeIcon::pixmap(name, 40));
+            }
+
+            auto *info = new QVBoxLayout();
+            info->setContentsMargins(0, 0, 0, 0);
+            info->setSpacing(2);
+            auto *n = new QLabel(name, this);
+            n->setWordWrap(true);
+            n->setStyleSheet(QStringLiteral(
+                "color:#191919; font-size:13px; background:transparent; border:none;"));
+            m_statusLab = new QLabel(this);
+            m_statusLab->setStyleSheet(QStringLiteral(
+                "color:#888888; font-size:11px; background:transparent; border:none;"));
+            info->addWidget(n);
+            info->addWidget(m_statusLab);
+            info->addStretch();
+
+            top->addWidget(icon, 0, Qt::AlignTop);
+            top->addLayout(info, 1);
+
+            m_bar = new QProgressBar(this);
+            m_bar->setRange(0, 1000);
+            m_bar->setValue(0);
+            m_bar->setTextVisible(false);
+            m_bar->setToolTip(QString());
+            m_bar->setFixedHeight(6);
+            m_bar->setStyleSheet(QStringLiteral(
+                "QProgressBar { background:rgba(0,0,0,0.08); border:none; border-radius:3px; }"
+                "QProgressBar::chunk { background:#07C160; border-radius:3px; }"));
+
+            auto *meta = new QHBoxLayout();
+            meta->setContentsMargins(0, 0, 0, 0);
+            meta->setSpacing(8);
+            m_sizeLab = new QLabel(this);
+            m_sizeLab->setStyleSheet(QStringLiteral(
+                "color:#888888; font-size:11px; background:transparent; border:none;"));
+            m_speedLab = new QLabel(this);
+            m_speedLab->setStyleSheet(QStringLiteral(
+                "color:#888888; font-size:11px; background:transparent; border:none;"));
+            meta->addWidget(m_sizeLab, 1);
+            meta->addWidget(m_speedLab, 0, Qt::AlignRight);
+
+            root->addLayout(top);
+            root->addWidget(m_bar);
+            root->addLayout(meta);
+
+            m_bar->hide();
+            m_sizeLab->hide();
+            m_speedLab->hide();
+
+            connect(&FileCache::instance(), &FileCache::statusChanged, this, [this](qint64 id) {
+                if (id == m_fileId)
+                    syncUi();
+            });
+            connect(&FileCache::instance(), &FileCache::progress, this,
+                    [this](qint64 id, qint64, qint64, double) {
+                if (id == m_fileId)
+                    syncUi();
+            });
+            connect(&FileCache::instance(), &FileCache::failed, this,
+                    [this](qint64 id, const QString &error) {
+                if (id != m_fileId)
+                    return;
+                const bool uploading = FileCache::instance().needsUpload(m_fileId)
+                    || FileCache::instance().status(m_fileId) == FileCache::Uploading;
+                if (!uploading)
+                {
+                    UiHelpers::showToast(window(),
+                                         error.isEmpty() ? QStringLiteral("下载失败") : error,
+                                         true);
+                }
+                syncUi();
+            });
+
+            onClick = [this]() {
+                const QString path = FileCache::instance().localPath(m_fileId);
+                if (!path.isEmpty())
+                {
+                    if (!QDesktopServices::openUrl(QUrl::fromLocalFile(path)))
+                        UiHelpers::showToast(window(), QStringLiteral("无法打开文件"), true);
+                    return;
+                }
+                if (FileCache::instance().needsUpload(m_fileId))
+                {
+                    FileCache::instance().startUpload(m_fileId);
+                    return;
+                }
+                m_wantReceive = true;
+                FileCache::instance().startDownload(m_fileId, m_url, m_mime, m_name, m_size);
+                syncUi();
+            };
+            onSaveAs = [this]() {
+                const QString src = FileCache::instance().localPath(m_fileId);
+                if (src.isEmpty())
+                {
+                    UiHelpers::showToast(window(), QStringLiteral("请先下载文件"), true);
+                    return;
+                }
+                const QString dest = QFileDialog::getSaveFileName(window(), QStringLiteral("保存文件"), m_name);
+                if (dest.isEmpty())
+                    return;
+                QFile::remove(dest);
+                if (!QFile::copy(src, dest))
+                    UiHelpers::showToast(window(), QStringLiteral("保存失败"), true);
+                else
+                    UiHelpers::showToast(window(), QStringLiteral("已保存"));
+            };
+
+            syncUi();
+        }
+
+        void bindActionSlot(QWidget *slot, QLabel *sendMark)
+        {
+            m_actionSlot = slot;
+            m_sendMark = sendMark;
+            if (!m_actionSlot)
+                return;
+            m_dl = fileCtlBtn(m_actionSlot, false, false);
+            m_pause = fileCtlBtn(m_actionSlot, true);
+            m_dl->setParent(m_actionSlot);
+            m_pause->setParent(m_actionSlot);
+            m_dl->move(0, 0);
+            m_pause->move(0, 0);
+            m_dl->raise();
+            m_pause->raise();
+            connect(m_dl, &QPushButton::clicked, this, [this]() {
+                if (FileCache::instance().needsUpload(m_fileId))
+                    FileCache::instance().startUpload(m_fileId);
+                else
+                {
+                    m_wantReceive = true;
+                    FileCache::instance().startDownload(m_fileId, m_url, m_mime, m_name, m_size);
+                }
+            });
+            connect(m_pause, &QPushButton::clicked, this, [this]() {
+                if (FileCache::instance().needsUpload(m_fileId)
+                    || FileCache::instance().status(m_fileId) == FileCache::Uploading)
+                    FileCache::instance().cancelUpload(m_fileId);
+                else
+                    FileCache::instance().cancelDownload(m_fileId);
+            });
+            syncUi();
+        }
+
+        void refreshActionSlot()
+        {
+            syncUi();
+        }
+
+    private:
+        void syncUi()
+        {
+            const FileCache::Status st = FileCache::instance().status(m_fileId);
+            const bool pendingUp = m_mine && FileCache::instance().needsUpload(m_fileId);
+            qint64 rec = 0, tot = m_size;
+            double bps = 0;
+            FileCache::instance().progressOf(m_fileId, &rec, &tot, &bps);
+            if (tot <= 0)
+                tot = m_size;
+
+            const bool received = (st == FileCache::Received) && !pendingUp;
+            const bool transferring = (st == FileCache::Downloading) || (st == FileCache::Uploading);
+            const bool transferStarted = transferring || (rec > 0 && !received);
+            const bool sentOnServer = m_mine && !pendingUp && m_fileId > 0;
+            const bool showAsSent = sentOnServer && !m_wantReceive && !transferStarted;
+
+            QString status = FileCache::statusText(st);
+            if (pendingUp && st != FileCache::Uploading)
+                status = QStringLiteral("已暂停");
+            else if (st == FileCache::NotReceived && rec > 0)
+                status = QStringLiteral("已暂停");
+            if (showAsSent)
+                status = QStringLiteral("已发送");
+            m_statusLab->setText(status);
+
+            if (showAsSent)
+            {
+                m_bar->hide();
+                m_sizeLab->hide();
+                m_speedLab->hide();
+            }
+            else if (transferStarted)
+            {
+                m_bar->show();
+                m_sizeLab->show();
+                m_speedLab->show();
+                if (tot > 0)
+                    m_bar->setValue(
+                        static_cast<int>(qBound(qint64(0), rec * 1000 / tot, qint64(1000))));
+                else
+                    m_bar->setValue(0);
+                m_sizeLab->setText(QStringLiteral("%1 / %2")
+                                       .arg(FileCache::formatSize(rec),
+                                            tot > 0 ? FileCache::formatSize(tot)
+                                                    : QStringLiteral("--")));
+                if (transferring)
+                    m_speedLab->setText(FileCache::formatSpeed(bps));
+                else
+                    m_speedLab->setText(QStringLiteral("已暂停"));
+            }
+            else
+            {
+                m_bar->hide();
+                m_sizeLab->hide();
+                m_speedLab->hide();
+            }
+
+            if (!m_actionSlot || !m_dl || !m_pause)
+                return;
+
+            const int sendState = parentWidget()
+                                      ? parentWidget()->property("sendStatus").toInt()
+                                      : LocalMsg::Ok;
+            const bool sendBusy = m_mine
+                && (sendState == LocalMsg::Sending || sendState == LocalMsg::Failed);
+            if (sendBusy)
+            {
+                m_dl->hide();
+                m_pause->hide();
+                if (m_actionSlot)
+                    m_actionSlot->show();
+                if (m_sendMark)
+                    m_sendMark->show();
+                return;
+            }
+
+            if (received || showAsSent)
+            {
+                m_dl->hide();
+                m_pause->hide();
+                if (m_sendMark)
+                    m_sendMark->hide();
+                if (m_actionSlot)
+                    m_actionSlot->hide();
+                return;
+            }
+
+            if (m_actionSlot)
+                m_actionSlot->show();
+
+            if (m_sendMark)
+                m_sendMark->hide();
+            m_dl->setIcon(QIcon(fileCtlIcon(false, pendingUp)));
+            m_dl->setVisible(!transferring);
+            m_pause->setVisible(transferring);
+        }
+
+        qint64 m_fileId = 0;
+        qint64 m_size = 0;
+        QString m_url;
+        QString m_mime;
+        QString m_name;
+        bool m_mine = false;
+        bool m_wantReceive = false;
+        QWidget *m_actionSlot = nullptr;
+        QLabel *m_sendMark = nullptr;
+        QPushButton *m_dl = nullptr;
+        QPushButton *m_pause = nullptr;
+        QLabel *m_statusLab = nullptr;
+        QProgressBar *m_bar = nullptr;
+        QLabel *m_sizeLab = nullptr;
+        QLabel *m_speedLab = nullptr;
+    };
+
+    inline void refreshRowFileAction(QWidget *row)
+    {
+        if (!row)
+            return;
+        if (auto *fb = row->findChild<FileBubble *>(QStringLiteral("fileBubble")))
+            fb->refreshActionSlot();
+    }
+
+    using ElideLabel = UiHelpers::ElideLabel;
+
+    class TrayPreviewRow : public QWidget
+    {
+    public:
+        std::function<void()> onActivate;
+
+        explicit TrayPreviewRow(QWidget *parent = nullptr)
+            : QWidget(parent)
+        {
+            setAttribute(Qt::WA_Hover);
+            setCursor(Qt::PointingHandCursor);
+            setAutoFillBackground(true);
+            setMinimumHeight(48);
+            applyHover(false);
+        }
+
+    protected:
+        void enterEvent(QEvent *event) override
+        {
+            applyHover(true);
+            QWidget::enterEvent(event);
+        }
+
+        void leaveEvent(QEvent *event) override
+        {
+            applyHover(false);
+            QWidget::leaveEvent(event);
+        }
+
+        void mouseReleaseEvent(QMouseEvent *event) override
+        {
+            if (event->button() == Qt::LeftButton && rect().contains(event->pos()) && onActivate)
+                onActivate();
+            QWidget::mouseReleaseEvent(event);
+        }
+
+    private:
+        void applyHover(bool on)
+        {
+            QPalette pal = palette();
+            pal.setColor(QPalette::Window, on ? QColor("#F2F2F2") : QColor("#FFFFFF"));
+            setPalette(pal);
+        }
+    };
+
+    inline QPixmap navIcon(int kind, const QColor &color)
+    {
+        const int size = 22;
+        QPixmap pm(size, size);
+        pm.fill(Qt::transparent);
+        QPainter p(&pm);
+        p.setRenderHint(QPainter::Antialiasing, true);
+        p.setPen(QPen(color, 1.6));
+        p.setBrush(Qt::NoBrush);
+        if (kind == 0)
+        {
+            p.drawRoundedRect(QRectF(2.5, 3.5, 17, 12.5), 2.5, 2.5);
+            QPainterPath tail;
+            tail.moveTo(6.5, 16);
+            tail.lineTo(8.5, 20);
+            tail.lineTo(12, 16);
+            p.setBrush(color);
+            p.setPen(Qt::NoPen);
+            p.drawPath(tail);
+        }
+        else if (kind == 1)
+        {
+            p.drawEllipse(QRectF(6.5, 3.2, 9, 9));
+            p.drawArc(QRect(3, 12, 16, 12), 20 * 16, 140 * 16);
+        }
+        else if (kind == 3)
+        {
+            QPolygonF star;
+            for (int i = 0; i < 5; ++i)
+            {
+                const qreal a = -1.5707963 + i * 1.25663706;
+                star << QPointF(11 + qCos(a) * 8.2, 11 + qSin(a) * 8.2);
+                const qreal b = a + 0.62831853;
+                star << QPointF(11 + qCos(b) * 3.4, 11 + qSin(b) * 3.4);
+            }
+            p.setBrush(color);
+            p.setPen(Qt::NoPen);
+            p.drawPolygon(star);
+        }
+        else
+        {
+            const QPointF c(11, 11);
+            for (int i = 0; i < 6; ++i)
+            {
+                const qreal a = i * 3.14159265 / 3.0;
+                const QPointF d(qCos(a), qSin(a));
+                p.drawLine(c + d * 5.2, c + d * 8.6);
+            }
+            p.drawEllipse(QRectF(6.6, 6.6, 8.8, 8.8));
+            p.drawEllipse(QRectF(8.6, 8.6, 4.8, 4.8));
+        }
+        return pm;
+    }
+
+    inline QPixmap chatToolIcon(int kind, const QColor &color, int size = 20)
+    {
+        const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
+        QPixmap pm(qRound(size * dpr), qRound(size * dpr));
+        pm.fill(Qt::transparent);
+        pm.setDevicePixelRatio(dpr);
+        QPainter p(&pm);
+        p.setRenderHint(QPainter::Antialiasing, true);
+        p.setPen(QPen(color, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
+        p.setBrush(Qt::NoBrush);
+        const qreal s = size;
+        if (kind == 0)
+        {
+            p.drawEllipse(QRectF(2.2, 3.2, s - 4.4, s - 5.2));
+            p.drawArc(QRectF(5.5, 7.5, s - 11.0, s - 12.0), 200 * 16, 140 * 16);
+            p.setBrush(color);
+            p.setPen(Qt::NoPen);
+            p.drawEllipse(QRectF(6.4, 7.2, 2.2, 2.2));
+            p.drawEllipse(QRectF(s - 8.6, 7.2, 2.2, 2.2));
+        }
+        else if (kind == 1)
+        {
+            p.drawRoundedRect(QRectF(2.5, 4.2, s - 5.0, s - 8.0), 2.0, 2.0);
+            p.setBrush(color);
+            p.setPen(Qt::NoPen);
+            p.drawEllipse(QRectF(5.2, 6.4, 3.0, 3.0));
+            QPolygonF hill;
+            hill << QPointF(3.6, s - 5.2) << QPointF(8.2, 10.2) << QPointF(11.2, 13.0)
+                 << QPointF(14.4, 9.4) << QPointF(s - 3.6, s - 5.2);
+            p.drawPolygon(hill);
+        }
+        else
+        {
+            p.drawRoundedRect(QRectF(5.0, 2.8, s - 9.2, s - 5.4), 1.2, 1.2);
+            p.drawLine(QPointF(5.0, 6.4), QPointF(s - 4.2, 6.4));
+            p.drawLine(QPointF(7.4, 9.4), QPointF(s - 7.2, 9.4));
+            p.drawLine(QPointF(7.4, 12.2), QPointF(s - 7.2, 12.2));
+        }
+        return pm;
+    }
+
+    inline QPixmap settingsNavIcon()
+    {
+        static QPixmap cached;
+        if (!cached.isNull())
+            return cached;
+        QPixmap src(QStringLiteral(":/icons/settings.png"));
+        if (src.isNull())
+        {
+            cached = navIcon(2, QColor("#A6A6A6"));
+            return cached;
+        }
+        QImage img = src.toImage().convertToFormat(QImage::Format_ARGB32);
+        for (int y = 0; y < img.height(); ++y)
+        {
+            auto *line = reinterpret_cast<QRgb *>(img.scanLine(y));
+            for (int x = 0; x < img.width(); ++x)
+            {
+                if (qRed(line[x]) < 40 && qGreen(line[x]) < 40 && qBlue(line[x]) < 40)
+                    line[x] = qRgba(0, 0, 0, 0);
+            }
+        }
+        cached = QPixmap::fromImage(img).scaled(22, 22, Qt::KeepAspectRatio, Qt::SmoothTransformation);
+        return cached;
+    }
+
+    inline QPushButton *navBtn(int kind, const QString &tip, QWidget *parent, bool checkable = true)
+    {
+        auto *btn = new QPushButton(parent);
+        btn->setCheckable(checkable);
+        btn->setFixedSize(56, 48);
+        btn->setCursor(Qt::PointingHandCursor);
+        btn->setIconSize(QSize(22, 22));
+        btn->setIcon(QIcon(kind == 2 ? settingsNavIcon() : navIcon(kind, QColor("#A6A6A6"))));
+        btn->setStyleSheet(QStringLiteral(
+            "QPushButton { background:transparent; border:none; border-radius:0; padding:0; }"
+            "QPushButton:hover { background:#3A3A3A; }"
+            "QPushButton:checked { background:transparent; }"));
+        if (checkable)
+        {
+            QObject::connect(btn, &QPushButton::toggled, btn, [btn, kind](bool on) {
+                btn->setIcon(QIcon(navIcon(kind, on ? QColor("#07C160") : QColor("#A6A6A6"))));
+            });
+        }
+        return btn;
+    }
+
+    inline QLabel *makeOnlineDot(QWidget *parent, bool online)
+    {
+        auto *dot = new QLabel(parent);
+        dot->setFixedSize(8, 8);
+        dot->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
+        dot->setAttribute(Qt::WA_StyledBackground, true);
+        dot->setStyleSheet(online
+            ? QStringLiteral("background:#07C160; border:none; border-radius:4px;")
+            : QStringLiteral("background:#C8C8C8; border:none; border-radius:4px;"));
+        return dot;
+    }
+
+    inline Conv convFromItem(const QListWidgetItem *item)
+    {
+        Conv c;
+        c.uid = item->data(RolePeerUid).toLongLong();
+        c.name = item->data(RoleName).toString();
+        c.preview = item->data(RolePreview).toString();
+        c.time = formatTs(item->data(RoleTs).toLongLong());
+        c.unread = item->data(RoleUnread).toInt();
+        c.avatarVer = item->data(RoleAvatarVer).toInt();
+        c.online = item->data(RoleOnline).toInt() == 1;
+        return c;
+    }
+
+    inline bool listOnlineOf(const QListWidget *list, qint64 uid)
+    {
+        if (!list)
+            return false;
+        for (int i = 0; i < list->count(); ++i)
+        {
+            auto *item = list->item(i);
+            if (item && item->data(RolePeerUid).toLongLong() == uid)
+                return item->data(RoleOnline).toInt() == 1;
+        }
+        return false;
+    }
+
+    inline QIcon blankTrayIcon()
+    {
+        QPixmap pm(32, 32);
+        pm.fill(Qt::transparent);
+        return QIcon(pm);
+    }
+
+    inline QWidget *makeFriendRow(const QString &name, bool online, qint64 uid, int avatarVer, QWidget *parent)
+    {
+        auto *row = new QWidget(parent);
+        row->setFixedHeight(48);
+        row->setMinimumWidth(0);
+        row->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed);
+        row->setAttribute(Qt::WA_TranslucentBackground);
+        row->setStyleSheet(QStringLiteral("background:transparent;"));
+
+        auto *lay = new QHBoxLayout(row);
+        lay->setContentsMargins(12, 8, 12, 8);
+        lay->setSpacing(10);
+
+        auto *avatar = new QLabel(row);
+        avatar->setFixedSize(32, 32);
+        avatar->setPixmap(AvatarCache::instance().pixmap(uid, avatarVer, name, 32));
+
+        auto *nameLab = new ElideLabel(name, row);
+        nameLab->setStyleSheet(QStringLiteral("color:#191919; font-size:14px; background:transparent;"));
+
+        lay->addWidget(avatar, 0, Qt::AlignVCenter);
+        lay->addWidget(nameLab, 1, Qt::AlignVCenter);
+        if (online)
+            lay->addWidget(makeOnlineDot(row, true), 0, Qt::AlignVCenter);
+        return row;
+    }
+
+    inline QWidget *makeConvRow(const Conv &c, QWidget *parent)
+    {
+        auto *row = new QWidget(parent);
+        row->setFixedHeight(68);
+        row->setMinimumWidth(0);
+        row->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed);
+        row->setAttribute(Qt::WA_TranslucentBackground);
+        row->setStyleSheet(QStringLiteral("background:transparent;"));
+
+        auto *grid = new QGridLayout(row);
+        grid->setContentsMargins(12, 10, 12, 10);
+        grid->setHorizontalSpacing(10);
+        grid->setVerticalSpacing(3);
+        grid->setColumnStretch(1, 1);
+        grid->setColumnMinimumWidth(1, 0);
+
+        auto *avatarBox = new QWidget(row);
+        avatarBox->setFixedSize(44, 44);
+        auto *avatar = new QLabel(avatarBox);
+        avatar->setGeometry(0, 0, 40, 40);
+        avatar->setPixmap(AvatarCache::instance().pixmap(c.uid, c.avatarVer, c.name, 40));
+        avatar->setScaledContents(false);
+        if (c.online)
+        {
+            auto *dot = makeOnlineDot(avatarBox, true);
+            dot->setFixedSize(10, 10);
+            dot->move(30, 30);
+            dot->setStyleSheet(QStringLiteral(
+                "background:#07C160; border:2px solid #FFFFFF; border-radius:5px;"));
+            dot->raise();
+        }
+        if (c.unread > 0)
+        {
+            auto *badge = new QLabel(c.unread > 99 ? QStringLiteral("99") : QString::number(c.unread), avatarBox);
+            badge->setObjectName(QStringLiteral("unreadBadge"));
+            badge->setAlignment(Qt::AlignCenter);
+            badge->setFixedSize(16, 16);
+            badge->move(28, 0);
+            badge->setStyleSheet(QStringLiteral(
+                "background:#FA5151; color:#FFFFFF; font-size:10px; border:none; border-radius:8px;"));
+            badge->raise();
+        }
+
+        auto *nameRow = new QWidget(row);
+        nameRow->setMinimumWidth(0);
+        nameRow->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
+        nameRow->setStyleSheet(QStringLiteral("background:transparent;"));
+        auto *nameLay = new QHBoxLayout(nameRow);
+        nameLay->setContentsMargins(0, 0, 0, 0);
+        nameLay->setSpacing(6);
+        auto *name = new ElideLabel(c.name, nameRow);
+        name->setStyleSheet(QStringLiteral("color:#191919; font-size:14px; background:transparent;"));
+        nameLay->addWidget(name, 1, Qt::AlignVCenter);
+
+        auto *prevWrap = new QWidget(row);
+        prevWrap->setMinimumWidth(0);
+        prevWrap->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
+        prevWrap->setStyleSheet(QStringLiteral("background:transparent;"));
+        auto *prevLay = new QHBoxLayout(prevWrap);
+        prevLay->setContentsMargins(0, 0, 0, 0);
+        prevLay->setSpacing(4);
+        QString previewText = c.preview;
+        int iconKind = -1;
+        if (previewText.startsWith(QStringLiteral("[表情]")))
+        {
+            iconKind = 0;
+            previewText = previewText.mid(4);
+            if (previewText.isEmpty())
+                previewText = QStringLiteral("表情");
+        }
+        else if (previewText.startsWith(QStringLiteral("[图片]")))
+        {
+            iconKind = 1;
+            previewText = previewText.mid(4);
+            if (previewText.isEmpty())
+                previewText = QStringLiteral("图片");
+        }
+        else if (previewText.startsWith(QStringLiteral("[文件]")))
+        {
+            iconKind = 2;
+            previewText = previewText.mid(4).trimmed();
+            if (previewText.isEmpty())
+                previewText = QStringLiteral("文件");
+        }
+        if (iconKind >= 0)
+        {
+            auto *ic = new QLabel(prevWrap);
+            ic->setFixedSize(14, 14);
+            ic->setPixmap(chatToolIcon(iconKind, QColor("#9A9A9A"), 14));
+            ic->setStyleSheet(QStringLiteral("background:transparent; border:none;"));
+            prevLay->addWidget(ic, 0, Qt::AlignVCenter);
+        }
+        auto *preview = new ElideLabel(previewText, prevWrap);
+        preview->setStyleSheet(QStringLiteral("color:#9A9A9A; font-size:12px; background:transparent;"));
+        prevLay->addWidget(preview, 1);
+
+        auto *time = new QLabel(c.time, row);
+        time->setStyleSheet(QStringLiteral("color:#B2B2B2; font-size:11px; background:transparent;"));
+        time->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
+        time->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred);
+        time->setMinimumWidth(time->fontMetrics().horizontalAdvance(
+            c.time.isEmpty() ? QStringLiteral("00:00") : c.time));
+
+        grid->addWidget(avatarBox, 0, 0, 2, 1, Qt::AlignVCenter);
+        grid->addWidget(nameRow, 0, 1);
+        grid->addWidget(time, 0, 2, Qt::AlignRight | Qt::AlignVCenter);
+        grid->addWidget(prevWrap, 1, 1, 1, 2);
+        return row;
+    }
+}  // namespace ClientInternal

+ 533 - 0
Client/src/mainframe/ClientSend.cpp

@@ -0,0 +1,533 @@
+// ClientSend.cpp — 发消息/文件/表情、转发收藏、头像刷新
+#include "ClientImpl.h"
+
+void Client::sendCurrentMessage()
+{
+    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0)
+        return;
+    const QString text = m_input->toPlainText().trimmed();
+    if (text.isEmpty())
+        return;
+    const int contentMax = AppConfig::instance().contentMax();
+    if (contentMax > 0 && text.size() > contentMax)
+    {
+        UiHelpers::showToast(this, QStringLiteral("消息过长"), true);
+        return;
+    }
+
+    m_input->clear();
+    sendOutgoing(m_currentPeerUid, m_currentPeerName, im::MsgType::Text, text,
+                 previewOf(im::MsgType::Text, text));
+}
+
+void Client::onChatNotify(const QJsonObject &msg)
+{
+    const qint64 from = Proto::jsonI64(msg, QStringLiteral("from_uid"));
+    const qint64 to = Proto::jsonI64(msg, QStringLiteral("to_uid"));
+    const qint64 peer = (from == m_uid) ? to : from;
+    QString name = msg.value(QStringLiteral("from_nickname")).toString();
+    if (name.isEmpty())
+        name = msg.value(QStringLiteral("from_account")).toString();
+    if (from == m_uid)
+    {
+        if (auto *item = findConvItem(peer))
+            name = item->data(RoleName).toString();
+    }
+    const QString text = msg.value(QStringLiteral("content")).toString();
+    int msgType = static_cast<int>(Proto::jsonI64(msg, QStringLiteral("msg_type")));
+    if (msgType <= 0)
+        msgType = msg.value(QStringLiteral("msg_type")).toInt(1);
+    const qint64 ts = Proto::jsonI64(msg, QStringLiteral("ts"));
+    qint64 msgId = Proto::jsonI64(msg, QStringLiteral("msg_id"));
+    if (msgId <= 0)
+        msgId = Proto::jsonI64(msg, QStringLiteral("id"));
+    saveLocalMessage(msgId, peer, from, to, msgType, text, ts);
+
+    if (from != m_uid && from > 0)
+        applyPresence(from, true);
+
+    const bool viewing = isViewingPeer(peer) && isVisible() && !isMinimized();
+    int unread = 0;
+    if (!viewing)
+    {
+        unread = 1;
+        if (auto *item = findConvItem(peer))
+            unread = item->data(RoleUnread).toInt() + 1;
+    }
+    upsertConv(peer, name, previewOf(msgType, text), ts, unread);
+    if (peer != m_currentPeerUid)
+        dropChatPage(peer);
+    if (viewing && from != m_uid)
+    {
+        if (msgId <= 0 || !findMsgRow(msgId))
+            addBubble(false, name, text, formatTs(ts), msgType, 0, msgId, peer);
+        markPeerRead(peer);
+    }
+    if (from != m_uid)
+    {
+        const bool quiet = viewing && isVisible() && !isMinimized() && isActiveWindow();
+        if (!quiet)
+            UiHelpers::playNotifySound();
+    }
+}
+
+QString Client::previewOf(int msgType, const QString &content) const
+{
+    if (msgType == im::MsgType::Sticker)
+        return QStringLiteral("[表情]");
+    const QJsonObject o = QJsonDocument::fromJson(content.toUtf8()).object();
+    if (msgType == im::MsgType::File)
+    {
+        const QString name = o.value(QStringLiteral("name")).toString();
+        const QString mime = o.value(QStringLiteral("mime")).toString();
+        if (FileCache::looksLikeImage(mime, name))
+            return QStringLiteral("[图片]");
+        return name.isEmpty() ? QStringLiteral("[文件]") : QStringLiteral("[文件] %1").arg(name);
+    }
+    if (msgType == im::MsgType::Card)
+    {
+        QString name = o.value(QStringLiteral("nickname")).toString();
+        if (name.isEmpty())
+            name = o.value(QStringLiteral("account")).toString();
+        return name.isEmpty() ? QStringLiteral("[名片]") : QStringLiteral("[名片] %1").arg(name);
+    }
+    return content;
+}
+
+void Client::sendSticker(qint64 packId, const QString &code)
+{
+    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0 || code.isEmpty())
+        return;
+    QJsonObject body;
+    body.insert(QStringLiteral("pack_id"), packId);
+    body.insert(QStringLiteral("code"), code);
+    const QString content = QString::fromUtf8(QJsonDocument(body).toJson(QJsonDocument::Compact));
+    sendOutgoing(m_currentPeerUid, m_currentPeerName, im::MsgType::Sticker, content,
+                 QStringLiteral("[表情]"));
+}
+
+int Client::avatarVerOf(qint64 uid) const
+{
+    if (uid <= 0)
+        return 0;
+    if (uid == m_uid)
+        return ImClient::instance().avatarVer();
+    int ver = 0;
+    if (m_friendList)
+    {
+        for (int i = 0; i < m_friendList->count(); ++i)
+        {
+            auto *item = m_friendList->item(i);
+            if (item && item->data(RolePeerUid).toLongLong() == uid)
+            {
+                ver = item->data(RoleAvatarVer).toInt();
+                break;
+            }
+        }
+    }
+    if (ver <= 0 && uid == m_profileUid)
+        ver = m_profileAvatarVer;
+    if (ver <= 0)
+    {
+        if (auto *item = findConvItem(uid))
+            ver = item->data(RoleAvatarVer).toInt();
+    }
+    return ver;
+}
+
+QPixmap Client::peerAvatar(qint64 uid, const QString &name, int size) const
+{
+    return AvatarCache::instance().pixmap(uid, avatarVerOf(uid), name, size);
+}
+
+void Client::refreshAvatar(qint64 uid)
+{
+    const int known = AvatarCache::instance().knownVer(uid);
+    if (uid == m_uid && m_navAvatar)
+    {
+        const int selfVer = qMax(ImClient::instance().avatarVer(), known);
+        m_navAvatar->setProperty("avatarVer", selfVer);
+        m_navAvatar->setPixmap(peerAvatar(m_uid, m_nickname, 36));
+    }
+    if (auto *item = findConvItem(uid))
+    {
+        Conv c = convFromItem(item);
+        c.avatarVer = qMax(item->data(RoleAvatarVer).toInt(), known);
+        item->setData(RoleAvatarVer, c.avatarVer);
+        if (QWidget *old = m_convList->itemWidget(item))
+            old->deleteLater();
+        m_convList->setItemWidget(item, makeConvRow(c, m_convList));
+    }
+    if (m_friendList)
+    {
+        for (int i = 0; i < m_friendList->count(); ++i)
+        {
+            auto *item = m_friendList->item(i);
+            if (!item || item->data(RolePeerUid).toLongLong() != uid)
+                continue;
+            const QString name = item->data(RoleName).toString();
+            const bool online = item->data(RoleOnline).toInt() == 1;
+            const int ver = qMax(item->data(RoleAvatarVer).toInt(), known);
+            item->setData(RoleAvatarVer, ver);
+            if (QWidget *old = m_friendList->itemWidget(item))
+                old->deleteLater();
+            m_friendList->setItemWidget(item, makeFriendRow(name, online, uid, ver, m_friendList));
+            break;
+        }
+    }
+    if (uid == m_profileUid)
+    {
+        if (known > m_profileAvatarVer)
+            m_profileAvatarVer = known;
+        if (m_profileAvatar)
+            m_profileAvatar->setPixmap(peerAvatar(uid, m_profileDisplayName, 72));
+    }
+}
+
+void Client::sendChatPayload(int msgType, const QString &content, const QString &preview)
+{
+    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0 || content.isEmpty())
+        return;
+    sendChatPayloadTo(m_currentPeerUid, m_currentPeerName, msgType, content, preview);
+}
+
+void Client::sendChatPayloadTo(qint64 peer, const QString &peerName, int msgType,
+                               const QString &content, const QString &preview)
+{
+    sendOutgoing(peer, peerName, msgType, content, preview);
+}
+
+void Client::sendOutgoing(qint64 peer, const QString &peerName, int msgType,
+                          const QString &content, const QString &preview,
+                          QWidget *existingRow, qint64 localId)
+{
+    if (peer <= 0 || content.isEmpty())
+        return;
+    if (localId == 0)
+        localId = LocalStore::instance().allocLocalId();
+    const qint64 ts = QDateTime::currentMSecsSinceEpoch();
+    saveLocalMessage(localId, peer, m_uid, peer, msgType, content, ts, LocalMsg::Sending);
+    m_inflightLocalIds.insert(localId);
+
+    QPointer<QWidget> row = existingRow;
+    if (row)
+        setBubbleSendState(row, false, true);
+    else if (isViewingPeer(peer))
+        row = addBubble(true, m_nickname, content, QStringLiteral("刚刚"), msgType,
+                        LocalMsg::Sending, localId, peer);
+    upsertConv(peer, peerName, preview, ts, 0);
+
+    QPointer<Client> self(this);
+    ImClient::instance().chat(peer, msgType, content,
+        [self, row, peer, peerName, content, msgType, preview, localId, ts](const QJsonObject &reply) {
+            if (!self)
+                return;
+            self->m_inflightLocalIds.remove(localId);
+            QWidget *markRow = row;
+            if (!markRow)
+                markRow = self->findOutgoingRow(localId);
+            if (!ImClient::ok(reply))
+            {
+                self->saveLocalMessage(localId, peer, self->m_uid, peer, msgType, content,
+                                       ts, LocalMsg::Failed);
+                if (markRow)
+                    self->setBubbleSendState(markRow, true);
+                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                return;
+            }
+            LocalStore::instance().removeMessage(localId);
+            const qint64 serverId = Proto::jsonI64(reply, QStringLiteral("msg_id"));
+            const qint64 serverTs = Proto::jsonI64(reply, QStringLiteral("ts"));
+            self->saveLocalMessage(serverId, peer, self->m_uid, peer, msgType, content,
+                                   serverTs > 0 ? serverTs : ts, LocalMsg::Ok);
+            if (markRow)
+            {
+                markRow->setProperty("outgoingId", QVariant::fromValue(serverId));
+                markRow->setProperty("msgId", QVariant::fromValue(serverId));
+                self->setBubbleSendState(markRow, false);
+            }
+            self->upsertConv(peer, peerName, preview, serverTs > 0 ? serverTs : ts, 0);
+        });
+}
+
+void Client::resendFailed(QWidget *row)
+{
+    if (!row || row->property("sendStatus").toInt() != LocalMsg::Failed)
+        return;
+    const qint64 localId = row->property("outgoingId").toLongLong();
+    const qint64 peer = row->property("outgoingPeer").toLongLong();
+    const int msgType = row->property("outgoingType").toInt();
+    const QString content = row->property("outgoingContent").toString();
+    if (localId == 0 || peer <= 0 || content.isEmpty())
+        return;
+    QString peerName = m_currentPeerName;
+    if (auto *item = findConvItem(peer))
+        peerName = item->data(RoleName).toString();
+    sendOutgoing(peer, peerName, msgType > 0 ? msgType : im::MsgType::Text, content,
+                 previewOf(msgType > 0 ? msgType : im::MsgType::Text, content), row, localId);
+}
+
+void Client::sendFile(bool imageOnly)
+{
+    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0)
+        return;
+    const QString filter = imageOnly
+        ? QStringLiteral("图片 (*.jpg *.jpeg *.png *.gif *.bmp *.webp)")
+        : QString();
+    const QString path = QFileDialog::getOpenFileName(
+        this, imageOnly ? QStringLiteral("选择图片") : QStringLiteral("选择文件"), QString(), filter);
+    if (path.isEmpty())
+        return;
+    QFileInfo info(path);
+    if (info.size() <= 0)
+    {
+        UiHelpers::showToast(this, QStringLiteral("文件无效"), true);
+        return;
+    }
+    const qint64 fileMax = AppConfig::instance().fileMax();
+    if (fileMax > 0 && info.size() > fileMax)
+    {
+        UiHelpers::showToast(this, QStringLiteral("文件不能超过 %1")
+                                       .arg(FileCache::formatSize(fileMax)), true);
+        return;
+    }
+    const QString mime = QMimeDatabase().mimeTypeForFile(path).name();
+    const QString name = info.fileName();
+    const qint64 size = info.size();
+    if (imageOnly && !FileCache::looksLikeImage(mime, name))
+    {
+        UiHelpers::showToast(this, QStringLiteral("请选择图片文件"), true);
+        return;
+    }
+
+    QPointer<Client> self(this);
+    const qint64 peer = m_currentPeerUid;
+    const QString peerName = m_currentPeerName;
+    ImClient::instance().putFile(name, mime, size, [self, path, name, mime, size, peer, peerName](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        const qint64 fileId = Proto::jsonI64(reply, QStringLiteral("file_id"));
+        const QString gotMime = reply.value(QStringLiteral("mime")).toString(mime);
+        const QString gotName = reply.value(QStringLiteral("name")).toString(name);
+        const QString url = reply.value(QStringLiteral("url")).toString();
+        if (fileId <= 0 || url.isEmpty())
+        {
+            UiHelpers::showToast(self, QStringLiteral("上传凭证无效"), true);
+            return;
+        }
+        if (!FileCache::instance().rememberPath(fileId, path, gotMime, gotName))
+        {
+            UiHelpers::showToast(self, QStringLiteral("无法保存本地文件"), true);
+            return;
+        }
+        FileCache::instance().bindMeta(fileId, url, gotMime, gotName, size);
+
+        QJsonObject content;
+        content.insert(QStringLiteral("file_id"), fileId);
+        content.insert(QStringLiteral("name"), gotName);
+        content.insert(QStringLiteral("size"), size);
+        content.insert(QStringLiteral("mime"), gotMime);
+        content.insert(QStringLiteral("url"), url);
+        const QString contentText = QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Compact));
+        const QString preview = FileCache::looksLikeImage(gotMime, gotName)
+            ? QStringLiteral("[图片]")
+            : QStringLiteral("[文件] %1").arg(gotName);
+
+        const qint64 localId = LocalStore::instance().allocLocalId();
+        const qint64 ts = QDateTime::currentMSecsSinceEpoch();
+        self->saveLocalMessage(localId, peer, self->m_uid, peer, im::MsgType::File, contentText, ts,
+                               LocalMsg::Sending);
+        self->m_inflightLocalIds.insert(localId);
+
+        auto *once = new QObject(self);
+        QObject::connect(&FileCache::instance(), &FileCache::uploaded, once,
+                         [self, once, fileId, peer, peerName, contentText, preview, localId](qint64 id) {
+            if (id != fileId)
+                return;
+            once->deleteLater();
+            if (!self)
+                return;
+            QWidget *row = self->findOutgoingRow(localId);
+            self->sendOutgoing(peer, peerName, im::MsgType::File, contentText, preview, row, localId);
+        });
+        QObject::connect(&FileCache::instance(), &FileCache::failed, once,
+                         [self, fileId](qint64 id, const QString &error) {
+            if (id != fileId || !self)
+                return;
+            UiHelpers::showToast(self, error.isEmpty() ? QStringLiteral("上传失败") : error, true);
+        });
+
+        FileCache::instance().startUpload(fileId, reply.value(QStringLiteral("token")).toObject());
+        if (self->isViewingPeer(peer))
+            self->addBubble(true, self->m_nickname, contentText, QStringLiteral("刚刚"),
+                            im::MsgType::File, LocalMsg::Sending, localId, peer);
+        self->upsertConv(peer, peerName, preview, ts, 0);
+    });
+}
+
+void Client::previewChatImage(qint64 fileId, const QString &name, const QString &content)
+{
+    if (fileId <= 0)
+        return;
+    auto show = [this, fileId, name, content]() {
+        const QByteArray data = FileCache::instance().bytes(fileId);
+        if (data.isEmpty())
+            return false;
+        QPixmap pm;
+        if (!pm.loadFromData(data))
+        {
+            UiHelpers::showToast(this, QStringLiteral("无法预览该图片"), true);
+            return true;
+        }
+        ImagePreviewDialog dlg(pm, name, data, this, !content.isEmpty(), !content.isEmpty());
+        dlg.exec();
+        if (dlg.wantsForward())
+            forwardPayload(im::MsgType::File, content, QStringLiteral("[图片]"));
+        if (dlg.wantsFavorite())
+            favoritePayload(im::MsgType::File, content);
+        return true;
+    };
+    if (show())
+        return;
+    UiHelpers::showToast(this, QStringLiteral("正在加载图片"));
+    QPointer<Client> self(this);
+    auto *once = new QObject(this);
+    connect(&FileCache::instance(), &FileCache::ready, once, [self, once, fileId, name, content](qint64 id) {
+        if (id != fileId)
+            return;
+        once->deleteLater();
+        if (!self)
+            return;
+        const QByteArray data = FileCache::instance().bytes(fileId);
+        QPixmap pm;
+        if (data.isEmpty() || !pm.loadFromData(data))
+            return;
+        ImagePreviewDialog dlg(pm, name, data, self, !content.isEmpty(), !content.isEmpty());
+        dlg.exec();
+        if (dlg.wantsForward())
+            self->forwardPayload(im::MsgType::File, content, QStringLiteral("[图片]"));
+        if (dlg.wantsFavorite())
+            self->favoritePayload(im::MsgType::File, content);
+    });
+    FileCache::instance().ensure(fileId, QJsonDocument::fromJson(content.toUtf8()).object()
+                                             .value(QStringLiteral("url")).toString());
+}
+
+void Client::favoritePayload(int msgType, const QString &content)
+{
+    if (content.isEmpty())
+        return;
+    QJsonObject body;
+    body.insert(QStringLiteral("msg_type"), msgType);
+    body.insert(QStringLiteral("content"), content);
+    if (m_currentPeerUid > 0)
+        body.insert(QStringLiteral("src_peer_uid"), m_currentPeerUid);
+
+    const bool waitFile = (msgType == im::MsgType::File);
+    const int timeoutMs = waitFile ? 30000 : 15000;
+    QPointer<Client> self(this);
+
+    if (!waitFile)
+    {
+        ImClient::instance().favoriteAdd(body, [self](const QJsonObject &reply) {
+            if (!self)
+                return;
+            if (!ImClient::ok(reply))
+            {
+                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                return;
+            }
+            UiHelpers::showToast(self, QStringLiteral("已收藏"));
+            if (self->m_favPanel)
+                self->m_favPanel->refresh();
+        }, timeoutMs);
+        return;
+    }
+
+    WaitDialog wait(this, QStringLiteral("收藏文件"),
+                    QStringLiteral("正在收藏文件,请稍候…"));
+    QJsonObject result;
+    ImClient::instance().favoriteAdd(body, [&wait, &result](const QJsonObject &reply) {
+        result = reply;
+        wait.finish();
+    }, timeoutMs);
+    const QPoint c = mapToGlobal(rect().center());
+    wait.move(c.x() - wait.width() / 2, c.y() - wait.height() / 2);
+    wait.exec();
+    if (!self)
+        return;
+    if (!ImClient::ok(result))
+    {
+        UiHelpers::showToast(self, ImClient::messageOf(result), true);
+        return;
+    }
+    UiHelpers::showToast(self, QStringLiteral("已收藏"));
+    if (self->m_favPanel)
+        self->m_favPanel->refresh();
+}
+
+void Client::forwardPayload(int msgType, const QString &content, const QString &preview,
+                            QWidget *host)
+{
+    Q_UNUSED(host);
+    if (content.isEmpty())
+        return;
+    QPointer<Client> self(this);
+    ImClient::instance().friendList([self, msgType, content, preview](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        const QJsonArray list = reply.value(QStringLiteral("list")).toArray();
+        // 不能在 socket readyRead / 其它模态 exec 里直接再 exec,否则转发框会挂起直到下一次点击。
+        QTimer::singleShot(0, self.data(), [self, msgType, content, preview, list]() {
+            if (!self)
+                return;
+            PickFriendDialog dlg(list, self->m_uid, self, QStringLiteral("转发给"), true);
+            dlg.setWindowModality(Qt::ApplicationModal);
+            dlg.raise();
+            dlg.activateWindow();
+            if (dlg.exec() != QDialog::Accepted)
+                return;
+            const QJsonObject o = dlg.selected();
+            const qint64 uid = Proto::jsonI64(o, QStringLiteral("uid"));
+            if (uid <= 0)
+                return;
+            QString name = o.value(QStringLiteral("remark")).toString();
+            if (name.isEmpty())
+                name = o.value(QStringLiteral("nickname")).toString();
+            if (name.isEmpty())
+                name = o.value(QStringLiteral("account")).toString();
+            self->sendChatPayloadTo(uid, name, msgType, content, preview);
+            UiHelpers::showToast(self, QStringLiteral("已转发"));
+        });
+    });
+}
+
+void Client::shareUserCard(const QJsonObject &user, QWidget *host)
+{
+    const qint64 uid = Proto::jsonI64(user, QStringLiteral("uid"));
+    if (uid <= 0)
+        return;
+    QJsonObject content;
+    content.insert(QStringLiteral("uid"), uid);
+    content.insert(QStringLiteral("account"), user.value(QStringLiteral("account")).toString());
+    QString nick = user.value(QStringLiteral("nickname")).toString();
+    if (nick.isEmpty())
+        nick = user.value(QStringLiteral("account")).toString();
+    content.insert(QStringLiteral("nickname"), nick);
+    content.insert(QStringLiteral("signature"), user.value(QStringLiteral("signature")).toString());
+    content.insert(QStringLiteral("avatar_ver"), user.value(QStringLiteral("avatar_ver")).toInt());
+    forwardPayload(im::MsgType::Card,
+                   QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Compact)),
+                   QStringLiteral("[名片] %1").arg(nick), host);
+}
+

+ 596 - 0
Client/src/mainframe/ClientSession.cpp

@@ -0,0 +1,596 @@
+// ClientSession.cpp — 重连、托盘、被踢/断线
+#include "ClientImpl.h"
+
+void Client::onKicked()
+{
+    if (m_loggingOut)
+        return;
+    m_loggingOut = true;
+    hideReconnectOverlay();
+    if (m_tray)
+        m_tray->hide();
+    if (m_trayPreview)
+        m_trayPreview->hide();
+    stopUnreadAlert();
+    UiHelpers::showToast(this, QStringLiteral("账号已在其他设备登录"));
+    ImClient::instance().disconnectFromServer();
+    emit loggedOut();
+    close();
+}
+
+void Client::onDisconnected()
+{
+    if (m_loggingOut || ImClient::instance().isIntentionalClose())
+        return;
+    if (m_reconnecting)
+    {
+        finishReconnectAttempt(false);
+        return;
+    }
+    startReconnect();
+}
+
+void Client::onConnected()
+{
+    if (!m_reconnecting || !m_reconnectInFlight)
+        return;
+    const QString password = reconnectPassword();
+    const QString account = m_account.isEmpty() ? ImClient::instance().account() : m_account;
+    if (account.isEmpty() || password.isEmpty())
+    {
+        finishReconnectAttempt(false);
+        return;
+    }
+    QPointer<Client> self(this);
+    ImClient::instance().login(account, password, [self](const QJsonObject &reply) {
+        if (!self || !self->m_reconnecting)
+            return;
+        if (ImClient::ok(reply))
+        {
+            self->finishReconnectOk();
+            return;
+        }
+        const int code = reply.value(QStringLiteral("code")).toInt();
+        if (code == Proto::Err::AuthFail || code == Proto::Err::BadAccount
+            || code == Proto::Err::Disabled)
+        {
+            self->m_loggingOut = true;
+            self->hideReconnectOverlay();
+            UiHelpers::showToast(self, QStringLiteral("登录已失效,请重新登录"), true);
+            emit self->loggedOut();
+            self->close();
+            return;
+        }
+        self->finishReconnectAttempt(false);
+    });
+}
+
+void Client::onConnectFailed(const QString &)
+{
+    if (!m_reconnecting)
+        return;
+    finishReconnectAttempt(false);
+}
+
+void Client::setupReconnectOverlay(QWidget *parent)
+{
+    m_reconnectMask = new QWidget(parent);
+    m_reconnectMask->setObjectName(QStringLiteral("ReconnectMask"));
+    m_reconnectMask->setAttribute(Qt::WA_StyledBackground, true);
+    m_reconnectMask->setStyleSheet(QStringLiteral(
+        "QWidget#ReconnectMask { background:rgba(0,0,0,88); }"));
+    m_reconnectMask->hide();
+
+    auto *lay = new QVBoxLayout(m_reconnectMask);
+    lay->setContentsMargins(24, 24, 24, 24);
+    lay->addStretch();
+
+    auto *card = new QWidget(m_reconnectMask);
+    card->setObjectName(QStringLiteral("ReconnectCard"));
+    card->setAttribute(Qt::WA_StyledBackground, true);
+    card->setFixedWidth(280);
+    card->setStyleSheet(QStringLiteral(
+        "QWidget#ReconnectCard { background:#FFFFFF; border:none; border-radius:8px; }"));
+    auto *cLay = new QVBoxLayout(card);
+    cLay->setContentsMargins(28, 28, 28, 24);
+    cLay->setSpacing(8);
+    cLay->setAlignment(Qt::AlignHCenter);
+
+    auto *spin = new SpinRing(card);
+    m_reconnectSpin = spin;
+    m_reconnectTitle = new QLabel(QStringLiteral("正在重连中…"), card);
+    m_reconnectTitle->setAlignment(Qt::AlignCenter);
+    m_reconnectTitle->setStyleSheet(QStringLiteral(
+        "font-size:16px; color:#1A1A1A; background:transparent; border:none; font-weight:bold;"));
+    m_reconnectHint = new QLabel(card);
+    m_reconnectHint->setAlignment(Qt::AlignCenter);
+    m_reconnectHint->setWordWrap(true);
+    m_reconnectHint->setStyleSheet(QStringLiteral(
+        "font-size:12px; color:#888888; background:transparent; border:none;"));
+    m_reconnectBtn = UiHelpers::primaryButton(QStringLiteral("重试"), card);
+    m_reconnectBtn->setFixedWidth(120);
+    m_reconnectBtn->hide();
+
+    cLay->addWidget(spin, 0, Qt::AlignHCenter);
+    cLay->addSpacing(6);
+    cLay->addWidget(m_reconnectTitle);
+    cLay->addWidget(m_reconnectHint);
+    cLay->addSpacing(8);
+    cLay->addWidget(m_reconnectBtn, 0, Qt::AlignHCenter);
+
+    lay->addWidget(card, 0, Qt::AlignHCenter);
+    lay->addStretch();
+
+    m_reconnectTimer = new QTimer(this);
+    m_reconnectTimer->setInterval(1000);
+    connect(m_reconnectTimer, &QTimer::timeout, this, &Client::onReconnectTick);
+    connect(m_reconnectBtn, &QPushButton::clicked, this, [this]() { startReconnect(); });
+}
+
+QString Client::reconnectPassword() const
+{
+    const QString session = ImClient::instance().sessionPassword();
+    if (!session.isEmpty())
+        return session;
+    return UserSettings::instance().lastPassword();
+}
+
+void Client::updateReconnectUi()
+{
+    if (!m_reconnectMask)
+        return;
+    m_reconnectMask->show();
+    m_reconnectMask->raise();
+    if (m_reconnectInFlight)
+    {
+        if (m_reconnectSpin)
+            m_reconnectSpin->show();
+        if (m_reconnectBtn)
+            m_reconnectBtn->hide();
+        m_reconnectTitle->setText(QStringLiteral("正在重连中…"));
+        m_reconnectHint->setText(QStringLiteral("第 %1 / %2 次")
+                                     .arg(m_reconnectAttempt)
+                                     .arg(kReconnectMaxTries));
+        return;
+    }
+    if (m_reconnectWaitLeft > 0)
+    {
+        if (m_reconnectSpin)
+            m_reconnectSpin->show();
+        if (m_reconnectBtn)
+            m_reconnectBtn->hide();
+        m_reconnectTitle->setText(QStringLiteral("正在重连中…"));
+        m_reconnectHint->setText(QStringLiteral("%1 秒后进行第 %2 / %3 次")
+                                     .arg(m_reconnectWaitLeft)
+                                     .arg(m_reconnectAttempt + 1)
+                                     .arg(kReconnectMaxTries));
+        return;
+    }
+}
+
+void Client::hideReconnectOverlay()
+{
+    m_reconnecting = false;
+    m_reconnectInFlight = false;
+    m_reconnectAttempt = 0;
+    m_reconnectWaitLeft = 0;
+    if (m_reconnectTimer)
+        m_reconnectTimer->stop();
+    if (m_reconnectMask)
+        m_reconnectMask->hide();
+}
+
+void Client::startReconnect()
+{
+    if (m_loggingOut)
+        return;
+    m_reconnecting = true;
+    m_reconnectInFlight = false;
+    m_reconnectAttempt = 0;
+    m_reconnectWaitLeft = 0;
+    if (m_reconnectTimer)
+        m_reconnectTimer->stop();
+    if (m_reconnectMask)
+    {
+        m_reconnectMask->show();
+        m_reconnectMask->raise();
+    }
+    attemptReconnect();
+}
+
+void Client::attemptReconnect()
+{
+    if (m_loggingOut || !m_reconnecting)
+        return;
+    if (m_reconnectAttempt >= kReconnectMaxTries)
+    {
+        showReconnectFailed();
+        return;
+    }
+    m_reconnectWaitLeft = 0;
+    if (m_reconnectTimer)
+        m_reconnectTimer->stop();
+    ++m_reconnectAttempt;
+    m_reconnectInFlight = true;
+    updateReconnectUi();
+
+    if (ImClient::instance().isConnected())
+    {
+        onConnected();
+        return;
+    }
+    ImClient::instance().connectToServer();
+}
+
+void Client::scheduleReconnectRetry()
+{
+    if (m_reconnectAttempt >= kReconnectMaxTries)
+    {
+        showReconnectFailed();
+        return;
+    }
+    m_reconnectInFlight = false;
+    m_reconnectWaitLeft = kReconnectIntervalSec;
+    if (m_reconnectTimer)
+        m_reconnectTimer->start();
+    updateReconnectUi();
+}
+
+void Client::onReconnectTick()
+{
+    if (!m_reconnecting || m_reconnectInFlight)
+        return;
+    if (m_reconnectWaitLeft > 0)
+        --m_reconnectWaitLeft;
+    if (m_reconnectWaitLeft <= 0)
+    {
+        if (m_reconnectTimer)
+            m_reconnectTimer->stop();
+        attemptReconnect();
+        return;
+    }
+    updateReconnectUi();
+}
+
+void Client::finishReconnectAttempt(bool ok)
+{
+    if (!m_reconnecting || !m_reconnectInFlight)
+        return;
+    m_reconnectInFlight = false;
+    if (ok)
+    {
+        finishReconnectOk();
+        return;
+    }
+    scheduleReconnectRetry();
+}
+
+void Client::finishReconnectOk()
+{
+    const QString nick = ImClient::instance().nickname();
+    if (!nick.isEmpty())
+        m_nickname = nick;
+    hideReconnectOverlay();
+    refreshConvList();
+    refreshFriendList();
+}
+
+void Client::showReconnectFailed()
+{
+    m_reconnecting = true;
+    m_reconnectInFlight = false;
+    m_reconnectWaitLeft = 0;
+    if (m_reconnectTimer)
+        m_reconnectTimer->stop();
+    if (m_reconnectSpin)
+        m_reconnectSpin->hide();
+    if (m_reconnectBtn)
+        m_reconnectBtn->show();
+    if (m_reconnectTitle)
+        m_reconnectTitle->setText(QStringLiteral("连接已断开"));
+    if (m_reconnectHint)
+        m_reconnectHint->setText(QStringLiteral("已重试 %1 次,请检查网络后重试")
+                                     .arg(kReconnectMaxTries));
+    if (m_reconnectMask)
+    {
+        m_reconnectMask->show();
+        m_reconnectMask->raise();
+    }
+}
+
+int Client::unreadTotal() const
+{
+    int n = 0;
+    if (!m_convList)
+        return 0;
+    for (int i = 0; i < m_convList->count(); ++i)
+        n += m_convList->item(i)->data(RoleUnread).toInt();
+    return n;
+}
+
+void Client::setupTray()
+{
+    if (!QSystemTrayIcon::isSystemTrayAvailable())
+        return;
+
+    auto *menu = new QMenu(this);
+    auto *openAct = menu->addAction(QStringLiteral("打开主面板"));
+    menu->addSeparator();
+    auto *quitAct = menu->addAction(QStringLiteral("退出"));
+    connect(openAct, &QAction::triggered, this, &Client::showFromTray);
+    connect(quitAct, &QAction::triggered, this, &Client::quitFromTray);
+
+    m_tray = new QSystemTrayIcon(this);
+    m_tray->setContextMenu(menu);
+    m_tray->setIcon(UiHelpers::appIcon());
+    m_tray->show();
+    connect(m_tray, &QSystemTrayIcon::activated, this, [this](QSystemTrayIcon::ActivationReason reason) {
+        if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick)
+            showFromTray();
+    });
+
+    m_trayPreview = new QWidget(this, Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
+    m_trayPreview->setAttribute(Qt::WA_ShowWithoutActivating);
+    m_trayPreview->setAttribute(Qt::WA_TranslucentBackground, false);
+    m_trayPreview->setObjectName(QStringLiteral("TrayPreview"));
+    m_trayPreview->setFixedWidth(280);
+    m_trayPreview->setStyleSheet(QStringLiteral(
+        "QWidget#TrayPreview { background:#FFFFFF; border:1px solid #E5E5E5; }"));
+    auto *lay = new QVBoxLayout(m_trayPreview);
+    lay->setContentsMargins(6, 8, 6, 8);
+    lay->setSpacing(2);
+    m_trayPreview->hide();
+
+    m_trayHover = new QTimer(this);
+    m_trayHover->setInterval(160);
+    connect(m_trayHover, &QTimer::timeout, this, &Client::pollTrayHover);
+    m_trayHover->start();
+    updateTray();
+}
+
+void Client::updateTray()
+{
+    const int unread = unreadTotal();
+    if (unread <= 0)
+        stopUnreadAlert();
+    else
+        startUnreadAlert();
+    if (!m_tray)
+        return;
+    if (!m_trayFlash || !m_trayFlash->isActive())
+        m_tray->setIcon(UiHelpers::appIcon());
+
+    auto *lay = qobject_cast<QVBoxLayout *>(m_trayPreview ? m_trayPreview->layout() : nullptr);
+    if (!lay)
+        return;
+    while (QLayoutItem *it = lay->takeAt(0))
+    {
+        if (it->widget())
+            it->widget()->deleteLater();
+        delete it;
+    }
+
+    auto addPreviewRow = [&](const QString &name, const QString &preview, int unread,
+                             qint64 peerUid, bool friends) {
+        auto *row = new TrayPreviewRow(m_trayPreview);
+        const QString peerName = name;
+        row->onActivate = [this, peerUid, peerName, friends]() {
+            showFromTray();
+            if (friends)
+                QTimer::singleShot(0, this, &Client::openFriendRequests);
+            else
+                openChat(peerUid, peerName);
+        };
+        auto *h = new QHBoxLayout(row);
+        h->setContentsMargins(8, 6, 8, 6);
+        h->setSpacing(8);
+
+        auto passthrough = [](QWidget *w) {
+            w->setAttribute(Qt::WA_TransparentForMouseEvents);
+        };
+
+        auto *avatar = new QLabel(row);
+        avatar->setFixedSize(32, 32);
+        avatar->setPixmap(UiHelpers::avatarPixmap(name, 32));
+        if (!friends)
+            avatar->setPixmap(peerAvatar(peerUid, name, 32));
+        passthrough(avatar);
+
+        auto *info = new QVBoxLayout();
+        info->setContentsMargins(0, 0, 0, 0);
+        info->setSpacing(1);
+        auto *nameLab = new ElideLabel(name, row);
+        nameLab->setStyleSheet(QStringLiteral("font-size:13px; color:#1A1A1A; background:transparent;"));
+        passthrough(nameLab);
+        auto *prev = new ElideLabel(preview, row);
+        prev->setStyleSheet(QStringLiteral("font-size:12px; color:#9A9A9A; background:transparent;"));
+        passthrough(prev);
+        info->addWidget(nameLab);
+        info->addWidget(prev);
+
+        auto *badge = new QLabel(row);
+        passthrough(badge);
+        badge->setAlignment(Qt::AlignCenter);
+        if (unread > 0)
+        {
+            badge->setObjectName(QStringLiteral("trayUnreadBadge"));
+            badge->setText(unread > 99 ? QStringLiteral("99") : QString::number(unread));
+            badge->setFixedSize(18, 18);
+            badge->setStyleSheet(QStringLiteral(
+                "QLabel#trayUnreadBadge { background:#FA5151; color:#FFFFFF; font-size:10px;"
+                " border:none; border-radius:9px; }"));
+        }
+        else
+        {
+            badge->hide();
+        }
+
+        h->addWidget(avatar, 0, Qt::AlignVCenter);
+        h->addLayout(info, 1);
+        h->addWidget(badge, 0, Qt::AlignVCenter);
+        lay->addWidget(row);
+    };
+
+    auto *title = new QLabel(m_trayPreview);
+    title->setStyleSheet(QStringLiteral("font-size:12px; color:#9A9A9A; padding:2px 8px 6px 8px;"));
+    if (unread <= 0 && m_pendingFriends <= 0)
+    {
+        title->setText(QStringLiteral("暂无未读消息"));
+        lay->addWidget(title);
+        m_trayPreview->adjustSize();
+        return;
+    }
+    if (unread > 0)
+    {
+        title->setText(QStringLiteral("%1 条未读消息").arg(unread));
+        lay->addWidget(title);
+    }
+    else
+    {
+        delete title;
+        title = nullptr;
+    }
+
+    int shown = 0;
+    if (m_convList)
+    {
+        for (int i = 0; i < m_convList->count() && shown < 5; ++i)
+        {
+            auto *item = m_convList->item(i);
+            const int n = item->data(RoleUnread).toInt();
+            if (n <= 0)
+                continue;
+            addPreviewRow(item->data(RoleName).toString(),
+                          item->data(RolePreview).toString(),
+                          n,
+                          item->data(RolePeerUid).toLongLong(),
+                          false);
+            ++shown;
+        }
+    }
+    if (unread > shown)
+    {
+        auto *more = new TrayPreviewRow(m_trayPreview);
+        more->setMinimumHeight(28);
+        more->onActivate = [this]() { showFromTray(); };
+        auto *moreLab = new QLabel(QStringLiteral("还有 %1 条未读").arg(unread - shown), more);
+        moreLab->setStyleSheet(QStringLiteral("font-size:12px; color:#B0B0B0; background:transparent;"));
+        moreLab->setAttribute(Qt::WA_TransparentForMouseEvents);
+        auto *moreLay = new QHBoxLayout(more);
+        moreLay->setContentsMargins(8, 4, 8, 4);
+        moreLay->addWidget(moreLab);
+        lay->addWidget(more);
+    }
+    if (m_pendingFriends > 0)
+        addPreviewRow(QStringLiteral("新的朋友"),
+                      QStringLiteral("%1 条好友申请").arg(m_pendingFriends),
+                      m_pendingFriends,
+                      0,
+                      true);
+    m_trayPreview->adjustSize();
+}
+
+void Client::pollTrayHover()
+{
+    if (!m_tray || !m_tray->isVisible() || !m_trayPreview)
+        return;
+    const QRect iconRect = m_tray->geometry();
+    if (!iconRect.isValid() || iconRect.isEmpty())
+        return;
+    const QPoint pos = QCursor::pos();
+    const bool overIcon = iconRect.adjusted(-4, -4, 4, 4).contains(pos);
+    const bool overPreview = m_trayPreview->isVisible() && m_trayPreview->geometry().adjusted(-6, -6, 6, 6).contains(pos);
+    const int unread = unreadTotal();
+    if ((overIcon || overPreview) && (unread > 0 || m_pendingFriends > 0))
+    {
+        QPoint p(iconRect.center().x() - m_trayPreview->width() / 2,
+                 iconRect.top() - m_trayPreview->height() - 8);
+        if (p.y() < 0)
+            p.setY(iconRect.bottom() + 8);
+        if (p.x() < 8)
+            p.setX(8);
+        m_trayPreview->move(p);
+        m_trayPreview->show();
+        m_trayPreview->raise();
+    }
+    else if (m_trayPreview->isVisible())
+    {
+        m_trayPreview->hide();
+    }
+}
+
+void Client::startUnreadAlert()
+{
+    const bool already = m_trayFlash && m_trayFlash->isActive();
+#ifdef Q_OS_WIN
+    if (!already)
+    {
+        FLASHWINFO info;
+        ZeroMemory(&info, sizeof(info));
+        info.cbSize = sizeof(info);
+        info.hwnd = reinterpret_cast<HWND>(winId());
+        info.dwFlags = FLASHW_TRAY | FLASHW_TIMER;
+        info.uCount = 0;
+        info.dwTimeout = 0;
+        FlashWindowEx(&info);
+    }
+#else
+    if (!already)
+        QApplication::alert(this, 0);
+#endif
+    if (!m_tray)
+        return;
+    if (!m_trayFlash)
+    {
+        m_trayFlash = new QTimer(this);
+        m_trayFlash->setInterval(450);
+        connect(m_trayFlash, &QTimer::timeout, this, [this]() {
+            m_trayFlashLit = !m_trayFlashLit;
+            m_tray->setIcon(m_trayFlashLit ? UiHelpers::appIcon() : blankTrayIcon());
+        });
+    }
+    if (!m_trayFlash->isActive())
+    {
+        m_trayFlashLit = true;
+        m_trayFlash->start();
+    }
+}
+
+void Client::stopUnreadAlert()
+{
+#ifdef Q_OS_WIN
+    FLASHWINFO info;
+    ZeroMemory(&info, sizeof(info));
+    info.cbSize = sizeof(info);
+    info.hwnd = reinterpret_cast<HWND>(winId());
+    info.dwFlags = FLASHW_STOP;
+    FlashWindowEx(&info);
+#endif
+    if (m_trayFlash)
+        m_trayFlash->stop();
+    m_trayFlashLit = true;
+    if (m_tray)
+        m_tray->setIcon(UiHelpers::appIcon());
+}
+
+void Client::showFromTray()
+{
+    if (m_trayPreview)
+        m_trayPreview->hide();
+    show();
+    raise();
+    activateWindow();
+    if (unreadTotal() <= 0)
+        stopUnreadAlert();
+    else
+        startUnreadAlert();
+}
+
+void Client::quitFromTray()
+{
+    m_forceQuit = true;
+    if (m_tray)
+        m_tray->hide();
+    QApplication::quit();
+}
+

+ 5 - 0
Client/src/network/ImClient.cpp

@@ -625,6 +625,11 @@ void ImClient::handlePacket(const Proto::Packet &pkt)
         emit friendAcceptedNotify(obj);
         return;
     }
+    if (pkt.seq == 0 && pkt.cmd == static_cast<quint16>(Proto::Cmd::PresenceNotify))
+    {
+        emit presenceNotify(obj);
+        return;
+    }
     if (pkt.seq == 0)
         return;
 

+ 1 - 0
Client/src/network/ImClient.h

@@ -91,6 +91,7 @@ signals:
     void chatNotify(const QJsonObject &msg);
     void friendRequestNotify(const QJsonObject &msg);
     void friendAcceptedNotify(const QJsonObject &msg);
+    void presenceNotify(const QJsonObject &msg);
 
 private:
     explicit ImClient(QObject *parent = nullptr);

+ 2 - 0
common/protocol_def.h

@@ -56,6 +56,7 @@ enum class Cmd : uint16_t {
     Kicked               = 101,
     FriendRequestNotify  = 102,
     FriendAcceptedNotify = 103,
+    PresenceNotify       = 104,
 };
 
 namespace Err {
@@ -156,6 +157,7 @@ inline const char* cmd_name(uint16_t cmd)
     case Cmd::Kicked:               return "Kicked";
     case Cmd::FriendRequestNotify:  return "FriendRequestNotify";
     case Cmd::FriendAcceptedNotify: return "FriendAcceptedNotify";
+    case Cmd::PresenceNotify:       return "PresenceNotify";
     }
     return "?";
 }

+ 1 - 1
docs/README.md

@@ -19,7 +19,7 @@
 | 打开会话 / 历史 | `Message` / `MsgHistory(11)` |
 | 发送 | `Message` / `Chat(4)`,收消息看推送 `ChatNotify(100)` |
 | 聊天图片/文件 | `Message` / `PutFile(29)` 取 COS 临时 token → 客户端直传 COS → `Chat(4)` `msg_type=3`;下载 `GetFile(30)` 或 CDN |
-| 好友列表 | `Friend` / `FriendList(9)` |
+| 好友列表 | `Friend` / `FriendList(9)`,在线变化看推送 `PresenceNotify(104)` |
 | 搜索/添加好友 | `Friend` / `SearchUser(14)` → `AddFriend(12)`,对方 `HandleFriendRequest(16)` |
 | 隐私设置 | `Account` / `GetPrivacy(17)` `SetPrivacy(18)` |
 | 表情包 | `Sticker` / `StickerPackList(19)` `StickerPack(20)` `GetSticker(21)`,发消息 `msg_type=2` |

+ 20 - 4
docs/protocol.md

@@ -50,7 +50,7 @@ out += body   # UTF-8 bytes
 |------|----|---------|-----|
 | System | 1 | 系统 | Heartbeat(1),SetConfig(13)(应答带 `limits`),推送 Kicked(101) |
 | Account | 2 | 账户 | Login(2) Logout(3) Register(5) SendCode(6) ResetPassword(7) ChangePassword(8) GetPrivacy(17) SetPrivacy(18) SetProfile(27) GetAvatar(28) |
-| Friend | 3 | 好友 | FriendList(9) AddFriend(12) SearchUser(14) FriendRequestList(15) HandleFriendRequest(16) DeleteFriend(24) SetRemark(25) GetProfile(26),推送 FriendRequestNotify(102) FriendAcceptedNotify(103) |
+| Friend | 3 | 好友 | FriendList(9) AddFriend(12) SearchUser(14) FriendRequestList(15) HandleFriendRequest(16) DeleteFriend(24) SetRemark(25) GetProfile(26),推送 FriendRequestNotify(102) FriendAcceptedNotify(103) PresenceNotify(104) |
 | Message | 4 | 消息 | Chat(4) ConvList(10) MsgHistory(11) DeleteConv(22) SetUnread(23) PutFile(29) GetFile(30) DeleteMsg(36),推送 ChatNotify(100) |
 | Favorite | 6 | 收藏夹 | FavoriteAdd(31) FavoriteList(32) FavoriteGet(33) FavoriteUpdate(34) FavoriteDelete(35) |
 
@@ -97,6 +97,7 @@ out += body   # UTF-8 bytes
 | 101 | Kicked | 推送 | 被挤下线 | 同一账号在别处登录 |
 | 102 | FriendRequestNotify | 推送 | 好友申请提示 | 收到加好友申请 |
 | 103 | FriendAcceptedNotify | 推送 | 好友同意提示 | 对方同意申请 |
+| 104 | PresenceNotify | 推送 | 会话/好友头像绿点 | 好友上线/离线 |
 
 未登录可发:Heartbeat / SetConfig / Login / Register / SendCode / ResetPassword。
 连接建立后客户端应立刻发送 SetConfig,服务端按该连接语言返回 `msg`,并在应答中下发 `[limits]`(账号/密码长度、文件头像上限等)。客户端不再在本地 `config.ini` 保留这些限制。其余请求若未登录,回 `code=2`。
@@ -309,7 +310,7 @@ Type=`System`。连接成功后立即发送,上报客户端语言并拉取服
 }
 ```
 
-`online`:`1` 在线,`0` 离线。`pending` 为待处理的好友申请数。
+`online`:`1` 在线,`0` 离线。这是拉取瞬间的快照;好友上线/离线时服务端会再推 `PresenceNotify`(cmd=104),客户端应立刻更新会话列表与好友列表头像绿点。`pending` 为待处理的好友申请数。
 
 ### 4.9 SearchUser(cmd=14)
 
@@ -397,7 +398,7 @@ GetSticker 成功:
 }
 ```
 
-`unread` 对应会话头像角标。`online`:`1` 在线,`0` 离线。
+`unread` 对应会话头像角标。`online`:`1` 在线,`0` 离线。实时变化见 `PresenceNotify`。
 
 ### 4.11 MsgHistory(cmd=11)
 
@@ -660,11 +661,26 @@ GetFile 仍可用于无 CDN 时的回退下载;配置了 `[cdn] domain` 时客
 
 收到后应回到登录窗。随后 TCP 会被服务端断开。
 
+### 4.19b PresenceNotify(cmd=104,Type=Friend,推送)
+
+好友上线或离线时,向其**在线好友**推送(`seq=0`)。文件传输助手不参与。同一账号被踢后由新连接继续保持在线,不会误推离线。
+
+```json
+{"uid":10003,"online":1}
+```
+
+| 字段 | 说明 |
+|------|------|
+| `uid` | 状态变化的用户 |
+| `online` | `1` 上线,`0` 离线 |
+
+登录成功后推 `online=1`;主动 Logout、连接断开且该 uid 已无其它会话时推 `online=0`。客户端应同时刷新消息列表与好友列表头像绿点,不必为此再拉 FriendList。
+
 ## 5. 连接行为
 
 - 接入后即可发包,无握手包
 - 建议 30s 内发一次心跳,避免中间设备掐连接
-- 推送复用同一包格式:`ChatNotify=100`、`Kicked=101`,`seq=0`
+- 推送复用同一包格式:`ChatNotify=100`、`Kicked=101`、`FriendRequestNotify=102`、`FriendAcceptedNotify=103`、`PresenceNotify=104`,`seq=0`
 - 关连接无需发 Logout;Logout 只清登录态
 - 密码在服务端以 SHA-256 十六进制存储,传输仍为明文 JSON,生产环境应叠 TLS 或另行加密
 

+ 4 - 0
server/src/im/handlers/account_handler.cpp

@@ -134,6 +134,7 @@ void AccountHandler::on_login()
     body["allow_search_phone"] = user.allow_search_phone;
     body["allow_add_friend"] = user.allow_add_friend;
     reply_ok(body);
+    server().notify_friend_presence(user.id, 1);
     Logger::info("login uid=" + std::to_string(user.id) + " account=" + user.username +
                  " conn=" + std::to_string(session()->connid()));
 }
@@ -146,6 +147,9 @@ void AccountHandler::on_logout()
     const UserId uid = session()->uid();
     server().sessions().bind_uid(session()->connid(), 0);
     session()->set_profile(0, "", "");
+    if (!server().sessions().find_by_uid(uid)) {
+        server().notify_friend_presence(uid, 0);
+    }
     reply_ok();
     Logger::info("logout uid=" + std::to_string(uid) +
                  " conn=" + std::to_string(session()->connid()));

+ 31 - 0
server/src/net/im_server.cpp

@@ -4,7 +4,10 @@
 #include <string>
 
 #include "common/logger.h"
+#include "common/protocol_def.h"
+#include "db/friend_store.h"
 #include "net/packet_log.h"
+#include "util/json.h"
 #include "HPSocket/HPSocket.h"
 
 namespace im {
@@ -68,6 +71,30 @@ void ImServer::disconnect(ConnId connid)
     server_.disConnect(connid);
 }
 
+void ImServer::notify_friend_presence(UserId uid, int online)
+{
+    if (uid <= 0 || uid == kFileHelperUid) {
+        return;
+    }
+    ylib::json body;
+    body["uid"] = static_cast<int64>(uid);
+    body["online"] = online ? 1 : 0;
+    const Packet pkt = Packet::make(Type::Friend, Cmd::PresenceNotify, 0, body.to_string());
+    try {
+        FriendStore store(mysql_);
+        for (const auto& f : store.list_friends(uid)) {
+            if (f.uid <= 0 || f.uid == kFileHelperUid) {
+                continue;
+            }
+            if (auto peer = sessions_.find_by_uid(f.uid)) {
+                send(peer->connid(), pkt);
+            }
+        }
+    } catch (const std::exception& e) {
+        Logger::warn(std::string("presence notify uid=") + std::to_string(uid) + ": " + e.what());
+    }
+}
+
 void ImServer::on_accept(ConnId connid)
 {
     auto session = std::make_shared<Session>(connid, remote_of(connid),
@@ -116,8 +143,12 @@ void ImServer::on_recv(ConnId connid, const char* data, uint32_t len)
 void ImServer::on_close(ConnId connid)
 {
     auto session = sessions_.get(connid);
+    const UserId uid = session ? session->uid() : 0;
     const std::string remote = session ? session->remote() : "";
     sessions_.remove(connid);
+    if (uid > 0 && !sessions_.find_by_uid(uid)) {
+        notify_friend_presence(uid, 0);
+    }
     Logger::info("close conn=" + std::to_string(connid) + " remote=" + remote +
                  " online=" + std::to_string(sessions_.size()));
 }

+ 3 - 0
server/src/net/im_server.h

@@ -19,6 +19,9 @@ public:
     void stop();
     bool send(ConnId connid, const Packet& packet);
     void disconnect(ConnId connid);
+    // 向该用户的在线好友推送 PresenceNotify。online=1 上线,0 离线。
+    // 踢号换连时旧连接 uid 已清,on_close 不会误推离线。
+    void notify_friend_presence(UserId uid, int online);
 
     SessionMgr& sessions() { return sessions_; }
     MysqlService& mysql() { return mysql_; }