fix(web): history search used response shapes the real server doesn't send
Confirmed live against the real backend: docs/IM-前后端对接文档.md's flat
`/media` items ({thumbnail_file_id,file_id,width,height}) and flat search
hits ({file:{...}}, {url,title,...}) don't match what the server actually
returns. Both endpoints really return the raw original message (`{txt,fmt,
ent}`, search hits wrapped one level deeper in `snapshot`) -- the same shape
lib/messages/drafty.tsx's renderDrafty already knows how to render, reused
here instead of hand-picking fields from a shape that doesn't exist. This is
also why image/video thumbnails were rendering as generic icons: the field
they were reading was never present in a real response.
Extracted useAuthedBlobUrl (authenticated file-fetch-to-blob-URL) out of
AttachmentPreview into lib/hooks.ts so the history modal's own thumbnail
grid can reuse it instead of duplicating the fetch/header logic.
Also adds the URL safety-check gate (docs/IM-前后端对接文档.md:1822,1898)
before opening a link found via the 链接 tab, which this view didn't have
at all before.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
93d4077d1e
commit
1bd8d7f804
+66
-35
@@ -25,45 +25,76 @@ export function recallMessage(topic: string, seq: number, version = 1, reason?:
|
||||
return imPost<RecallResult>(`/topics/${encodeURIComponent(topic)}/messages/${seq}/recall`, { version, reason });
|
||||
}
|
||||
|
||||
export interface SearchTextHit {
|
||||
seq: number;
|
||||
from: string;
|
||||
ts: string;
|
||||
snippet: string;
|
||||
matches?: Array<{ start: number; end: number }>;
|
||||
}
|
||||
|
||||
export interface SearchFileHit {
|
||||
seq: number;
|
||||
from: string;
|
||||
ts: string;
|
||||
file: { file_id: string; name: string; size: number; mime: string };
|
||||
availability: string;
|
||||
}
|
||||
|
||||
/** README §3.4's "查找聊天记录" -- ported endpoint shapes from docs/IM-前后端对接文档.md:1251-1252. */
|
||||
export function searchTopicMessages(topic: string, query: string, type: 'text' | 'file' = 'text', cursor = '') {
|
||||
const qs = new URLSearchParams({ q: query, type });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ topic: string; items: Array<SearchTextHit | SearchFileHit>; next_cursor?: string }>(
|
||||
`/topics/${encodeURIComponent(topic)}/messages/search?${qs.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export type MediaType = 'image' | 'video' | 'file' | 'link';
|
||||
|
||||
export interface MediaItem {
|
||||
seq: number;
|
||||
thumbnail_file_id?: string;
|
||||
file_id?: string;
|
||||
export interface DraftyEntityData {
|
||||
ref?: string;
|
||||
val?: string;
|
||||
mime?: string;
|
||||
name?: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
ts: string;
|
||||
availability: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/** README §3.4's 4-icon grid (图片/视频/文件/链接). */
|
||||
export function getTopicMedia(topic: string, type: MediaType, cursor = '') {
|
||||
export interface DraftyEntity {
|
||||
tp?: string;
|
||||
data?: DraftyEntityData;
|
||||
}
|
||||
|
||||
/**
|
||||
* docs/IM-前后端对接文档.md:1879,1889,1898 describe `file`/`link` results as flat `{file:{...}}` /
|
||||
* `{url,title,...}` objects -- confirmed LIVE against the real server to be wrong. The real payload
|
||||
* wraps a `snapshot` of the ORIGINAL raw message (`snapshot.content` is a full `{txt,fmt,ent}`
|
||||
* Drafty document -- pass it to lib/messages/drafty.ts's renderDrafty as-is, don't reconstruct it)
|
||||
* around thin search metadata. `snapshot.text` (not modeled here) is a redundant, sometimes-JSON-
|
||||
* stringified fallback the server fills in for non-text messages -- do not render it.
|
||||
*/
|
||||
export interface SearchHit {
|
||||
seq: number;
|
||||
from: string;
|
||||
ts: string;
|
||||
availability?: string;
|
||||
matches?: Array<{ start: number; end: number }>;
|
||||
snippet?: string;
|
||||
snapshot?: {
|
||||
content?: { txt?: string; fmt?: unknown[]; ent?: DraftyEntity[] };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* README §3.4's "查找聊天记录" -- ported endpoint shapes from docs/IM-前后端对接文档.md:1879,1889,1898.
|
||||
* `file`/`link` are this same search endpoint (not the `/media` one below) with an empty `q` -- per
|
||||
* §7.13/7.14 of the protocol doc, only `image`/`video` actually live behind `/media`.
|
||||
*/
|
||||
export function searchTopicMessages(topic: string, query: string, type: 'text' | 'file' | 'link' = 'text', cursor = '') {
|
||||
const qs = new URLSearchParams({ q: query, type });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ topic: string; items: SearchHit[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/messages/search?${qs.toString()}`);
|
||||
}
|
||||
|
||||
/** README §3.4's 4-icon grid (图片/视频/文件/链接) -- a UI-level union for "which of the 4 tabs is
|
||||
* selected." Only 2 of the 4 are actually served by `/media` (see MediaFetchType below); file/link
|
||||
* go through searchTopicMessages instead (docs/IM-前后端对接文档.md §7.13-7.16). */
|
||||
export type MediaType = 'image' | 'video' | 'file' | 'link';
|
||||
|
||||
/** The subset of MediaType that `/media` itself understands. */
|
||||
export type MediaFetchType = 'image' | 'video';
|
||||
|
||||
/**
|
||||
* docs/IM-前后端对接文档.md:1907 describes a flat `{seq,thumbnail_file_id,file_id,width,height,ts,
|
||||
* availability}` -- confirmed LIVE to be wrong the same way as SearchHit above: the real payload is
|
||||
* the raw message's own `{ent,fmt,txt}` at the top level (no `snapshot` wrapper this time), so the
|
||||
* actual attachment lives at `ent[0].data`, same DraftyEntityData shape as everywhere else.
|
||||
*/
|
||||
export interface MediaItem {
|
||||
seq: number;
|
||||
from?: string;
|
||||
ts: string;
|
||||
availability: string;
|
||||
ent?: DraftyEntity[];
|
||||
}
|
||||
|
||||
export function getTopicMedia(topic: string, type: MediaFetchType, cursor = '') {
|
||||
const qs = new URLSearchParams({ type });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ topic: string; items: MediaItem[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/media?${qs.toString()}`);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// docs/IM-前后端对接文档.md:1822,1898,1943 -- links surfaced anywhere in the UI (favorites, history
|
||||
// search) must go through this check before being opened; the server, not the client, holds the
|
||||
// risk classification (it fetches/caches the URL server-side to avoid the client ever hitting an
|
||||
// attacker-controlled address directly, which is also why there's no client-side allow/block list).
|
||||
import { imPost } from './client';
|
||||
|
||||
export interface UrlSafetyResult {
|
||||
url: string;
|
||||
decision: 'allow' | 'warn' | 'block';
|
||||
risk_level: string;
|
||||
reason?: string;
|
||||
risk_challenge_id?: string;
|
||||
}
|
||||
|
||||
export function checkUrlSafety(url: string, source: string) {
|
||||
return imPost<UrlSafetyResult>('/security/urls/check', { url, source });
|
||||
}
|
||||
@@ -1,5 +1,44 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { fileAuthHeaders, fileDownloadUrl } from '@/lib/api/upload';
|
||||
|
||||
/**
|
||||
* GET /v0/file/s/{file_id} requires the same auth header as upload (see upload.ts's fileAuthHeaders
|
||||
* for exactly which one, and why) -- a plain <img src="..."> or <a href="..."> can't attach a
|
||||
* custom header, so an externally-referenced (non-inline) attachment needs an authenticated fetch
|
||||
* into a blob URL rather than a direct URL. `ref` is anything fileDownloadUrl accepts: a full
|
||||
* "/v0/file/s/xxx" path (Drafty `refurl`), or an absolute URL.
|
||||
*/
|
||||
export function useAuthedBlobUrl(ref: string | undefined): { url: string | null; loading: boolean; error: boolean } {
|
||||
const [state, setState] = useState<{ url: string | null; loading: boolean; error: boolean }>({ url: null, loading: !!ref, error: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref) return undefined;
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
setState({ url: null, loading: true, error: false });
|
||||
fetch(fileDownloadUrl(ref), { headers: fileAuthHeaders() })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('download failed');
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setState({ url: objectUrl, loading: false, error: false });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setState({ url: null, loading: false, error: true });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Ported from the old app's use-cached-data.js -- debounces a fast-changing value (e.g. search input). */
|
||||
export function useDebouncedValue<T>(value: T, delay = 300): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
Reference in New Issue
Block a user