diff --git a/web/src/features/messages/MessageBubble.tsx b/web/src/features/messages/MessageBubble.tsx index 3a2d8df3..f1958e90 100644 --- a/web/src/features/messages/MessageBubble.tsx +++ b/web/src/features/messages/MessageBubble.tsx @@ -144,8 +144,19 @@ export function MessageRow({ .catch(() => onError?.('重试发送失败')); }; + // In selectMode, the WHOLE row toggles selection (not just the checkbox) -- confirmed by the user + // that tapping only the small checkbox target is the wrong UX for a bulk-select list. A recalled + // message renders a spacer instead of a checkbox (see below) and must stay unselectable, so this + // guards the same way. + const rowSelectable = selectMode && !isRecalled(msg) && typeof msg.seq === 'number'; + const toggleThisRow = () => rowSelectable && onToggleSelect?.(msg.seq as number); + return ( -
+
{selectMode ? ( // A recalled message (content nulled out, rendered as the "⚠ 消息已删除" placeholder below) // has nothing real to forward/favorite -- an empty flex-none spacer of the same size keeps @@ -154,9 +165,11 @@ export function MessageRow({ isRecalled(msg) ? (
) : ( - +
) ) : null}
{replyInfo ? ( - '消息')} /> + '消息')} locked={selectMode} /> ) : null} {isRecalled(msg) ? ( '⚠ 消息已删除' @@ -235,12 +248,19 @@ export function MessageRow({ ) ) : ( - renderDrafty(msg.content) + renderDrafty(msg.content, undefined, !selectMode) )}
{failed ? ( - ) : ( diff --git a/web/src/features/messages/QuoteBar.tsx b/web/src/features/messages/QuoteBar.tsx index c34391f4..df6d9ed3 100644 --- a/web/src/features/messages/QuoteBar.tsx +++ b/web/src/features/messages/QuoteBar.tsx @@ -76,9 +76,14 @@ interface QuoteBarProps { /** Resolves a quoted sender uid to a display label (group remark/nick -> peer name); the parent * already owns this lookup, and must never surface a raw uid to the user. */ resolveName: (uid: string | undefined) => string; + /** MessageBubble's selectMode: a tap here must only toggle the row's own selection (same reasoning + * as the avatar/sender-name buttons going inert during bulk-select), not also jump to the quoted + * message -- so `locate` is left unwired and this renders as a plain, non-interactive div instead + * of `role="button"`. */ + locked?: boolean; } -export function QuoteBar({ topicName, reply, resolveName }: QuoteBarProps) { +export function QuoteBar({ topicName, reply, resolveName, locked }: QuoteBarProps) { const messages = useTopicMessages(topicName); const quoted: StoredMessage | undefined = useMemo( () => messages.find((m) => typeof m.seq === 'number' && m.seq === reply.seq), @@ -108,9 +113,9 @@ export function QuoteBar({ topicName, reply, resolveName }: QuoteBarProps) { if (deleted) { return (
该消息已删除 @@ -120,10 +125,10 @@ export function QuoteBar({ topicName, reply, resolveName }: QuoteBarProps) { return (
{name} diff --git a/web/src/lib/messages/drafty.tsx b/web/src/lib/messages/drafty.tsx index c52957aa..b16f472b 100644 --- a/web/src/lib/messages/drafty.tsx +++ b/web/src/lib/messages/drafty.tsx @@ -58,7 +58,7 @@ function highlightMatches(text: string, query: string, keyPrefix: string | numbe * own callback type has a REAL 5th parameter (`stack: unknown[]`, see treeBottomUp's actual call), * so a `formatNode` typed to accept `highlightQuery` in that same slot would stop structurally * matching the formatter type `Drafty.format` expects wherever it's passed directly by reference. */ -function makeFormatNode(highlightQuery: string | undefined) { +function makeFormatNode(highlightQuery: string | undefined, interactive: boolean) { return function formatNode( style: string | undefined, data: Record | undefined, @@ -85,8 +85,11 @@ function makeFormatNode(highlightQuery: string | undefined) { // The mention's uid lives on the entity's own `data` (Drafty.mention(name, uid) -- SDK // source: `ent:[{tp:'MN', data:{val:uid}}]`), not on `values`/`children` (that's just the // rendered label text). MENTION_ALL_SENTINEL ("所有人") isn't a real user -- nothing to open. + // `interactive=false` (MessageBubble's selectMode) falls through to the same inert as + // the sentinel case -- a tap during bulk-select must only toggle the row, same reasoning as + // the avatar/sender-name buttons right above this component's own JSX going inert there. const uid = typeof data?.val === 'string' ? data.val : undefined; - if (uid && uid !== MENTION_ALL_SENTINEL) { + if (interactive && uid && uid !== MENTION_ALL_SENTINEL) { return (