Procházet zdrojové kódy

fix: 文件预览与打开优先使用云盘链接,修刷新按钮无效

## 核心:链接改用云盘公网地址
原先回读历史只能拿到 Matrix 的 mxc:// → 转 /_matrix/media/...
用户要的是云盘链接(s3.fmode.cn):
- 不需要 blob 中转,Safari 兼容好
- 换设备、刷新后仍可访问

修法:发送文件时把 cloudUrl 写进 Matrix 事件的
com.fmode.cloud_url 字段(CLOUD_URL_KEY),回读时直接取。
不再依赖内存 Map _cloudUrls(刷新即丢)。
老消息无该字段时退回 Matrix 地址(向后兼容)。

消费侧:
- MatrixClientService.timelineToMessages 的 media.url 优先取云盘
- 移动端 openFile 改用 cloudUrl || url(桌面端此前已如此)

## 刷新按钮无效
reload() 原先一律调 retryFrame(),而它只操作 HTML 的 iframe,
PDF 没有 iframe → 点了完全没反应。
现在按类型分流:html → retryFrame;pdf → reloadTick 通知
app-pdf-preview 重新加载;文本 → 重新 fetch。

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct před 3 hodinami
rodič
revize
38289eb33d

+ 27 - 2
src/app/components/preview/file-preview/file-preview.ts

@@ -1071,6 +1071,13 @@ export class FilePreviewComponent {
    */
   readonly copied = signal(false);
   private copiedTimer: ReturnType<typeof setTimeout> | null = null;
+  /**
+   * 重新加载计数器(2026-11-13)。
+   *
+   * 点刷新时递增,传给子组件(如 `app-pdf-preview`)作为重载信号 ——
+   * 子组件内部据此重新走一遍加载流程。原先刷新按钮对 PDF 完全无效。
+   */
+  readonly reloadTick = signal(0);
   private frameTimer: ReturnType<typeof setTimeout> | null = null;
   private frameRetry = 0;
   private readonly blobUrl = signal('');
@@ -1532,9 +1539,27 @@ export class FilePreviewComponent {
     void this.fetchText(this.url());
   }
 
-  /** 重新加载(仅 HTML;PDF 的错误态自带「重试」) */
+  /** 重新加载(按类型分流 —— 2026-11-13 修正「点了没反应」) */
   reload(): void {
-    this.retryFrame();
+    const k = this.kind();
+    /*
+     * 🔴 原先不管什么类型都调 `retryFrame()`,而它只操作 HTML 的 iframe。
+     *    PDF 没有 iframe → 点了完全没反应(用户实测)。
+     */
+    if (k === 'html') {
+      this.retryFrame();
+      return;
+    }
+    if (k === 'pdf') {
+      /* PDF:清空已渲染状态,让它按当前 src 重新走一遍加载 */
+      this.frameRetry++;
+      this.frameStalled.set(false);
+      this.reloadTick.update((n) => n + 1);
+      return;
+    }
+    /* 文本 / Markdown:重新拉一次源文 */
+    this.loadedFor = '';
+    void this.fetchText(this.url());
   }
 
   close(): void {

+ 24 - 0
src/app/components/preview/pdf-preview/pdf-preview.ts

@@ -143,6 +143,14 @@ export class PdfPreviewComponent implements AfterViewInit, OnDestroy {
    */
   readonly zoom = input(1);
   readonly close = output<void>();
+  /**
+   * 重新加载信号(2026-11-13)。
+   *
+   * 宿主每点一次「重新加载」,这个值 +1;本组件据此把文档释放掉、
+   * 按当前 `url` 重新走一遍加载流程。
+   * 原先宿主的刷新按钮对本组件**完全无效**(它只操作 HTML 的 iframe)。
+   */
+  readonly reloadTick = input(0);
 
   /**
    * 缩放由宿主统一实现(2026-10-31):宿主把所有类型包进同一个 .fp-content 做 zoom,
@@ -175,6 +183,8 @@ export class PdfPreviewComponent implements AfterViewInit, OnDestroy {
   /** 上次重绘时的 zoom(相同则不重复重绘) */
   /** 是否已经历过首次 zoom 求值(effect 首帧只记录基线,不重绘) */
   private zoomSeen = false;
+  /** 重新加载 effect 的首次执行标记(同 zoomSeen,避免首帧重复 load) */
+  private reloadSeen = false;
   private lastZoom = 1;
 
   constructor() {
@@ -183,6 +193,20 @@ export class PdfPreviewComponent implements AfterViewInit, OnDestroy {
       if (u) void this.load(u);
     });
 
+    /*
+     * 重新加载(2026-11-13):宿主点刷新 → reloadTick 递增 → 丢掉当前文档重新加载。
+     * 首次建立 effect 时也会执行一次,用 `reloadSeen` 跳过,避免与上面的 url effect 重复加载。
+     */
+    effect(() => {
+      const tick = this.reloadTick();
+      if (!this.reloadSeen) {
+        this.reloadSeen = true;
+        return;
+      }
+      const u = this.url();
+      if (u) void this.load(u);
+    });
+
     /*
      * 缩放变化 → 防抖后按新比例重绘(这是"放大仍清晰"的核心)。
      * 用 effect 而非 CSS 变量监听:zoom 是信号,变化即触发。

+ 19 - 4
src/app/core/chat.service.ts

@@ -13,7 +13,7 @@ import {
 } from './api/models';
 import { DEMO_MESSAGES } from './api/demo-data';
 import { AlertService } from './alert.service';
-import { extractDeliveredLinks, extractS3Links, isUserUploadUrl, prettifyStorageKey, stripLinks } from './s3-link';
+import { extractDeliveredLinks, extractS3Links, isCloudUrl, isUserUploadUrl, prettifyStorageKey, stripLinks } from './s3-link';
 import { isOversize, notifyOversize, oversizeMessage } from './upload-limits';
 import { batchPhase, FileProgress, overallPct, UploadPhase } from './upload-state';
 import { environment } from '../../environments/environment';
@@ -21,6 +21,7 @@ import { LiveService } from './live.service';
 import { SessionService } from './session.service';
 import { UnreadService } from './unread.service';
 import {
+  CLOUD_URL_KEY,
   DISPATCH_KEY,
   LOCAL_ID_KEY,
   MxDispatchMeta,
@@ -2304,6 +2305,17 @@ export class ChatService {
             file,
             { name: file.name, size: file.size, mimeType },
             sid ?? undefined,
+            undefined,
+            /*
+             * 🔴 把云盘链接写进 Matrix 事件的 content(2026-11-13)。
+             *
+             * 为什么必须写:不带的话,回读历史只能拿到 `mxc://`,
+             * 而 App 展示/预览要的是**云盘公网链接**。原先靠内存 Map
+             * `_cloudUrls` 补,**刷新就丢** —— 用户反馈「链接不对」「刷新后打不开」。
+             *
+             * 容器只认 `url`(mxc://),未知字段会被忽略,不影响它。
+             */
+            { [CLOUD_URL_KEY]: cloudUrl },
           );
           this.setFileProgress(pending[i].id, { phase: 'done', pct: 100 });
           /*
@@ -3098,10 +3110,13 @@ export class ChatService {
               mimeType: file.mimeType,
               url: file.url,
               /*
-               * 我自己刚发的附件:回读只有 `mxc://`,用发送时记下的云盘地址补上,
-               * 右栏「文件」才能长期访问(2026-11-10)。
+               * `url` 已由 MatrixClientService 优先取事件里的云盘链接
+               * (`com.fmode.cloud_url`),所以这里直接复用。
+               * 内存 Map 只作老消息的兜底 —— 新消息不再依赖它。
                */
-              cloudUrl: this._cloudUrls.get(this.cloudKeyOf(file.name, file.size)),
+              cloudUrl: isCloudUrl(file.url)
+                ? file.url
+                : this._cloudUrls.get(this.cloudKeyOf(file.name, file.size)),
             }
           : linkCard,
         ts: new Date(m.ts).toISOString(),

+ 45 - 2
src/app/core/matrix-client.service.ts

@@ -133,6 +133,39 @@ export const SESSION_KEY = 'com.fmode.session';
 /** Matrix content 里存放本地消息 id 的字段名(把乐观消息和自己的 echo 对上) */
 export const LOCAL_ID_KEY = 'com.fmode.local';
 
+/**
+ * Matrix content 里存放**云盘公网链接**的字段名(2026-11-13)。
+ *
+ * ## 为什么要有它
+ *
+ * 用户发的文件是**两份**:一份进 Matrix 媒体库(`mxc://`,给容器读),
+ * 一份进云盘(`s3.fmode.cn`,给 App 长期展示与预览)。
+ *
+ * 但回读历史时只能拿到 `mxc://`,App 需要的却是云盘链接:
+ *  · 预览/新标签打开要走云盘(Matrix 地址需要 blob 中转,Safari 兼容差)
+ *  · 云盘链接换设备、刷新后都还能用
+ *
+ * 原先靠内存 Map 记住云盘地址,**刷新就丢**。现在写进事件本身,永久可回读。
+ */
+export const CLOUD_URL_KEY = 'com.fmode.cloud_url';
+
+/**
+ * 从消息 content 取云盘公网链接。
+ *
+ * 只接受 http(s) 且域名在白名单内 —— 防止历史/异常数据里塞进 `javascript:` 之类。
+ */
+export function extractCloudUrl(content: Record<string, unknown> | undefined): string | undefined {
+  const raw = content?.[CLOUD_URL_KEY];
+  if (typeof raw !== 'string' || !raw) return undefined;
+  try {
+    const u = new URL(raw);
+    if (u.protocol !== 'https:' && u.protocol !== 'http:') return undefined;
+    return u.toString();
+  } catch {
+    return undefined;
+  }
+}
+
 /**
  * 从消息 content 解析群聊派活标记。
  *
@@ -940,11 +973,21 @@ export class MatrixClientService implements OnDestroy {
         replyTo: extractReplyTo(e.getContent()),
         fmodeKind: extractFmodeKind(e.getContent()),
         body: (e.getContent()['body'] as string) ?? '',
-        // 原生附件 → 转成 FileCard(mxc:// 在这里转成可访问的 HTTP 地址)
+        /*
+         * 原生附件 → 转成 FileCard。
+         *
+         * 🔴 **优先用云盘链接**(2026-11-13 用户实测反馈):
+         *    原先一律用 `mxc://` 转出来的 `/_matrix/media/...`,
+         *    但那个地址 ① 预览/新标签要用 blob 中转(Safari 兼容差)
+         *    ② 平台清理媒体后就失效。用户要的是**云盘公网链接**。
+         *    事件里带了 `com.fmode.cloud_url` 就直接用它;
+         *    老消息没这个字段才退回 Matrix 地址。
+         */
         media: (() => {
           const m = parseNativeMedia(e.getContent());
           if (!m) return undefined;
-          const http = this.mxcUrlToHttp(m.mxcUrl);
+          const cloud = extractCloudUrl(e.getContent());
+          const http = cloud ?? this.mxcUrlToHttp(m.mxcUrl);
           return http
             ? { name: m.name, url: http, mimeType: m.mimeType, size: m.size ?? -1 }
             : undefined;

+ 13 - 2
src/app/mobile/pages/chat/chat.ts

@@ -1545,8 +1545,19 @@ export class MbChatPage implements OnDestroy {
    * 与 PC 端 `openFile()` 同口径:按类型分流 —— 图片进灯箱,其余进通用预览器。
    * 原实现用 `window.open` 新开标签页,移动端体验割裂(离开 App、失去返回路径)。
    */
-  protected openFile(f: { name: string; url?: string; mimeType?: string; size?: number }): void {
-    const url = f.url;
+  protected openFile(f: {
+    name: string;
+    url?: string;
+    cloudUrl?: string;
+    mimeType?: string;
+    size?: number;
+  }): void {
+    /*
+     * 🔴 优先用云盘链接(2026-11-13 用户实测反馈):
+     *    `url` 可能是本地 blob(刷新即失效)或 Matrix 媒体地址(需要 blob 中转、
+     *    Safari 兼容差);`cloudUrl` 才是长期可访问、能直接打开的云盘链接。
+     */
+    const url = f.cloudUrl || f.url;
     if (!url) return;
     const mime = f.mimeType ?? '';
     if (previewKindOf(f.name || url, mime) === 'image') {