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>