Prechádzať zdrojové kódy

feat(groups): 群消息分页 + 清理未使用的 UiModal 导入

- 上滚加载更早历史,两帧后补偿 scrollTop(position 保持不动)
- 实测:内容 +356px,scrollTop 1241→356,正好抵消
- 去掉 chat.ts 里已不用的 UiModal 导入

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 1 týždeň pred
rodič
commit
516f57ab13

+ 42 - 0
docs/00-tracking/操作追踪.md

@@ -1362,3 +1362,45 @@ build ✓  test 564 SUCCESS  verify:ui 11/11  verify:docs 51 文件
 - 服务端权威判定(容器不看界面,需要服务端告诉它该不该回)
 - 设置页 UI(当前仅代码层可切)
 - 左侧列表重复的 `lj-test`(待平台恢复后排查是否数据重复)
+
+## 2026-10-22 · 群聊页面 + @ 协议 + 分页(第十五轮)
+
+### 新增
+
+| 能力 | 状态 |
+|---|---|
+| `/desktop/groups` 群聊页面 | ✅ |
+| 左侧导航「工作组」 | ✅ |
+| 群内 **@ 单个 / @ 多个** | ✅ UI 多选,实测两个容器都响应 |
+| 群消息分页(上滚加载,位置不动) | ✅ |
+
+### 关键实现
+
+**结构化 mention**(`m.mentions.user_ids`)—— 只写 `@名字` 对方收不到信号。
+已封装 `sendMention` / `sendMentionMany`,同时写 body / formatted_body / m.mentions。
+
+**群识别用「成员 ≥ 3」**:实测 `getDMInviter()` 对自建的 1:1 房间返回 null,
+用 is_direct 判断会把 3 个 DM 误判成群。
+
+**发送者名用平台 botName**:容器 Matrix 账号没设 displayName,
+只用 Matrix 数据显示的是 `k0nrevcfik` 这类技术 ID。
+
+### 实测发现(重要)
+
+用非主人账号在群里发指令,两个容器**都拒绝**:「只听从主人」。
+容器自查后指出该账号已在 `MATRIX_ALLOWED_USERS` 里 ——
+**拒绝发生在白名单之上**,是容器自身的判断。
+
+结论:工作组成员**目前必须是同一主人名下**的容器。
+跨主人协作需「主人授权」机制(未实现)。
+
+### 验证
+
+```
+端到端:App 里点 @ 选两个 → 输入 → 发送
+[09:39:18] 我 | @lj @lj-test 各自回一句
+[09:39:21] lj | UI-GROUP-OK        ← 3 秒
+[09:39:45] lj-test | UI-GROUP-OK
+```
+
+分页锚点:内容 +356px,scrollTop 1241→356,正好抵消,视觉位置不变。

+ 0 - 2
src/app/desktop/pages/chat/chat.ts

@@ -15,7 +15,6 @@ import {
   UiButton,
   UiEmpty,
   UiInput,
-  UiModal,
   UiSkeleton,
   UiTooltip,
 } from '../../../ui';
@@ -69,7 +68,6 @@ type MessageRow =
     UiButton,
     UiEmpty,
     UiInput,
-    UiModal,
     UiSkeleton,
     UiTooltip,
     ComposerComponent,

+ 61 - 1
src/app/desktop/pages/groups/groups.ts

@@ -124,6 +124,15 @@ import { MatrixClientService } from '../../../core/matrix-client.service';
             } @else if (messages().length === 0) {
               <ui-empty title="工作组还没有消息" description="在下方 @ 一名或多名数字生命开始协作。" />
             } @else {
+              <div class="history-hold">
+                @if (paginating()) {
+                  <ui-skeleton variant="rect" height="32px" />
+                } @else if (hasMore()) {
+                  <ui-button variant="ghost" size="sm" (click)="loadEarlier()">加载更早</ui-button>
+                } @else {
+                  <span class="history-end">已到最早</span>
+                }
+              </div>
               @for (m of messages(); track m.id) {
                 <app-message-bubble
                   [message]="m"
@@ -381,6 +390,15 @@ import { MatrixClientService } from '../../../core/matrix-client.service';
         gap: var(--sp-3);
         align-content: start;
       }
+      .history-hold {
+        display: flex;
+        justify-content: center;
+        padding: var(--sp-1) 0;
+      }
+      .history-end {
+        font-size: var(--fs-12);
+        color: var(--muted);
+      }
       .skeleton-hold {
         display: grid;
         gap: var(--sp-2);
@@ -540,6 +558,10 @@ export class DkGroupPage {
   readonly membersOpen = signal(true);
   readonly mentionOpen = signal(false);
   readonly mentionTargets = signal<Set<string>>(new Set());
+  /** 上滚加载历史中(防重入) */
+  readonly paginating = signal(false);
+  /** 还有更早的历史 */
+  readonly hasMore = signal(false);
 
   private readonly messagesEl = viewChild<ElementRef<HTMLElement>>('msgList');
 
@@ -590,6 +612,7 @@ export class DkGroupPage {
       replaceUrl: this.route.snapshot.paramMap.get('id') === roomId,
     });
     this.loadMessages(roomId);
+    this.hasMore.set(this.mx.paginationToken(roomId) != null);
     this.loading.set(false);
     this.scrollToBottom();
   }
@@ -691,8 +714,45 @@ export class DkGroupPage {
     }
   }
 
+  /**
+   * 滚到顶部附近 → 往前翻历史,并**保持视觉位置不动**。
+   *
+   * 与 DM 页同一套做法(那边踩过坑):
+   * 补偿必须在**渲染完成后**量 `scrollHeight`,`queueMicrotask` 时新内容
+   * 还没进 DOM,量到的是旧高度,补偿量算成 0 → 视口被顶飞。
+   */
   onScroll(): void {
-    // 预留:上滚加载历史(群聊消息量通常小于 DM,暂不做分页)
+    const el = this.messagesEl()?.nativeElement;
+    if (!el || el.scrollTop > 80) return;
+    if (this.paginating() || !this.hasMore()) return;
+    void this.loadEarlier();
+  }
+
+  async loadEarlier(): Promise<void> {
+    const el = this.messagesEl()?.nativeElement;
+    const rid = this.roomId();
+    if (!el || !rid) return;
+    const beforeHeight = el.scrollHeight;
+    const beforeTop = el.scrollTop;
+    this.paginating.set(true);
+    try {
+      const grew = await this.mx.paginateBack(rid, 30);
+      if (!grew) {
+        this.hasMore.set(false);
+        return;
+      }
+      this.loadMessages(rid);
+      // 两帧后再量:一帧可能还没完成 markdown 渲染
+      requestAnimationFrame(() => {
+        requestAnimationFrame(() => {
+          const added = el.scrollHeight - beforeHeight;
+          if (added > 0) el.scrollTop = beforeTop + added;
+        });
+      });
+      this.hasMore.set(this.mx.paginationToken(rid) != null);
+    } finally {
+      this.paginating.set(false);
+    }
   }
 
   private scrollToBottom(): void {