Bladeren bron

修复部分BUG

a158 2 weken geleden
bovenliggende
commit
43a50ac75c
54 gewijzigde bestanden met toevoegingen van 4205 en 265 verwijderingen
  1. 31 4
      Client/Client.vcxproj
  2. 18 0
      Client/Client.vcxproj.filters
  3. 34 12
      Client/res/qss/app.qss
  4. 3 1
      Client/src/app/main.cpp
  5. 13 0
      Client/src/auth/LoginWindow.cpp
  6. 1 0
      Client/src/auth/LoginWindow.h
  7. 528 7
      Client/src/chat/FileCache.cpp
  8. 33 0
      Client/src/chat/FileCache.h
  9. 113 21
      Client/src/common/AppConfig.cpp
  10. 17 6
      Client/src/common/AppConfig.h
  11. 3 3
      Client/src/common/ClientConfig.h
  12. 83 1
      Client/src/common/UiHelpers.cpp
  13. 2 0
      Client/src/common/UiHelpers.h
  14. 142 44
      Client/src/common/Updater.cpp
  15. 15 0
      Client/src/common/UserSettings.cpp
  16. 4 0
      Client/src/common/UserSettings.h
  17. 110 7
      Client/src/mainframe/Client.cpp
  18. 29 3
      Client/src/mainframe/Client.h
  19. 117 52
      Client/src/mainframe/ClientChat.cpp
  20. 51 4
      Client/src/mainframe/ClientConv.cpp
  21. 4 0
      Client/src/mainframe/ClientImpl.h
  22. 530 2
      Client/src/mainframe/ClientInternal.h
  23. 240 30
      Client/src/mainframe/ClientSend.cpp
  24. 2 4
      Client/src/mainframe/ClientSession.cpp
  25. 8 0
      Client/src/mainframe/FavoritePanel.cpp
  26. 1 0
      Client/src/network/ImClient.cpp
  27. 443 0
      Client/src/widgets/ChatInputEdit.cpp
  28. 55 0
      Client/src/widgets/ChatInputEdit.h
  29. 24 3
      Client/src/widgets/ConfirmDialog.cpp
  30. 2 0
      Client/src/widgets/ConfirmDialog.h
  31. 27 0
      Client/src/widgets/FramelessHelper.cpp
  32. 1 0
      Client/src/widgets/FramelessHelper.h
  33. 310 31
      Client/src/widgets/ImagePreviewDialog.cpp
  34. 387 0
      Client/src/widgets/ScreenshotEditor.cpp
  35. 54 0
      Client/src/widgets/ScreenshotEditor.h
  36. 357 0
      Client/src/widgets/VideoPreviewDialog.cpp
  37. 35 0
      Client/src/widgets/VideoPreviewDialog.h
  38. 25 7
      docs/protocol.md
  39. 4 7
      server/config/config.ini.example
  40. 19 0
      server/config/update.json
  41. 20 0
      server/config/update.json.example
  42. 1 0
      server/sql/init.sql
  43. 2 0
      server/sql/migrate.sql
  44. 195 7
      server/src/config/app_config.cpp
  45. 9 2
      server/src/config/app_config.h
  46. 2 1
      server/src/db/account_store.cpp
  47. 1 0
      server/src/db/account_store.h
  48. 1 0
      server/src/db/mysql_service.cpp
  49. 32 4
      server/src/im/handler.cpp
  50. 1 0
      server/src/im/handler.h
  51. 3 0
      server/src/im/handlers/account_handler.cpp
  52. 42 2
      server/src/im/handlers/message_handler.cpp
  53. 3 0
      server/src/net/session.h
  54. 18 0
      server/src/third/cos.cpp

+ 31 - 4
Client/Client.vcxproj

@@ -37,12 +37,12 @@
   </ImportGroup>
   <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'" Label="QtSettings">
     <QtInstall>5.12.11_msvc2017_64</QtInstall>
-    <QtModules>core;gui;widgets;network;sql</QtModules>
+    <QtModules>core;gui;widgets;network;sql;multimedia;multimediawidgets</QtModules>
     <QtBuildConfig>debug</QtBuildConfig>
   </PropertyGroup>
   <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x64'" Label="QtSettings">
     <QtInstall>5.12.11_msvc2017_64</QtInstall>
-    <QtModules>core;gui;widgets;network;sql</QtModules>
+    <QtModules>core;gui;widgets;network;sql;multimedia;multimediawidgets</QtModules>
     <QtBuildConfig>release</QtBuildConfig>
   </PropertyGroup>
   <Target Name="QtMsBuildNotFound" BeforeTargets="CustomBuild;ClCompile" Condition="!Exists('$(QtMsBuild)\qt.targets') or !Exists('$(QtMsBuild)\qt.props')">
@@ -75,6 +75,9 @@
     <ClCompile>
       <PreprocessorDefinitions>_DEBUG;_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
     </ClCompile>
+    <Link>
+      <UACExecutionLevel>RequireAdministrator</UACExecutionLevel>
+    </Link>
   </ItemDefinitionGroup>
   <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'" Label="Configuration">
     <ClCompile>
@@ -99,7 +102,16 @@ if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qjpegd.dll" c
 if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgif.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgif.dll" "$(OutDir)imageformats\qgif.dll"
 if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgifd.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgifd.dll" "$(OutDir)imageformats\qgifd.dll"
 if not exist "$(OutDir)3rdparty\7z\x64" mkdir "$(OutDir)3rdparty\7z\x64"
-if exist "$(ProjectDir)3rdparty\7z\x64\7za.exe" copy /Y "$(ProjectDir)3rdparty\7z\x64\7za.exe" "$(OutDir)3rdparty\7z\x64\7za.exe"</Command>
+if exist "$(ProjectDir)3rdparty\7z\x64\7za.exe" copy /Y "$(ProjectDir)3rdparty\7z\x64\7za.exe" "$(OutDir)3rdparty\7z\x64\7za.exe"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5Multimedia.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5Multimedia.dll" "$(OutDir)Qt5Multimedia.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5Multimediad.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5Multimediad.dll" "$(OutDir)Qt5Multimediad.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5MultimediaWidgets.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5MultimediaWidgets.dll" "$(OutDir)Qt5MultimediaWidgets.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5MultimediaWidgetsd.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5MultimediaWidgetsd.dll" "$(OutDir)Qt5MultimediaWidgetsd.dll"
+if not exist "$(OutDir)mediaservice" mkdir "$(OutDir)mediaservice"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\dsengine.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\dsengine.dll" "$(OutDir)mediaservice\dsengine.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\dsengined.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\dsengined.dll" "$(OutDir)mediaservice\dsengined.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\wmfengine.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\wmfengine.dll" "$(OutDir)mediaservice\wmfengine.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\wmfengined.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\wmfengined.dll" "$(OutDir)mediaservice\wmfengined.dll"</Command>
     </PostBuildEvent>
   </ItemDefinitionGroup>
   <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x64'" Label="Configuration">
@@ -129,7 +141,16 @@ if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qjpegd.dll" c
 if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgif.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgif.dll" "$(OutDir)imageformats\qgif.dll"
 if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgifd.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgifd.dll" "$(OutDir)imageformats\qgifd.dll"
 if not exist "$(OutDir)3rdparty\7z\x64" mkdir "$(OutDir)3rdparty\7z\x64"
-if exist "$(ProjectDir)3rdparty\7z\x64\7za.exe" copy /Y "$(ProjectDir)3rdparty\7z\x64\7za.exe" "$(OutDir)3rdparty\7z\x64\7za.exe"</Command>
+if exist "$(ProjectDir)3rdparty\7z\x64\7za.exe" copy /Y "$(ProjectDir)3rdparty\7z\x64\7za.exe" "$(OutDir)3rdparty\7z\x64\7za.exe"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5Multimedia.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5Multimedia.dll" "$(OutDir)Qt5Multimedia.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5Multimediad.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5Multimediad.dll" "$(OutDir)Qt5Multimediad.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5MultimediaWidgets.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5MultimediaWidgets.dll" "$(OutDir)Qt5MultimediaWidgets.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5MultimediaWidgetsd.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\bin\Qt5MultimediaWidgetsd.dll" "$(OutDir)Qt5MultimediaWidgetsd.dll"
+if not exist "$(OutDir)mediaservice" mkdir "$(OutDir)mediaservice"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\dsengine.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\dsengine.dll" "$(OutDir)mediaservice\dsengine.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\dsengined.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\dsengined.dll" "$(OutDir)mediaservice\dsengined.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\wmfengine.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\wmfengine.dll" "$(OutDir)mediaservice\wmfengine.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\wmfengined.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\mediaservice\wmfengined.dll" "$(OutDir)mediaservice\wmfengined.dll"</Command>
     </PostBuildEvent>
   </ItemDefinitionGroup>
   <ItemGroup>
@@ -176,10 +197,16 @@ if exist "$(ProjectDir)3rdparty\7z\x64\7za.exe" copy /Y "$(ProjectDir)3rdparty\7
     <ClInclude Include="src\widgets\PromptDialog.h" />
     <ClInclude Include="src\widgets\AvatarCropDialog.h" />
     <ClInclude Include="src\widgets\ImagePreviewDialog.h" />
+    <ClInclude Include="src\widgets\VideoPreviewDialog.h" />
+    <ClInclude Include="src\widgets\ScreenshotEditor.h" />
+    <ClInclude Include="src\widgets\ChatInputEdit.h" />
     <ClCompile Include="src\widgets\ConfirmDialog.cpp" />
     <ClCompile Include="src\widgets\PromptDialog.cpp" />
     <ClCompile Include="src\widgets\AvatarCropDialog.cpp" />
     <ClCompile Include="src\widgets\ImagePreviewDialog.cpp" />
+    <ClCompile Include="src\widgets\VideoPreviewDialog.cpp" />
+    <ClCompile Include="src\widgets\ScreenshotEditor.cpp" />
+    <ClCompile Include="src\widgets\ChatInputEdit.cpp" />
     <ClCompile Include="src\widgets\FramelessHelper.cpp" />
     <ClCompile Include="src\auth\LoginWindow.cpp" />
     <ClCompile Include="src\auth\RegisterDialog.cpp" />

+ 18 - 0
Client/Client.vcxproj.filters

@@ -138,6 +138,24 @@
     <ClCompile Include="src\widgets\ImagePreviewDialog.cpp">
       <Filter>src\widgets</Filter>
     </ClCompile>
+    <ClInclude Include="src\widgets\VideoPreviewDialog.h">
+      <Filter>src\widgets</Filter>
+    </ClInclude>
+    <ClCompile Include="src\widgets\VideoPreviewDialog.cpp">
+      <Filter>src\widgets</Filter>
+    </ClCompile>
+    <ClInclude Include="src\widgets\ScreenshotEditor.h">
+      <Filter>src\widgets</Filter>
+    </ClInclude>
+    <ClCompile Include="src\widgets\ScreenshotEditor.cpp">
+      <Filter>src\widgets</Filter>
+    </ClCompile>
+    <ClInclude Include="src\widgets\ChatInputEdit.h">
+      <Filter>src\widgets</Filter>
+    </ClInclude>
+    <ClCompile Include="src\widgets\ChatInputEdit.cpp">
+      <Filter>src\widgets</Filter>
+    </ClCompile>
     <ClInclude Include="src\mainframe\PickFriendDialog.h">
       <Filter>src\mainframe</Filter>
     </ClInclude>

+ 34 - 12
Client/res/qss/app.qss

@@ -56,7 +56,7 @@ QPushButton[role="primary"] {
     color: #FFFFFF;
     border: 1px solid #2E2E2E;
     padding: 0 16px;
-    font-size: 13px;
+    font-size: 12px;
 }
 QPushButton[role="primary"]:hover {
     background: #3E3E3E;
@@ -128,46 +128,68 @@ QScrollArea {
     background: transparent;
 }
 QScrollBar:vertical {
-    width: 6px;
+    width: 8px;
     background: transparent;
-    margin: 0;
-    border-radius: 0px;
+    margin: 3px 1px 3px 0;
+    border: none;
 }
 QScrollBar::handle:vertical {
-    background: #C8C8C8;
-    min-height: 32px;
-    border-radius: 0px;
+    background: #8A8A8A;
+    min-height: 36px;
+    border-radius: 4px;
 }
 QScrollBar::handle:vertical:hover {
-    background: #A8A8A8;
+    background: #5E5E5E;
+}
+QScrollBar::handle:vertical:pressed {
+    background: #3F3F3F;
 }
 QScrollBar::add-line:vertical,
 QScrollBar::sub-line:vertical {
     height: 0;
     border: none;
+    background: none;
 }
 QScrollBar::add-page:vertical,
 QScrollBar::sub-page:vertical {
     background: transparent;
 }
 QScrollBar:horizontal {
-    height: 6px;
+    height: 8px;
     background: transparent;
-    margin: 0;
+    margin: 0 3px 1px 3px;
+    border: none;
 }
 QScrollBar::handle:horizontal {
-    background: #C8C8C8;
-    min-width: 32px;
+    background: #8A8A8A;
+    min-width: 36px;
+    border-radius: 4px;
+}
+QScrollBar::handle:horizontal:hover {
+    background: #5E5E5E;
+}
+QScrollBar::handle:horizontal:pressed {
+    background: #3F3F3F;
 }
 QScrollBar::add-line:horizontal,
 QScrollBar::sub-line:horizontal {
     width: 0;
     border: none;
+    background: none;
+}
+QScrollBar::add-page:horizontal,
+QScrollBar::sub-page:horizontal {
+    background: transparent;
 }
 QSplitter::handle:horizontal {
     background: #E5E5E5;
     width: 1px;
 }
+QSplitter#chatInputSplit::handle:vertical {
+    background: #E5E5E5;
+    height: 1px;
+    margin: 0;
+}
 QMenu {
     background: #FFFFFF;
     border: 1px solid #E5E5E5;

+ 3 - 1
Client/src/app/main.cpp

@@ -61,11 +61,13 @@ int main(int argc, char *argv[])
                      [&](const QString &account, const QString &nickname) {
         auto *mainWin = new Client(account, nickname);
         mainWin->setAttribute(Qt::WA_DeleteOnClose);
-        QObject::connect(mainWin, &Client::loggedOut, &login, [&login]() {
+        QObject::connect(mainWin, &Client::loggedOut, &login,
+                         [&login](const QString &reason) {
             login.prepareAfterLogout();
             login.show();
             login.raise();
             login.activateWindow();
+            login.showLogoutNotice(reason);
         });
         login.hide();
         mainWin->show();

+ 13 - 0
Client/src/auth/LoginWindow.cpp

@@ -1,6 +1,7 @@
 #include "LoginWindow.h"
 #include "RegisterDialog.h"
 #include "ForgotPasswordDialog.h"
+#include "ConfirmDialog.h"
 #include "UiHelpers.h"
 #include "TitleBar.h"
 #include "FramelessHelper.h"
@@ -218,6 +219,18 @@ void LoginWindow::prepareAfterLogout()
     showPage(PageLogin);
 }
 
+void LoginWindow::showLogoutNotice(const QString &reason)
+{
+    if (reason.trimmed().isEmpty())
+        return;
+    const QString text = reason;
+    QTimer::singleShot(280, this, [this, text]() {
+        if (!isVisible())
+            return;
+        ConfirmDialog::notice(this, QStringLiteral("下线通知"), text);
+    });
+}
+
 void LoginWindow::applySavedLogin()
 {
     const auto &u = UserSettings::instance();

+ 1 - 0
Client/src/auth/LoginWindow.h

@@ -23,6 +23,7 @@ public:
     explicit LoginWindow(QWidget *parent = nullptr);
     QString lastAccount() const;
     void prepareAfterLogout();
+    void showLogoutNotice(const QString &reason);
     bool willAutoLogin() const;
 
 signals:

+ 528 - 7
Client/src/chat/FileCache.cpp

@@ -14,11 +14,224 @@
 #include <QImage>
 #include <QImageReader>
 #include <QIODevice>
+#include <QColor>
 #include <QJsonObject>
 #include <QJsonValue>
 #include <QMap>
+#include <QAbstractVideoBuffer>
+#include <QAbstractVideoSurface>
+#include <QMediaContent>
+#include <QMediaPlayer>
 #include <QPointer>
 #include <QRegularExpression>
+#include <QRunnable>
+#include <QThreadPool>
+#include <QTimer>
+#include <QUrl>
+#include <QVideoFrame>
+#include <QVideoSurfaceFormat>
+
+#include <functional>
+#include <memory>
+
+namespace {
+inline quint8 clampByte(int v)
+{
+    return static_cast<quint8>(v < 0 ? 0 : (v > 255 ? 255 : v));
+}
+
+inline QRgb yuvToRgb(int y, int u, int v)
+{
+    const int c = y - 16;
+    const int d = u - 128;
+    const int e = v - 128;
+    return qRgb(clampByte((298 * c + 409 * e + 128) >> 8),
+                clampByte((298 * c - 100 * d - 208 * e + 128) >> 8),
+                clampByte((298 * c + 516 * d + 128) >> 8));
+}
+
+QImage nv12ToImage(const uchar *data, int w, int h, int yStride)
+{
+    if (!data || w <= 0 || h <= 0 || yStride < w)
+        return {};
+    QImage img(w, h, QImage::Format_RGB32);
+    const uchar *uv = data + yStride * h;
+    for (int row = 0; row < h; ++row)
+    {
+        auto *dst = reinterpret_cast<QRgb *>(img.scanLine(row));
+        const uchar *yp = data + row * yStride;
+        const uchar *uvp = uv + (row / 2) * yStride;
+        for (int x = 0; x < w; ++x)
+        {
+            const int uvx = x & ~1;
+            dst[x] = yuvToRgb(yp[x], uvp[uvx], uvp[uvx + 1]);
+        }
+    }
+    return img;
+}
+
+QImage yuyvToImage(const uchar *data, int w, int h, int stride)
+{
+    if (!data || w <= 0 || h <= 0 || stride < w * 2)
+        return {};
+    QImage img(w, h, QImage::Format_RGB32);
+    for (int row = 0; row < h; ++row)
+    {
+        auto *dst = reinterpret_cast<QRgb *>(img.scanLine(row));
+        const uchar *src = data + row * stride;
+        for (int x = 0; x + 1 < w; x += 2)
+        {
+            const int y0 = src[0];
+            const int u = src[1];
+            const int y1 = src[2];
+            const int v = src[3];
+            dst[x] = yuvToRgb(y0, u, v);
+            dst[x + 1] = yuvToRgb(y1, u, v);
+            src += 4;
+        }
+    }
+    return img;
+}
+
+class PosterSurface : public QAbstractVideoSurface
+{
+public:
+    explicit PosterSurface(QObject *parent = nullptr)
+        : QAbstractVideoSurface(parent)
+    {
+    }
+
+    std::function<void(const QImage &)> onFrame;
+
+    QList<QVideoFrame::PixelFormat> supportedPixelFormats(
+        QAbstractVideoBuffer::HandleType type = QAbstractVideoBuffer::NoHandle) const override
+    {
+        Q_UNUSED(type);
+        return {QVideoFrame::Format_RGB32, QVideoFrame::Format_ARGB32,
+                QVideoFrame::Format_ARGB32_Premultiplied, QVideoFrame::Format_RGB24,
+                QVideoFrame::Format_Jpeg, QVideoFrame::Format_NV12, QVideoFrame::Format_YUYV};
+    }
+
+    bool present(const QVideoFrame &frame) override
+    {
+        if (m_done)
+            return true;
+        if (!frame.isValid())
+            return true;
+        QVideoFrame clone(frame);
+        if (!clone.map(QAbstractVideoBuffer::ReadOnly))
+            return false;
+        QImage img;
+        const QImage::Format fmt = QVideoFrame::imageFormatFromPixelFormat(clone.pixelFormat());
+        if (fmt != QImage::Format_Invalid)
+        {
+            img = QImage(clone.bits(), clone.width(), clone.height(), clone.bytesPerLine(), fmt)
+                      .copy();
+        }
+        else if (clone.pixelFormat() == QVideoFrame::Format_Jpeg)
+        {
+            img.loadFromData(clone.bits(), clone.mappedBytes(), "JPEG");
+        }
+        else if (clone.pixelFormat() == QVideoFrame::Format_NV12)
+        {
+            img = nv12ToImage(clone.bits(), clone.width(), clone.height(), clone.bytesPerLine());
+        }
+        else if (clone.pixelFormat() == QVideoFrame::Format_YUYV)
+        {
+            img = yuyvToImage(clone.bits(), clone.width(), clone.height(), clone.bytesPerLine());
+        }
+        clone.unmap();
+        if (img.isNull())
+            return true;
+        if (img.format() != QImage::Format_RGB32 && img.format() != QImage::Format_ARGB32
+            && img.format() != QImage::Format_RGB888)
+            img = img.convertToFormat(QImage::Format_RGB32);
+        m_done = true;
+        if (onFrame)
+            onFrame(img);
+        return true;
+    }
+
+private:
+    bool m_done = false;
+};
+}
+
+class ThumbJob : public QRunnable
+{
+public:
+    QPointer<FileCache> cache;
+    qint64 fileId = 0;
+    int maxEdge = 180;
+    QString path;
+    QByteArray bytes;
+    bool video = false;
+    QImage poster;
+    qreal dpr = 1.0;
+
+    void run() override
+    {
+        QImage img;
+        int srcW = 0;
+        int srcH = 0;
+        if (!video && !path.isEmpty())
+        {
+            QImageReader reader(path);
+            reader.setAutoTransform(true);
+            QSize sz = reader.size();
+            srcW = sz.width();
+            srcH = sz.height();
+            if (sz.isValid())
+            {
+                const int cap = qMax(32, maxEdge);
+                sz.scale(QSize(cap, cap), Qt::KeepAspectRatio);
+                reader.setScaledSize(sz);
+            }
+            img = reader.read();
+        }
+        if (img.isNull() && !video && !bytes.isEmpty())
+            img.loadFromData(bytes);
+        if (img.isNull() && !poster.isNull())
+            img = poster;
+        if (srcW <= 0 || srcH <= 0)
+        {
+            if (!poster.isNull())
+            {
+                srcW = poster.width();
+                srcH = poster.height();
+            }
+            else if (!img.isNull())
+            {
+                srcW = img.width();
+                srcH = img.height();
+            }
+        }
+        QImage out;
+        if (!img.isNull())
+        {
+            const int cap = qMax(32, maxEdge);
+            QSize scaled = img.size();
+            scaled.scale(QSize(cap, cap), Qt::KeepAspectRatio);
+            out = img.scaled(qRound(scaled.width() * dpr), qRound(scaled.height() * dpr),
+                             Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
+            out.setDevicePixelRatio(dpr);
+        }
+        FileCache *c = cache.data();
+        if (!c)
+            return;
+        QMetaObject::invokeMethod(c, "finishThumb", Qt::QueuedConnection,
+                                  Q_ARG(qint64, fileId), Q_ARG(int, maxEdge), Q_ARG(QImage, out));
+        if (srcW > 0 && srcH > 0)
+        {
+            const qint64 id = fileId;
+            const int w = srcW;
+            const int h = srcH;
+            QMetaObject::invokeMethod(c, [c, id, w, h]() {
+                c->rememberMediaSize(id, w, h);
+            }, Qt::QueuedConnection);
+        }
+    }
+};
 
 FileCache &FileCache::instance()
 {
@@ -42,6 +255,29 @@ bool FileCache::looksLikeImage(const QString &mime, const QString &name)
         || ext == QLatin1String("bmp") || ext == QLatin1String("webp");
 }
 
+bool FileCache::looksLikeVideo(const QString &mime, const QString &name)
+{
+    const QString m = mime.toLower();
+    if (m.startsWith(QStringLiteral("video/")))
+        return true;
+    const QString ext = QFileInfo(name).suffix().toLower();
+    return ext == QLatin1String("mp4") || ext == QLatin1String("mov")
+        || ext == QLatin1String("m4v") || ext == QLatin1String("avi")
+        || ext == QLatin1String("mkv") || ext == QLatin1String("webm")
+        || ext == QLatin1String("wmv") || ext == QLatin1String("flv")
+        || ext == QLatin1String("mpeg") || ext == QLatin1String("mpg")
+        || ext == QLatin1String("3gp");
+}
+
+QString FileCache::convPreview(const QString &mime, const QString &name)
+{
+    if (looksLikeImage(mime, name))
+        return QStringLiteral("[图片]");
+    if (looksLikeVideo(mime, name))
+        return QStringLiteral("[视频]");
+    return name.isEmpty() ? QStringLiteral("[文件]") : QStringLiteral("[文件] %1").arg(name);
+}
+
 QString FileCache::formatSize(qint64 bytes)
 {
     if (bytes < 0)
@@ -246,6 +482,8 @@ void FileCache::remember(qint64 fileId, const QByteArray &bytes, const QString &
     m_localPath.insert(fileId, dest);
     emit ready(fileId);
     emit statusChanged(fileId);
+    if (looksLikeVideo(m_mime.value(fileId), m_name.value(fileId)))
+        requestVideoPoster(fileId);
 }
 
 bool FileCache::rememberPath(qint64 fileId, const QString &srcPath, const QString &mime, const QString &name)
@@ -273,6 +511,8 @@ bool FileCache::rememberPath(qint64 fileId, const QString &srcPath, const QStrin
     m_localPath.insert(fileId, dest);
     m_pendingUpload.insert(fileId);
     emit statusChanged(fileId);
+    if (looksLikeVideo(m_mime.value(fileId), m_name.value(fileId, src.fileName())))
+        requestVideoPoster(fileId);
     return true;
 }
 
@@ -439,11 +679,96 @@ QString FileCache::name(qint64 fileId) const
     return m_name.value(fileId);
 }
 
+QPixmap FileCache::cachedThumbnail(qint64 fileId, int maxEdge) const
+{
+    return m_thumb.value(QStringLiteral("%1/%2").arg(fileId).arg(maxEdge));
+}
+
+QSize FileCache::mediaSize(qint64 fileId) const
+{
+    const_cast<FileCache *>(this)->loadMediaSizes();
+    return m_mediaSize.value(fileId);
+}
+
+void FileCache::rememberMediaSize(qint64 fileId, int w, int h)
+{
+    if (fileId <= 0 || w <= 0 || h <= 0)
+        return;
+    loadMediaSizes();
+    const QSize sz(w, h);
+    if (m_mediaSize.value(fileId) == sz)
+        return;
+    m_mediaSize.insert(fileId, sz);
+    persistMediaSizes();
+}
+
+QSize FileCache::probeImageSize(qint64 fileId)
+{
+    QSize s = mediaSize(fileId);
+    if (!s.isEmpty())
+        return s;
+    const QString path = m_localPath.value(fileId);
+    if (path.isEmpty() || !QFileInfo::exists(path))
+        return {};
+    if (!looksLikeImage(m_mime.value(fileId), m_name.value(fileId)))
+        return {};
+    QImageReader reader(path);
+    reader.setAutoTransform(true);
+    s = reader.size();
+    if (s.width() > 0 && s.height() > 0)
+        rememberMediaSize(fileId, s.width(), s.height());
+    return s;
+}
+
+void FileCache::loadMediaSizes()
+{
+    if (m_mediaSizeLoaded)
+        return;
+    m_mediaSizeLoaded = true;
+    const QString path = cacheDir() + QStringLiteral("/dims.txt");
+    QFile f(path);
+    if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
+        return;
+    while (!f.atEnd())
+    {
+        const QByteArray line = f.readLine().trimmed();
+        if (line.isEmpty())
+            continue;
+        const QList<QByteArray> parts = line.split(' ');
+        if (parts.size() < 3)
+            continue;
+        const qint64 id = parts.at(0).toLongLong();
+        const int w = parts.at(1).toInt();
+        const int h = parts.at(2).toInt();
+        if (id > 0 && w > 0 && h > 0)
+            m_mediaSize.insert(id, QSize(w, h));
+    }
+}
+
+void FileCache::persistMediaSizes()
+{
+    const QString dir = cacheDir();
+    if (dir.isEmpty())
+        return;
+    QDir().mkpath(dir);
+    QFile f(dir + QStringLiteral("/dims.txt"));
+    if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text))
+        return;
+    for (auto it = m_mediaSize.constBegin(); it != m_mediaSize.constEnd(); ++it)
+    {
+        const QSize s = it.value();
+        f.write(QByteArray::number(it.key()) + ' ' + QByteArray::number(s.width()) + ' '
+                + QByteArray::number(s.height()) + '\n');
+    }
+}
+
 QPixmap FileCache::thumbnail(qint64 fileId, int maxEdge)
 {
-    const QString key = QStringLiteral("%1/%2").arg(fileId).arg(maxEdge);
-    if (m_thumb.contains(key))
-        return m_thumb.value(key);
+    const QPixmap cached = cachedThumbnail(fileId, maxEdge);
+    if (!cached.isNull())
+        return cached;
+    if (looksLikeVideo(m_mime.value(fileId), m_name.value(fileId)))
+        return {};
     QImage img;
     const QString path = findComplete(fileId);
     if (!path.isEmpty())
@@ -465,24 +790,218 @@ QPixmap FileCache::thumbnail(qint64 fileId, int maxEdge)
         return {};
     const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
     const int cap = qMax(32, maxEdge);
-    QSize box(cap, cap);
     QSize scaled = img.size();
-    scaled.scale(box, Qt::KeepAspectRatio);
+    scaled.scale(QSize(cap, cap), Qt::KeepAspectRatio);
     const QImage out = img.scaled(qRound(scaled.width() * dpr), qRound(scaled.height() * dpr),
                                   Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
     QPixmap pm = QPixmap::fromImage(out);
     pm.setDevicePixelRatio(dpr);
-    m_thumb.insert(key, pm);
+    m_thumb.insert(QStringLiteral("%1/%2").arg(fileId).arg(maxEdge), pm);
     return pm;
 }
 
+void FileCache::requestThumbnail(qint64 fileId, int maxEdge)
+{
+    if (fileId <= 0)
+        return;
+    const QString key = QStringLiteral("%1/%2").arg(fileId).arg(maxEdge);
+    if (m_thumb.contains(key))
+    {
+        emit thumbReady(fileId, maxEdge);
+        return;
+    }
+    if (m_thumbQueued.contains(key))
+        return;
+    m_thumbQueued.insert(key);
+    m_thumbWait.enqueue(qMakePair(fileId, maxEdge));
+    pumpThumbs();
+}
+
+void FileCache::pumpThumbs()
+{
+    while (m_thumbActive < 2 && !m_thumbWait.isEmpty())
+    {
+        const auto job = m_thumbWait.dequeue();
+        ++m_thumbActive;
+        startThumbJob(job.first, job.second);
+    }
+}
+
+void FileCache::startThumbJob(qint64 fileId, int maxEdge)
+{
+    auto *job = new ThumbJob;
+    job->setAutoDelete(true);
+    job->cache = this;
+    job->fileId = fileId;
+    job->maxEdge = maxEdge;
+    job->path = findComplete(fileId);
+    job->bytes = m_bytes.value(fileId);
+    job->video = looksLikeVideo(m_mime.value(fileId), m_name.value(fileId, QFileInfo(job->path).fileName()));
+    if (job->video && m_posterSrc.contains(fileId))
+        job->poster = m_posterSrc.value(fileId).toImage();
+    job->dpr = qApp ? qApp->devicePixelRatio() : 1.0;
+    QThreadPool::globalInstance()->start(job);
+}
+
+void FileCache::finishThumb(qint64 fileId, int maxEdge, QImage img)
+{
+    if (m_thumbActive > 0)
+        --m_thumbActive;
+    const QString key = QStringLiteral("%1/%2").arg(fileId).arg(maxEdge);
+    if (img.isNull())
+    {
+        m_thumbQueued.remove(key);
+        pumpThumbs();
+        return;
+    }
+    const qreal dpr = img.devicePixelRatio() > 0 ? img.devicePixelRatio() : 1.0;
+    QPixmap pm = QPixmap::fromImage(img);
+    pm.setDevicePixelRatio(dpr);
+    m_thumb.insert(key, pm);
+    m_thumbQueued.remove(key);
+    emit thumbReady(fileId, maxEdge);
+    pumpThumbs();
+}
+
+void FileCache::requestVideoPoster(qint64 fileId)
+{
+    if (fileId <= 0 || m_posterSrc.contains(fileId))
+        return;
+    if (m_posterGrabbing.contains(fileId))
+        return;
+    for (qint64 id : m_posterWait)
+    {
+        if (id == fileId)
+            return;
+    }
+    const QString path = findComplete(fileId);
+    if (path.isEmpty() || !QFileInfo::exists(path))
+        return;
+    const QString name = m_name.value(fileId, QFileInfo(path).fileName());
+    if (!looksLikeVideo(m_mime.value(fileId), name))
+        return;
+    m_posterWait.enqueue(fileId);
+    pumpPosters();
+}
+
+void FileCache::pumpPosters()
+{
+    while (m_posterActive < 1 && !m_posterWait.isEmpty())
+    {
+        const qint64 id = m_posterWait.dequeue();
+        if (m_posterSrc.contains(id) || findComplete(id).isEmpty())
+            continue;
+        ++m_posterActive;
+        beginPosterGrab(id);
+    }
+}
+
+void FileCache::beginPosterGrab(qint64 fileId)
+{
+    auto abortGrab = [this]() {
+        if (m_posterActive > 0)
+            --m_posterActive;
+        pumpPosters();
+    };
+    if (fileId <= 0 || m_posterSrc.contains(fileId) || m_posterGrabbing.contains(fileId))
+    {
+        abortGrab();
+        return;
+    }
+    const QString path = findComplete(fileId);
+    if (path.isEmpty() || !QFileInfo::exists(path))
+    {
+        abortGrab();
+        return;
+    }
+    const QString name = m_name.value(fileId, QFileInfo(path).fileName());
+    if (!looksLikeVideo(m_mime.value(fileId), name))
+    {
+        abortGrab();
+        return;
+    }
+
+    m_posterGrabbing.insert(fileId);
+    auto *player = new QMediaPlayer(this);
+    auto *surface = new PosterSurface(player);
+    auto *timeout = new QTimer(player);
+    timeout->setSingleShot(true);
+    timeout->setInterval(5000);
+
+    auto finished = std::make_shared<bool>(false);
+    QPointer<FileCache> self(this);
+    auto finish = [self, fileId, player, finished](const QImage &img) {
+        if (*finished)
+            return;
+        *finished = true;
+        if (!self)
+        {
+            player->deleteLater();
+            return;
+        }
+        self->m_posterGrabbing.remove(fileId);
+        if (self->m_posterActive > 0)
+            --self->m_posterActive;
+        player->stop();
+        player->setVideoOutput(static_cast<QAbstractVideoSurface *>(nullptr));
+        if (!img.isNull())
+            self->finishPoster(fileId, QPixmap::fromImage(img));
+        player->deleteLater();
+        self->pumpPosters();
+    };
+
+    surface->onFrame = [self, finish](const QImage &img) {
+        if (!self)
+            return;
+        const QImage copy = img;
+        QMetaObject::invokeMethod(self.data(), [finish, copy]() { finish(copy); },
+                                  Qt::QueuedConnection);
+    };
+    QObject::connect(timeout, &QTimer::timeout, player, [finish]() { finish(QImage()); });
+    QObject::connect(
+        player, static_cast<void (QMediaPlayer::*)(QMediaPlayer::Error)>(&QMediaPlayer::error),
+        player, [finish](QMediaPlayer::Error e) {
+            if (e != QMediaPlayer::NoError)
+                finish(QImage());
+        });
+    player->setMuted(true);
+    player->setVolume(0);
+    player->setVideoOutput(surface);
+    player->setMedia(QMediaContent(QUrl::fromLocalFile(path)));
+    timeout->start();
+    player->play();
+}
+
+void FileCache::finishPoster(qint64 fileId, const QPixmap &pm)
+{
+    if (fileId <= 0 || pm.isNull())
+        return;
+    m_posterSrc.insert(fileId, pm);
+    const qreal dpr = pm.devicePixelRatio() > 0 ? pm.devicePixelRatio() : 1.0;
+    if (mediaSize(fileId).isEmpty())
+        rememberMediaSize(fileId, qMax(1, qRound(pm.width() / dpr)),
+                          qMax(1, qRound(pm.height() / dpr)));
+    const QString prefix = QString::number(fileId) + QLatin1Char('/');
+    for (auto it = m_thumb.begin(); it != m_thumb.end();)
+    {
+        if (it.key().startsWith(prefix))
+            it = m_thumb.erase(it);
+        else
+            ++it;
+    }
+    emit posterReady(fileId);
+    requestThumbnail(fileId, 180);
+}
+
 void FileCache::ensure(qint64 fileId, const QString &url, const QString &mime, const QString &name)
 {
     if (fileId <= 0 || m_fetching.contains(fileId))
         return;
     noteMeta(fileId, url, mime, name, 0);
-    if (loadDisk(fileId))
+    const QString local = findComplete(fileId);
+    if (!local.isEmpty())
     {
+        m_localPath.insert(fileId, local);
         emit ready(fileId);
         emit statusChanged(fileId);
         return;
@@ -727,4 +1246,6 @@ void FileCache::finishDownload(qint64 fileId, bool ok, const QString &error)
     m_size.insert(fileId, QFileInfo(dest).size());
     emit ready(fileId);
     emit statusChanged(fileId);
+    if (looksLikeVideo(m_mime.value(fileId), m_name.value(fileId)))
+        requestVideoPoster(fileId);
 }

+ 33 - 0
Client/src/chat/FileCache.h

@@ -2,11 +2,15 @@
 
 #include <QByteArray>
 #include <QHash>
+#include <QImage>
 #include <QJsonObject>
 #include <QMap>
 #include <QObject>
+#include <QPair>
 #include <QPixmap>
+#include <QQueue>
 #include <QSet>
+#include <QSize>
 #include <QString>
 #include <atomic>
 #include <memory>
@@ -27,6 +31,8 @@ public:
     static FileCache &instance();
 
     static bool looksLikeImage(const QString &mime, const QString &name = QString());
+    static bool looksLikeVideo(const QString &mime, const QString &name = QString());
+    static QString convPreview(const QString &mime, const QString &name);
     static QString formatSize(qint64 bytes);
     static QString formatSpeed(double bytesPerSec);
     static QString statusText(Status st);
@@ -40,6 +46,12 @@ public:
     QString name(qint64 fileId) const;
     QString localPath(qint64 fileId) const;
     QPixmap thumbnail(qint64 fileId, int maxEdge);
+    QPixmap cachedThumbnail(qint64 fileId, int maxEdge) const;
+    QSize mediaSize(qint64 fileId) const;
+    void rememberMediaSize(qint64 fileId, int w, int h);
+    QSize probeImageSize(qint64 fileId);
+    void requestThumbnail(qint64 fileId, int maxEdge);
+    void requestVideoPoster(qint64 fileId);
     void ensure(qint64 fileId, const QString &url = QString(), const QString &mime = QString(),
                 const QString &name = QString());
 
@@ -61,6 +73,11 @@ signals:
     void statusChanged(qint64 fileId);
     void progress(qint64 fileId, qint64 received, qint64 total, double bytesPerSec);
     void failed(qint64 fileId, const QString &error);
+    void posterReady(qint64 fileId);
+    void thumbReady(qint64 fileId, int maxEdge);
+
+private slots:
+    void finishThumb(qint64 fileId, int maxEdge, QImage img);
 
 private:
     struct Job
@@ -86,6 +103,13 @@ private:
     void noteMeta(qint64 fileId, const QString &url, const QString &mime, const QString &name,
                   qint64 size);
     void finishUpload(qint64 fileId, bool ok, const QString &error);
+    void finishPoster(qint64 fileId, const QPixmap &pm);
+    void pumpThumbs();
+    void startThumbJob(qint64 fileId, int maxEdge);
+    void pumpPosters();
+    void beginPosterGrab(qint64 fileId);
+    void loadMediaSizes();
+    void persistMediaSizes();
     static QMap<QString, QString> headerMap(const QJsonObject &headers);
 
     QHash<qint64, QByteArray> m_bytes;
@@ -95,6 +119,15 @@ private:
     QHash<qint64, QString> m_localPath;
     QHash<qint64, qint64> m_size;
     QHash<QString, QPixmap> m_thumb;
+    QHash<qint64, QPixmap> m_posterSrc;
+    QSet<qint64> m_posterGrabbing;
+    QQueue<qint64> m_posterWait;
+    int m_posterActive = 0;
+    QQueue<QPair<qint64, int>> m_thumbWait;
+    QSet<QString> m_thumbQueued;
+    int m_thumbActive = 0;
+    QHash<qint64, QSize> m_mediaSize;
+    bool m_mediaSizeLoaded = false;
     QSet<qint64> m_fetching;
     QHash<qint64, Job> m_jobs;
     QHash<qint64, Job> m_uploads;

+ 113 - 21
Client/src/common/AppConfig.cpp

@@ -1,7 +1,11 @@
 #include "AppConfig.h"
 #include "Logger.h"
 
+#include <algorithm>
+
 #include <QDir>
+#include <QJsonArray>
+#include <QJsonObject>
 #include <QJsonValue>
 #include <QList>
 #include <QStandardPaths>
@@ -47,6 +51,14 @@ void AppConfig::applyServerLimits(const QJsonObject &limits)
             return v.toString().toInt();
         return 0;
     };
+    auto take64 = [](const QJsonObject &o, const QString &key) -> qint64 {
+        const QJsonValue v = o.value(key);
+        if (v.isDouble())
+            return static_cast<qint64>(v.toDouble());
+        if (v.isString())
+            return v.toString().toLongLong();
+        return 0;
+    };
 
     m_accountMin = take(limits, QStringLiteral("account_min"));
     m_accountMax = take(limits, QStringLiteral("account_max"));
@@ -61,7 +73,7 @@ void AppConfig::applyServerLimits(const QJsonObject &limits)
     m_historyMax = take(limits, QStringLiteral("history_max"));
     m_codeLen = take(limits, QStringLiteral("code_len"));
     m_avatarMax = take(limits, QStringLiteral("avatar_max"));
-    m_fileMax = take(limits, QStringLiteral("file_max"));
+    m_fileMax = take64(limits, QStringLiteral("file_max"));
     m_groupMaxMembers = take(limits, QStringLiteral("group_max_members"));
     m_groupNameMax = take(limits, QStringLiteral("group_name_max"));
     m_groupMaxAdmins = take(limits, QStringLiteral("group_max_admins"));
@@ -72,7 +84,7 @@ void AppConfig::applyServerLimits(const QJsonObject &limits)
                             "max_packet=%7 heartbeat=%8s sms_interval=%9s")
                  .arg(m_accountMin).arg(m_accountMax)
                  .arg(m_passwordMin).arg(m_passwordMax)
-                 .arg(m_fileMax).arg(m_avatarMax)
+                 .arg(QString::number(m_fileMax)).arg(m_avatarMax)
                  .arg(m_maxPacket).arg(m_heartbeatSec).arg(m_smsIntervalSec));
 }
 
@@ -88,37 +100,117 @@ void AppConfig::applyServerCdn(const QJsonObject &cdn)
 
 void AppConfig::applyServerUpdate(const QJsonObject &update)
 {
-    m_updateVersion = update.value(QStringLiteral("version")).toString().trimmed();
-    m_updateUrl = update.value(QStringLiteral("url")).toString().trimmed();
+    m_releases.clear();
     m_updateNotes = update.value(QStringLiteral("notes")).toString().trimmed();
-    if (!m_updateVersion.isEmpty())
-        LOG_INFO(QStringLiteral("server client version=%1 url=%2")
-                     .arg(m_updateVersion, m_updateUrl));
+    m_updateLatest = update.value(QStringLiteral("latest")).toString().trimmed();
+    if (m_updateLatest.isEmpty())
+        m_updateLatest = update.value(QStringLiteral("version")).toString().trimmed();
+
+    const QJsonValue rels = update.value(QStringLiteral("releases"));
+    if (rels.isArray())
+    {
+        const QJsonArray arr = rels.toArray();
+        for (const QJsonValue &v : arr)
+        {
+            const UpdateRelease r = parseRelease(v.toObject());
+            if (!r.version.isEmpty() && !r.urls.isEmpty())
+                m_releases.append(r);
+        }
+    }
+    else
+    {
+        const UpdateRelease r = parseRelease(update);
+        if (!r.version.isEmpty() && !r.urls.isEmpty())
+            m_releases.append(r);
+    }
+
+    std::sort(m_releases.begin(), m_releases.end(),
+              [](const UpdateRelease &a, const UpdateRelease &b) {
+                  return compareVersion(a.version, b.version) < 0;
+              });
+    if (m_updateLatest.isEmpty() && !m_releases.isEmpty())
+        m_updateLatest = m_releases.last().version;
+    if (!m_updateLatest.isEmpty())
+        LOG_INFO(QStringLiteral("server client latest=%1 releases=%2")
+                     .arg(m_updateLatest)
+                     .arg(m_releases.size()));
 }
 
-bool AppConfig::hasNewerUpdate() const
+AppConfig::UpdateRelease AppConfig::parseRelease(const QJsonObject &o)
+{
+    UpdateRelease r;
+    r.version = o.value(QStringLiteral("version")).toString().trimmed();
+    r.notes = o.value(QStringLiteral("notes")).toString().trimmed();
+    const QJsonValue urls = o.value(QStringLiteral("urls"));
+    if (urls.isArray())
+    {
+        const QJsonArray arr = urls.toArray();
+        for (const QJsonValue &v : arr)
+        {
+            const QString u = v.toString().trimmed();
+            if (!u.isEmpty() && !r.urls.contains(u))
+                r.urls.append(u);
+        }
+    }
+    const QString one = o.value(QStringLiteral("url")).toString().trimmed();
+    if (!one.isEmpty() && !r.urls.contains(one))
+        r.urls.append(one);
+    return r;
+}
+
+int AppConfig::compareVersion(const QString &a, const QString &b)
 {
-    if (m_updateVersion.isEmpty() || m_updateUrl.isEmpty())
-        return false;
     auto parts = [](const QString &s) {
         QList<int> out;
         const QStringList bits = s.split(QLatin1Char('.'));
-        for (const QString &b : bits)
-            out.append(b.toInt());
-        while (out.size() < 3)
+        for (const QString &bit : bits)
+        {
+            QString num;
+            for (const QChar c : bit)
+            {
+                if (c.isDigit())
+                    num.append(c);
+                else
+                    break;
+            }
+            out.append(num.toInt());
+        }
+        if (out.isEmpty())
             out.append(0);
         return out;
     };
-    const QList<int> remote = parts(m_updateVersion);
-    const QList<int> local = parts(appVersion());
-    for (int i = 0; i < 3; ++i)
+    QList<int> pa = parts(a);
+    QList<int> pb = parts(b);
+    const int n = qMax(pa.size(), pb.size());
+    while (pa.size() < n)
+        pa.append(0);
+    while (pb.size() < n)
+        pb.append(0);
+    for (int i = 0; i < n; ++i)
     {
-        if (remote.at(i) > local.at(i))
-            return true;
-        if (remote.at(i) < local.at(i))
-            return false;
+        if (pa.at(i) < pb.at(i))
+            return -1;
+        if (pa.at(i) > pb.at(i))
+            return 1;
+    }
+    return 0;
+}
+
+QVector<AppConfig::UpdateRelease> AppConfig::pendingUpdates() const
+{
+    QVector<UpdateRelease> out;
+    const QString local = appVersion();
+    for (const UpdateRelease &r : m_releases)
+    {
+        if (compareVersion(r.version, local) > 0)
+            out.append(r);
     }
-    return false;
+    return out;
+}
+
+bool AppConfig::hasNewerUpdate() const
+{
+    return !pendingUpdates().isEmpty();
 }
 
 QString AppConfig::resourceUrl(const QString &path) const

+ 17 - 6
Client/src/common/AppConfig.h

@@ -2,6 +2,8 @@
 
 #include <QJsonObject>
 #include <QString>
+#include <QStringList>
+#include <QVector>
 #include <QtGlobal>
 
 #include "ClientConfig.h"
@@ -9,9 +11,17 @@
 class AppConfig
 {
 public:
+    struct UpdateRelease
+    {
+        QString version;
+        QString notes;
+        QStringList urls;
+    };
+
     static AppConfig &instance();
 
     static QString dataDir();
+    static int compareVersion(const QString &a, const QString &b);
 
     bool load();
 
@@ -40,14 +50,14 @@ public:
     int historyMax() const { return m_historyMax; }
     int codeLen() const { return m_codeLen; }
     int avatarMax() const { return m_avatarMax; }
-    int fileMax() const { return m_fileMax; }
+    qint64 fileMax() const { return m_fileMax; }
     int groupMaxMembers() const { return m_groupMaxMembers; }
     int groupNameMax() const { return m_groupNameMax; }
     int groupMaxAdmins() const { return m_groupMaxAdmins; }
     QString cdnDomain() const { return m_cdnDomain; }
-    QString updateVersion() const { return m_updateVersion; }
-    QString updateUrl() const { return m_updateUrl; }
+    QString updateVersion() const { return m_updateLatest; }
     QString updateNotes() const { return m_updateNotes; }
+    QVector<UpdateRelease> pendingUpdates() const;
     bool hasNewerUpdate() const;
 
     void applyServerLimits(const QJsonObject &limits);
@@ -63,6 +73,7 @@ public:
 
 private:
     AppConfig() = default;
+    static UpdateRelease parseRelease(const QJsonObject &o);
 
     quint32 m_maxPacket = 0;
     int m_heartbeatSec = 0;
@@ -80,12 +91,12 @@ private:
     int m_historyMax = 0;
     int m_codeLen = 0;
     int m_avatarMax = 0;
-    int m_fileMax = 0;
+    qint64 m_fileMax = 0;
     int m_groupMaxMembers = 200;
     int m_groupNameMax = 32;
     int m_groupMaxAdmins = 10;
     QString m_cdnDomain;
-    QString m_updateVersion;
-    QString m_updateUrl;
+    QString m_updateLatest;
     QString m_updateNotes;
+    QVector<UpdateRelease> m_releases;
 };

+ 3 - 3
Client/src/common/ClientConfig.h

@@ -6,12 +6,12 @@
 
 namespace ClientConfig
 {
-//constexpr const char *kHost = "49.232.135.144";
-constexpr const char* kHost = "192.168.94.166";
+constexpr const char *kHost = "49.232.135.144";
+//constexpr const char* kHost = "192.168.94.166";
 constexpr uint16_t kPort = 6000;
 constexpr uint16_t kMagic = 0x494D;
 constexpr bool kLogEnabled = true;
 constexpr int kLogLevel = 0;
-constexpr const char *kVersion = "1.0.1";
+constexpr const char *kVersion = "1.0.3";
 constexpr im::i18n::Lang kLang = im::i18n::Lang::Zh;
 }

+ 83 - 1
Client/src/common/UiHelpers.cpp

@@ -1,9 +1,12 @@
 #include "UiHelpers.h"
 #include "AppTheme.h"
 
+#include <QAbstractScrollArea>
 #include <QAction>
 #include <QApplication>
 #include <QCheckBox>
+#include <QEasingCurve>
+#include <QAbstractAnimation>
 #include <QGraphicsOpacityEffect>
 #include <QIcon>
 #include <QLabel>
@@ -12,10 +15,12 @@
 #include <QPainterPath>
 #include <QPropertyAnimation>
 #include <QPushButton>
+#include <QScrollBar>
 #include <QSizePolicy>
 #include <QStyle>
 #include <QTimer>
 #include <QVBoxLayout>
+#include <QWheelEvent>
 #include <QFile>
 #include <QDir>
 
@@ -73,6 +78,75 @@ namespace
         }
         return pm;
     }
+
+    class SmoothScrollHelper : public QObject
+    {
+    public:
+        explicit SmoothScrollHelper(QAbstractScrollArea *area)
+            : QObject(area)
+            , m_area(area)
+        {
+            m_anim = new QPropertyAnimation(this);
+            m_anim->setEasingCurve(QEasingCurve::OutCubic);
+            area->installEventFilter(this);
+            if (area->viewport())
+                area->viewport()->installEventFilter(this);
+            if (auto *v = area->verticalScrollBar())
+                v->installEventFilter(this);
+            if (auto *h = area->horizontalScrollBar())
+                h->installEventFilter(this);
+        }
+
+    protected:
+        bool eventFilter(QObject *watched, QEvent *event) override
+        {
+            Q_UNUSED(watched);
+            if (event->type() != QEvent::Wheel || !m_area)
+                return QObject::eventFilter(watched, event);
+            auto *we = static_cast<QWheelEvent *>(event);
+            const QPoint pixel = we->pixelDelta();
+            const QPoint angle = we->angleDelta();
+            const bool horiz = qAbs(pixel.x()) > qAbs(pixel.y())
+                || (pixel.isNull() && qAbs(angle.x()) > qAbs(angle.y()));
+            QScrollBar *bar = horiz ? m_area->horizontalScrollBar() : m_area->verticalScrollBar();
+            if (!bar || bar->maximum() <= bar->minimum())
+                return QObject::eventFilter(watched, event);
+
+            int delta = 0;
+            if (horiz)
+                delta = !pixel.isNull() ? pixel.x() : qRound(angle.x() * 96.0 / 120.0);
+            else
+                delta = !pixel.isNull() ? pixel.y() : qRound(angle.y() * 96.0 / 120.0);
+            if (delta == 0)
+                return QObject::eventFilter(watched, event);
+
+            if (m_anim->state() == QAbstractAnimation::Running && m_anim->targetObject() == bar)
+                m_target -= delta;
+            else
+                m_target = bar->value() - delta;
+            m_target = qBound(bar->minimum(), m_target, bar->maximum());
+            if (m_target == bar->value())
+                return true;
+
+            const int dist = qAbs(m_target - bar->value());
+            const int dur = pixel.isNull()
+                ? qBound(160, 140 + dist / 3, 340)
+                : qBound(80, 70 + dist / 4, 160);
+            m_anim->stop();
+            m_anim->setTargetObject(bar);
+            m_anim->setPropertyName("value");
+            m_anim->setStartValue(bar->value());
+            m_anim->setEndValue(m_target);
+            m_anim->setDuration(dur);
+            m_anim->start();
+            return true;
+        }
+
+    private:
+        QAbstractScrollArea *m_area = nullptr;
+        QPropertyAnimation *m_anim = nullptr;
+        int m_target = 0;
+    };
 }
 
 namespace UiHelpers
@@ -140,7 +214,7 @@ namespace UiHelpers
         btn->setMinimumWidth(120);
         btn->setStyleSheet(QStringLiteral(
             "QPushButton { background-color:#2E2E2E; color:#FFFFFF; border:1px solid #2E2E2E;"
-            " font-size:13px; border-radius:2px; padding:0 16px; }"
+            " font-size:12px; border-radius:2px; padding:0 16px; }"
             "QPushButton:hover { background-color:#3E3E3E; border-color:#3E3E3E; color:#FFFFFF; }"
             "QPushButton:pressed { background-color:#1A1A1A; border-color:#1A1A1A; color:#FFFFFF; }"
             "QPushButton:disabled { background-color:#C8C8C8; border-color:#C8C8C8; color:#FFFFFF; }"));
@@ -401,6 +475,14 @@ namespace UiHelpers
 #endif
     }
 
+    void enableSmoothScroll(QAbstractScrollArea *area)
+    {
+        if (!area || area->property("nimSmoothScroll").toBool())
+            return;
+        area->setProperty("nimSmoothScroll", true);
+        new SmoothScrollHelper(area);
+    }
+
     void showToast(QWidget *parent, const QString &text, bool error)
     {
         if (!parent)

+ 2 - 0
Client/src/common/UiHelpers.h

@@ -15,6 +15,7 @@ class QLineEdit;
 class QPushButton;
 class QCheckBox;
 class QTimer;
+class QAbstractScrollArea;
 
 namespace UiHelpers
 {
@@ -111,6 +112,7 @@ namespace UiHelpers
     QIcon appIcon();
     QColor colorFromName(const QString &name);
     void playNotifySound();
+    void enableSmoothScroll(QAbstractScrollArea *area);
 
     void showToast(QWidget *parent, const QString &text, bool error = false);
     void setError(QLabel *label, const QString &text);

+ 142 - 44
Client/src/common/Updater.cpp

@@ -31,6 +31,7 @@
 #include <QThread>
 #include <QTimer>
 #include <QVBoxLayout>
+#include <QVector>
 #include <QWidget>
 
 #ifndef NOMINMAX
@@ -332,10 +333,14 @@ namespace
         {
             const int base = 50 + (900 * i) / zipCount;
             const int span = 900 / zipCount;
+            if (progress)
+                progress(base, QStringLiteral("正在安装更新 %1/%2 …").arg(i + 1).arg(zipPaths.size()));
             if (!unzipInto(zipPaths.at(i), targetDir, [&](int pct) {
                     if (progress)
                         progress(base + span * qBound(0, pct, 100) / 100,
-                                 QStringLiteral("正在安装更新 …"));
+                                 QStringLiteral("正在安装更新 %1/%2 …")
+                                     .arg(i + 1)
+                                     .arg(zipPaths.size()));
                 }))
                 return 3;
         }
@@ -404,62 +409,133 @@ namespace
         return true;
     }
 
-    void runDownload(QWidget *host, const QString &url, const QString &ver)
+    void runDownload(QWidget *host, const QVector<AppConfig::UpdateRelease> &steps)
     {
-        UpdateChrome ui = makeUpdateChrome(host, QStringLiteral("正在下载 %1 …").arg(ver), true);
+        if (steps.isEmpty())
+            return;
+
+        UpdateChrome ui = makeUpdateChrome(host, QStringLiteral("正在下载更新 …"), true);
         ui.dlg->setAttribute(Qt::WA_DeleteOnClose);
 
-        auto cancelFlag = std::make_shared<std::atomic<bool>>(false);
-        const QString zip = QDir::temp().filePath(
-            QStringLiteral("nim-update-%1.zip").arg(QDateTime::currentMSecsSinceEpoch()));
-        QFile::remove(zip);
+        struct Job
+        {
+            QVector<AppConfig::UpdateRelease> steps;
+            QStringList zips;
+            int step = 0;
+            int url = 0;
+            HttpFetch::CancelFlag cancel;
+        };
+        auto job = std::make_shared<Job>();
+        job->steps = steps;
+        job->cancel = std::make_shared<std::atomic<bool>>(false);
 
-        QObject::connect(ui.cancel, &QPushButton::clicked, ui.dlg, [cancelFlag, dlg = ui.dlg]() {
-            cancelFlag->store(true);
+        QObject::connect(ui.cancel, &QPushButton::clicked, ui.dlg, [job, dlg = ui.dlg]() {
+            job->cancel->store(true);
             dlg->reject();
         });
 
         QPointer<QDialog> dlgRef(ui.dlg);
         QPointer<QProgressBar> barRef(ui.bar);
         QPointer<QLabel> hintRef(ui.hint);
+        QPointer<QLabel> labRef(ui.lab);
         QPointer<QWidget> hostRef(host);
-        HttpFetch::instance().download(
-            url, zip, 0, 0, cancelFlag,
-            [barRef, hintRef](qint64 rec, qint64 tot) {
-                if (!barRef)
-                    return;
-                if (tot > 0)
-                {
-                    barRef->setValue(static_cast<int>(qBound(qint64(0), rec * 1000 / tot, qint64(1000))));
-                    if (hintRef)
-                        hintRef->setText(QStringLiteral("%1%").arg(rec * 100 / tot));
-                }
-            },
-            [dlgRef, hostRef, zip, ver](bool ok, const QString &error) {
-                if (!dlgRef)
-                    return;
-                if (!ok)
-                {
-                    QFile::remove(zip);
-                    dlgRef->close();
-                    if (hostRef)
-                        UiHelpers::showToast(hostRef,
-                                             error.isEmpty() ? QStringLiteral("下载失败") : error, true);
-                    return;
-                }
-                if (!runSelfUpdaterAndExit(QStringList() << QDir::toNativeSeparators(zip)))
+
+        auto cleanupZips = [job]() {
+            for (const QString &z : job->zips)
+                QFile::remove(z);
+            job->zips.clear();
+        };
+
+        auto next = std::make_shared<std::function<void()>>();
+        *next = [job, next, dlgRef, barRef, hintRef, labRef, hostRef, cleanupZips]() {
+            if (!dlgRef)
+            {
+                cleanupZips();
+                return;
+            }
+            if (job->cancel->load())
+            {
+                cleanupZips();
+                dlgRef->close();
+                return;
+            }
+            if (job->step >= job->steps.size())
+            {
+                if (!runSelfUpdaterAndExit(job->zips))
                 {
-                    QFile::remove(zip);
+                    cleanupZips();
                     dlgRef->close();
                     if (hostRef)
                         UiHelpers::showToast(hostRef, QStringLiteral("启动更新程序失败"), true);
                     return;
                 }
-                LOG_INFO(QStringLiteral("applying update %1").arg(ver));
+                LOG_INFO(QStringLiteral("applying %1 incremental updates").arg(job->zips.size()));
                 dlgRef->close();
-                // 立刻结束,尽快放开 NIM.exe / DLL,方便副本解压覆盖。
                 ::ExitProcess(0);
-            });
+                return;
+            }
+
+            const AppConfig::UpdateRelease &rel = job->steps.at(job->step);
+            if (job->url >= rel.urls.size())
+            {
+                cleanupZips();
+                dlgRef->close();
+                if (hostRef)
+                    UiHelpers::showToast(hostRef,
+                                         QStringLiteral("下载 %1 失败").arg(rel.version), true);
+                return;
+            }
+
+            const int n = job->steps.size();
+            if (labRef)
+                labRef->setText(QStringLiteral("正在下载 %1(%2/%3)…")
+                                    .arg(rel.version)
+                                    .arg(job->step + 1)
+                                    .arg(n));
+            if (barRef)
+                barRef->setValue((1000 * job->step) / qMax(1, n));
+            if (hintRef)
+                hintRef->setText(QStringLiteral("%1%").arg((100 * job->step) / qMax(1, n)));
+
+            const QString zip = QDir::temp().filePath(
+                QStringLiteral("nim-update-%1-%2.zip")
+                    .arg(rel.version, QString::number(QDateTime::currentMSecsSinceEpoch())));
+            QFile::remove(zip);
+            const QString url = rel.urls.at(job->url);
+            HttpFetch::instance().download(
+                url, zip, 0, 0, job->cancel,
+                [job, barRef, hintRef](qint64 rec, qint64 tot) {
+                    if (!barRef || tot <= 0)
+                        return;
+                    const int n = qMax(1, job->steps.size());
+                    const int base = (1000 * job->step) / n;
+                    const int span = 1000 / n;
+                    barRef->setValue(base + static_cast<int>(rec * span / tot));
+                    if (hintRef)
+                        hintRef->setText(QStringLiteral("%1%").arg((base + rec * span / tot) / 10));
+                },
+                [job, next, zip, dlgRef, hostRef, cleanupZips](bool ok, const QString &) {
+                    if (!ok)
+                    {
+                        QFile::remove(zip);
+                        if (job->cancel->load())
+                        {
+                            cleanupZips();
+                            if (dlgRef)
+                                dlgRef->close();
+                            return;
+                        }
+                        ++job->url;
+                        (*next)();
+                        return;
+                    }
+                    job->zips.append(QDir::toNativeSeparators(zip));
+                    ++job->step;
+                    job->url = 0;
+                    (*next)();
+                });
+        };
+        (*next)();
         ui.dlg->exec();
     }
 }
@@ -540,16 +616,38 @@ void Updater::promptIfNeeded(QWidget *host)
     if (s_asked || !host)
         return;
     const auto &cfg = AppConfig::instance();
-    if (!cfg.hasNewerUpdate())
+    const QVector<AppConfig::UpdateRelease> pending = cfg.pendingUpdates();
+    if (pending.isEmpty())
         return;
     s_asked = true;
-    QString text = QStringLiteral("当前 %1,最新 %2。")
-                       .arg(cfg.appVersion(), cfg.updateVersion());
-    if (!cfg.updateNotes().isEmpty())
-        text += QLatin1Char('\n') + cfg.updateNotes();
+    QString chain;
+    for (int i = 0; i < pending.size(); ++i)
+    {
+        if (i > 0)
+            chain += QStringLiteral(" → ");
+        chain += pending.at(i).version;
+    }
+    QString text = QStringLiteral("当前 %1,将依次更新到 %2。")
+                       .arg(cfg.appVersion(), pending.last().version);
+    if (pending.size() > 1)
+        text += QStringLiteral("\n增量包:%1").arg(chain);
+    QString notes = cfg.updateNotes();
+    if (notes.isEmpty())
+    {
+        for (const auto &r : pending)
+        {
+            if (!r.notes.isEmpty())
+            {
+                notes = r.notes;
+                break;
+            }
+        }
+    }
+    if (!notes.isEmpty())
+        text += QLatin1Char('\n') + notes;
     const int act = ConfirmDialog::chooseAction(host, QStringLiteral("发现新版本"), text,
                                                 QStringLiteral("稍后"), QStringLiteral("立即更新"));
     if (act != 2)
         return;
-    runDownload(host, cfg.updateUrl(), cfg.updateVersion());
+    runDownload(host, pending);
 }

+ 15 - 0
Client/src/common/UserSettings.cpp

@@ -82,6 +82,11 @@ bool UserSettings::load()
         m_closeAction = 2;
     else
         m_closeAction = 0;
+    m_chatInputHeight = ini.value(QStringLiteral("ui/chat_input_height"), 148).toInt();
+    if (m_chatInputHeight < 120)
+        m_chatInputHeight = 148;
+    if (m_chatInputHeight > 480)
+        m_chatInputHeight = 480;
     return true;
 }
 
@@ -113,6 +118,7 @@ void UserSettings::persist() const
         ini.setValue(QStringLiteral("window/close_action"), QStringLiteral("quit"));
     else
         ini.remove(QStringLiteral("window/close_action"));
+    ini.setValue(QStringLiteral("ui/chat_input_height"), m_chatInputHeight);
     ini.sync();
 }
 
@@ -146,3 +152,12 @@ void UserSettings::setCloseAction(int action)
     m_closeAction = (action == 1 || action == 2) ? action : 0;
     persist();
 }
+
+void UserSettings::setChatInputHeight(int h)
+{
+    h = qBound(120, h, 480);
+    if (m_chatInputHeight == h)
+        return;
+    m_chatInputHeight = h;
+    persist();
+}

+ 4 - 0
Client/src/common/UserSettings.h

@@ -31,6 +31,9 @@ public:
     int closeAction() const { return m_closeAction; }
     void setCloseAction(int action);
 
+    int chatInputHeight() const { return m_chatInputHeight; }
+    void setChatInputHeight(int h);
+
 private:
     UserSettings() = default;
     void persist() const;
@@ -45,4 +48,5 @@ private:
     QRect m_windowRect;
     bool m_windowMaximized = false;
     int m_closeAction = 0;
+    int m_chatInputHeight = 148;
 };

+ 110 - 7
Client/src/mainframe/Client.cpp

@@ -26,6 +26,7 @@ Client::Client(const QString &account, const QString &nickname, QWidget *parent)
 
 Client::~Client()
 {
+    unregisterScreenshotHotkey();
     m_chatPages.clear();
     LocalStore::instance().close();
 }
@@ -202,6 +203,7 @@ QWidget *Client::buildConversationPane()
     m_convList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
     m_convList->setResizeMode(QListView::Adjust);
     m_convList->setFocusPolicy(Qt::NoFocus);
+    UiHelpers::enableSmoothScroll(m_convList);
     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; }"
@@ -316,6 +318,7 @@ QWidget *Client::buildFriendPane()
     m_friendList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
     m_friendList->setResizeMode(QListView::Adjust);
     m_friendList->setFocusPolicy(Qt::NoFocus);
+    UiHelpers::enableSmoothScroll(m_friendList);
     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; }"
@@ -455,6 +458,7 @@ QWidget *Client::buildChatPane()
 
     m_chatPageStack = new QStackedWidget(m_chatBody);
     m_chatPageStack->setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    m_chatPageStack->setMinimumHeight(80);
     ChatPage first = createChatPage();
     m_scroll = first.scroll;
     m_bubbleHost = first.host;
@@ -462,8 +466,9 @@ QWidget *Client::buildChatPane()
 
     auto *inputWrap = new QWidget(m_chatBody);
     m_inputWrap = inputWrap;
-    inputWrap->setFixedHeight(148);
-    inputWrap->setStyleSheet(QStringLiteral("background:#FFFFFF; border-top:1px solid #E5E5E5;"));
+    inputWrap->setMinimumHeight(120);
+    inputWrap->setMaximumHeight(480);
+    inputWrap->setStyleSheet(QStringLiteral("background:#FFFFFF;"));
     auto *iLay = new QVBoxLayout(inputWrap);
     iLay->setContentsMargins(0, 0, 0, 0);
     iLay->setSpacing(0);
@@ -497,9 +502,8 @@ QWidget *Client::buildChatPane()
     tLay->addWidget(tip);
     tLay->addStretch();
 
-    m_input = new QTextEdit(inputWrap);
+    m_input = new ChatInputEdit(inputWrap);
     m_input->setPlaceholderText(QStringLiteral("输入消息"));
-    m_input->setAcceptRichText(false);
     m_input->installEventFilter(this);
 
     auto *sendRow = new QHBoxLayout();
@@ -556,10 +560,26 @@ QWidget *Client::buildChatPane()
     });
     connect(selCancel, &QPushButton::clicked, this, [this]() { exitMsgSelectMode(); });
 
+    m_chatSplit = new QSplitter(Qt::Vertical, m_chatBody);
+    m_chatSplit->setObjectName(QStringLiteral("chatInputSplit"));
+    m_chatSplit->setChildrenCollapsible(false);
+    m_chatSplit->setHandleWidth(1);
+    m_chatSplit->addWidget(m_chatPageStack);
+    m_chatSplit->addWidget(inputWrap);
+    m_chatSplit->setStretchFactor(0, 1);
+    m_chatSplit->setStretchFactor(1, 0);
+    connect(m_chatSplit, &QSplitter::splitterMoved, this, [this]() {
+        if (!m_chatSplit || !m_inputWrap || !m_inputWrap->isVisible())
+            return;
+        const QList<int> sz = m_chatSplit->sizes();
+        if (sz.size() >= 2)
+            UserSettings::instance().setChatInputHeight(sz.at(1));
+    });
+
     body->addWidget(header);
-    body->addWidget(m_chatPageStack, 1);
+    body->addWidget(m_chatSplit, 1);
     body->addWidget(m_msgSelectBar);
-    body->addWidget(inputWrap);
+    QTimer::singleShot(0, this, [this]() { restoreChatInputSplit(); });
 
     m_chatStack->addWidget(m_chatEmpty);
     m_chatStack->addWidget(m_friendHintPage);
@@ -618,7 +638,12 @@ QWidget *Client::buildChatPane()
     });
     connect(m_favDetail, &FavoriteDetailPage::previewImage, this,
             [this](qint64 fileId, const QString &name, const QString &content) {
-        previewChatImage(fileId, name, content);
+        const QJsonObject o = QJsonDocument::fromJson(content.toUtf8()).object();
+        const QString mime = o.value(QStringLiteral("mime")).toString();
+        if (FileCache::looksLikeVideo(mime, name))
+            previewChatVideo(fileId, name, content);
+        else
+            previewChatImage(fileId, name, content);
     });
     m_profileChatBtn = sendMsg;
     m_profileShareBtn = shareMsg;
@@ -680,6 +705,18 @@ void Client::persistWindow()
     UserSettings::instance().saveWindow(r, isMaximized());
 }
 
+void Client::restoreChatInputSplit()
+{
+    if (!m_chatSplit || !m_inputWrap || !m_inputWrap->isVisible())
+        return;
+    const int total = m_chatSplit->height();
+    if (total < 200)
+        return;
+    const int inputH = qBound(120, UserSettings::instance().chatInputHeight(),
+                              qMin(480, total - 80));
+    m_chatSplit->setSizes({total - inputH, inputH});
+}
+
 void Client::onChangePassword()
 {
     ChangePasswordDialog dlg(this);
@@ -807,9 +844,73 @@ bool Client::nativeEvent(const QByteArray &eventType, void *message, qintptr *re
     return QMainWindow::nativeEvent(eventType, message, result);
 }
 
+void Client::captureScreenshot()
+{
+    if (m_loggingOut || m_screenshotOpen)
+        return;
+    m_screenshotOpen = true;
+    const QPixmap shot = ScreenshotEditor::capture(this);
+    m_screenshotOpen = false;
+    if (shot.isNull())
+        return;
+    if (isMinimized() || !isVisible())
+        showFromTray();
+    raise();
+    activateWindow();
+    insertComposerImage(shot);
+}
+
+#ifdef Q_OS_WIN
+namespace
+{
+    HHOOK g_shotHook = nullptr;
+    QPointer<Client> g_shotClient;
+
+    LRESULT CALLBACK shotHookProc(int nCode, WPARAM wParam, LPARAM lParam)
+    {
+        if (nCode == HC_ACTION && (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN))
+        {
+            const auto *kb = reinterpret_cast<KBDLLHOOKSTRUCT *>(lParam);
+            if (kb && kb->vkCode == VK_SNAPSHOT)
+            {
+                Client *c = g_shotClient.data();
+                if (c)
+                    QTimer::singleShot(0, c, &Client::captureScreenshot);
+                return 1;
+            }
+        }
+        return CallNextHookEx(g_shotHook, nCode, wParam, lParam);
+    }
+}
+#endif
+
+void Client::registerScreenshotHotkey()
+{
+#ifdef Q_OS_WIN
+    if (g_shotHook)
+        return;
+    g_shotClient = this;
+    g_shotHook = SetWindowsHookExW(WH_KEYBOARD_LL, shotHookProc, GetModuleHandleW(nullptr), 0);
+#endif
+}
+
+void Client::unregisterScreenshotHotkey()
+{
+#ifdef Q_OS_WIN
+    if (g_shotClient == this)
+        g_shotClient.clear();
+    if (g_shotHook)
+    {
+        UnhookWindowsHookEx(g_shotHook);
+        g_shotHook = nullptr;
+    }
+#endif
+}
+
 void Client::showEvent(QShowEvent *event)
 {
     QMainWindow::showEvent(event);
+    registerScreenshotHotkey();
     if (m_geomRestored)
         return;
     m_geomRestored = true;
@@ -827,6 +928,7 @@ void Client::showEvent(QShowEvent *event)
     }
     if (u.windowMaximized())
         showMaximized();
+    QTimer::singleShot(0, this, [this]() { restoreChatInputSplit(); });
 }
 
 void Client::changeEvent(QEvent *event)
@@ -880,6 +982,7 @@ void Client::closeEvent(QCloseEvent *event)
         m_tray->hide();
     if (m_trayPreview)
         m_trayPreview->hide();
+    unregisterScreenshotHotkey();
     if (m_currentConv.id > 0)
         ImClient::instance().setUnread(m_currentConv.id, 0, {});
     if (m_forceQuit || !m_loggingOut)

+ 29 - 3
Client/src/mainframe/Client.h

@@ -7,6 +7,7 @@
 #include <QJsonObject>
 #include <QSet>
 #include <QVector>
+#include <functional>
 
 #include "LocalStore.h"
 #include "ClientInternal.h"
@@ -22,6 +23,7 @@ class QListWidget;
 class QListWidgetItem;
 class QLabel;
 class QTextEdit;
+class ChatInputEdit;
 class QScrollArea;
 class QWidget;
 class QVBoxLayout;
@@ -29,6 +31,7 @@ class QStackedWidget;
 class QLineEdit;
 class QPushButton;
 class QJsonArray;
+class QSplitter;
 class QSystemTrayIcon;
 class QMenu;
 class QTimer;
@@ -43,9 +46,10 @@ class Client : public QMainWindow
 public:
     explicit Client(const QString &account, const QString &nickname, QWidget *parent = nullptr);
     ~Client();
+    void captureScreenshot();
 
 signals:
-    void loggedOut();
+    void loggedOut(const QString &reason = QString());
 
 protected:
     void showEvent(QShowEvent *event) override;
@@ -82,6 +86,7 @@ private:
     void showGroupProfile(qint64 groupId);
     void fillProfilePage(const QJsonObject &user);
     void persistWindow();
+    void restoreChatInputSplit();
     void removeLocalConv(qint64 uid, int convType = 0);
     void markPeerRead(qint64 peerUid, int convType = 0);
     void saveLocalMessage(qint64 msgId, qint64 peerUid, qint64 fromUid, qint64 toUid,
@@ -98,7 +103,12 @@ private:
     void resendFailed(QWidget *row);
     void sendSticker(qint64 packId, const QString &code);
     void sendFile(bool imageOnly = false);
+    void sendLocalFile(const QString &path, const std::function<void()> &done = {});
+    void sendImagePixmap(const QPixmap &pm, const std::function<void()> &done = {});
+    void insertComposerImage(const QPixmap &pm);
+    void flushDraftQueue();
     void previewChatImage(qint64 fileId, const QString &name, const QString &content);
+    void previewChatVideo(qint64 fileId, const QString &name, const QString &content);
     void favoritePayload(int msgType, const QString &content);
     void sendChatPayload(int msgType, const QString &content, const QString &preview);
     void sendChatPayloadTo(qint64 peer, const QString &peerName, int msgType,
@@ -138,6 +148,8 @@ private:
     };
     ChatPage createChatPage();
     void bindChatScroll(QScrollArea *scroll);
+    void scheduleVisibleMediaLoad();
+    void loadVisibleChatMedia();
     void stashChatPage(const ClientInternal::ConvId &id);
     bool activateChatPage(const ClientInternal::ConvId &id);
     bool chatPageHasAll(const QJsonArray &list) const;
@@ -156,7 +168,8 @@ private:
     void applyDeletedMessages(const QVector<qint64> &ids);
     void syncConvPreviewAfterDelete(qint64 peerUid, int convType = 0);
     void upsertConv(qint64 peerUid, const QString &name, const QString &preview,
-                    qint64 ts, int unread, int convType = 0);
+                    qint64 ts, int unread, int convType = 0, int memberCount = -1);
+    void applyChatTitle();
     void showSettings();
     void showSettings(int page);
     void openAddFriend();
@@ -164,6 +177,8 @@ private:
     void onChangePassword();
     void onLogout();
     void onKicked();
+    void registerScreenshotHotkey();
+    void unregisterScreenshotHotkey();
     void onDisconnected();
     void onConnected();
     void onConnectFailed(const QString &reason);
@@ -206,6 +221,15 @@ private:
     QString m_currentPeerName;
     bool m_loggingOut = false;
     bool m_forceQuit = false;
+    bool m_screenshotOpen = false;
+    struct DraftPart
+    {
+        bool image = false;
+        QString text;
+        QPixmap pixmap;
+    };
+    QVector<DraftPart> m_draftQueue;
+    bool m_draftBusy = false;
     bool m_geomRestored = false;
     bool m_stickChatToBottom = true;
     TitleBar *m_titleBar = nullptr;
@@ -269,8 +293,9 @@ private:
     QWidget *m_bubbleHost = nullptr;
     QVBoxLayout *m_bubbleLay = nullptr;
     QScrollArea *m_scroll = nullptr;
-    QTextEdit *m_input = nullptr;
+    ChatInputEdit *m_input = nullptr;
     QWidget *m_inputWrap = nullptr;
+    QSplitter *m_chatSplit = nullptr;
     QWidget *m_msgSelectBar = nullptr;
     QLabel *m_msgSelectHint = nullptr;
     bool m_msgSelectMode = false;
@@ -280,6 +305,7 @@ private:
     QTimer *m_trayFlash = nullptr;
     bool m_trayFlashLit = true;
     QTimer *m_geomSave = nullptr;
+    QTimer *m_mediaLoadTimer = nullptr;
     QWidget *m_reconnectMask = nullptr;
     QWidget *m_reconnectSpin = nullptr;
     QLabel *m_reconnectTitle = nullptr;

+ 117 - 52
Client/src/mainframe/ClientChat.cpp

@@ -47,12 +47,15 @@ Client::ChatPage Client::createChatPage()
     p.scroll->setStyleSheet(QStringLiteral("background:#EDEDED;"));
     p.host = new QWidget(p.scroll);
     p.host->setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    p.host->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Minimum);
     p.lay = new QVBoxLayout(p.host);
     p.lay->setContentsMargins(16, 16, 16, 16);
     p.lay->setSpacing(12);
+    p.lay->setSizeConstraint(QLayout::SetMinimumSize);
     p.lay->addStretch();
     p.scroll->setWidget(p.host);
     bindChatScroll(p.scroll);
+    UiHelpers::enableSmoothScroll(p.scroll);
     if (m_chatPageStack)
         m_chatPageStack->addWidget(p.scroll);
     return p;
@@ -65,6 +68,7 @@ void Client::bindChatScroll(QScrollArea *scroll)
     connect(scroll->verticalScrollBar(), &QScrollBar::rangeChanged, this, [this, scroll](int, int max) {
         if (m_scroll != scroll)
             return;
+        scheduleVisibleMediaLoad();
         if (m_historyLoadingOlder || m_historyInsertAt >= 0 || m_historyRestoringScroll)
             return;
         if (m_stickChatToBottom)
@@ -80,9 +84,45 @@ void Client::bindChatScroll(QScrollArea *scroll)
         m_stickChatToBottom = (max - v) <= 24;
         if (v <= kLoadOlderPx)
             loadOlderHistory();
+        scheduleVisibleMediaLoad();
     });
 }
 
+void Client::scheduleVisibleMediaLoad()
+{
+    if (!m_mediaLoadTimer)
+    {
+        m_mediaLoadTimer = new QTimer(this);
+        m_mediaLoadTimer->setSingleShot(true);
+        m_mediaLoadTimer->setInterval(32);
+        connect(m_mediaLoadTimer, &QTimer::timeout, this, &Client::loadVisibleChatMedia);
+    }
+    m_mediaLoadTimer->start();
+}
+
+void Client::loadVisibleChatMedia()
+{
+    if (!m_scroll || !m_bubbleHost || !m_scroll->viewport())
+        return;
+    QWidget *vp = m_scroll->viewport();
+    const int pad = 360;
+    const QRect vis(0, -pad, vp->width(), vp->height() + pad * 2);
+    const auto widgets = m_bubbleHost->findChildren<QWidget *>();
+    for (QWidget *w : widgets)
+    {
+        const QString n = w->objectName();
+        if (n != QLatin1String("imageBubble") && n != QLatin1String("videoBubble"))
+            continue;
+        const QRect r(w->mapTo(vp, QPoint(0, 0)), w->size());
+        if (!vis.intersects(r))
+            continue;
+        if (n == QLatin1String("imageBubble"))
+            static_cast<ImageBubble *>(w)->ensureMedia();
+        else
+            static_cast<VideoBubble *>(w)->ensureMedia();
+    }
+}
+
 void Client::stashChatPage(const ConvId &id)
 {
     if (!id.isValid())
@@ -374,6 +414,7 @@ void Client::applyHistory(const QJsonArray &list)
     if (m_scroll)
         m_scroll->setUpdatesEnabled(true);
     scrollChatToBottom();
+    scheduleVisibleMediaLoad();
     LOG_INFO(QStringLiteral("applyHistory peer=%1 src=%2 rows=%3 rebuild sqlite=%4ms merge=%5ms build=%6ms total=%7ms")
                  .arg(QString::number(m_currentConv.id),
                       list.isEmpty() ? QStringLiteral("local") : QStringLiteral("server"),
@@ -521,6 +562,7 @@ int Client::prependHistory(const QVector<LocalStore::Msg> &rows)
     else
         m_stickChatToBottom = wasStick;
     rememberChatPageState();
+    scheduleVisibleMediaLoad();
     return add.size();
 }
 
@@ -625,8 +667,8 @@ void Client::openChat(qint64 peerUid, const QString &name, int convType)
     }
     m_currentConv = key;
     m_currentPeerName = name;
-    m_chatTitle->setText(name);
     m_chatStack->setCurrentWidget(m_chatBody);
+    QTimer::singleShot(0, this, [this]() { restoreChatInputSplit(); });
     applyNavSelection(0);
     markPeerRead(peerUid, convType);
     if (auto *item = findConvItem(peerUid, convType))
@@ -645,6 +687,7 @@ void Client::openChat(qint64 peerUid, const QString &name, int convType)
             m_convList->blockSignals(false);
         }
     }
+    applyChatTitle();
     const qint64 uiNs = clock.nsecsElapsed() - stashNs;
     if (sameChat)
     {
@@ -778,6 +821,7 @@ void Client::exitMsgSelectMode()
         m_msgSelectBar->hide();
     if (m_inputWrap)
         m_inputWrap->show();
+    restoreChatInputSplit();
     if (!m_bubbleLay)
         return;
     for (int i = 0; i < m_bubbleLay->count(); ++i)
@@ -979,7 +1023,7 @@ QWidget *Client::addBubble(bool mine, const QString &name, const QString &text,
     row->setProperty("mine", mine);
     row->setObjectName(QStringLiteral("msgRow"));
     row->setMinimumWidth(0);
-    row->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
+    row->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Minimum);
     auto *lay = new QHBoxLayout(row);
     lay->setContentsMargins(0, 0, 0, 0);
     lay->setSpacing(8);
@@ -1032,6 +1076,8 @@ QWidget *Client::addBubble(bool mine, const QString &name, const QString &text,
 
     ClickWidget *bubble = nullptr;
     FileBubble *fileBubble = nullptr;
+    VideoBubble *videoBubble = nullptr;
+    ImageBubble *imageBubble = nullptr;
     if (msgType == im::MsgType::Sticker)
     {
         qint64 packId = 0;
@@ -1069,55 +1115,65 @@ QWidget *Client::addBubble(bool mine, const QString &name, const QString &text,
         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();
+        const int mediaW = static_cast<int>(Proto::jsonI64(o, QStringLiteral("w")));
+        const int mediaH = static_cast<int>(Proto::jsonI64(o, QStringLiteral("h")));
         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();
+            imageBubble = new ImageBubble(row, fileId, fname, fileUrl, mime, mediaW, mediaH);
+            imageBubble->onClick = [this, fileId, fname, text]() {
+                previewChatImage(fileId, fname, text);
             };
-            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]() {
+            imageBubble->onForward = [this, text]() {
                 forwardPayload(im::MsgType::File, text, QStringLiteral("[图片]"));
             };
-            box->onFavorite = [this, text]() {
+            imageBubble->onFavorite = [this, text]() {
                 favoritePayload(im::MsgType::File, text);
             };
-            bubble = box;
+            imageBubble->onSaveAs = [this, fileId, fname]() {
+                const QString src = FileCache::instance().localPath(fileId);
+                QByteArray data = FileCache::instance().bytes(fileId);
+                if (src.isEmpty() && data.isEmpty())
+                {
+                    UiHelpers::showToast(this, QStringLiteral("请先下载图片"), true);
+                    return;
+                }
+                const QString dest = QFileDialog::getSaveFileName(
+                    this, QStringLiteral("保存图片"),
+                    fname.isEmpty() ? QStringLiteral("image.png") : fname);
+                if (dest.isEmpty())
+                    return;
+                if (!src.isEmpty())
+                {
+                    QFile::remove(dest);
+                    if (!QFile::copy(src, dest))
+                        UiHelpers::showToast(this, QStringLiteral("保存失败"), true);
+                    else
+                        UiHelpers::showToast(this, QStringLiteral("已保存"));
+                    return;
+                }
+                QFile f(dest);
+                if (!f.open(QIODevice::WriteOnly) || f.write(data) != data.size())
+                    UiHelpers::showToast(this, QStringLiteral("保存失败"), true);
+                else
+                    UiHelpers::showToast(this, QStringLiteral("已保存"));
+            };
+            bubble = imageBubble;
+        }
+        else if (FileCache::looksLikeVideo(mime, fname))
+        {
+            videoBubble = new VideoBubble(row, fileId, fname, fsize, fileUrl, mime, mine, mediaW,
+                                         mediaH);
+            videoBubble->bindActionSlot(actionSlot, sendMark);
+            videoBubble->onClick = [this, fileId, fname, text]() {
+                previewChatVideo(fileId, fname, text);
+            };
+            videoBubble->onForward = [this, text]() {
+                forwardPayload(im::MsgType::File, text, QStringLiteral("[视频]"));
+            };
+            videoBubble->onFavorite = [this, text]() {
+                favoritePayload(im::MsgType::File, text);
+            };
+            bubble = videoBubble;
         }
         else
         {
@@ -1251,6 +1307,7 @@ QWidget *Client::addBubble(bool mine, const QString &name, const QString &text,
         };
     }
 
+    QWidget *nameCol = nullptr;
     if (mine)
     {
         row->setProperty("outgoingId", QVariant::fromValue(msgId));
@@ -1270,27 +1327,30 @@ QWidget *Client::addBubble(bool mine, const QString &name, const QString &text,
         lay->addWidget(avatar, 0, Qt::AlignTop);
         if (m_currentConv.isGroup() && bubble)
         {
-            auto *col = new QWidget(row);
-            col->setStyleSheet(QStringLiteral("background:transparent;"));
-            auto *colLay = new QVBoxLayout(col);
+            nameCol = new QWidget(row);
+            nameCol->setStyleSheet(QStringLiteral("background:transparent;"));
+            nameCol->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Minimum);
+            auto *colLay = new QVBoxLayout(nameCol);
             colLay->setContentsMargins(0, 0, 0, 0);
             colLay->setSpacing(2);
-            auto *nick = new QLabel(name, col);
+            auto *nick = new QLabel(name, nameCol);
             nick->setStyleSheet(QStringLiteral("color:#888888; font-size:11px; background:transparent;"));
             colLay->addWidget(nick);
             colLay->addWidget(bubble, 0, Qt::AlignLeft);
-            lay->addWidget(col, 0, Qt::AlignTop);
+            lay->addWidget(nameCol, 0, Qt::AlignTop);
         }
         else
         {
             lay->addWidget(bubble, 0, Qt::AlignTop);
         }
-        if (fileBubble)
+        if (fileBubble || videoBubble)
             lay->addWidget(actionSlot, 0, Qt::AlignVCenter);
         lay->addStretch();
-        if (!fileBubble)
+        if (!fileBubble && !videoBubble)
             actionSlot->hide();
     }
+    if ((imageBubble || videoBubble) && bubble)
+        lockChatMediaRow(row, bubble, nameCol);
 
     if (m_historyInsertAt >= 0)
         m_bubbleLay->insertWidget(m_historyInsertAt++, row);
@@ -1311,6 +1371,10 @@ QWidget *Client::addBubble(bool mine, const QString &name, const QString &text,
         setBubbleSendState(row, false, true);
     if (!m_buildingHistory && m_historyInsertAt < 0)
     {
+        if (imageBubble)
+            imageBubble->ensureMedia();
+        if (videoBubble)
+            videoBubble->ensureMedia();
         if (msgId != 0)
         {
             m_historyFp += QString::number(msgId) + QLatin1Char(',');
@@ -1357,7 +1421,8 @@ void Client::setBubbleSendState(QWidget *row, bool failed, bool sending)
     }
     if (QWidget *slot = mark->parentWidget())
     {
-        if (!row->findChild<QWidget *>(QStringLiteral("fileBubble")))
+        if (!row->findChild<QWidget *>(QStringLiteral("fileBubble"))
+            && !row->findChild<QWidget *>(QStringLiteral("videoBubble")))
             slot->setVisible(failed || sending);
     }
     refreshRowFileAction(row);

+ 51 - 4
Client/src/mainframe/ClientConv.cpp

@@ -109,6 +109,8 @@ void Client::applyConvList(const QJsonArray &list)
             m_convList->blockSignals(false);
         }
     }
+    if (keep.isGroup())
+        applyChatTitle();
     updateTray();
 }
 
@@ -160,7 +162,7 @@ void Client::applyFriendList(const QJsonArray &list)
 }
 
 void Client::upsertConv(qint64 peerUid, const QString &name, const QString &preview,
-                        qint64 ts, int unread, int convType)
+                        qint64 ts, int unread, int convType, int memberCount)
 {
     Conv c;
     c.uid = peerUid;
@@ -175,6 +177,8 @@ void Client::upsertConv(qint64 peerUid, const QString &name, const QString &prev
         c.online = exist->data(RoleOnline).toInt() == 1;
         c.memberCount = exist->data(RoleMemberCount).toInt();
     }
+    if (memberCount >= 0)
+        c.memberCount = memberCount;
     if (c.convType != ConvGroup)
     {
         if (c.avatarVer <= 0)
@@ -216,6 +220,26 @@ void Client::upsertConv(qint64 peerUid, const QString &name, const QString &prev
     updateTray();
 }
 
+void Client::applyChatTitle()
+{
+    if (!m_chatTitle)
+        return;
+    if (!m_currentConv.isGroup())
+    {
+        m_chatTitle->setText(m_currentPeerName);
+        return;
+    }
+    int n = 0;
+    if (auto *item = findConvItem(m_currentConv.id, ConvGroup))
+    {
+        const QString stored = item->data(RoleName).toString();
+        if (!stored.isEmpty())
+            m_currentPeerName = stored;
+        n = item->data(RoleMemberCount).toInt();
+    }
+    m_chatTitle->setText(groupTitle(m_currentPeerName, n));
+}
+
 void Client::selectConversation(int row)
 {
     if (row < 0)
@@ -835,6 +859,8 @@ void Client::openCreateGroup()
                 QString gname = reply.value(QStringLiteral("name")).toString();
                 if (gname.isEmpty())
                     gname = QStringLiteral("群聊");
+                const int n = reply.value(QStringLiteral("member_count")).toInt();
+                self->upsertConv(gid, gname, QString(), QDateTime::currentMSecsSinceEpoch(), 0, ConvGroup, n);
                 self->openChat(gid, gname, ConvGroup);
             });
         });
@@ -984,16 +1010,37 @@ void Client::onGroupChangeNotify(const QJsonObject &msg)
     if (event == QLatin1String("info") && gid > 0)
     {
         const int avatarVer = msg.value(QStringLiteral("avatar_ver")).toInt();
+        const int n = msg.value(QStringLiteral("member_count")).toInt(-1);
         if (auto *item = findConvItem(gid, ConvGroup))
         {
             if (avatarVer > 0)
                 item->setData(RoleAvatarVer, avatarVer);
             const qint64 ts = item->data(RoleTs).toLongLong();
             upsertConv(gid, gname, item->data(RolePreview).toString(), ts,
-                       item->data(RoleUnread).toInt(), ConvGroup);
+                       item->data(RoleUnread).toInt(), ConvGroup, n);
+        }
+        if (m_currentConv == ConvId{ConvGroup, gid})
+        {
+            m_currentPeerName = gname;
+            applyChatTitle();
+        }
+    }
+    else if (gid > 0)
+    {
+        const int n = msg.value(QStringLiteral("member_count")).toInt(-1);
+        if (n >= 0)
+        {
+            if (auto *item = findConvItem(gid, ConvGroup))
+            {
+                item->setData(RoleMemberCount, n);
+                Conv c = convFromItem(item);
+                if (QWidget *old = m_convList->itemWidget(item))
+                    old->deleteLater();
+                m_convList->setItemWidget(item, makeConvRow(c, m_convList));
+            }
+            if (m_currentConv == ConvId{ConvGroup, gid})
+                applyChatTitle();
         }
-        if (m_currentConv == ConvId{ConvGroup, gid} && m_chatTitle)
-            m_chatTitle->setText(gname);
     }
     if (event == QLatin1String("invite") && uid != m_uid && gid > 0)
         refreshConvList();

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

@@ -15,6 +15,9 @@
 #include "FileCache.h"
 #include "FileTypeIcon.h"
 #include "ImagePreviewDialog.h"
+#include "VideoPreviewDialog.h"
+#include "ScreenshotEditor.h"
+#include "ChatInputEdit.h"
 #include "StickerPanel.h"
 #include "UiHelpers.h"
 #include "AppTheme.h"
@@ -44,6 +47,7 @@
 #include <QDateTime>
 #include <QDesktopServices>
 #include <QDialog>
+#include <QDir>
 #include <QElapsedTimer>
 #include <QEvent>
 #include <QFile>

+ 530 - 2
Client/src/mainframe/ClientInternal.h

@@ -43,8 +43,12 @@
 #include <QPen>
 #include <QPixmap>
 #include <QPolygonF>
+#include <QPointer>
 #include <QProgressBar>
 #include <QPushButton>
+#include <QResizeEvent>
+#include <QScrollArea>
+#include <QScrollBar>
 #include <QSizePolicy>
 #include <QTextLayout>
 #include <QTextOption>
@@ -102,6 +106,13 @@ namespace ClientInternal {
         return UiHelpers::groupIconPixmap(size);
     }
 
+    inline QString groupTitle(const QString &name, int memberCount)
+    {
+        if (memberCount > 0)
+            return name + QStringLiteral(" (%1)").arg(memberCount);
+        return name;
+    }
+
     constexpr int kReconnectMaxTries = 3;
     constexpr int kReconnectIntervalSec = 10;
 
@@ -305,7 +316,7 @@ namespace ClientInternal {
             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;
+            QAction *save = onSaveAs ? menu.addAction(QStringLiteral("保存")) : nullptr;
             if ((copy || fav || fwd || save) && (onDelete || onMultiSelect))
                 menu.addSeparator();
             QAction *del = onDelete ? menu.addAction(QStringLiteral("删除")) : nullptr;
@@ -329,6 +340,80 @@ namespace ClientInternal {
         }
     };
 
+    inline QSize chatMediaBox(int srcW, int srcH, bool video)
+    {
+        constexpr int kMax = 180;
+        if (srcW <= 0 || srcH <= 0)
+            return video ? QSize(180, 101) : QSize(160, 120);
+        QSize s(srcW, srcH);
+        s.scale(kMax, kMax, Qt::KeepAspectRatio);
+        return QSize(qMax(48, s.width()), qMax(48, s.height()));
+    }
+
+    inline void lockMediaBox(QWidget *w, const QSize &box)
+    {
+        if (!w)
+            return;
+        w->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
+        w->setMinimumSize(box);
+        w->setMaximumSize(box);
+        w->resize(box);
+        w->updateGeometry();
+    }
+
+    inline void lockChatMediaRow(QWidget *row, QWidget *bubble, QWidget *nameCol = nullptr)
+    {
+        if (!row || !bubble)
+            return;
+        int h = bubble->minimumSize().height();
+        if (h < 48)
+            h = bubble->sizeHint().height();
+        if (nameCol)
+        {
+            const int colH = h + 18;
+            nameCol->setMinimumHeight(colH);
+            nameCol->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Minimum);
+            h = colH;
+        }
+        row->setMinimumHeight(qMax(36, h));
+        row->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Minimum);
+        row->updateGeometry();
+    }
+
+    inline void paintChatMedia(QPainter *p, const QRect &r, const QPixmap &pm, const QColor &fill)
+    {
+        p->setRenderHint(QPainter::Antialiasing);
+        p->setRenderHint(QPainter::SmoothPixmapTransform);
+        QPainterPath path;
+        path.addRoundedRect(QRectF(r).adjusted(0.5, 0.5, -0.5, -0.5), 4, 4);
+        p->setClipPath(path);
+        p->fillRect(r, fill);
+        if (pm.isNull())
+            return;
+        const qreal dpr = qMax(1.0, pm.devicePixelRatio());
+        QSize pix(qMax(1, qRound(pm.width() / dpr)), qMax(1, qRound(pm.height() / dpr)));
+        pix.scale(r.size(), Qt::KeepAspectRatio);
+        QRect dest(QPoint(0, 0), pix);
+        dest.moveCenter(r.center());
+        p->drawPixmap(dest, pm);
+    }
+
+    inline void stickChatIfNearBottom(QWidget *w)
+    {
+        if (!w)
+            return;
+        for (QWidget *p = w->parentWidget(); p; p = p->parentWidget())
+        {
+            if (auto *sa = qobject_cast<QScrollArea *>(p))
+            {
+                QScrollBar *bar = sa->verticalScrollBar();
+                if (bar && (bar->maximum() - bar->value()) <= 48)
+                    bar->setValue(bar->maximum());
+                return;
+            }
+        }
+    }
+
     inline QPixmap fileCtlIcon(bool pause, bool upload = false)
     {
         const int size = 22;
@@ -383,6 +468,86 @@ namespace ClientInternal {
         return btn;
     }
 
+    class ImageBubble : public ClickWidget
+    {
+    public:
+        ImageBubble(QWidget *parent, qint64 fileId, const QString &name, const QString &url,
+                    const QString &mime, int hintW = 0, int hintH = 0)
+            : ClickWidget(parent)
+            , m_fileId(fileId)
+            , m_url(url)
+            , m_mime(mime)
+            , m_name(name)
+        {
+            setObjectName(QStringLiteral("imageBubble"));
+            setFrameShape(QFrame::NoFrame);
+            setStyleSheet(QStringLiteral("background:transparent; border:none;"));
+            FileCache::instance().bindMeta(fileId, url, mime, name, 0);
+            if (hintW > 0 && hintH > 0)
+                FileCache::instance().rememberMediaSize(fileId, hintW, hintH);
+
+            QSize src = FileCache::instance().probeImageSize(fileId);
+            if (src.isEmpty())
+                src = QSize(hintW, hintH);
+            m_box = chatMediaBox(src.width(), src.height(), false);
+            lockMediaBox(this, m_box);
+
+            connect(&FileCache::instance(), &FileCache::thumbReady, this,
+                    [this](qint64 id, int edge) {
+                if (id == m_fileId && edge == 180)
+                    applyThumb(FileCache::instance().cachedThumbnail(m_fileId, 180));
+            });
+            connect(&FileCache::instance(), &FileCache::ready, this, [this](qint64 id) {
+                if (id != m_fileId || !m_asked)
+                    return;
+                FileCache::instance().requestThumbnail(m_fileId, 180);
+            });
+        }
+
+        QSize sizeHint() const override { return m_box; }
+        QSize minimumSizeHint() const override { return m_box; }
+        bool hasHeightForWidth() const override { return false; }
+
+        void ensureMedia()
+        {
+            if (m_asked)
+                return;
+            m_asked = true;
+            const QPixmap pm = FileCache::instance().cachedThumbnail(m_fileId, 180);
+            if (!pm.isNull())
+            {
+                applyThumb(pm);
+                return;
+            }
+            FileCache::instance().requestThumbnail(m_fileId, 180);
+            FileCache::instance().ensure(m_fileId, m_url, m_mime, m_name);
+        }
+
+    protected:
+        void paintEvent(QPaintEvent *) override
+        {
+            QPainter p(this);
+            paintChatMedia(&p, rect(), m_pm, QColor(QStringLiteral("#F0F0F0")));
+        }
+
+    private:
+        void applyThumb(const QPixmap &got)
+        {
+            if (got.isNull())
+                return;
+            m_pm = got;
+            update();
+        }
+
+        qint64 m_fileId = 0;
+        QString m_url;
+        QString m_mime;
+        QString m_name;
+        bool m_asked = false;
+        QSize m_box;
+        QPixmap m_pm;
+    };
+
     class FileBubble : public ClickWidget
     {
     public:
@@ -685,12 +850,357 @@ namespace ClientInternal {
         QLabel *m_speedLab = nullptr;
     };
 
+    inline QPixmap videoPlayOverlay(int size)
+    {
+        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.setBrush(QColor(0, 0, 0, 140));
+        p.setPen(Qt::NoPen);
+        p.drawEllipse(0, 0, size, size);
+        QPainterPath tri;
+        const qreal s = size;
+        tri.moveTo(s * 0.38, s * 0.28);
+        tri.lineTo(s * 0.38, s * 0.72);
+        tri.lineTo(s * 0.74, s * 0.50);
+        tri.closeSubpath();
+        p.setBrush(QColor(255, 255, 255, 230));
+        p.drawPath(tri);
+        return pm;
+    }
+
+    class VideoBubble : public ClickWidget
+    {
+    public:
+        VideoBubble(QWidget *parent, qint64 fileId, const QString &name, qint64 size,
+                    const QString &url, const QString &mime, bool mine, int hintW = 0,
+                    int hintH = 0)
+            : ClickWidget(parent)
+            , m_fileId(fileId)
+            , m_size(size)
+            , m_url(url)
+            , m_mime(mime)
+            , m_name(name)
+            , m_mine(mine)
+            , m_hintW(hintW)
+            , m_hintH(hintH)
+        {
+            setObjectName(QStringLiteral("videoBubble"));
+            setFrameShape(QFrame::NoFrame);
+            setStyleSheet(QStringLiteral("background:transparent; border:none;"));
+            FileCache::instance().bindMeta(fileId, url, mime, name, size);
+            if (hintW > 0 && hintH > 0)
+                FileCache::instance().rememberMediaSize(fileId, hintW, hintH);
+            m_box = reservedSize();
+            lockMediaBox(this, m_box);
+
+            m_overlay = new QWidget(this);
+            m_overlay->setAttribute(Qt::WA_TransparentForMouseEvents);
+            m_overlay->setStyleSheet(QStringLiteral("background:transparent;"));
+            auto *ovLay = new QVBoxLayout(m_overlay);
+            ovLay->setContentsMargins(6, 0, 6, 6);
+            ovLay->setSpacing(3);
+            ovLay->addStretch();
+            m_metaLab = new QLabel(m_overlay);
+            m_metaLab->setStyleSheet(QStringLiteral(
+                "color:#FFFFFF; font-size:11px; background:transparent; border:none;"));
+            m_bar = new QProgressBar(m_overlay);
+            m_bar->setRange(0, 1000);
+            m_bar->setValue(0);
+            m_bar->setTextVisible(false);
+            m_bar->setFixedHeight(4);
+            m_bar->setStyleSheet(QStringLiteral(
+                "QProgressBar { background:rgba(255,255,255,0.25); border:none; border-radius:2px; }"
+                "QProgressBar::chunk { background:#07C160; border-radius:2px; }"));
+            ovLay->addWidget(m_metaLab);
+            ovLay->addWidget(m_bar);
+            m_overlay->setGeometry(QRect(QPoint(0, 0), m_box));
+
+            connect(&FileCache::instance(), &FileCache::posterReady, this, [this](qint64 id) {
+                if (id == m_fileId)
+                    applyPoster();
+            });
+            connect(&FileCache::instance(), &FileCache::thumbReady, this,
+                    [this](qint64 id, int) {
+                if (id == m_fileId)
+                    applyPoster();
+            });
+            connect(&FileCache::instance(), &FileCache::ready, this, [this](qint64 id) {
+                if (id != m_fileId || !m_asked)
+                    return;
+                FileCache::instance().requestVideoPoster(m_fileId);
+                applyPoster();
+                syncUi();
+            });
+            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())
+                    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("已保存"));
+            };
+
+            applyPoster();
+            syncUi();
+        }
+
+        QSize sizeHint() const override { return m_box; }
+        QSize minimumSizeHint() const override { return m_box; }
+        bool hasHeightForWidth() const override { return false; }
+
+        void ensureMedia()
+        {
+            if (m_asked)
+                return;
+            m_asked = true;
+            applyPoster();
+            if (FileCache::instance().localPath(m_fileId).isEmpty() && !m_mine)
+            {
+                m_wantReceive = true;
+                FileCache::instance().startDownload(m_fileId, m_url, m_mime, m_name, m_size);
+            }
+            else
+            {
+                FileCache::instance().requestVideoPoster(m_fileId);
+            }
+            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();
+        }
+
+    protected:
+        void paintEvent(QPaintEvent *) override
+        {
+            QPainter p(this);
+            paintChatMedia(&p, rect(), m_pm, QColor(QStringLiteral("#1A1A1A")));
+            const QPixmap play = videoPlayOverlay(36);
+            const qreal dpr = qMax(1.0, play.devicePixelRatio());
+            const QSize ps(qRound(play.width() / dpr), qRound(play.height() / dpr));
+            QRect pr(QPoint(0, 0), ps);
+            pr.moveCenter(rect().center());
+            p.drawPixmap(pr, play);
+        }
+
+        void resizeEvent(QResizeEvent *e) override
+        {
+            ClickWidget::resizeEvent(e);
+            if (m_overlay)
+                m_overlay->setGeometry(rect());
+        }
+
+    private:
+        void applyPoster()
+        {
+            const QPixmap got = FileCache::instance().cachedThumbnail(m_fileId, 180);
+            if (got.isNull())
+                return;
+            m_pm = got;
+            update();
+        }
+
+        QSize reservedSize() const
+        {
+            QSize src = FileCache::instance().mediaSize(m_fileId);
+            if (src.isEmpty())
+                src = QSize(m_hintW, m_hintH);
+            return chatMediaBox(src.width(), src.height(), true);
+        }
+
+        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;
+
+            if (showAsSent || received || !transferStarted)
+            {
+                m_bar->hide();
+                m_metaLab->hide();
+            }
+            else
+            {
+                m_bar->show();
+                m_metaLab->show();
+                if (tot > 0)
+                    m_bar->setValue(
+                        static_cast<int>(qBound(qint64(0), rec * 1000 / tot, qint64(1000))));
+                else
+                    m_bar->setValue(0);
+                QString meta = QStringLiteral("%1 / %2")
+                                   .arg(FileCache::formatSize(rec),
+                                        tot > 0 ? FileCache::formatSize(tot)
+                                                : QStringLiteral("--"));
+                if (transferring)
+                    meta += QStringLiteral("  ") + FileCache::formatSpeed(bps);
+                else
+                    meta += QStringLiteral("  已暂停");
+                m_metaLab->setText(meta);
+            }
+
+            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_asked = false;
+        bool m_wantReceive = false;
+        int m_hintW = 0;
+        int m_hintH = 0;
+        QSize m_box;
+        QPixmap m_pm;
+        QWidget *m_actionSlot = nullptr;
+        QLabel *m_sendMark = nullptr;
+        QPushButton *m_dl = nullptr;
+        QPushButton *m_pause = nullptr;
+        QWidget *m_overlay = nullptr;
+        QProgressBar *m_bar = nullptr;
+        QLabel *m_metaLab = nullptr;
+    };
+
     inline void refreshRowFileAction(QWidget *row)
     {
         if (!row)
             return;
         if (auto *fb = row->findChild<FileBubble *>(QStringLiteral("fileBubble")))
             fb->refreshActionSlot();
+        if (auto *vb = row->findChild<VideoBubble *>(QStringLiteral("videoBubble")))
+            vb->refreshActionSlot();
     }
 
     using ElideLabel = UiHelpers::ElideLabel;
@@ -824,6 +1334,16 @@ namespace ClientInternal {
                  << QPointF(14.4, 9.4) << QPointF(s - 3.6, s - 5.2);
             p.drawPolygon(hill);
         }
+        else if (kind == 3)
+        {
+            p.drawRoundedRect(QRectF(2.6, 4.6, s - 5.2, s - 9.0), 2.0, 2.0);
+            p.setBrush(color);
+            p.setPen(Qt::NoPen);
+            QPolygonF tri;
+            tri << QPointF(s * 0.40, s * 0.36) << QPointF(s * 0.40, s * 0.68)
+                << QPointF(s * 0.68, s * 0.52);
+            p.drawPolygon(tri);
+        }
         else
         {
             p.drawRoundedRect(QRectF(5.0, 2.8, s - 9.2, s - 5.4), 1.2, 1.2);
@@ -1010,7 +1530,8 @@ namespace ClientInternal {
         auto *nameLay = new QHBoxLayout(nameRow);
         nameLay->setContentsMargins(0, 0, 0, 0);
         nameLay->setSpacing(6);
-        auto *name = new ElideLabel(c.name, nameRow);
+        auto *name = new ElideLabel(
+            c.convType == ConvGroup ? groupTitle(c.name, c.memberCount) : c.name, nameRow);
         name->setStyleSheet(QStringLiteral("color:#191919; font-size:14px; background:transparent;"));
         nameLay->addWidget(name, 1, Qt::AlignVCenter);
 
@@ -1037,6 +1558,13 @@ namespace ClientInternal {
             if (previewText.isEmpty())
                 previewText = QStringLiteral("图片");
         }
+        else if (previewText.startsWith(QStringLiteral("[视频]")))
+        {
+            iconKind = 3;
+            previewText = previewText.mid(4);
+            if (previewText.isEmpty())
+                previewText = QStringLiteral("视频");
+        }
         else if (previewText.startsWith(QStringLiteral("[文件]")))
         {
             iconKind = 2;

+ 240 - 30
Client/src/mainframe/ClientSend.cpp

@@ -1,23 +1,57 @@
 // ClientSend.cpp — 发消息/文件/表情、转发收藏、头像刷新
 #include "ClientImpl.h"
 
+#include <QImageReader>
+
 void Client::sendCurrentMessage()
 {
-    if (!m_chatBody->isVisible() || m_currentConv.id <= 0)
+    if (!m_chatBody->isVisible() || m_currentConv.id <= 0 || !m_input)
         return;
-    const QString text = m_input->toPlainText().trimmed();
-    if (text.isEmpty())
+    const QVector<ChatInputEdit::Part> parts = m_input->parts();
+    if (parts.isEmpty())
         return;
     const int contentMax = AppConfig::instance().contentMax();
-    if (contentMax > 0 && text.size() > contentMax)
+    for (const auto &p : parts)
     {
-        UiHelpers::showToast(this, QStringLiteral("消息过长"), true);
-        return;
+        if (!p.image && contentMax > 0 && p.text.size() > contentMax)
+        {
+            UiHelpers::showToast(this, QStringLiteral("消息过长"), true);
+            return;
+        }
+    }
+    m_input->resetDraft();
+    for (const auto &p : parts)
+    {
+        DraftPart d;
+        d.image = p.image;
+        d.text = p.text;
+        d.pixmap = p.pixmap;
+        m_draftQueue.push_back(d);
     }
+    flushDraftQueue();
+}
 
-    m_input->clear();
-    sendOutgoing(m_currentConv.id, m_currentPeerName, im::MsgType::Text, text,
-                 previewOf(im::MsgType::Text, text), nullptr, 0, m_currentConv.type);
+void Client::flushDraftQueue()
+{
+    if (m_draftBusy || m_draftQueue.isEmpty())
+        return;
+    const DraftPart part = m_draftQueue.takeFirst();
+    m_draftBusy = true;
+    QPointer<Client> self(this);
+    auto next = [self]() {
+        if (!self)
+            return;
+        self->m_draftBusy = false;
+        self->flushDraftQueue();
+    };
+    if (part.image)
+    {
+        sendImagePixmap(part.pixmap, next);
+        return;
+    }
+    sendOutgoing(m_currentConv.id, m_currentPeerName, im::MsgType::Text, part.text,
+                 previewOf(im::MsgType::Text, part.text), nullptr, 0, m_currentConv.type);
+    QTimer::singleShot(0, this, [next]() { next(); });
 }
 
 void Client::onChatNotify(const QJsonObject &msg)
@@ -132,6 +166,8 @@ QString Client::previewOf(int msgType, const QString &content) const
         const QString mime = o.value(QStringLiteral("mime")).toString();
         if (FileCache::looksLikeImage(mime, name))
             return QStringLiteral("[图片]");
+        if (FileCache::looksLikeVideo(mime, name))
+            return QStringLiteral("[视频]");
         return name.isEmpty() ? QStringLiteral("[文件]") : QStringLiteral("[文件] %1").arg(name);
     }
     if (msgType == im::MsgType::Card)
@@ -365,11 +401,37 @@ void Client::sendFile(bool imageOnly)
 {
     if (!m_chatBody->isVisible() || m_currentConv.id <= 0)
         return;
-    const QString filter = imageOnly
-        ? QStringLiteral("图片 (*.jpg *.jpeg *.png *.gif *.bmp *.webp)")
-        : QString();
+    if (imageOnly)
+    {
+        const QStringList paths = QFileDialog::getOpenFileNames(
+            this, QStringLiteral("选择图片"), QString(),
+            QStringLiteral("图片 (*.jpg *.jpeg *.png *.gif *.bmp *.webp)"));
+        if (paths.isEmpty() || !m_input)
+            return;
+        int inserted = 0;
+        for (const QString &path : paths)
+        {
+            const qint64 fileMax = AppConfig::instance().fileMax();
+            const QFileInfo info(path);
+            if (fileMax > 0 && info.size() > fileMax)
+            {
+                UiHelpers::showToast(this, QStringLiteral("文件不能超过 %1")
+                                               .arg(FileCache::formatSize(fileMax)), true);
+                continue;
+            }
+            if (m_input->insertImageFile(path))
+                ++inserted;
+            else
+                UiHelpers::showToast(this, QStringLiteral("无法插入图片"), true);
+        }
+        if (inserted > 0)
+            m_input->setFocus();
+        return;
+    }
+    const QString filter =
+        QStringLiteral("所有文件 (*.*);;视频 (*.mp4 *.mov *.m4v *.avi *.mkv *.webm *.wmv)");
     const QString path = QFileDialog::getOpenFileName(
-        this, imageOnly ? QStringLiteral("选择图片") : QStringLiteral("选择文件"), QString(), filter);
+        this, QStringLiteral("选择文件"), QString(), filter);
     if (path.isEmpty())
         return;
     QFileInfo info(path);
@@ -378,32 +440,97 @@ void Client::sendFile(bool imageOnly)
         UiHelpers::showToast(this, QStringLiteral("文件无效"), true);
         return;
     }
+    sendLocalFile(path);
+}
+
+void Client::insertComposerImage(const QPixmap &pm)
+{
+    if (pm.isNull() || !m_input)
+        return;
+    if (!m_chatBody || !m_chatBody->isVisible() || m_currentConv.id <= 0)
+    {
+        QApplication::clipboard()->setPixmap(pm);
+        UiHelpers::showToast(this, QStringLiteral("截图已复制到剪贴板,打开会话后可粘贴发送"));
+        return;
+    }
+    m_input->insertImage(pm);
+    m_input->setFocus();
+}
+
+void Client::sendImagePixmap(const QPixmap &pm, const std::function<void()> &done)
+{
+    if (pm.isNull())
+    {
+        if (done)
+            done();
+        return;
+    }
+    if (!m_chatBody || m_currentConv.id <= 0)
+    {
+        QApplication::clipboard()->setPixmap(pm);
+        UiHelpers::showToast(this, QStringLiteral("截图已复制到剪贴板,打开会话后可粘贴发送"));
+        if (done)
+            done();
+        return;
+    }
+    const QString dir = QStandardPaths::writableLocation(QStandardPaths::TempLocation);
+    QDir().mkpath(dir);
+    const QString path = dir + QStringLiteral("/nim-shot-%1.jpg").arg(QDateTime::currentMSecsSinceEpoch());
+    if (!pm.save(path, "JPEG", 88))
+    {
+        UiHelpers::showToast(this, QStringLiteral("截图保存失败"), true);
+        if (done)
+            done();
+        return;
+    }
+    sendLocalFile(path, done);
+}
+
+void Client::sendLocalFile(const QString &path, const std::function<void()> &done)
+{
+    auto finish = [done]() {
+        if (done)
+            done();
+    };
+    if (m_currentConv.id <= 0)
+    {
+        finish();
+        return;
+    }
+    QFileInfo info(path);
+    if (info.size() <= 0)
+    {
+        UiHelpers::showToast(this, QStringLiteral("文件无效"), true);
+        finish();
+        return;
+    }
     const qint64 fileMax = AppConfig::instance().fileMax();
     if (fileMax > 0 && info.size() > fileMax)
     {
         UiHelpers::showToast(this, QStringLiteral("文件不能超过 %1")
                                        .arg(FileCache::formatSize(fileMax)), true);
+        finish();
         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_currentConv.id;
     const int convType = m_currentConv.type;
     const QString peerName = m_currentPeerName;
-    ImClient::instance().putFile(name, mime, size, [self, path, name, mime, size, peer, peerName, convType](const QJsonObject &reply) {
+    ImClient::instance().putFile(name, mime, size,
+        [self, path, name, mime, size, peer, peerName, convType, finish](const QJsonObject &reply) {
         if (!self)
+        {
+            finish();
             return;
+        }
         if (!ImClient::ok(reply))
         {
             UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            finish();
             return;
         }
         const qint64 fileId = Proto::jsonI64(reply, QStringLiteral("file_id"));
@@ -413,25 +540,49 @@ void Client::sendFile(bool imageOnly)
         if (fileId <= 0 || url.isEmpty())
         {
             UiHelpers::showToast(self, QStringLiteral("上传凭证无效"), true);
+            finish();
             return;
         }
         if (!FileCache::instance().rememberPath(fileId, path, gotMime, gotName))
         {
             UiHelpers::showToast(self, QStringLiteral("无法保存本地文件"), true);
+            finish();
             return;
         }
         FileCache::instance().bindMeta(fileId, url, gotMime, gotName, size);
 
+        int mediaW = 0;
+        int mediaH = 0;
+        if (FileCache::looksLikeImage(gotMime, gotName))
+        {
+            QImageReader reader(path);
+            reader.setAutoTransform(true);
+            const QSize sz = reader.size();
+            mediaW = sz.width();
+            mediaH = sz.height();
+        }
+        else
+        {
+            const QSize known = FileCache::instance().mediaSize(fileId);
+            mediaW = known.width();
+            mediaH = known.height();
+        }
+        if (mediaW > 0 && mediaH > 0)
+            FileCache::instance().rememberMediaSize(fileId, mediaW, mediaH);
+
         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);
+        if (mediaW > 0 && mediaH > 0)
+        {
+            content.insert(QStringLiteral("w"), mediaW);
+            content.insert(QStringLiteral("h"), mediaH);
+        }
         const QString contentText = QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Compact));
-        const QString preview = FileCache::looksLikeImage(gotMime, gotName)
-            ? QStringLiteral("[图片]")
-            : QStringLiteral("[文件] %1").arg(gotName);
+        const QString preview = FileCache::convPreview(gotMime, gotName);
 
         const qint64 localId = LocalStore::instance().allocLocalId();
         const qint64 ts = QDateTime::currentMSecsSinceEpoch();
@@ -441,20 +592,27 @@ void Client::sendFile(bool imageOnly)
 
         auto *once = new QObject(self);
         QObject::connect(&FileCache::instance(), &FileCache::uploaded, once,
-                         [self, once, fileId, peer, peerName, contentText, preview, localId, convType](qint64 id) {
+                         [self, once, fileId, peer, peerName, contentText, preview, localId, convType, finish](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, convType);
+            if (self)
+            {
+                QWidget *row = self->findOutgoingRow(localId);
+                self->sendOutgoing(peer, peerName, im::MsgType::File, contentText, preview, row, localId, convType);
+            }
+            finish();
         });
         QObject::connect(&FileCache::instance(), &FileCache::failed, once,
-                         [self, fileId](qint64 id, const QString &error) {
-            if (id != fileId || !self)
+                         [self, once, fileId, finish](qint64 id, const QString &error) {
+            if (id != fileId)
                 return;
-            UiHelpers::showToast(self, error.isEmpty() ? QStringLiteral("上传失败") : error, true);
+            once->deleteLater();
+            if (self)
+            {
+                UiHelpers::showToast(self, error.isEmpty() ? QStringLiteral("上传失败") : error, true);
+            }
+            finish();
         });
 
         FileCache::instance().startUpload(fileId, reply.value(QStringLiteral("token")).toObject());
@@ -513,6 +671,58 @@ void Client::previewChatImage(qint64 fileId, const QString &name, const QString
                                              .value(QStringLiteral("url")).toString());
 }
 
+void Client::previewChatVideo(qint64 fileId, const QString &name, const QString &content)
+{
+    if (fileId <= 0)
+        return;
+    auto show = [this, fileId, name, content]() {
+        const QString path = FileCache::instance().localPath(fileId);
+        if (path.isEmpty() || !QFileInfo::exists(path))
+            return false;
+        VideoPreviewDialog dlg(path, name, 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 QString path = FileCache::instance().localPath(fileId);
+        if (path.isEmpty())
+        {
+            UiHelpers::showToast(self, QStringLiteral("无法预览该视频"), true);
+            return;
+        }
+        VideoPreviewDialog dlg(path, name, 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(&FileCache::instance(), &FileCache::failed, once, [self, once, fileId](qint64 id, const QString &error) {
+        if (id != fileId)
+            return;
+        once->deleteLater();
+        if (self)
+            UiHelpers::showToast(self, error.isEmpty() ? QStringLiteral("视频加载失败") : error, true);
+    });
+    const QJsonObject o = QJsonDocument::fromJson(content.toUtf8()).object();
+    FileCache::instance().ensure(fileId, o.value(QStringLiteral("url")).toString(),
+                                 o.value(QStringLiteral("mime")).toString(), name);
+}
+
 void Client::favoritePayload(int msgType, const QString &content)
 {
     if (content.isEmpty())

+ 2 - 4
Client/src/mainframe/ClientSession.cpp

@@ -12,9 +12,8 @@ void Client::onKicked()
     if (m_trayPreview)
         m_trayPreview->hide();
     stopUnreadAlert();
-    UiHelpers::showToast(this, QStringLiteral("账号已在其他设备登录"));
     ImClient::instance().disconnectFromServer();
-    emit loggedOut();
+    emit loggedOut(QStringLiteral("账号已在其他设备登录,当前设备已被强制下线。"));
     close();
 }
 
@@ -56,8 +55,7 @@ void Client::onConnected()
         {
             self->m_loggingOut = true;
             self->hideReconnectOverlay();
-            UiHelpers::showToast(self, QStringLiteral("登录已失效,请重新登录"), true);
-            emit self->loggedOut();
+            emit self->loggedOut(QStringLiteral("登录已失效,请重新登录。"));
             self->close();
             return;
         }

+ 8 - 0
Client/src/mainframe/FavoritePanel.cpp

@@ -415,6 +415,7 @@ FavoritePanel::FavoritePanel(QWidget *parent)
     m_list->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
     m_list->setResizeMode(QListView::Adjust);
     m_list->setContextMenuPolicy(Qt::CustomContextMenu);
+    UiHelpers::enableSmoothScroll(m_list);
     m_list->setStyleSheet(QStringLiteral(
         "QListWidget { background:#F5F5F5; border:none; outline:0; }"
         "QListWidget::item { height:52px; padding:0; margin:0; border:none; }"
@@ -785,6 +786,13 @@ void FavoriteDetailPage::render(const QJsonObject &item)
     }
 
     auto *card = new FavFileCard(m_body, fileId, name, size, url, mime);
+    if (FileCache::looksLikeVideo(mime, name))
+    {
+        card->setCursor(Qt::PointingHandCursor);
+        card->setProperty("fileId", fileId);
+        card->setProperty("fileName", name);
+        card->installEventFilter(this);
+    }
     lay->addWidget(card, 0, Qt::AlignLeft);
     lay->addStretch();
 }

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

@@ -129,6 +129,7 @@ void ImClient::login(const QString &account, const QString &password, Reply cb)
         if (ok(reply))
         {
             applyLoginProfile(reply);
+            applyLimitsFromReply(reply);
             m_sessionPassword = password;
             LOG_INFO(QStringLiteral("login ok uid=%1 account=%2")
                          .arg(m_uid)

+ 443 - 0
Client/src/widgets/ChatInputEdit.cpp

@@ -0,0 +1,443 @@
+#include "ChatInputEdit.h"
+#include "UiHelpers.h"
+
+#include <QAction>
+#include <QApplication>
+#include <QClipboard>
+#include <QContextMenuEvent>
+#include <QFileDialog>
+#include <QFileInfo>
+#include <QImage>
+#include <QImageReader>
+#include <QKeyEvent>
+#include <QKeySequence>
+#include <QMenu>
+#include <QMimeData>
+#include <QSet>
+#include <QTextBlock>
+#include <QTextCursor>
+#include <QTextDocument>
+#include <QTextFragment>
+#include <QTextImageFormat>
+#include <QUrl>
+#include <QVariant>
+
+ChatInputEdit::ChatInputEdit(QWidget *parent)
+    : QTextEdit(parent)
+{
+    setAcceptRichText(false);
+    setAcceptDrops(true);
+    setContextMenuPolicy(Qt::DefaultContextMenu);
+    connect(document(), &QTextDocument::contentsChanged, this, [this]() { pruneOriginals(); });
+}
+
+void ChatInputEdit::insertImage(const QPixmap &pm)
+{
+    if (pm.isNull())
+        return;
+    const QString key = QStringLiteral("nim-img-%1").arg(++m_seq);
+    m_originals.insert(key, pm);
+
+    const qreal dpr = pm.devicePixelRatio() > 0 ? pm.devicePixelRatio() : 1.0;
+    const int iw = qMax(1, qRound(pm.width() / dpr));
+    const int ih = qMax(1, qRound(pm.height() / dpr));
+    QSize shown(iw, ih);
+    shown.scale(180, 100, Qt::KeepAspectRatio);
+    QPixmap thumb = pm.scaled(qRound(shown.width() * dpr), qRound(shown.height() * dpr),
+                              Qt::KeepAspectRatio, Qt::SmoothTransformation);
+    thumb.setDevicePixelRatio(dpr);
+    document()->addResource(QTextDocument::ImageResource, QUrl(key), QVariant::fromValue(thumb));
+
+    QTextImageFormat fmt;
+    fmt.setName(key);
+    fmt.setWidth(shown.width());
+    fmt.setHeight(shown.height());
+    textCursor().insertImage(fmt);
+}
+
+bool ChatInputEdit::insertImageFile(const QString &path)
+{
+    if (path.isEmpty() || !looksLikeImageFile(path))
+        return false;
+    QPixmap pm;
+    if (!pm.load(path))
+        return false;
+    insertImage(pm);
+    return true;
+}
+
+QImage ChatInputEdit::imageFromMime(const QMimeData *source)
+{
+    if (!source)
+        return {};
+    if (source->hasImage())
+    {
+        const QImage img = qvariant_cast<QImage>(source->imageData());
+        if (!img.isNull())
+            return img;
+    }
+    const char *fmts[] = {"image/png", "image/jpeg", "image/bmp", "image/gif", "image/webp"};
+    for (const char *f : fmts)
+    {
+        if (!source->hasFormat(QLatin1String(f)))
+            continue;
+        QImage img;
+        if (img.loadFromData(source->data(QLatin1String(f))))
+            return img;
+    }
+    return {};
+}
+
+bool ChatInputEdit::pasteClipboardImage()
+{
+    const QMimeData *md = QApplication::clipboard() ? QApplication::clipboard()->mimeData() : nullptr;
+    const QImage fromMime = imageFromMime(md);
+    if (!fromMime.isNull())
+    {
+        insertImage(QPixmap::fromImage(fromMime));
+        return true;
+    }
+    if (md && md->hasUrls())
+    {
+        bool any = false;
+        for (const QUrl &u : md->urls())
+        {
+            if (u.isLocalFile() && insertImageFile(u.toLocalFile()))
+                any = true;
+        }
+        if (any)
+            return true;
+    }
+    if (QApplication::clipboard())
+    {
+        const QImage img = QApplication::clipboard()->image();
+        if (!img.isNull())
+        {
+            insertImage(QPixmap::fromImage(img));
+            return true;
+        }
+        const QPixmap pm = QApplication::clipboard()->pixmap();
+        if (!pm.isNull())
+        {
+            insertImage(pm);
+            return true;
+        }
+    }
+    return false;
+}
+
+void ChatInputEdit::resetDraft()
+{
+    QTextEdit::clear();
+    m_originals.clear();
+}
+
+bool ChatInputEdit::hasSendableContent() const
+{
+    if (!m_originals.isEmpty())
+        return true;
+    return !toPlainText().trimmed().isEmpty();
+}
+
+QVector<ChatInputEdit::Part> ChatInputEdit::parts() const
+{
+    QVector<Part> out;
+    QString pending;
+    auto flushText = [&]() {
+        const QString t = pending.trimmed();
+        pending.clear();
+        if (t.isEmpty())
+            return;
+        Part p;
+        p.text = t;
+        out.push_back(p);
+    };
+
+    for (QTextBlock block = document()->begin(); block.isValid(); block = block.next())
+    {
+        if (block != document()->begin())
+            pending += QLatin1Char('\n');
+        for (auto it = block.begin(); !it.atEnd(); ++it)
+        {
+            const QTextFragment frag = it.fragment();
+            if (!frag.isValid())
+                continue;
+            if (frag.charFormat().isImageFormat())
+            {
+                flushText();
+                const QString name = frag.charFormat().toImageFormat().name();
+                const QPixmap pm = m_originals.value(name);
+                if (!pm.isNull())
+                {
+                    Part p;
+                    p.image = true;
+                    p.pixmap = pm;
+                    out.push_back(p);
+                }
+                continue;
+            }
+            pending += frag.text();
+        }
+    }
+    flushText();
+    return out;
+}
+
+bool ChatInputEdit::canInsertFromMimeData(const QMimeData *source) const
+{
+    if (!source)
+        return false;
+    if (!imageFromMime(source).isNull())
+        return true;
+    if (source->hasUrls())
+    {
+        for (const QUrl &u : source->urls())
+        {
+            if (u.isLocalFile() && looksLikeImageFile(u.toLocalFile()))
+                return true;
+        }
+    }
+    return QTextEdit::canInsertFromMimeData(source);
+}
+
+void ChatInputEdit::insertFromMimeData(const QMimeData *source)
+{
+    if (!source)
+        return;
+    const QImage img = imageFromMime(source);
+    if (!img.isNull())
+    {
+        insertImage(QPixmap::fromImage(img));
+        return;
+    }
+    bool any = false;
+    if (source->hasUrls())
+    {
+        for (const QUrl &u : source->urls())
+        {
+            if (u.isLocalFile() && insertImageFile(u.toLocalFile()))
+                any = true;
+        }
+        if (any)
+            return;
+    }
+    QTextEdit::insertFromMimeData(source);
+}
+
+void ChatInputEdit::keyPressEvent(QKeyEvent *event)
+{
+    if (event->matches(QKeySequence::Paste))
+    {
+        if (pasteClipboardImage())
+        {
+            event->accept();
+            return;
+        }
+    }
+    QTextEdit::keyPressEvent(event);
+}
+
+QString ChatInputEdit::imageKeyAt(const QPoint &pos) const
+{
+    QTextCursor c = cursorForPosition(pos);
+    auto keyOf = [](const QTextCharFormat &fmt) -> QString {
+        if (!fmt.isImageFormat())
+            return {};
+        return fmt.toImageFormat().name();
+    };
+    QString key = keyOf(c.charFormat());
+    if (key.isEmpty() && c.position() > 0)
+    {
+        QTextCursor left(c);
+        left.movePosition(QTextCursor::Left);
+        key = keyOf(left.charFormat());
+    }
+    return key;
+}
+
+QPixmap ChatInputEdit::originalOf(const QString &key) const
+{
+    return m_originals.value(key);
+}
+
+bool ChatInputEdit::canPasteContent() const
+{
+    const QMimeData *md = QApplication::clipboard() ? QApplication::clipboard()->mimeData() : nullptr;
+    if (!md)
+        return false;
+    if (!imageFromMime(md).isNull())
+        return true;
+    if (md->hasUrls())
+    {
+        for (const QUrl &u : md->urls())
+        {
+            if (u.isLocalFile() && looksLikeImageFile(u.toLocalFile()))
+                return true;
+        }
+    }
+    return md->hasText() && !md->text().isEmpty();
+}
+
+void ChatInputEdit::copyImage(const QString &key)
+{
+    const QPixmap pm = originalOf(key);
+    if (pm.isNull() || !QApplication::clipboard())
+        return;
+    QApplication::clipboard()->setPixmap(pm);
+}
+
+void ChatInputEdit::deleteImage(const QString &key)
+{
+    if (key.isEmpty())
+        return;
+    QTextCursor c(document());
+    for (QTextBlock block = document()->begin(); block.isValid(); block = block.next())
+    {
+        for (auto it = block.begin(); !it.atEnd(); ++it)
+        {
+            const QTextFragment frag = it.fragment();
+            if (!frag.isValid() || !frag.charFormat().isImageFormat())
+                continue;
+            if (frag.charFormat().toImageFormat().name() != key)
+                continue;
+            c.setPosition(frag.position());
+            c.setPosition(frag.position() + frag.length(), QTextCursor::KeepAnchor);
+            c.removeSelectedText();
+            return;
+        }
+    }
+}
+
+void ChatInputEdit::cutImage(const QString &key)
+{
+    copyImage(key);
+    deleteImage(key);
+}
+
+void ChatInputEdit::saveImage(const QString &key)
+{
+    const QPixmap pm = originalOf(key);
+    if (pm.isNull())
+        return;
+    const QString path = QFileDialog::getSaveFileName(
+        this, QStringLiteral("保存图片"), QStringLiteral("image.png"),
+        QStringLiteral("PNG (*.png);;JPEG (*.jpg *.jpeg)"));
+    if (path.isEmpty())
+        return;
+    const QString suf = QFileInfo(path).suffix().toLower();
+    const char *fmt = (suf == QLatin1String("jpg") || suf == QLatin1String("jpeg")) ? "JPEG" : "PNG";
+    if (!pm.save(path, fmt, 90))
+        UiHelpers::showToast(this, QStringLiteral("保存失败"), true);
+    else
+        UiHelpers::showToast(this, QStringLiteral("已保存"));
+}
+
+void ChatInputEdit::copySelection()
+{
+    const QString key = imageKeyAt(viewport()->mapFromGlobal(QCursor::pos()));
+    if (!key.isEmpty() && !textCursor().hasSelection())
+    {
+        copyImage(key);
+        return;
+    }
+    copy();
+}
+
+void ChatInputEdit::cutSelection()
+{
+    if (textCursor().hasSelection())
+    {
+        cut();
+        return;
+    }
+    const QString key = imageKeyAt(viewport()->mapFromGlobal(QCursor::pos()));
+    if (!key.isEmpty())
+        cutImage(key);
+}
+
+void ChatInputEdit::contextMenuEvent(QContextMenuEvent *event)
+{
+    const QString imgKey = imageKeyAt(event->pos());
+    QMenu menu(this);
+    if (!imgKey.isEmpty())
+    {
+        QAction *save = menu.addAction(QStringLiteral("保存"));
+        QAction *copy = menu.addAction(QStringLiteral("复制"));
+        QAction *cut = menu.addAction(QStringLiteral("剪切"));
+        QAction *del = menu.addAction(QStringLiteral("删除"));
+        menu.addSeparator();
+        QAction *paste = menu.addAction(QStringLiteral("粘贴"));
+        paste->setEnabled(canPasteContent());
+        QAction *sel = menu.addAction(QStringLiteral("全选"));
+        sel->setEnabled(!document()->isEmpty());
+        QAction *picked = menu.exec(event->globalPos());
+        if (picked == save)
+            saveImage(imgKey);
+        else if (picked == copy)
+            copyImage(imgKey);
+        else if (picked == cut)
+            cutImage(imgKey);
+        else if (picked == del)
+            deleteImage(imgKey);
+        else if (picked == paste)
+        {
+            if (!pasteClipboardImage())
+                this->paste();
+        }
+        else if (picked == sel)
+            this->selectAll();
+        return;
+    }
+
+    const bool hasSel = textCursor().hasSelection();
+    QAction *cut = menu.addAction(QStringLiteral("剪切"));
+    cut->setEnabled(hasSel);
+    QAction *copy = menu.addAction(QStringLiteral("复制"));
+    copy->setEnabled(hasSel);
+    QAction *paste = menu.addAction(QStringLiteral("粘贴"));
+    paste->setEnabled(canPasteContent());
+    menu.addSeparator();
+    QAction *sel = menu.addAction(QStringLiteral("全选"));
+    sel->setEnabled(!document()->isEmpty());
+    QAction *picked = menu.exec(event->globalPos());
+    if (picked == cut)
+        this->cut();
+    else if (picked == copy)
+        this->copy();
+    else if (picked == paste)
+    {
+        if (!pasteClipboardImage())
+            this->paste();
+    }
+    else if (picked == sel)
+        selectAll();
+}
+
+void ChatInputEdit::pruneOriginals()
+{
+    QSet<QString> live;
+    for (QTextBlock block = document()->begin(); block.isValid(); block = block.next())
+    {
+        for (auto it = block.begin(); !it.atEnd(); ++it)
+        {
+            const QTextFragment frag = it.fragment();
+            if (frag.isValid() && frag.charFormat().isImageFormat())
+                live.insert(frag.charFormat().toImageFormat().name());
+        }
+    }
+    for (auto it = m_originals.begin(); it != m_originals.end();)
+    {
+        if (!live.contains(it.key()))
+            it = m_originals.erase(it);
+        else
+            ++it;
+    }
+}
+
+bool ChatInputEdit::looksLikeImageFile(const QString &path)
+{
+    if (path.isEmpty() || !QFileInfo::exists(path))
+        return false;
+    QImageReader reader(path);
+    return reader.canRead();
+}

+ 55 - 0
Client/src/widgets/ChatInputEdit.h

@@ -0,0 +1,55 @@
+#pragma once
+
+#include <QHash>
+#include <QPixmap>
+#include <QString>
+#include <QTextEdit>
+#include <QVector>
+
+class QContextMenuEvent;
+class QKeyEvent;
+class QMimeData;
+class QPoint;
+
+class ChatInputEdit : public QTextEdit
+{
+public:
+    struct Part
+    {
+        bool image = false;
+        QString text;
+        QPixmap pixmap;
+    };
+
+    explicit ChatInputEdit(QWidget *parent = nullptr);
+
+    void insertImage(const QPixmap &pm);
+    bool insertImageFile(const QString &path);
+    bool pasteClipboardImage();
+    void resetDraft();
+    QVector<Part> parts() const;
+    bool hasSendableContent() const;
+
+protected:
+    bool canInsertFromMimeData(const QMimeData *source) const override;
+    void insertFromMimeData(const QMimeData *source) override;
+    void contextMenuEvent(QContextMenuEvent *event) override;
+    void keyPressEvent(QKeyEvent *event) override;
+
+private:
+    void pruneOriginals();
+    QString imageKeyAt(const QPoint &pos) const;
+    QPixmap originalOf(const QString &key) const;
+    bool canPasteContent() const;
+    void copyImage(const QString &key);
+    void cutImage(const QString &key);
+    void deleteImage(const QString &key);
+    void saveImage(const QString &key);
+    void copySelection();
+    void cutSelection();
+    static bool looksLikeImageFile(const QString &path);
+    static QImage imageFromMime(const QMimeData *source);
+
+    QHash<QString, QPixmap> m_originals;
+    int m_seq = 0;
+};

+ 24 - 3
Client/src/widgets/ConfirmDialog.cpp

@@ -150,9 +150,18 @@ ConfirmDialog::ConfirmDialog(QWidget *parent, const QString &title, const QStrin
     ok->setStyleSheet(btnQss + QStringLiteral("QPushButton { color:%1; }")
                           .arg(dangerOk ? QStringLiteral("#FA5151") : QStringLiteral("#07C160")));
 
-    btnLay->addWidget(cancel, 1);
-    btnLay->addWidget(split);
-    btnLay->addWidget(ok, 1);
+    if (cancelText.isEmpty())
+    {
+        cancel->hide();
+        split->hide();
+        btnLay->addWidget(ok, 1);
+    }
+    else
+    {
+        btnLay->addWidget(cancel, 1);
+        btnLay->addWidget(split);
+        btnLay->addWidget(ok, 1);
+    }
 
     root->addWidget(body);
     root->addWidget(line);
@@ -173,6 +182,18 @@ ConfirmDialog::ConfirmDialog(QWidget *parent, const QString &title, const QStrin
     setFixedHeight(height());
 }
 
+void ConfirmDialog::notice(QWidget *parent, const QString &title, const QString &text,
+                           const QString &okText)
+{
+    ConfirmDialog dlg(parent, title, text, okText, QString(), false);
+    if (parent)
+    {
+        const QPoint c = parent->mapToGlobal(parent->rect().center());
+        dlg.move(c.x() - dlg.width() / 2, c.y() - dlg.height() / 2);
+    }
+    dlg.exec();
+}
+
 bool ConfirmDialog::ask(QWidget *parent, const QString &title, const QString &text,
                         const QString &okText, const QString &cancelText, bool dangerOk)
 {

+ 2 - 0
Client/src/widgets/ConfirmDialog.h

@@ -30,6 +30,8 @@ public:
                     const QString &okText = QStringLiteral("删除"),
                     const QString &cancelText = QStringLiteral("取消"),
                     bool dangerOk = true);
+    static void notice(QWidget *parent, const QString &title, const QString &text,
+                       const QString &okText = QStringLiteral("知道了"));
     // 1=左侧, 2=右侧, 0=取消/关闭
     static int chooseAction(QWidget *parent, const QString &title, const QString &text,
                             const QString &leftText, const QString &rightText);

+ 27 - 0
Client/src/widgets/FramelessHelper.cpp

@@ -2,11 +2,14 @@
 #include "TitleBar.h"
 
 #include <QAbstractButton>
+#include <QAbstractScrollArea>
 #include <QApplication>
 #include <QCursor>
 #include <QEvent>
 #include <QMouseEvent>
+#include <QRect>
 #include <QScreen>
+#include <QScrollBar>
 #include <QTimer>
 #include <QWindow>
 #include <QWindowStateChangeEvent>
@@ -227,6 +230,28 @@ bool FramelessHelper::inCaptionButtons(const QPoint &pos) const
     return pos.y() >= 0 && pos.y() < barH && pos.x() > m_host->width() - band;
 }
 
+bool FramelessHelper::overScrollBar(const QPoint &pos) const
+{
+    if (!m_host)
+        return false;
+    const auto bars = m_host->findChildren<QScrollBar *>();
+    for (QScrollBar *bar : bars)
+    {
+        if (!bar || !bar->isVisible() || bar->window() != m_host)
+            continue;
+        QRect r(bar->mapTo(m_host, QPoint(0, 0)), bar->size());
+        if (r.width() <= 0 || r.height() <= 0)
+            continue;
+        if (bar->orientation() == Qt::Vertical)
+            r.adjust(-6, 0, 6, 0);
+        else
+            r.adjust(0, -6, 0, 6);
+        if (r.contains(pos))
+            return true;
+    }
+    return false;
+}
+
 FramelessHelper::Region FramelessHelper::hitRegion(const QPoint &pos) const
 {
     const int kEdge = 8;
@@ -253,6 +278,8 @@ FramelessHelper::Region FramelessHelper::hitRegion(const QPoint &pos) const
         return BottomLeft;
     if (right && bottom)
         return BottomRight;
+    if (overScrollBar(pos))
+        return None;
     if (left)
         return Left;
     if (right)

+ 1 - 0
Client/src/widgets/FramelessHelper.h

@@ -52,6 +52,7 @@ private:
     void rememberNormalGeometry();
     void restoreNormalGeometry();
     bool inCaptionButtons(const QPoint &posInHost) const;
+    bool overScrollBar(const QPoint &posInHost) const;
     Region hitRegion(const QPoint &posInHost) const;
     void startResize(Region region);
     void applyResize(const QPoint &globalPos);

+ 310 - 31
Client/src/widgets/ImagePreviewDialog.cpp

@@ -2,15 +2,250 @@
 #include "TitleBar.h"
 #include "UiHelpers.h"
 
+#include <functional>
+
 #include <QFile>
 #include <QFileDialog>
 #include <QFrame>
-#include <QIODevice>
 #include <QHBoxLayout>
+#include <QIODevice>
+#include <QKeySequence>
 #include <QLabel>
+#include <QMouseEvent>
+#include <QPainter>
 #include <QPushButton>
-#include <QScrollArea>
+#include <QShortcut>
+#include <QWheelEvent>
 #include <QVBoxLayout>
+#include <QtMath>
+
+namespace
+{
+    constexpr qreal kAbsMinZoom = 0.25;
+    constexpr qreal kAbsMaxZoom = 8.0;
+
+    class ImageZoomView : public QWidget
+    {
+    public:
+        explicit ImageZoomView(QWidget *parent = nullptr)
+            : QWidget(parent)
+        {
+            setMouseTracking(true);
+            setFocusPolicy(Qt::WheelFocus);
+            setStyleSheet(QStringLiteral("background:#1A1A1A;"));
+            setMinimumSize(200, 160);
+        }
+
+        void setImage(const QPixmap &pm)
+        {
+            m_src = pm;
+            m_fitted = false;
+            updateFit();
+            update();
+        }
+
+        qreal zoom() const { return m_zoom; }
+
+        qreal minZoom() const { return m_minZoom; }
+        qreal maxZoom() const { return m_maxZoom; }
+
+        std::function<void()> onZoomChanged;
+
+        void setZoom(qreal z, const QPoint *anchor = nullptr)
+        {
+            z = qBound(m_minZoom, z, m_maxZoom);
+            if (qFuzzyCompare(z, m_zoom))
+            {
+                notifyZoom();
+                return;
+            }
+            const QPoint a = anchor ? *anchor : QPoint(width() / 2, height() / 2);
+            const QPointF img = (QPointF(a) - m_offset) / qMax(qreal(0.0001), m_zoom);
+            m_zoom = z;
+            m_offset = QPointF(a) - img * m_zoom;
+            clampOffset();
+            update();
+            notifyZoom();
+        }
+
+        void zoomBy(qreal factor, const QPoint *anchor = nullptr)
+        {
+            setZoom(m_zoom * factor, anchor);
+        }
+
+        void fitToView()
+        {
+            if (logicalSize().isEmpty() || width() <= 2 || height() <= 2)
+                return;
+            updateFitRange();
+            m_zoom = qBound(m_minZoom, m_fitZoom, m_maxZoom);
+            const QSizeF ls = logicalSize();
+            m_offset = QPointF((width() - ls.width() * m_zoom) / 2.0,
+                               (height() - ls.height() * m_zoom) / 2.0);
+            update();
+            notifyZoom();
+        }
+
+        void zoomToOriginal()
+        {
+            setZoom(1.0);
+            const QSizeF ls = logicalSize();
+            m_offset = QPointF((width() - ls.width() * m_zoom) / 2.0,
+                               (height() - ls.height() * m_zoom) / 2.0);
+            clampOffset();
+            update();
+        }
+
+    protected:
+        void paintEvent(QPaintEvent *) override
+        {
+            QPainter p(this);
+            p.fillRect(rect(), QColor(QStringLiteral("#1A1A1A")));
+            if (m_src.isNull())
+                return;
+            p.setRenderHint(QPainter::SmoothPixmapTransform, m_zoom < 3.0);
+            const QSizeF ls = logicalSize();
+            const QRectF dest(m_offset, QSizeF(ls.width() * m_zoom, ls.height() * m_zoom));
+            p.drawPixmap(dest, m_src, m_src.rect());
+        }
+
+        void resizeEvent(QResizeEvent *event) override
+        {
+            QWidget::resizeEvent(event);
+            if (!m_fitted)
+            {
+                m_fitted = true;
+                fitToView();
+            }
+            else
+            {
+                updateFitRange();
+                m_zoom = qBound(m_minZoom, m_zoom, m_maxZoom);
+                clampOffset();
+            }
+        }
+
+        void wheelEvent(QWheelEvent *event) override
+        {
+            const QPoint pos =
+#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
+                event->position().toPoint();
+#else
+                event->pos();
+#endif
+            const int delta = event->angleDelta().y();
+            if (delta == 0)
+                return;
+            zoomBy(delta > 0 ? 1.15 : 1.0 / 1.15, &pos);
+            event->accept();
+        }
+
+        void mousePressEvent(QMouseEvent *event) override
+        {
+            if (event->button() == Qt::LeftButton)
+            {
+                m_panning = true;
+                m_last = event->pos();
+                setCursor(Qt::ClosedHandCursor);
+            }
+            else if (event->button() == Qt::RightButton)
+            {
+                fitToView();
+            }
+        }
+
+        void mouseMoveEvent(QMouseEvent *event) override
+        {
+            if (!m_panning)
+                return;
+            m_offset += QPointF(event->pos() - m_last);
+            m_last = event->pos();
+            clampOffset();
+            update();
+        }
+
+        void mouseReleaseEvent(QMouseEvent *event) override
+        {
+            if (event->button() == Qt::LeftButton)
+            {
+                m_panning = false;
+                unsetCursor();
+            }
+        }
+
+        void mouseDoubleClickEvent(QMouseEvent *) override
+        {
+            if (qAbs(m_zoom - m_fitZoom) < 0.02)
+                zoomToOriginal();
+            else
+                fitToView();
+        }
+
+    private:
+        QSizeF logicalSize() const
+        {
+            if (m_src.isNull())
+                return {};
+            const qreal dpr = m_src.devicePixelRatio() > 0 ? m_src.devicePixelRatio() : 1.0;
+            return QSizeF(m_src.width() / dpr, m_src.height() / dpr);
+        }
+
+        void updateFitRange()
+        {
+            const QSizeF ls = logicalSize();
+            if (ls.isEmpty() || width() <= 2 || height() <= 2)
+            {
+                m_fitZoom = 1.0;
+                m_minZoom = kAbsMinZoom;
+                m_maxZoom = kAbsMaxZoom;
+                return;
+            }
+            m_fitZoom = qMin(width() / ls.width(), height() / ls.height());
+            m_minZoom = qMin(kAbsMinZoom, m_fitZoom);
+            m_maxZoom = kAbsMaxZoom;
+        }
+
+        void updateFit()
+        {
+            updateFitRange();
+            m_zoom = qBound(m_minZoom, m_fitZoom, m_maxZoom);
+            const QSizeF ls = logicalSize();
+            m_offset = QPointF((width() - ls.width() * m_zoom) / 2.0,
+                               (height() - ls.height() * m_zoom) / 2.0);
+        }
+
+        void notifyZoom()
+        {
+            if (onZoomChanged)
+                onZoomChanged();
+        }
+
+        void clampOffset()
+        {
+            const QSizeF ls = logicalSize();
+            const qreal w = ls.width() * m_zoom;
+            const qreal h = ls.height() * m_zoom;
+            if (w <= width())
+                m_offset.setX((width() - w) / 2.0);
+            else
+                m_offset.setX(qBound(width() - w, m_offset.x(), 0.0));
+            if (h <= height())
+                m_offset.setY((height() - h) / 2.0);
+            else
+                m_offset.setY(qBound(height() - h, m_offset.y(), 0.0));
+        }
+
+        QPixmap m_src;
+        qreal m_zoom = 1.0;
+        qreal m_fitZoom = 1.0;
+        qreal m_minZoom = kAbsMinZoom;
+        qreal m_maxZoom = kAbsMaxZoom;
+        QPointF m_offset;
+        QPoint m_last;
+        bool m_panning = false;
+        bool m_fitted = false;
+    };
+}
 
 ImagePreviewDialog::ImagePreviewDialog(const QPixmap &image, const QString &name,
                                        const QByteArray &bytes, QWidget *parent, bool canForward,
@@ -20,17 +255,8 @@ ImagePreviewDialog::ImagePreviewDialog(const QPixmap &image, const QString &name
     setObjectName(QStringLiteral("ChromeWindow"));
     setWindowTitle(name.isEmpty() ? QStringLiteral("图片") : name);
     UiHelpers::setupFramelessDialog(this);
-
-    const qreal dpr = image.devicePixelRatio() > 0 ? image.devicePixelRatio() : 1.0;
-    const int iw = qMax(1, qRound(image.width() / dpr));
-    const int ih = qMax(1, qRound(image.height() / dpr));
-    const int maxW = 560;
-    const int maxH = 480;
-    QSize shown(iw, ih);
-    shown.scale(maxW, maxH, Qt::KeepAspectRatio);
-    const int winW = qBound(320, shown.width() + 24, 600);
-    const int winH = qBound(280, shown.height() + 108, 620);
-    setFixedSize(winW, winH);
+    resize(720, 560);
+    setMinimumSize(400, 320);
 
     auto *root = new QVBoxLayout(this);
     root->setContentsMargins(0, 0, 0, 0);
@@ -39,30 +265,41 @@ ImagePreviewDialog::ImagePreviewDialog(const QPixmap &image, const QString &name
     auto *bar = new TitleBar(this);
     bar->setTitle(name.isEmpty() ? QStringLiteral("图片") : name);
     bar->setTheme(TitleBar::Light);
-    bar->setMaximizable(false);
-    bar->bind(this, false);
+    bar->setMaximizable(true);
+    bar->bind(this, true);
     root->addWidget(bar);
 
-    auto *area = new QScrollArea(this);
-    area->setWidgetResizable(true);
-    area->setFrameShape(QFrame::NoFrame);
-    area->setStyleSheet(QStringLiteral("background:#1A1A1A; border:none;"));
-    auto *img = new QLabel(area);
-    img->setAlignment(Qt::AlignCenter);
-    img->setStyleSheet(QStringLiteral("background:transparent;"));
-    const QPixmap fit = image.scaled(qRound(shown.width() * dpr), qRound(shown.height() * dpr),
-                                     Qt::KeepAspectRatio, Qt::SmoothTransformation);
-    QPixmap display = fit;
-    display.setDevicePixelRatio(dpr);
-    img->setPixmap(display);
-    area->setWidget(img);
-    root->addWidget(area, 1);
+    auto *view = new ImageZoomView(this);
+    view->setImage(image);
+    root->addWidget(view, 1);
 
     auto *btns = new QWidget(this);
     btns->setFixedHeight(48);
     btns->setStyleSheet(QStringLiteral("background:#FFFFFF; border-top:1px solid #E5E5E5;"));
     auto *bLay = new QHBoxLayout(btns);
-    bLay->setContentsMargins(16, 8, 16, 8);
+    bLay->setContentsMargins(12, 8, 16, 8);
+    bLay->setSpacing(8);
+
+    auto *zoomOut = UiHelpers::ghostButton(QStringLiteral("-"), btns);
+    zoomOut->setFixedSize(32, 32);
+    auto *zoomLab = new QLabel(btns);
+    zoomLab->setFixedWidth(52);
+    zoomLab->setAlignment(Qt::AlignCenter);
+    zoomLab->setStyleSheet(QStringLiteral("color:#666666; font-size:12px; background:transparent;"));
+    auto *zoomIn = UiHelpers::ghostButton(QStringLiteral("+"), btns);
+    zoomIn->setFixedSize(32, 32);
+    auto *fit = UiHelpers::ghostButton(QStringLiteral("适应"), btns);
+    fit->setFixedSize(56, 32);
+    auto *one = UiHelpers::ghostButton(QStringLiteral("原图"), btns);
+    one->setFixedSize(56, 32);
+
+    auto refreshZoom = [view, zoomLab, zoomOut, zoomIn]() {
+        zoomLab->setText(QStringLiteral("%1%").arg(qRound(view->zoom() * 100)));
+        zoomOut->setEnabled(view->zoom() > view->minZoom() + 0.001);
+        zoomIn->setEnabled(view->zoom() < view->maxZoom() - 0.001);
+    };
+    refreshZoom();
+
     auto *save = UiHelpers::ghostButton(QStringLiteral("保存"), btns);
     save->setFixedSize(88, 32);
     auto *fwd = UiHelpers::ghostButton(QStringLiteral("转发"), btns);
@@ -73,15 +310,55 @@ ImagePreviewDialog::ImagePreviewDialog(const QPixmap &image, const QString &name
     fav->setVisible(canFavorite);
     auto *ok = UiHelpers::primaryButton(QStringLiteral("关闭"), btns);
     ok->setFixedSize(88, 32);
+
+    bLay->addWidget(zoomOut);
+    bLay->addWidget(zoomLab);
+    bLay->addWidget(zoomIn);
+    bLay->addWidget(fit);
+    bLay->addWidget(one);
+    bLay->addStretch();
     bLay->addWidget(save);
     if (canForward)
         bLay->addWidget(fwd);
     if (canFavorite)
         bLay->addWidget(fav);
-    bLay->addStretch();
     bLay->addWidget(ok);
     root->addWidget(btns);
 
+    auto afterZoom = [refreshZoom]() { refreshZoom(); };
+    connect(zoomOut, &QPushButton::clicked, this, [view, afterZoom]() {
+        view->zoomBy(1.0 / 1.25);
+        afterZoom();
+    });
+    connect(zoomIn, &QPushButton::clicked, this, [view, afterZoom]() {
+        view->zoomBy(1.25);
+        afterZoom();
+    });
+    connect(fit, &QPushButton::clicked, this, [view, afterZoom]() {
+        view->fitToView();
+        afterZoom();
+    });
+    connect(one, &QPushButton::clicked, this, [view, afterZoom]() {
+        view->zoomToOriginal();
+        afterZoom();
+    });
+    auto *scIn = new QShortcut(QKeySequence::ZoomIn, this);
+    connect(scIn, &QShortcut::activated, this, [view, afterZoom]() {
+        view->zoomBy(1.25);
+        afterZoom();
+    });
+    auto *scOut = new QShortcut(QKeySequence::ZoomOut, this);
+    connect(scOut, &QShortcut::activated, this, [view, afterZoom]() {
+        view->zoomBy(1.0 / 1.25);
+        afterZoom();
+    });
+    auto *scFit = new QShortcut(Qt::Key_0, this);
+    scFit->setContext(Qt::WindowShortcut);
+    connect(scFit, &QShortcut::activated, this, [view, afterZoom]() {
+        view->fitToView();
+        afterZoom();
+    });
+
     connect(ok, &QPushButton::clicked, this, &QDialog::accept);
     connect(fwd, &QPushButton::clicked, this, [this]() {
         m_forward = true;
@@ -104,4 +381,6 @@ ImagePreviewDialog::ImagePreviewDialog(const QPixmap &image, const QString &name
         }
         UiHelpers::showToast(this, QStringLiteral("已保存"));
     });
+
+    view->onZoomChanged = afterZoom;
 }

+ 387 - 0
Client/src/widgets/ScreenshotEditor.cpp

@@ -0,0 +1,387 @@
+#include "ScreenshotEditor.h"
+#include "UiHelpers.h"
+
+#include <QApplication>
+#include <QGuiApplication>
+#include <QHBoxLayout>
+#include <QKeyEvent>
+#include <QKeySequence>
+#include <QMouseEvent>
+#include <QPainter>
+#include <QPainterPath>
+#include <QPaintEvent>
+#include <QPushButton>
+#include <QScreen>
+#include <QToolButton>
+
+namespace
+{
+    QPixmap grabDesktop()
+    {
+        const QList<QScreen *> screens = QGuiApplication::screens();
+        if (screens.isEmpty())
+            return {};
+        QRect virt;
+        for (QScreen *s : screens)
+            virt = virt.united(s->geometry());
+        if (!virt.isValid())
+            return {};
+
+        qreal maxDpr = 1.0;
+        for (QScreen *s : screens)
+            maxDpr = qMax(maxDpr, s->devicePixelRatio());
+
+        QPixmap canvas(qRound(virt.width() * maxDpr), qRound(virt.height() * maxDpr));
+        canvas.fill(Qt::black);
+        canvas.setDevicePixelRatio(maxDpr);
+
+        QPainter p(&canvas);
+        for (QScreen *s : screens)
+        {
+            if (!s)
+                continue;
+            const QPixmap g = s->grabWindow(0);
+            if (g.isNull())
+                continue;
+            const QPoint topLeft = s->geometry().topLeft() - virt.topLeft();
+            p.drawPixmap(QRect(topLeft, s->geometry().size()), g);
+        }
+        return canvas;
+    }
+
+    QRect virtGeometry()
+    {
+        QRect virt;
+        for (QScreen *s : QGuiApplication::screens())
+            virt = virt.united(s->geometry());
+        return virt;
+    }
+}
+
+QPixmap ScreenshotEditor::capture(QWidget *host)
+{
+    Q_UNUSED(host);
+    const QPixmap shot = grabDesktop();
+    if (shot.isNull())
+        return {};
+    ScreenshotEditor dlg(shot, nullptr);
+    dlg.setWindowModality(Qt::ApplicationModal);
+    if (dlg.exec() != QDialog::Accepted)
+        return {};
+    return dlg.resultPixmap();
+}
+
+ScreenshotEditor::ScreenshotEditor(const QPixmap &shot, QWidget *parent)
+    : QDialog(parent, Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::Tool)
+    , m_shot(shot)
+{
+    setWindowModality(Qt::ApplicationModal);
+    setMouseTracking(true);
+    setCursor(Qt::CrossCursor);
+    setFocusPolicy(Qt::StrongFocus);
+    const QRect virt = virtGeometry();
+    if (virt.isValid())
+        setGeometry(virt);
+    else
+        showFullScreen();
+    buildToolbar();
+}
+
+void ScreenshotEditor::buildToolbar()
+{
+    m_toolbar = new QWidget(this);
+    m_toolbar->setObjectName(QStringLiteral("ShotToolbar"));
+    m_toolbar->setStyleSheet(QStringLiteral(
+        "QWidget#ShotToolbar { background:#FFFFFF; border:1px solid #E5E5E5; border-radius:6px; }"));
+    auto *lay = new QHBoxLayout(m_toolbar);
+    lay->setContentsMargins(8, 6, 8, 6);
+    lay->setSpacing(6);
+
+    const QColor colors[] = {
+        QColor(QStringLiteral("#FA5151")),
+        QColor(QStringLiteral("#FFC300")),
+        QColor(QStringLiteral("#07C160")),
+        QColor(QStringLiteral("#10AEFF")),
+        QColor(QStringLiteral("#FFFFFF")),
+        QColor(QStringLiteral("#191919")),
+    };
+    for (const QColor &c : colors)
+    {
+        auto *btn = new QToolButton(m_toolbar);
+        btn->setFixedSize(22, 22);
+        btn->setCursor(Qt::PointingHandCursor);
+        btn->setAutoRaise(true);
+        const QString border = (c == QColor(QStringLiteral("#FFFFFF")))
+            ? QStringLiteral("1px solid #CCCCCC")
+            : QStringLiteral("1px solid transparent");
+        btn->setStyleSheet(QStringLiteral(
+            "QToolButton { background:%1; border:%2; border-radius:11px; }"
+            "QToolButton:hover { border:2px solid #07C160; }")
+                               .arg(c.name(), border));
+        connect(btn, &QToolButton::clicked, this, [this, c]() { m_color = c; });
+        lay->addWidget(btn);
+    }
+
+    auto *undo = UiHelpers::ghostButton(QStringLiteral("撤销"), m_toolbar);
+    undo->setFixedSize(56, 28);
+    connect(undo, &QPushButton::clicked, this, [this]() {
+        if (!m_strokes.isEmpty())
+        {
+            m_strokes.removeLast();
+            update();
+        }
+    });
+    auto *cancel = UiHelpers::ghostButton(QStringLiteral("取消"), m_toolbar);
+    cancel->setFixedSize(56, 28);
+    connect(cancel, &QPushButton::clicked, this, &QDialog::reject);
+    auto *ok = UiHelpers::primaryButton(QStringLiteral("完成"), m_toolbar);
+    ok->setFixedSize(56, 28);
+    connect(ok, &QPushButton::clicked, this, [this]() { finish(); });
+
+    lay->addWidget(undo);
+    lay->addWidget(cancel);
+    lay->addWidget(ok);
+    m_toolbar->adjustSize();
+    m_toolbar->hide();
+}
+
+void ScreenshotEditor::updateToolbar()
+{
+    if (!m_toolbar)
+        return;
+    if (!m_hasSel || m_sel.width() < 8 || m_sel.height() < 8)
+    {
+        m_toolbar->hide();
+        return;
+    }
+    m_toolbar->adjustSize();
+    const int tw = m_toolbar->width();
+    const int th = m_toolbar->height();
+    int x = m_sel.right() - tw;
+    int y = m_sel.bottom() + 8;
+    if (x < 8)
+        x = 8;
+    if (x + tw > width() - 8)
+        x = qMax(8, width() - 8 - tw);
+    if (y + th > height() - 8)
+        y = m_sel.top() - 8 - th;
+    if (y < 8)
+        y = 8;
+    m_toolbar->move(x, y);
+    m_toolbar->show();
+    m_toolbar->raise();
+}
+
+QPoint ScreenshotEditor::toLocal(const QPoint &pos) const
+{
+    return pos - m_sel.topLeft();
+}
+
+bool ScreenshotEditor::inSelection(const QPoint &pos) const
+{
+    return m_hasSel && m_sel.adjusted(0, 0, -1, -1).contains(pos);
+}
+
+void ScreenshotEditor::finish()
+{
+    if (!m_hasSel || m_sel.width() < 8 || m_sel.height() < 8)
+        return;
+    accept();
+}
+
+QPixmap ScreenshotEditor::resultPixmap() const
+{
+    if (!m_hasSel || m_sel.width() < 2 || m_sel.height() < 2)
+        return {};
+    const qreal dpr = m_shot.devicePixelRatio() > 0 ? m_shot.devicePixelRatio() : 1.0;
+    const QRect src(qRound(m_sel.x() * dpr), qRound(m_sel.y() * dpr),
+                    qRound(m_sel.width() * dpr), qRound(m_sel.height() * dpr));
+    QPixmap out = m_shot.copy(src.intersected(m_shot.rect()));
+    if (out.isNull())
+        return {};
+    out.setDevicePixelRatio(dpr);
+    if (!m_strokes.isEmpty())
+    {
+        QPainter p(&out);
+        p.setRenderHint(QPainter::Antialiasing, true);
+        p.scale(dpr, dpr);
+        for (const Stroke &s : m_strokes)
+        {
+            QPen pen(s.color, s.width, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin);
+            p.setPen(pen);
+            p.setBrush(Qt::NoBrush);
+            p.drawPath(s.path);
+        }
+    }
+    return out;
+}
+
+void ScreenshotEditor::paintEvent(QPaintEvent *)
+{
+    QPainter p(this);
+    p.drawPixmap(rect(), m_shot);
+    const bool liveSel = (m_selecting || m_hasSel) && m_sel.width() > 1 && m_sel.height() > 1;
+    QPainterPath dim;
+    dim.addRect(rect());
+    if (liveSel)
+    {
+        QPainterPath hole;
+        hole.addRect(m_sel);
+        dim = dim.subtracted(hole);
+        p.fillPath(dim, QColor(0, 0, 0, 120));
+        p.setPen(QPen(QColor(QStringLiteral("#07C160")), 1));
+        p.setBrush(Qt::NoBrush);
+        p.drawRect(m_sel.adjusted(0, 0, -1, -1));
+
+        if (m_hasSel && !m_selecting)
+        {
+            p.save();
+            p.setRenderHint(QPainter::Antialiasing, true);
+            p.setClipRect(m_sel);
+            p.translate(m_sel.topLeft());
+            auto drawStroke = [&p](const Stroke &s) {
+                QPen pen(s.color, s.width, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin);
+                p.setPen(pen);
+                p.setBrush(Qt::NoBrush);
+                p.drawPath(s.path);
+            };
+            for (const Stroke &s : m_strokes)
+                drawStroke(s);
+            if (m_drawing)
+                drawStroke(m_cur);
+            p.restore();
+        }
+
+        const QString t = QStringLiteral("%1 × %2").arg(m_sel.width()).arg(m_sel.height());
+        const QFontMetrics fm(font());
+        const int tw = fm.horizontalAdvance(t) + 10;
+        const int th = fm.height() + 6;
+        QRect lab(m_sel.left(), m_sel.top() - th - 4, tw, th);
+        if (lab.top() < 0)
+            lab.moveTop(m_sel.top() + 4);
+        p.fillRect(lab, QColor(0, 0, 0, 160));
+        p.setPen(Qt::white);
+        p.drawText(lab, Qt::AlignCenter, t);
+    }
+    else
+    {
+        p.fillPath(dim, QColor(0, 0, 0, 80));
+        if (!m_selecting)
+        {
+            p.setPen(QColor(255, 255, 255, 220));
+            p.drawText(rect(), Qt::AlignCenter, QStringLiteral("拖动鼠标选择截图区域,Esc 取消"));
+        }
+    }
+}
+
+void ScreenshotEditor::mousePressEvent(QMouseEvent *event)
+{
+    if (event->button() != Qt::LeftButton)
+        return;
+    const QPoint pos = event->pos();
+    if (m_hasSel && inSelection(pos))
+    {
+        m_drawing = true;
+        m_cur.color = m_color;
+        m_cur.width = 3.2;
+        m_cur.path = QPainterPath();
+        m_cur.path.moveTo(toLocal(pos));
+        update();
+        return;
+    }
+    if (m_hasSel && m_toolbar && m_toolbar->isVisible() && m_toolbar->geometry().contains(pos))
+        return;
+    m_hasSel = false;
+    m_strokes.clear();
+    m_selecting = true;
+    m_dragOrigin = pos;
+    m_sel = QRect(pos, QSize(1, 1));
+    if (m_toolbar)
+        m_toolbar->hide();
+    setCursor(Qt::CrossCursor);
+    grabMouse();
+    update();
+}
+
+void ScreenshotEditor::mouseMoveEvent(QMouseEvent *event)
+{
+    const QPoint pos = event->pos();
+    if (m_selecting)
+    {
+        m_sel = QRect(m_dragOrigin, pos).normalized();
+        repaint();
+        return;
+    }
+    if (m_drawing)
+    {
+        m_cur.path.lineTo(toLocal(pos));
+        update();
+        return;
+    }
+    if (m_hasSel && inSelection(pos))
+        setCursor(Qt::CrossCursor);
+    else if (m_hasSel)
+        setCursor(Qt::ArrowCursor);
+}
+
+void ScreenshotEditor::mouseReleaseEvent(QMouseEvent *event)
+{
+    if (event->button() != Qt::LeftButton)
+        return;
+    if (mouseGrabber() == this)
+        releaseMouse();
+    if (m_selecting)
+    {
+        m_selecting = false;
+        m_sel = QRect(m_dragOrigin, event->pos()).normalized();
+        if (m_sel.width() < 8 || m_sel.height() < 8)
+        {
+            m_hasSel = false;
+            m_sel = QRect();
+            if (m_toolbar)
+                m_toolbar->hide();
+        }
+        else
+        {
+            m_hasSel = true;
+            updateToolbar();
+        }
+        update();
+        return;
+    }
+    if (m_drawing)
+    {
+        m_drawing = false;
+        if (!m_cur.path.isEmpty())
+            m_strokes.push_back(m_cur);
+        m_cur = Stroke();
+        update();
+    }
+}
+
+void ScreenshotEditor::keyPressEvent(QKeyEvent *event)
+{
+    if (event->key() == Qt::Key_Escape)
+    {
+        if (mouseGrabber() == this)
+            releaseMouse();
+        reject();
+        return;
+    }
+    if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter)
+    {
+        finish();
+        return;
+    }
+    if (event->matches(QKeySequence::Undo) || (event->modifiers() & Qt::ControlModifier
+                                               && event->key() == Qt::Key_Z))
+    {
+        if (!m_strokes.isEmpty())
+        {
+            m_strokes.removeLast();
+            update();
+        }
+        return;
+    }
+    QDialog::keyPressEvent(event);
+}

+ 54 - 0
Client/src/widgets/ScreenshotEditor.h

@@ -0,0 +1,54 @@
+#pragma once
+
+#include <QDialog>
+#include <QPixmap>
+#include <QPoint>
+#include <QRect>
+#include <QVector>
+#include <QColor>
+#include <QPainterPath>
+
+class QMouseEvent;
+class QKeyEvent;
+class QPaintEvent;
+class QWidget;
+
+class ScreenshotEditor : public QDialog
+{
+public:
+    static QPixmap capture(QWidget *host = nullptr);
+
+protected:
+    void paintEvent(QPaintEvent *event) override;
+    void mousePressEvent(QMouseEvent *event) override;
+    void mouseMoveEvent(QMouseEvent *event) override;
+    void mouseReleaseEvent(QMouseEvent *event) override;
+    void keyPressEvent(QKeyEvent *event) override;
+
+private:
+    struct Stroke
+    {
+        QColor color;
+        qreal width = 3.0;
+        QPainterPath path;
+    };
+
+    explicit ScreenshotEditor(const QPixmap &shot, QWidget *parent = nullptr);
+    QPixmap resultPixmap() const;
+    void buildToolbar();
+    void updateToolbar();
+    void finish();
+    QPoint toLocal(const QPoint &pos) const;
+    bool inSelection(const QPoint &pos) const;
+
+    QPixmap m_shot;
+    QRect m_sel;
+    QPoint m_dragOrigin;
+    bool m_selecting = false;
+    bool m_drawing = false;
+    bool m_hasSel = false;
+    QColor m_color = QColor(QStringLiteral("#FA5151"));
+    QVector<Stroke> m_strokes;
+    Stroke m_cur;
+    QWidget *m_toolbar = nullptr;
+};

+ 357 - 0
Client/src/widgets/VideoPreviewDialog.cpp

@@ -0,0 +1,357 @@
+#include "VideoPreviewDialog.h"
+#include "TitleBar.h"
+#include "UiHelpers.h"
+
+#include <QDesktopServices>
+#include <QEvent>
+#include <QFile>
+#include <QFileDialog>
+#include <QFileInfo>
+#include <QHBoxLayout>
+#include <QKeyEvent>
+#include <QLabel>
+#include <QMediaContent>
+#include <QMediaPlayer>
+#include <QMouseEvent>
+#include <QPushButton>
+#include <QSlider>
+#include <QStyle>
+#include <QStyleOptionSlider>
+#include <QTimer>
+#include <QUrl>
+#include <QVBoxLayout>
+#include <QVideoWidget>
+#include <climits>
+#include <functional>
+
+namespace {
+class ClickVideoWidget : public QVideoWidget
+{
+public:
+    std::function<void()> onToggle;
+    explicit ClickVideoWidget(QWidget *parent = nullptr)
+        : QVideoWidget(parent)
+    {
+        setCursor(Qt::PointingHandCursor);
+        setAspectRatioMode(Qt::KeepAspectRatio);
+    }
+
+protected:
+    void mouseReleaseEvent(QMouseEvent *e) override
+    {
+        if (e->button() == Qt::LeftButton && onToggle)
+            onToggle();
+        QVideoWidget::mouseReleaseEvent(e);
+    }
+};
+
+class SeekSlider : public QSlider
+{
+public:
+    explicit SeekSlider(QWidget *parent = nullptr)
+        : QSlider(Qt::Horizontal, parent)
+    {
+        setCursor(Qt::PointingHandCursor);
+        setTracking(true);
+        setMouseTracking(true);
+        setFocusPolicy(Qt::ClickFocus);
+    }
+
+    int valueAt(const QPoint &pos) const
+    {
+        QStyleOptionSlider opt;
+        initStyleOption(&opt);
+        const QRect groove = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderGroove, this);
+        const QRect handle = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderHandle, this);
+        const int span = qMax(1, groove.width() - handle.width());
+        int x = pos.x() - groove.x() - handle.width() / 2;
+        x = qBound(0, x, span);
+        return QStyle::sliderValueFromPosition(minimum(), maximum(), x, span, opt.upsideDown);
+    }
+
+protected:
+    void mousePressEvent(QMouseEvent *e) override
+    {
+        if (e->button() != Qt::LeftButton)
+        {
+            QSlider::mousePressEvent(e);
+            return;
+        }
+        m_dragging = true;
+        setValue(valueAt(e->pos()));
+        emit sliderMoved(value());
+        emit sliderPressed();
+        e->accept();
+    }
+
+    void mouseMoveEvent(QMouseEvent *e) override
+    {
+        if (!m_dragging || !(e->buttons() & Qt::LeftButton))
+        {
+            QSlider::mouseMoveEvent(e);
+            return;
+        }
+        setValue(valueAt(e->pos()));
+        emit sliderMoved(value());
+        e->accept();
+    }
+
+    void mouseReleaseEvent(QMouseEvent *e) override
+    {
+        if (e->button() != Qt::LeftButton || !m_dragging)
+        {
+            QSlider::mouseReleaseEvent(e);
+            return;
+        }
+        m_dragging = false;
+        setValue(valueAt(e->pos()));
+        emit sliderMoved(value());
+        emit sliderReleased();
+        e->accept();
+    }
+
+private:
+    bool m_dragging = false;
+};
+}
+
+VideoPreviewDialog::VideoPreviewDialog(const QString &path, const QString &name, QWidget *parent,
+                                       bool canForward, bool canFavorite)
+    : QDialog(parent)
+{
+    setObjectName(QStringLiteral("ChromeWindow"));
+    const QString title = name.isEmpty() ? QStringLiteral("视频") : name;
+    setWindowTitle(title);
+    UiHelpers::setupFramelessDialog(this);
+    resize(760, 520);
+    setMinimumSize(480, 360);
+
+    auto *root = new QVBoxLayout(this);
+    root->setContentsMargins(0, 0, 0, 0);
+    root->setSpacing(0);
+
+    auto *bar = new TitleBar(this);
+    bar->setTitle(title);
+    bar->setTheme(TitleBar::Light);
+    bar->setMaximizable(true);
+    bar->bind(this, true);
+    root->addWidget(bar);
+
+    auto *video = new ClickVideoWidget(this);
+    video->setStyleSheet(QStringLiteral("background:#000000;"));
+    video->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+    video->onToggle = [this]() { togglePlay(); };
+    root->addWidget(video, 1);
+#ifdef Q_OS_WIN
+    (void)video->winId();
+#endif
+
+    m_player = new QMediaPlayer(this);
+    m_player->setVideoOutput(video);
+    m_player->setNotifyInterval(100);
+    m_player->setMedia(QMediaContent(QUrl::fromLocalFile(path)));
+
+    auto *ctrl = new QWidget(this);
+    ctrl->setFixedHeight(48);
+    ctrl->setStyleSheet(QStringLiteral("background:#1A1A1A;"));
+    auto *cLay = new QHBoxLayout(ctrl);
+    cLay->setContentsMargins(12, 8, 12, 8);
+    cLay->setSpacing(10);
+
+    auto *play = new QPushButton(ctrl);
+    m_playBtn = play;
+    play->setCursor(Qt::PointingHandCursor);
+    play->setFixedSize(36, 28);
+    play->setFlat(true);
+    play->setFocusPolicy(Qt::NoFocus);
+    play->setToolTip(QStringLiteral("播放 / 暂停(空格)"));
+    play->setIcon(style()->standardIcon(QStyle::SP_MediaPause));
+    play->setStyleSheet(QStringLiteral(
+        "QPushButton { background:transparent; border:none; color:#FFFFFF; }"));
+
+    auto *slider = new SeekSlider(ctrl);
+    m_slider = slider;
+    slider->setRange(0, 0);
+    slider->setStyleSheet(QStringLiteral(
+        "QSlider::groove:horizontal { height:8px; background:#444; border-radius:4px; }"
+        "QSlider::handle:horizontal { width:14px; height:14px; margin:-3px 0; "
+        "background:#FFFFFF; border-radius:7px; }"
+        "QSlider::sub-page:horizontal { background:#07C160; border-radius:4px; }"));
+
+    auto *timeLab = new QLabel(QStringLiteral("00:00 / 00:00"), ctrl);
+    timeLab->setStyleSheet(QStringLiteral("color:#CCCCCC; font-size:11px; background:transparent;"));
+    timeLab->setMinimumWidth(92);
+
+    cLay->addWidget(play);
+    cLay->addWidget(slider, 1);
+    cLay->addWidget(timeLab);
+    root->addWidget(ctrl);
+
+    auto *btns = new QWidget(this);
+    btns->setFixedHeight(48);
+    btns->setStyleSheet(QStringLiteral("background:#FFFFFF; border-top:1px solid #E5E5E5;"));
+    auto *bLay = new QHBoxLayout(btns);
+    bLay->setContentsMargins(16, 8, 16, 8);
+    auto *openExt = UiHelpers::ghostButton(QStringLiteral("系统播放器"), btns);
+    openExt->setFixedSize(100, 32);
+    auto *save = UiHelpers::ghostButton(QStringLiteral("保存"), btns);
+    save->setFixedSize(88, 32);
+    auto *fwd = UiHelpers::ghostButton(QStringLiteral("转发"), btns);
+    fwd->setFixedSize(88, 32);
+    fwd->setVisible(canForward);
+    auto *fav = UiHelpers::ghostButton(QStringLiteral("收藏"), btns);
+    fav->setFixedSize(88, 32);
+    fav->setVisible(canFavorite);
+    auto *ok = UiHelpers::primaryButton(QStringLiteral("关闭"), btns);
+    ok->setFixedSize(88, 32);
+    bLay->addWidget(openExt);
+    bLay->addWidget(save);
+    if (canForward)
+        bLay->addWidget(fwd);
+    if (canFavorite)
+        bLay->addWidget(fav);
+    bLay->addStretch();
+    bLay->addWidget(ok);
+    root->addWidget(btns);
+
+    auto fmtTime = [](qint64 ms) {
+        if (ms < 0)
+            ms = 0;
+        const int sec = static_cast<int>(ms / 1000);
+        return QStringLiteral("%1:%2")
+            .arg(sec / 60, 2, 10, QLatin1Char('0'))
+            .arg(sec % 60, 2, 10, QLatin1Char('0'));
+    };
+
+    auto refreshTime = [this, timeLab, fmtTime]() {
+        const qint64 pos = m_seeking && m_slider ? m_slider->value() : (m_player ? m_player->position() : 0);
+        const qint64 dur = m_player ? m_player->duration() : 0;
+        timeLab->setText(fmtTime(pos) + QStringLiteral(" / ") + fmtTime(dur));
+    };
+
+    connect(play, &QPushButton::clicked, this, [this]() { togglePlay(); });
+    connect(m_player, &QMediaPlayer::stateChanged, this,
+            [this](QMediaPlayer::State st) {
+        if (!m_playBtn)
+            return;
+        m_playBtn->setIcon(style()->standardIcon(st == QMediaPlayer::PlayingState
+                                                     ? QStyle::SP_MediaPause
+                                                     : QStyle::SP_MediaPlay));
+    });
+    connect(m_player, &QMediaPlayer::durationChanged, this, [slider, refreshTime](qint64 d) {
+        slider->setRange(0, static_cast<int>(qBound(qint64(0), d, qint64(INT_MAX))));
+        refreshTime();
+    });
+    connect(m_player, &QMediaPlayer::positionChanged, this, [this, slider, refreshTime](qint64 pos) {
+        if (!m_seeking)
+            slider->setValue(static_cast<int>(qBound(qint64(0), pos, qint64(INT_MAX))));
+        refreshTime();
+    });
+    connect(slider, &QSlider::sliderPressed, this, [this]() { m_seeking = true; });
+    connect(slider, &QSlider::sliderMoved, this, [this, refreshTime](int v) {
+        m_seeking = true;
+        refreshTime();
+        seekTo(v);
+    });
+    connect(slider, &QSlider::sliderReleased, this, [this, slider]() {
+        seekTo(slider->value());
+        m_seeking = false;
+    });
+
+    auto openSystem = [path, this]() {
+        if (!QDesktopServices::openUrl(QUrl::fromLocalFile(path)))
+            UiHelpers::showToast(this, QStringLiteral("无法打开系统播放器"), true);
+    };
+    connect(openExt, &QPushButton::clicked, this, openSystem);
+    connect(save, &QPushButton::clicked, this, [this, path, name]() {
+        const QString dest = QFileDialog::getSaveFileName(
+            this, QStringLiteral("保存视频"),
+            name.isEmpty() ? QFileInfo(path).fileName() : name);
+        if (dest.isEmpty())
+            return;
+        QFile::remove(dest);
+        if (!QFile::copy(path, dest))
+            UiHelpers::showToast(this, QStringLiteral("保存失败"), true);
+        else
+            UiHelpers::showToast(this, QStringLiteral("已保存"));
+    });
+    connect(m_player,
+            static_cast<void (QMediaPlayer::*)(QMediaPlayer::Error)>(&QMediaPlayer::error),
+            this, [this, openSystem](QMediaPlayer::Error e) {
+        if (e == QMediaPlayer::NoError)
+            return;
+        UiHelpers::showToast(this, QStringLiteral("无法内置播放,已尝试用系统播放器打开"), true);
+        openSystem();
+    });
+
+    connect(ok, &QPushButton::clicked, this, &QDialog::accept);
+    connect(fwd, &QPushButton::clicked, this, [this]() {
+        m_player->stop();
+        m_forward = true;
+        accept();
+    });
+    connect(fav, &QPushButton::clicked, this, [this]() {
+        m_player->stop();
+        m_favorite = true;
+        accept();
+    });
+    connect(this, &QDialog::finished, this, [this]() {
+        if (m_player)
+            m_player->stop();
+    });
+
+    setFocusPolicy(Qt::StrongFocus);
+    installEventFilter(this);
+    slider->installEventFilter(this);
+    QTimer::singleShot(0, this, [this]() {
+        setFocus();
+        if (m_player)
+            m_player->play();
+    });
+}
+
+void VideoPreviewDialog::togglePlay()
+{
+    if (!m_player)
+        return;
+    if (m_player->state() == QMediaPlayer::PlayingState)
+        m_player->pause();
+    else
+    {
+        if (m_player->mediaStatus() == QMediaPlayer::EndOfMedia)
+            m_player->setPosition(0);
+        m_player->play();
+    }
+}
+
+void VideoPreviewDialog::seekTo(int ms)
+{
+    if (!m_player)
+        return;
+    m_player->setPosition(qMax(0, ms));
+}
+
+void VideoPreviewDialog::keyPressEvent(QKeyEvent *event)
+{
+    if (event->key() == Qt::Key_Space)
+    {
+        togglePlay();
+        event->accept();
+        return;
+    }
+    QDialog::keyPressEvent(event);
+}
+
+bool VideoPreviewDialog::eventFilter(QObject *watched, QEvent *event)
+{
+    if (event->type() == QEvent::KeyPress)
+    {
+        auto *ke = static_cast<QKeyEvent *>(event);
+        if (ke->key() == Qt::Key_Space)
+        {
+            togglePlay();
+            return true;
+        }
+    }
+    return QDialog::eventFilter(watched, event);
+}

+ 35 - 0
Client/src/widgets/VideoPreviewDialog.h

@@ -0,0 +1,35 @@
+#pragma once
+
+#include <QDialog>
+#include <QString>
+
+class QEvent;
+class QKeyEvent;
+class QMediaPlayer;
+class QPushButton;
+class QSlider;
+
+class VideoPreviewDialog : public QDialog
+{
+public:
+    VideoPreviewDialog(const QString &path, const QString &name, QWidget *parent = nullptr,
+                       bool canForward = false, bool canFavorite = false);
+
+    bool wantsForward() const { return m_forward; }
+    bool wantsFavorite() const { return m_favorite; }
+
+protected:
+    void keyPressEvent(QKeyEvent *event) override;
+    bool eventFilter(QObject *watched, QEvent *event) override;
+
+private:
+    void togglePlay();
+    void seekTo(int ms);
+
+    bool m_forward = false;
+    bool m_favorite = false;
+    bool m_seeking = false;
+    QMediaPlayer *m_player = nullptr;
+    QPushButton *m_playBtn = nullptr;
+    QSlider *m_slider = nullptr;
+};

+ 25 - 7
docs/protocol.md

@@ -212,9 +212,25 @@ Type=`System`。连接成功后立即发送,上报客户端语言并拉取服
       "domain":"https://cdn.example.com"
     },
     "update":{
-      "version":"1.0.1",
-      "url":"https://cdn.example.com/NIM-1.0.1.zip",
-      "notes":"修复文件上传"
+      "latest":"1.0.3",
+      "version":"1.0.3",
+      "notes":"修复文件上传",
+      "releases":[
+        {
+          "version":"1.0.1",
+          "notes":"修复登录",
+          "urls":["https://cdn.example.com/NIM-1.0.1.zip"]
+        },
+        {
+          "version":"1.0.2",
+          "urls":["https://cdn.example.com/NIM-1.0.2.zip","https://mirror.example.com/NIM-1.0.2.zip"]
+        },
+        {
+          "version":"1.0.3",
+          "notes":"修复文件上传",
+          "urls":["https://cdn.example.com/NIM-1.0.3.zip"]
+        }
+      ]
     }
 }
 ```
@@ -223,7 +239,7 @@ Type=`System`。连接成功后立即发送,上报客户端语言并拉取服
 
 `cdn.domain` 只来自 `[cdn] domain`,不会按 COS 自动推导。未配置时下发空串,客户端不走 HTTP 下载。
 
-`update` 来自服务端 `[update]`:`version` 为最新客户端版本,`url` 为 ZIP 安装包地址,`notes` 为说明。客户端用本地 `[app] version` 比较,更高则提示更新。ZIP 须把安装目录内容放在根(`NIM.exe` 在压缩包根目录);客户端先下载 ZIP,再复制一份自身到临时目录并以 `--apply-update` 用自带 `3rdparty/7z/x64/7za.exe` 解压覆盖原目录(失败再回退系统 tar / Expand-Archive),然后拉起新程序并清理临时副本。`url` 为空时客户端忽略。
+`update` 来自服务端 `update.json`(`[update] file`)。`releases` 按版本升序,每项可配多个 `urls`(镜像,失败则试下一个)。客户端用本地版本比较,只下载 **高于当前版本** 的包,并按 `1.0.1 → 1.0.2 → 1.0.3` 依次解压覆盖,避免跳过增量包丢文件。ZIP 须把安装目录内容放在根(`NIM.exe` 在压缩包根目录);全部下载完后复制一份自身到临时目录并以 `--apply-update` 用自带 `3rdparty/7z/x64/7za.exe` 按顺序解压(失败再回退系统 tar / Expand-Archive),然后拉起新程序并清理临时副本。没有可用 `releases` 时客户端忽略。
 
 
 ### 4.2 Login(cmd=2)
@@ -239,9 +255,11 @@ Type=`System`。连接成功后立即发送,上报客户端语言并拉取服
 成功:
 
 ```json
-{"code":0,"msg":"ok","uid":10002,"account":"alice","nickname":"Alice","phone":"13800138000","signature":"","avatar_ver":0}
+{"code":0,"msg":"ok","uid":10002,"account":"alice","nickname":"Alice","phone":"13800138000","signature":"","avatar_ver":0,"limits":{"file_max":524288}}
 ```
 
+`limits` 与 SetConfig 相同。其中 `file_max`:该用户 `im_user.file_max>0` 时用用户值,否则用配置 `[limits] file_max`。客户端登录后应覆盖本地预校验。
+
 同一账号在新连接登录成功时,旧连接会收到 **Kicked** 推送并被断开。系统账号(文件传输助手)不可登录。
 
 ### 4.3 Logout(cmd=3)
@@ -603,7 +621,7 @@ GetSticker 成功:
 |------|------|------|
 | `name` | string | 文件名,服务端会去掉路径并截断 |
 | `mime` | string | 可选,缺省 `application/octet-stream` |
-| `size` | number | **必填**,字节数,`1 … file_max` |
+| `size` | number | **必填**,字节数,`1 …` 当前用户有效 `file_max`(用户表已设置则用之,否则配置) |
 | `sha256` | string | 可选,十六进制,最长 64 |
 | `purpose` | string | 可选。`favorite` 时对象键走 `{prefix}/favorites/{uid}/{file_id}`,供收藏笔记插附件 |
 
@@ -854,7 +872,7 @@ Kick 另带 `uid`。群主不能 Leave(须 Transfer 或 Dismiss)。管理员
 
 | 表 | 用途 |
 |----|------|
-| `im_user` | 用户(含手机号、签名;`status`:1 正常 / 0 禁用 / 2 系统账号) |
+| `im_user` | 用户(含手机号、签名、`file_max` 上传上限;`status`:1 正常 / 0 禁用 / 2 系统账号) |
 | `im_sms_code` | 验证码 |
 | `im_friend` | 双向好友(含 `remark` 备注) |
 | `im_message` | 消息 |

+ 4 - 7
server/config/config.ini.example

@@ -60,7 +60,7 @@ history_default=50
 history_max=100
 # 头像最大字节(JPEG)
 avatar_max=81920
-# 聊天图片/文件最大字节(客户端直传 COS,PutFile 只校验 size)
+# 聊天图片/文件默认最大字节。用户 im_user.file_max>0 时以用户设置为准
 file_max=104857600
 # 群人数上限(含群主)
 group_max_members=200
@@ -90,12 +90,9 @@ prefix=im
 domain=
 
 [update]
-# 客户端最新版本号,与客户端 [app] version 比较,更高则提示更新
-version=1.0.0
-# 最新安装包 ZIP 下载地址;留空则不下发更新
-url=
-# 更新说明,展示在提示框
-notes=
+# 增量更新清单 JSON,路径相对本配置文件。见同目录 update.json
+# 客户端从当前版本起按 version 顺序逐个下载并覆盖,不会跳到最新包
+file=update.json
 
 [mysql]
 # 1=启用 MySQL;0=不连库(多数业务不可用)

+ 19 - 0
server/config/update.json

@@ -0,0 +1,19 @@
+{
+  "notes": "",
+  "releases": [
+    {
+      "version": "1.0.2",
+      "notes": "",
+      "urls": [
+        "https://bj.assets.im.youyanen.com/im/update_client/1.0.2.zip"
+      ]
+    },
+    {
+      "version": "1.0.1",
+      "notes": "",
+      "urls": [
+        "https://bj.assets.im.youyanen.com/im/update_client/1.0.1.zip"
+      ]
+    }
+  ]
+}

+ 20 - 0
server/config/update.json.example

@@ -0,0 +1,20 @@
+{
+  "notes": "",
+  "releases": [
+    {
+      "version": "1.0.1",
+      "notes": "",
+      "urls": [
+        "https://cdn.example.com/client/NIM-1.0.1.zip"
+      ]
+    },
+    {
+      "version": "1.0.2",
+      "notes": "",
+      "urls": [
+        "https://cdn.example.com/client/NIM-1.0.2.zip",
+        "https://mirror.example.com/client/NIM-1.0.2.zip"
+      ]
+    }
+  ]
+}

+ 1 - 0
server/sql/init.sql

@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS im_user (
     allow_search_phone   TINYINT NOT NULL DEFAULT 1 COMMENT '1允许通过手机号搜索',
     allow_add_friend     TINYINT NOT NULL DEFAULT 1 COMMENT '1允许他人添加好友',
     avatar_ver  INT             NOT NULL DEFAULT 0,
+    file_max    BIGINT          NOT NULL DEFAULT 0 COMMENT '单文件上传上限字节,0表示用配置 file_max',
     status      TINYINT         NOT NULL DEFAULT 1 COMMENT '1正常 0禁用 2系统账号',
     created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
     updated_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

+ 2 - 0
server/sql/migrate.sql

@@ -194,3 +194,5 @@ CREATE TABLE IF NOT EXISTS im_group_inbox (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
 ALTER TABLE im_group ADD COLUMN avatar_ver INT NOT NULL DEFAULT 0;
+
+ALTER TABLE im_user ADD COLUMN file_max BIGINT NOT NULL DEFAULT 0 COMMENT '单文件上传上限字节,0表示用配置 file_max';

+ 195 - 7
server/src/config/app_config.cpp

@@ -1,11 +1,14 @@
 #include "config/app_config.h"
 
+#include <algorithm>
 #include <cstdlib>
 #include <unistd.h>
+#include <vector>
 
 #include "common/logger.h"
 #include "util/file.h"
 #include "util/ini.h"
+#include "util/json.h"
 
 namespace im {
 namespace {
@@ -60,6 +63,172 @@ std::string exe_dir()
     return dir_of(buf);
 }
 
+int compare_version(const std::string& a, const std::string& b)
+{
+    auto parts = [](const std::string& s) {
+        std::vector<int> out;
+        std::string cur;
+        for (char c : s) {
+            if (c == '.') {
+                out.push_back(cur.empty() ? 0 : std::atoi(cur.c_str()));
+                cur.clear();
+            } else if (c >= '0' && c <= '9') {
+                cur.push_back(c);
+            }
+        }
+        out.push_back(cur.empty() ? 0 : std::atoi(cur.c_str()));
+        return out;
+    };
+    auto pa = parts(a);
+    auto pb = parts(b);
+    const size_t n = std::max(pa.size(), pb.size());
+    pa.resize(n, 0);
+    pb.resize(n, 0);
+    for (size_t i = 0; i < n; ++i) {
+        if (pa[i] < pb[i]) {
+            return -1;
+        }
+        if (pa[i] > pb[i]) {
+            return 1;
+        }
+    }
+    return 0;
+}
+
+std::string json_text(const ylib::json& j, const char* key)
+{
+    if (key == nullptr || !j.exist(key)) {
+        return {};
+    }
+    if (j[key].is_string()) {
+        return j[key].to<std::string>();
+    }
+    return j[key].to_string();
+}
+
+void add_url(std::vector<std::string>& urls, std::string u)
+{
+    while (!u.empty() && (u.back() == ' ' || u.back() == '\t' || u.back() == '\r' || u.back() == '\n')) {
+        u.pop_back();
+    }
+    size_t i = 0;
+    while (i < u.size() && (u[i] == ' ' || u[i] == '\t')) {
+        ++i;
+    }
+    if (i > 0) {
+        u = u.substr(i);
+    }
+    if (u.empty()) {
+        return;
+    }
+    for (const auto& e : urls) {
+        if (e == u) {
+            return;
+        }
+    }
+    urls.push_back(std::move(u));
+}
+
+void add_urls_from_json(std::vector<std::string>& urls, const ylib::json& item)
+{
+    if (item.exist("urls") && item["urls"].is_array()) {
+        const ylib::json& arr = item["urls"];
+        for (size_t i = 0; i < arr.size(); ++i) {
+            if (arr[i].is_string()) {
+                add_url(urls, arr[i].to<std::string>());
+            } else {
+                add_url(urls, arr[i].to_string());
+            }
+        }
+    }
+    add_url(urls, json_text(item, "url"));
+}
+
+void sort_releases(std::vector<UpdateRelease>& releases)
+{
+    std::sort(releases.begin(), releases.end(),
+              [](const UpdateRelease& a, const UpdateRelease& b) {
+                  return compare_version(a.version, b.version) < 0;
+              });
+}
+
+bool load_update_json(const std::string& path, UpdateConfig& out)
+{
+    ylib::buffer raw = ylib::file::read(path);
+    std::string text = raw.to_string();
+    if (text.size() >= 3 && static_cast<unsigned char>(text[0]) == 0xEF &&
+        static_cast<unsigned char>(text[1]) == 0xBB &&
+        static_cast<unsigned char>(text[2]) == 0xBF) {
+        text.erase(0, 3);
+    }
+    ylib::json root;
+    if (text.empty() || !root.parse(text)) {
+        Logger::error("update json parse failed: " + path);
+        return false;
+    }
+    out.notes = json_text(root, "notes");
+    ylib::json list;
+    if (root.exist("releases") && root["releases"].is_array()) {
+        list = root["releases"];
+    } else if (root.is_array()) {
+        list = root;
+    } else if (root.exist("version")) {
+        list = ylib::json::type::array;
+        list.push_back(root);
+    }
+    std::vector<UpdateRelease> releases;
+    for (size_t i = 0; i < list.size(); ++i) {
+        const ylib::json& item = list[i];
+        UpdateRelease r;
+        r.version = json_text(item, "version");
+        r.notes = json_text(item, "notes");
+        add_urls_from_json(r.urls, item);
+        if (r.version.empty() || r.urls.empty()) {
+            continue;
+        }
+        bool merged = false;
+        for (auto& e : releases) {
+            if (e.version == r.version) {
+                for (const auto& u : r.urls) {
+                    add_url(e.urls, u);
+                }
+                if (e.notes.empty()) {
+                    e.notes = r.notes;
+                }
+                merged = true;
+                break;
+            }
+        }
+        if (!merged) {
+            releases.push_back(std::move(r));
+        }
+    }
+    sort_releases(releases);
+    out.releases = std::move(releases);
+    if (!out.releases.empty()) {
+        out.latest = out.releases.back().version;
+    }
+    return true;
+}
+
+std::string resolve_update_file(const std::string& cfg_path, const std::string& rel)
+{
+    if (rel.empty()) {
+        return {};
+    }
+    if (rel[0] == '/' || (rel.size() > 1 && rel[1] == ':')) {
+        return rel;
+    }
+    std::string dir = ylib::file::parent_dir(cfg_path);
+    if (dir.empty()) {
+        dir = dir_of(cfg_path);
+    }
+    if (!dir.empty() && dir.back() != '/') {
+        dir.push_back('/');
+    }
+    return dir + rel;
+}
+
 }  // namespace
 
 std::string AppConfig::resolve_path(int argc, char** argv)
@@ -178,9 +347,32 @@ bool AppConfig::load(const std::string& filepath)
         cdn_.domain.pop_back();
     }
 
-    update_.version = ini.read("update", "version", update_.version);
-    update_.url = ini.read("update", "url", update_.url);
-    update_.notes = ini.read("update", "notes", update_.notes);
+    const std::string update_file = ini.read("update", "file", "update.json");
+    const std::string update_path = resolve_update_file(filepath, update_file);
+    bool loaded = false;
+    if (!update_path.empty() && ylib::file::exist(update_path)) {
+        loaded = load_update_json(update_path, update_);
+        if (loaded) {
+            Logger::info("client update file=" + update_path +
+                         " latest=" + update_.latest +
+                         " releases=" + std::to_string(update_.releases.size()));
+        }
+    }
+    if (!loaded) {
+        UpdateRelease one;
+        one.version = ini.read("update", "version", "");
+        one.notes = ini.read("update", "notes", "");
+        add_url(one.urls, ini.read("update", "url", ""));
+        update_.notes = one.notes;
+        if (!one.version.empty() && !one.urls.empty()) {
+            update_.releases.push_back(std::move(one));
+            update_.latest = update_.releases.back().version;
+        }
+        if (!update_.latest.empty()) {
+            Logger::info("client update version=" + update_.latest +
+                         " releases=" + std::to_string(update_.releases.size()));
+        }
+    }
 
     log_.enable = ini.read("log", "enable", "1") != "0";
     log_.level = Logger::parse_level(ini.read("log", "level", "info"));
@@ -188,10 +380,6 @@ bool AppConfig::load(const std::string& filepath)
 
     Logger::info("config loaded: " + filepath + " log=" +
                  (log_.enable ? "on" : "off") + "/" + Logger::level_name(log_.level));
-    if (!update_.version.empty()) {
-        Logger::info("client update version=" + update_.version +
-                     (update_.url.empty() ? "" : " url=" + update_.url));
-    }
     return true;
 }
 

+ 9 - 2
server/src/config/app_config.h

@@ -2,6 +2,7 @@
 
 #include <cstdint>
 #include <string>
+#include <vector>
 
 #include "common/logger.h"
 
@@ -103,10 +104,16 @@ struct CdnConfig {
     std::string domain;
 };
 
-struct UpdateConfig {
+struct UpdateRelease {
     std::string version;
-    std::string url;
     std::string notes;
+    std::vector<std::string> urls;
+};
+
+struct UpdateConfig {
+    std::string notes;
+    std::string latest;
+    std::vector<UpdateRelease> releases;
 };
 
 class AppConfig {

+ 2 - 1
server/src/db/account_store.cpp

@@ -21,13 +21,14 @@ UserRow read_user(ylib::mysql::result* rs)
     row.allow_search_phone = rs->get_int32("allow_search_phone");
     row.allow_add_friend = rs->get_int32("allow_add_friend");
     row.avatar_ver = rs->get_int32("avatar_ver");
+    row.file_max = rs->get_int64("file_max");
     return row;
 }
 
 const char* kUserCols =
     "id, username, password, nickname, phone, signature, status, "
     "allow_search_account, allow_search_phone, allow_add_friend, "
-    "IFNULL(avatar_ver,0) AS avatar_ver";
+    "IFNULL(avatar_ver,0) AS avatar_ver, IFNULL(file_max,0) AS file_max";
 
 }  // namespace
 

+ 1 - 0
server/src/db/account_store.h

@@ -20,6 +20,7 @@ struct UserRow {
     int allow_search_phone = 1;
     int allow_add_friend = 1;
     int avatar_ver = 0;
+    int64_t file_max = 0;
 };
 
 // 账户库:im_user / im_sms_code,对应 AccountHandler。

+ 1 - 0
server/src/db/mysql_service.cpp

@@ -198,6 +198,7 @@ bool MysqlService::ensure_schema()
         "ALTER TABLE im_friend ADD COLUMN remark VARCHAR(64) NOT NULL DEFAULT ''",
         "ALTER TABLE im_user ADD COLUMN avatar_ver INT NOT NULL DEFAULT 0",
         "ALTER TABLE im_group ADD COLUMN avatar_ver INT NOT NULL DEFAULT 0",
+        "ALTER TABLE im_user ADD COLUMN file_max BIGINT NOT NULL DEFAULT 0",
         "ALTER TABLE im_message ADD COLUMN sender_deleted TINYINT NOT NULL DEFAULT 0",
         "ALTER TABLE im_message ADD COLUMN receiver_deleted TINYINT NOT NULL DEFAULT 0",
         "CREATE TABLE IF NOT EXISTS im_friend_request ("

+ 32 - 4
server/src/im/handler.cpp

@@ -127,7 +127,7 @@ ylib::json IHandler::limits_json()
     o["history_default"] = static_cast<int64>(lim.history_default);
     o["history_max"] = static_cast<int64>(lim.history_max);
     o["avatar_max"] = static_cast<int64>(lim.avatar_max);
-    o["file_max"] = static_cast<int64>(lim.file_max);
+    o["file_max"] = effective_file_max();
     o["group_max_members"] = static_cast<int64>(lim.group_max_members);
     o["group_name_max"] = static_cast<int64>(lim.group_name_max);
     o["group_max_admins"] = static_cast<int64>(lim.group_max_admins);
@@ -142,6 +142,18 @@ ylib::json IHandler::limits_json()
     return o;
 }
 
+int64 IHandler::effective_file_max()
+{
+    const int64 cfg = static_cast<int64>(config().limits().file_max);
+    if (session() && session()->uid() > 0) {
+        const int64 user = session()->file_max();
+        if (user > 0) {
+            return user;
+        }
+    }
+    return cfg;
+}
+
 ylib::json IHandler::cdn_json()
 {
     ylib::json o;
@@ -151,10 +163,26 @@ ylib::json IHandler::cdn_json()
 
 ylib::json IHandler::update_json()
 {
+    const UpdateConfig& u = config().update();
     ylib::json o;
-    o["version"] = config().update().version;
-    o["url"] = config().update().url;
-    o["notes"] = config().update().notes;
+    o["notes"] = u.notes;
+    o["latest"] = u.latest;
+    o["version"] = u.latest;
+    ylib::json releases;
+    releases = ylib::json::type::array;
+    for (const auto& r : u.releases) {
+        ylib::json item;
+        item["version"] = r.version;
+        item["notes"] = r.notes;
+        ylib::json urls;
+        urls = ylib::json::type::array;
+        for (const auto& one : r.urls) {
+            urls.push_back(one);
+        }
+        item["urls"] = urls;
+        releases.push_back(item);
+    }
+    o["releases"] = releases;
     return o;
 }
 

+ 1 - 0
server/src/im/handler.h

@@ -50,6 +50,7 @@ protected:
     void reply_ok();
     void reply_ok(ylib::json body);
     ylib::json limits_json();
+    int64 effective_file_max();
     ylib::json cdn_json();
     ylib::json update_json();
     // 按本连接 Session 语言翻译 Msg

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

@@ -113,6 +113,7 @@ void AccountHandler::on_login()
     const ConnId old = server().sessions().bind_uid(session()->connid(), user.id);
     session()->set_profile(user.id, user.username,
                            user.nickname.empty() ? user.username : user.nickname);
+    session()->set_file_max(user.file_max);
     kick_if_needed(old);
     server().bind_cluster(user.id);
 
@@ -133,6 +134,7 @@ void AccountHandler::on_login()
     body["allow_search_account"] = user.allow_search_account;
     body["allow_search_phone"] = user.allow_search_phone;
     body["allow_add_friend"] = user.allow_add_friend;
+    body["limits"] = limits_json();
     reply_ok(body);
     server().notify_friend_presence(user.id, 1);
     Logger::info("login uid=" + std::to_string(user.id) + " account=" + user.username +
@@ -147,6 +149,7 @@ void AccountHandler::on_logout()
     const UserId uid = session()->uid();
     server().sessions().bind_uid(session()->connid(), 0);
     session()->set_profile(0, "", "");
+    session()->set_file_max(0);
     server().unbind_cluster(uid);
     if (!server().is_online(uid)) {
         server().notify_friend_presence(uid, 0);

+ 42 - 2
server/src/im/handlers/message_handler.cpp

@@ -80,9 +80,38 @@ std::string chat_preview(int msg_type, const std::string& content, size_t max_by
             }
             return e == "jpg" || e == "jpeg" || e == "png" || e == "gif" || e == "bmp" || e == "webp";
         };
+        auto is_video = [](const std::string& mime, const std::string& name) {
+            if (mime.size() >= 6) {
+                const bool vid = (mime[0] == 'v' || mime[0] == 'V') &&
+                                 (mime[1] == 'i' || mime[1] == 'I') &&
+                                 (mime[2] == 'd' || mime[2] == 'D') &&
+                                 (mime[3] == 'e' || mime[3] == 'E') &&
+                                 (mime[4] == 'o' || mime[4] == 'O') &&
+                                 mime[5] == '/';
+                if (vid) {
+                    return true;
+                }
+            }
+            auto pos = name.find_last_of('.');
+            if (pos == std::string::npos) {
+                return false;
+            }
+            std::string e = name.substr(pos + 1);
+            for (char& c : e) {
+                if (c >= 'A' && c <= 'Z') {
+                    c = static_cast<char>(c - 'A' + 'a');
+                }
+            }
+            return e == "mp4" || e == "mov" || e == "m4v" || e == "avi" || e == "mkv" ||
+                   e == "webm" || e == "wmv" || e == "flv" || e == "mpeg" || e == "mpg" ||
+                   e == "3gp";
+        };
         if (is_image(mime, name)) {
             return "[图片]";
         }
+        if (is_video(mime, name)) {
+            return "[视频]";
+        }
         if (!name.empty()) {
             return preview_text(std::string("[文件] ") + name, max_bytes);
         }
@@ -141,6 +170,16 @@ int64 json_num(const ylib::json& j, const char* key, int64 fallback = 0)
     }
 }
 
+void copy_media_wh(const ylib::json& meta, ylib::json& fixed)
+{
+    const int w = static_cast<int>(json_num(meta, "w"));
+    const int h = static_cast<int>(json_num(meta, "h"));
+    if (w > 0 && h > 0) {
+        fixed["w"] = w;
+        fixed["h"] = h;
+    }
+}
+
 }  // namespace
 
 void MessageHandler::handle()
@@ -258,6 +297,7 @@ void MessageHandler::on_chat()
         fixed["size"] = file.size;
         fixed["mime"] = file.mime;
         fixed["url"] = key;
+        copy_media_wh(meta, fixed);
         content = fixed.to_string();
     } else if (msg_type == MsgType::Card) {
         ylib::json meta;
@@ -391,6 +431,7 @@ void MessageHandler::on_group_chat()
         fixed["size"] = file.size;
         fixed["mime"] = file.mime;
         fixed["url"] = key;
+        copy_media_wh(meta, fixed);
         content = fixed.to_string();
     } else if (msg_type == MsgType::Card) {
         ylib::json meta;
@@ -777,7 +818,6 @@ void MessageHandler::on_put_file()
     if (!require_login()) {
         return;
     }
-    const auto& lim = config().limits();
     std::string name = safe_filename(json_str("name"));
     std::string mime = json_str("mime");
     if (mime.empty()) {
@@ -791,7 +831,7 @@ void MessageHandler::on_put_file()
         reply_error(Err::BadParam, i18n::Msg::BadParam);
         return;
     }
-    if (size > static_cast<int64>(lim.file_max)) {
+    if (size > effective_file_max()) {
         reply_error(Err::BadParam, i18n::Msg::FileTooLarge);
         return;
     }

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

@@ -26,6 +26,8 @@ public:
     std::string nickname() const;
     void set_nickname(std::string nickname);
     void set_profile(UserId uid, std::string account, std::string nickname);
+    int64_t file_max() const { return file_max_.load(std::memory_order_acquire); }
+    void set_file_max(int64_t bytes) { file_max_.store(bytes, std::memory_order_release); }
 
     i18n::Lang lang() const
     {
@@ -51,6 +53,7 @@ public:
 private:
     const ConnId connid_ = 0;
     std::atomic<UserId> uid_{0};
+    std::atomic<int64_t> file_max_{0};
     std::atomic<int> lang_{static_cast<int>(i18n::Lang::Zh)};
     std::atomic<bool> closed_{false};
     std::string remote_;

+ 18 - 0
server/src/third/cos.cpp

@@ -666,6 +666,24 @@ std::string object_ext(const std::string& name, const std::string& mime)
     if (m == "application/zip" || m == "application/x-zip-compressed") {
         return ".zip";
     }
+    if (m == "video/mp4") {
+        return ".mp4";
+    }
+    if (m == "video/quicktime") {
+        return ".mov";
+    }
+    if (m == "video/webm") {
+        return ".webm";
+    }
+    if (m == "video/x-msvideo") {
+        return ".avi";
+    }
+    if (m == "video/x-matroska") {
+        return ".mkv";
+    }
+    if (m == "video/x-ms-wmv") {
+        return ".wmv";
+    }
     return {};
 }