修复文本收藏/聊天记录检索不识别已撤回状态、P2P来源收藏名称解析404
问题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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
117bf43c25
commit
b5531b0b7f
+11
-5
@@ -11064,13 +11064,18 @@ div.toast.show {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shared by VoiceCard/LinkText/MediaThumb's "removed" state (content-library-view.jsx) -- same
|
/* Shared by VoiceCard/LinkText/MediaThumb/text-favorite's "removed" state (content-library-view.jsx)
|
||||||
opacity + red label language as .library-file-card.removed/em above, but these three don't share
|
-- 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
|
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 <p className="library-text-content">
|
||||||
|
rather than introducing a whole new component just for this one state. */
|
||||||
.library-voice-card.removed,
|
.library-voice-card.removed,
|
||||||
.library-link-text.removed,
|
.library-link-text.removed,
|
||||||
.library-media-thumb.removed {
|
.library-media-thumb.removed,
|
||||||
|
.library-text-content.removed {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -11080,7 +11085,8 @@ div.toast.show {
|
|||||||
|
|
||||||
.library-voice-card.removed em,
|
.library-voice-card.removed em,
|
||||||
.library-link-text.removed em,
|
.library-link-text.removed em,
|
||||||
.library-media-thumb.removed em {
|
.library-media-thumb.removed em,
|
||||||
|
.library-text-content.removed em {
|
||||||
color: #d74a53;
|
color: #d74a53;
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|||||||
@@ -8,21 +8,35 @@ import HashNavigation from '../lib/navigation.js';
|
|||||||
import { bytesToHumanSize, secondsToTime, shortDateFormat } from '../lib/strformat.js';
|
import { bytesToHumanSize, secondsToTime, shortDateFormat } from '../lib/strformat.js';
|
||||||
import {
|
import {
|
||||||
getFavorites, getFavorite, batchDeleteFavorites, forwardFavorites, createFileDownloader,
|
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';
|
} from '../lib/im-api.js';
|
||||||
import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js';
|
import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js';
|
||||||
import { topicDisplayName } from '../lib/utils.js';
|
import { topicDisplayName } from '../lib/utils.js';
|
||||||
|
|
||||||
// favoritePayload's source.topic is the raw conversation topic id (grpXXX/usrXXX) -- resolve it to a
|
// favoritePayload's source.topic is the raw conversation topic id -- for a group that's grpXXX, but
|
||||||
// display name the same way every other list in this app does, cached since many favorites usually
|
// for a P2P source it's the *composite* p2p topic id (e.g. "p2pU_as9N_wgZphV5XTJmzy3A"), not the bare
|
||||||
// share a handful of source conversations.
|
// "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();
|
const sourceNameCache = new Map();
|
||||||
function resolveSourceName(topic, onDone) {
|
function resolveSourceName(topic, onDone) {
|
||||||
if (!topic) return;
|
if (!topic) return;
|
||||||
if (sourceNameCache.has(topic)) { onDone(sourceNameCache.get(topic)); return; }
|
if (sourceNameCache.has(topic)) { onDone(sourceNameCache.get(topic)); return; }
|
||||||
if (Tinode.isSelfTopicName(topic)) { sourceNameCache.set(topic, '已保存的消息'); onDone('已保存的消息'); 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);
|
const call = topic.startsWith('grp') ? getGroup(topic).then(g => g.name) :
|
||||||
call.then(name => { sourceNameCache.set(topic, name); onDone(name); }).catch(() => {});
|
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
|
// 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 <article className={'library-favorite-item' + (selecting ? ' selecting' : '')}>
|
return <article className={'library-favorite-item' + (selecting ? ' selecting' : '')}>
|
||||||
{selecting ? <button type="button" className={'library-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择收藏"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
|
{selecting ? <button type="button" className={'library-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择收藏"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
|
||||||
<div className="library-favorite-content">
|
<div className="library-favorite-content">
|
||||||
{item.type === 'text' ? <p className="library-text-content">{plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)}</p> : null}
|
|
||||||
{/* 需求 7.3「失效文件需展示明确状态而非直接报错」原来只有 file 类型传了 removed(FileCard),
|
{/* 需求 7.3「失效文件需展示明确状态而非直接报错」原来只有 file 类型传了 removed(FileCard),
|
||||||
voice/link/image/video 三类完全没处理——即便后端把"问题13"(availability 恒为 available)
|
text/voice/link/image/video 全部没处理——即便后端把"问题13"(availability 恒为 available)
|
||||||
修好,这三类在 UI 上也不会有任何变化,因为组件本身就不认识这个状态,只会静默显示一个和
|
修好,这五类在 UI 上也不会有任何变化,因为组件本身就不认识这个状态,只会静默显示一个和
|
||||||
"还在加载中"没法区分的占位。四个类型现在统一都传。后端 messageAvailability(im_http.go)
|
"还在加载中"没法区分的占位(text 类型更糟:源消息撤回后 snapshot 里的旧文本会原样一直
|
||||||
在源消息被删/被撤回时给 'removed'。 */}
|
显示下去,没有"加载中"和"已失效"的区别,看起来像是这条消息完全没被撤回)。现在五个类型
|
||||||
|
统一都传。后端 messageAvailability(im_http.go)在源消息被删/被撤回时给 'removed'。 */}
|
||||||
|
{item.type === 'text' ? (item.availability === 'removed' ?
|
||||||
|
<p className="library-text-content removed"><Icon>block</Icon><em>文本已失效</em></p> :
|
||||||
|
<p className="library-text-content">{plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)}</p>) : null}
|
||||||
{item.type === 'file' ? <FileCard name={att && att.name} size={att && Drafty.getEntitySize(att)} mime={att && att.mime}
|
{item.type === 'file' ? <FileCard name={att && att.name} size={att && Drafty.getEntitySize(att)} mime={att && att.mime}
|
||||||
removed={item.availability === 'removed'} onOpen={onOpen} /> : null}
|
removed={item.availability === 'removed'} onOpen={onOpen} /> : null}
|
||||||
{item.type === 'voice' ? <VoiceCard att={att} head={item.snapshot && item.snapshot.head} removed={item.availability === 'removed'} /> : null}
|
{item.type === 'voice' ? <VoiceCard att={att} head={item.snapshot && item.snapshot.head} removed={item.availability === 'removed'} /> : null}
|
||||||
@@ -619,9 +636,18 @@ function History({view, hashParams, myUserId, onToast}) {
|
|||||||
{items === null ? <p className="library-empty-hint">加载中…</p> : !list.length ?
|
{items === null ? <p className="library-empty-hint">加载中…</p> : !list.length ?
|
||||||
<div className="library-empty"><Icon>{view === 'history-search' ? 'search_off' : 'inbox'}</Icon><strong>暂无内容</strong></div> :
|
<div className="library-empty"><Icon>{view === 'history-search' ? 'search_off' : 'inbox'}</Icon><strong>暂无内容</strong></div> :
|
||||||
view === 'history-search' ? <section className="library-message-results">
|
view === 'history-search' ? <section className="library-message-results">
|
||||||
|
{/* 同一处"问题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 => <button type="button" key={item.seq} onClick={() => jump(item.seq)}>
|
{list.map(item => <button type="button" key={item.seq} onClick={() => jump(item.seq)}>
|
||||||
<Avatar item={{}} /><span><strong>{nameOf(item.from)}</strong><p>{item.type === 'text' ?
|
<Avatar item={{}} /><span><strong>{nameOf(item.from)}</strong><p>{item.type === 'text' ?
|
||||||
<Highlight text={item.snippet || ''} query={query} /> : (TYPE_LABEL[item.type] || item.snippet || '')}</p>
|
(item.availability === 'removed' ? '文本已失效' : <Highlight text={item.snippet || ''} query={query} />) :
|
||||||
|
(TYPE_LABEL[item.type] || item.snippet || '')}</p>
|
||||||
<small>{shortDateFormat(new Date(item.ts))}</small></span><Icon>chevron_right</Icon>
|
<small>{shortDateFormat(new Date(item.ts))}</small></span><Icon>chevron_right</Icon>
|
||||||
</button>)}
|
</button>)}
|
||||||
</section> :
|
</section> :
|
||||||
|
|||||||
Reference in New Issue
Block a user