From b5531b0b7f69738d57a8e99175a9761fe46ea29f Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 6 Sep 2026 02:13:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=96=87=E6=9C=AC=E6=94=B6?= =?UTF-8?q?=E8=97=8F/=E8=81=8A=E5=A4=A9=E8=AE=B0=E5=BD=95=E6=A3=80?= =?UTF-8?q?=E7=B4=A2=E4=B8=8D=E8=AF=86=E5=88=AB=E5=B7=B2=E6=92=A4=E5=9B=9E?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E3=80=81P2P=E6=9D=A5=E6=BA=90=E6=94=B6?= =?UTF-8?q?=E8=97=8F=E5=90=8D=E7=A7=B0=E8=A7=A3=E6=9E=90404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题13(收藏availability恒为available)后端修复后暴露的真实缺口: text类型收藏和History的history-search结果都只判断item.type,从不看 availability,撤回的文本消息会原样一直显示旧内容(History里甚至会 显示后端messageText()对nil content兜底出的字面量"null")。 另外,P2P来源的收藏来源名称解析函数把复合p2p话题id当用户uid传给 /users/{id}/profile,必现404,永远显示原始话题字符串而非好友昵称; 改用已有的getConversationContext接口正确解析。 第十一轮最小粒度测试 Ch7 发现。 Co-Authored-By: Claude Sonnet 5 --- css/base.css | 16 +++++++--- src/views/content-library-view.jsx | 50 +++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/css/base.css b/css/base.css index 753f61b6..6a9a293d 100644 --- a/css/base.css +++ b/css/base.css @@ -11064,13 +11064,18 @@ div.toast.show { white-space: nowrap; } -/* Shared by VoiceCard/LinkText/MediaThumb's "removed" state (content-library-view.jsx) -- same - opacity + red label language as .library-file-card.removed/em above, but these three don't share +/* Shared by VoiceCard/LinkText/MediaThumb/text-favorite's "removed" state (content-library-view.jsx) + -- same opacity + red label language as .library-file-card.removed/em above, but these don't share its grid layout (voice/media assume an icon+waveform or an image backdrop that isn't there once - the source message is gone), so the removed state uses a simple centered flex row instead. */ + the source message is gone), so the removed state uses a simple centered flex row instead. + .library-text-content.removed covers a recalled/deleted *text* favorite or history-search hit -- + plain text has no dedicated wrapper component of its own (FavoriteItem/History render it inline), + so the same modifier class is applied straight to the existing

+ rather than introducing a whole new component just for this one state. */ .library-voice-card.removed, .library-link-text.removed, -.library-media-thumb.removed { +.library-media-thumb.removed, +.library-text-content.removed { display: flex; align-items: center; justify-content: center; @@ -11080,7 +11085,8 @@ div.toast.show { .library-voice-card.removed em, .library-link-text.removed em, -.library-media-thumb.removed em { +.library-media-thumb.removed em, +.library-text-content.removed em { color: #d74a53; font-size: 0.74rem; font-style: normal; diff --git a/src/views/content-library-view.jsx b/src/views/content-library-view.jsx index 97bbab06..df15c0f4 100644 --- a/src/views/content-library-view.jsx +++ b/src/views/content-library-view.jsx @@ -8,21 +8,35 @@ import HashNavigation from '../lib/navigation.js'; import { bytesToHumanSize, secondsToTime, shortDateFormat } from '../lib/strformat.js'; import { getFavorites, getFavorite, batchDeleteFavorites, forwardFavorites, createFileDownloader, - searchTopicMessages, getMessageContext, getTopicMedia, checkURL, getFileUrl, resolveFileUrl, getGroup, getUserProfile + searchTopicMessages, getMessageContext, getTopicMedia, checkURL, getFileUrl, resolveFileUrl, getGroup, getUserProfile, + getConversationContext } from '../lib/im-api.js'; import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js'; import { topicDisplayName } from '../lib/utils.js'; -// favoritePayload's source.topic is the raw conversation topic id (grpXXX/usrXXX) -- resolve it to a -// display name the same way every other list in this app does, cached since many favorites usually -// share a handful of source conversations. +// favoritePayload's source.topic is the raw conversation topic id -- for a group that's grpXXX, but +// for a P2P source it's the *composite* p2p topic id (e.g. "p2pU_as9N_wgZphV5XTJmzy3A"), not the bare +// "usrXXX" uid the comment here used to assume. Passing that composite id straight to +// /users/{id}/profile 404s every time (confirmed live: MALFORMED_REQUEST/"user not found"), so every +// P2P-sourced favorite silently fell back to displaying the raw p2pXXX topic string instead of the +// friend's name -- the .catch(()=>{}) below swallowed the failure with no visible error. There is no +// way to derive the peer's uid from that composite string on the client: im_be's ParseP2P (server/ +// im_http.go's resolveConversationContextTopic) decodes it, but that decoding never runs client-side, +// and the already-subscribed chatList doesn't help either since Tinode keys a P2P contact by the +// peer's bare uid there, not by this composite id, so no local list lookup can bridge the two. +// GET /conversations/{topic}/context (im-api.js's getConversationContext, already used elsewhere for +// relationship state) exists precisely to resolve a p2p topic server-side and conveniently returns +// the resolved peer's own `public` data in the same response -- reuse that instead of guessing at a +// uid shape that doesn't apply here. Cached since many favorites usually share a handful of source +// conversations. const sourceNameCache = new Map(); function resolveSourceName(topic, onDone) { if (!topic) return; if (sourceNameCache.has(topic)) { onDone(sourceNameCache.get(topic)); return; } if (Tinode.isSelfTopicName(topic)) { sourceNameCache.set(topic, '已保存的消息'); onDone('已保存的消息'); return; } - const call = topic.startsWith('grp') ? getGroup(topic).then(g => g.name) : getUserProfile(topic).then(p => p.display_name); - call.then(name => { sourceNameCache.set(topic, name); onDone(name); }).catch(() => {}); + const call = topic.startsWith('grp') ? getGroup(topic).then(g => g.name) : + getConversationContext(topic).then(ctx => ctx.peer && topicDisplayName(ctx.peer_user_id, ctx.peer.public, ctx.peer_user_id)); + call.then(name => { if (name) { sourceNameCache.set(topic, name); onDone(name); } }).catch(() => {}); } // Generic multi-param navigate -- content-library-view.jsx's original helper was a bare @@ -194,12 +208,15 @@ function FavoriteItem({item, onOpen, selecting, selected, onSelect}) { return

{selecting ? : null}
- {item.type === 'text' ?

{plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)}

: null} {/* 需求 7.3「失效文件需展示明确状态而非直接报错」原来只有 file 类型传了 removed(FileCard), - voice/link/image/video 三类完全没处理——即便后端把"问题13"(availability 恒为 available) - 修好,这三类在 UI 上也不会有任何变化,因为组件本身就不认识这个状态,只会静默显示一个和 - "还在加载中"没法区分的占位。四个类型现在统一都传。后端 messageAvailability(im_http.go) - 在源消息被删/被撤回时给 'removed'。 */} + text/voice/link/image/video 全部没处理——即便后端把"问题13"(availability 恒为 available) + 修好,这五类在 UI 上也不会有任何变化,因为组件本身就不认识这个状态,只会静默显示一个和 + "还在加载中"没法区分的占位(text 类型更糟:源消息撤回后 snapshot 里的旧文本会原样一直 + 显示下去,没有"加载中"和"已失效"的区别,看起来像是这条消息完全没被撤回)。现在五个类型 + 统一都传。后端 messageAvailability(im_http.go)在源消息被删/被撤回时给 'removed'。 */} + {item.type === 'text' ? (item.availability === 'removed' ? +

block文本已失效

: +

{plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)}

) : null} {item.type === 'file' ? : null} {item.type === 'voice' ? : null} @@ -619,9 +636,18 @@ function History({view, hashParams, myUserId, onToast}) { {items === null ?

加载中…

: !list.length ?
{view === 'history-search' ? 'search_off' : 'inbox'}暂无内容
: view === 'history-search' ?
+ {/* 同一处"问题13"修复暴露的缺口,History 自己的一份,只影响 type==='text':非 text 类型 + 始终走 TYPE_LABEL[item.type] 的固定方括号标签("[图片]"等),从不读 item.snippet, + 所以 removed 与否对它们没有可观察差异,不需要改。text 类型是唯一真正显示 item.snippet + 的分支——一个没有搜索关键词的浏览(query 为空,后端不加内容过滤,撤回/删除的消息也会 + 原样出现在结果里)如果命中一条已撤回的文本消息,item.snippet 现在会是后端 + messageText() 对 nil content 兜底出来的字面量字符串 "null"(json.Marshal(nil)),而不 + 是空字符串——不判断 availability 的话,用户会看到一行显示"null"的消息,比"看起来没被 + 撤回"更令人困惑。 */} {list.map(item => )}
: