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:
co-authored by
Claude Sonnet 5
parent
95c0677b55
commit
1cc6f5068a
@@ -144,8 +144,19 @@ export function MessageRow({
|
|||||||
.catch(() => onError?.('重试发送失败'));
|
.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 (
|
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 ? (
|
{selectMode ? (
|
||||||
// A recalled message (content nulled out, rendered as the "⚠ 消息已删除" placeholder below)
|
// 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
|
// 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) ? (
|
isRecalled(msg) ? (
|
||||||
<div className="h-5 w-5 flex-none" />
|
<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} />
|
<SelectionCheckbox selected={selected} />
|
||||||
</button>
|
</div>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
@@ -213,7 +226,7 @@ export function MessageRow({
|
|||||||
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''} ${menuActive ? 'outline outline-2 outline-offset-2 outline-accent' : ''}`}
|
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''} ${menuActive ? 'outline outline-2 outline-offset-2 outline-accent' : ''}`}
|
||||||
>
|
>
|
||||||
{replyInfo ? (
|
{replyInfo ? (
|
||||||
<QuoteBar topicName={topic.name} reply={replyInfo} resolveName={resolveQuotedName ?? (() => '消息')} />
|
<QuoteBar topicName={topic.name} reply={replyInfo} resolveName={resolveQuotedName ?? (() => '消息')} locked={selectMode} />
|
||||||
) : null}
|
) : null}
|
||||||
{isRecalled(msg) ? (
|
{isRecalled(msg) ? (
|
||||||
'⚠ 消息已删除'
|
'⚠ 消息已删除'
|
||||||
@@ -235,12 +248,19 @@ export function MessageRow({
|
|||||||
<FileAttachment data={mobileAttachment} />
|
<FileAttachment data={mobileAttachment} />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
renderDrafty(msg.content)
|
renderDrafty(msg.content, undefined, !selectMode)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{failed ? (
|
{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>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -76,9 +76,14 @@ interface QuoteBarProps {
|
|||||||
/** Resolves a quoted sender uid to a display label (group remark/nick -> peer name); the parent
|
/** 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. */
|
* already owns this lookup, and must never surface a raw uid to the user. */
|
||||||
resolveName: (uid: string | undefined) => string;
|
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 messages = useTopicMessages(topicName);
|
||||||
const quoted: StoredMessage | undefined = useMemo(
|
const quoted: StoredMessage | undefined = useMemo(
|
||||||
() => messages.find((m) => typeof m.seq === 'number' && m.seq === reply.seq),
|
() => messages.find((m) => typeof m.seq === 'number' && m.seq === reply.seq),
|
||||||
@@ -108,9 +113,9 @@ export function QuoteBar({ topicName, reply, resolveName }: QuoteBarProps) {
|
|||||||
if (deleted) {
|
if (deleted) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role={locked ? undefined : 'button'}
|
||||||
tabIndex={0}
|
tabIndex={locked ? undefined : 0}
|
||||||
onClick={locate}
|
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"
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role={locked ? undefined : 'button'}
|
||||||
tabIndex={0}
|
tabIndex={locked ? undefined : 0}
|
||||||
onClick={locate}
|
onClick={locked ? undefined : locate}
|
||||||
title="查看原消息"
|
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]"
|
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>
|
<span className="max-w-[40%] flex-none truncate text-accent">{name}</span>
|
||||||
|
|||||||
@@ -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),
|
* 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
|
* 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. */
|
* 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(
|
return function formatNode(
|
||||||
style: string | undefined,
|
style: string | undefined,
|
||||||
data: Record<string, unknown> | 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
|
// 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
|
// 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.
|
// 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;
|
const uid = typeof data?.val === 'string' ? data.val : undefined;
|
||||||
if (uid && uid !== MENTION_ALL_SENTINEL) {
|
if (interactive && uid && uid !== MENTION_ALL_SENTINEL) {
|
||||||
return (
|
return (
|
||||||
<button key={key} type="button" onClick={() => openUserCard(uid)} className="inline border-0 bg-transparent p-0 text-accent hover:underline">
|
<button key={key} type="button" onClick={() => openUserCard(uid)} className="inline border-0 bg-transparent p-0 text-accent hover:underline">
|
||||||
{children}
|
{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
|
/** `highlightQuery` is opt-in and additive -- every existing call site that omits it renders exactly
|
||||||
* as before; only HistorySearchModal's search results pass one. */
|
* as before; only HistorySearchModal's search results pass one. Same for `interactive` (default
|
||||||
export function renderDrafty(content: DraftyContent | string | null | undefined, highlightQuery?: string): ReactNode {
|
* 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 (content == null) return null;
|
||||||
if (typeof content === 'string') return highlightQuery ? <>{highlightMatches(content, highlightQuery, 'plain')}</> : content;
|
if (typeof content === 'string') return highlightQuery ? <>{highlightMatches(content, highlightQuery, 'plain')}</> : content;
|
||||||
const flatText = flatTextContentOf(content);
|
const flatText = flatTextContentOf(content);
|
||||||
@@ -152,5 +157,5 @@ export function renderDrafty(content: DraftyContent | string | null | undefined,
|
|||||||
const text = Drafty.toPlainText(content);
|
const text = Drafty.toPlainText(content);
|
||||||
return highlightQuery ? <>{highlightMatches(text, highlightQuery, 'plain')}</> : text;
|
return highlightQuery ? <>{highlightMatches(text, highlightQuery, 'plain')}</> : text;
|
||||||
}
|
}
|
||||||
return Drafty.format(content, makeFormatNode(highlightQuery));
|
return Drafty.format(content, makeFormatNode(highlightQuery, interactive));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user