Make whole message row tappable to toggle selection

The selection checkbox was the only clickable target in multi-select
mode; tapping the rest of the row (bubble, avatar, timestamp) did
nothing. Moves the toggle handler to the row container so tapping
anywhere selects/deselects, matching the multi-select UX in every
other list this app has (favorites, blocklist, etc).

Nested controls that keep their own click behavior during selectMode
(retry-failed-send, @mention profile card, quote-bar jump-to-message)
would otherwise double-fire alongside the new row click -- retry stops
propagation, mentions and the quote bar go inert instead (same
treatment this file already gives the avatar/sender-name buttons
during selectMode), so a tap only ever does the one thing: toggle the
row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-25 13:42:27 +08:00
co-authored by Claude Sonnet 5
parent 95c0677b55
commit 1cc6f5068a
3 changed files with 49 additions and 19 deletions
+26 -6
View File
@@ -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 (
<div data-seq={msg.seq} className={`flex items-center gap-2 ${highlighted ? 'animate-[jump-highlight_2s_ease-out]' : ''}`}>
<div
data-seq={msg.seq}
onClick={toggleThisRow}
className={`flex items-center gap-2 ${rowSelectable ? 'cursor-pointer' : ''} ${highlighted ? 'animate-[jump-highlight_2s_ease-out]' : ''}`}
>
{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) ? (
<div className="h-5 w-5 flex-none" />
) : (
<button type="button" onClick={() => typeof msg.seq === 'number' && onToggleSelect?.(msg.seq)} className="flex-none">
// No onClick of its own -- the row-level handler above already toggles it, and a second
// handler here would double-toggle on every click (select then immediately un-select).
<div className="flex-none">
<SelectionCheckbox selected={selected} />
</button>
</div>
)
) : null}
<div
@@ -213,7 +226,7 @@ export function MessageRow({
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''} ${menuActive ? 'outline outline-2 outline-offset-2 outline-accent' : ''}`}
>
{replyInfo ? (
<QuoteBar topicName={topic.name} reply={replyInfo} resolveName={resolveQuotedName ?? (() => '消息')} />
<QuoteBar topicName={topic.name} reply={replyInfo} resolveName={resolveQuotedName ?? (() => '消息')} locked={selectMode} />
) : null}
{isRecalled(msg) ? (
'⚠ 消息已删除'
@@ -235,12 +248,19 @@ export function MessageRow({
<FileAttachment data={mobileAttachment} />
)
) : (
renderDrafty(msg.content)
renderDrafty(msg.content, undefined, !selectMode)
)}
</div>
</div>
{failed ? (
<button type="button" onClick={retry} className="text-xs text-unread">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
retry();
}}
className="text-xs text-unread"
>
发送失败,点击重试
</button>
) : (
+13 -8
View File
@@ -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 (
<div
role="button"
tabIndex={0}
onClick={locate}
role={locked ? undefined : 'button'}
tabIndex={locked ? undefined : 0}
onClick={locked ? undefined : locate}
className="mb-1.5 truncate rounded border-l-2 border-text-tertiary-3 bg-black/[0.04] px-2 py-1 text-xs italic text-text-tertiary-3"
>
该消息已删除
@@ -120,10 +125,10 @@ export function QuoteBar({ topicName, reply, resolveName }: QuoteBarProps) {
return (
<div
role="button"
tabIndex={0}
onClick={locate}
title="查看原消息"
role={locked ? undefined : 'button'}
tabIndex={locked ? undefined : 0}
onClick={locked ? undefined : locate}
title={locked ? undefined : '查看原消息'}
className="mb-1.5 flex max-w-full items-center gap-1.5 rounded border-l-2 border-accent bg-black/[0.04] px-2 py-1 text-xs text-[#4a4f57]"
>
<span className="max-w-[40%] flex-none truncate text-accent">{name}</span>
+10 -5
View File
@@ -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<string, unknown> | 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 <span> 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 (
<button key={key} type="button" onClick={() => openUserCard(uid)} className="inline border-0 bg-transparent p-0 text-accent hover:underline">
{children}
@@ -142,8 +145,10 @@ function makeFormatNode(highlightQuery: string | undefined) {
}
/** `highlightQuery` is opt-in and additive -- every existing call site that omits it renders exactly
* as before; only HistorySearchModal's search results pass one. */
export function renderDrafty(content: DraftyContent | string | null | undefined, highlightQuery?: string): ReactNode {
* as before; only HistorySearchModal's search results pass one. Same for `interactive` (default
* true): MessageBubble's selectMode is the only caller that passes `false`, to keep an @mention from
* opening a profile card mid-bulk-select (see makeFormatNode's own comment on the MN case). */
export function renderDrafty(content: DraftyContent | string | null | undefined, highlightQuery?: string, interactive = true): ReactNode {
if (content == null) return null;
if (typeof content === 'string') return highlightQuery ? <>{highlightMatches(content, highlightQuery, 'plain')}</> : content;
const flatText = flatTextContentOf(content);
@@ -152,5 +157,5 @@ export function renderDrafty(content: DraftyContent | string | null | undefined,
const text = Drafty.toPlainText(content);
return highlightQuery ? <>{highlightMatches(text, highlightQuery, 'plain')}</> : text;
}
return Drafty.format(content, makeFormatNode(highlightQuery));
return Drafty.format(content, makeFormatNode(highlightQuery, interactive));
}