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?.('重试发送失败'));
|
||||
};
|
||||
|
||||
// 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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user