mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix: flush debounce on Tab/Enter to prevent stale autocomplete (#1661)
Signed-off-by: Cameron Hotchkies <chotchkies@block.xyz> Signed-off-by: npub1m0vvn9qm5md0a080p27qzkm9uaw49e699ukwfq7fc0756xq0y5zqhzhdk2 <dbd8c9941ba6dafebcef0abc015b65e75d52e7452f2ce483c9c3fd4d180f2504@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Goose <opensource@block.xyz> Co-authored-by: npub1m0vvn9qm5md0a080p27qzkm9uaw49e699ukwfq7fc0756xq0y5zqhzhdk2 <dbd8c9941ba6dafebcef0abc015b65e75d52e7452f2ce483c9c3fd4d180f2504@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
Goose
npub1m0vvn9qm5md0a080p27qzkm9uaw49e699ukwfq7fc0756xq0y5zqhzhdk2
npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap
parent
ce9e963d97
commit
841282141b
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { flushMentionDebounce } from "./flushMentionDebounce.ts";
|
||||
|
||||
function ref(current) {
|
||||
return { current };
|
||||
}
|
||||
|
||||
function candidate(overrides = {}) {
|
||||
return {
|
||||
kind: "identity",
|
||||
displayName: "Beta",
|
||||
isAgent: false,
|
||||
isMember: true,
|
||||
pubkey: "b".repeat(64),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("flushMentionDebounce returns the fresh suggestion with its fresh start index", () => {
|
||||
const debounceTimerRef = ref(setTimeout(() => {}, 1000));
|
||||
|
||||
const flushed = flushMentionDebounce({
|
||||
debounceTimerRef,
|
||||
latestValueRef: ref("@Alpha @be"),
|
||||
latestCursorRef: ref("@Alpha @be".length),
|
||||
searchableNamesLowerRef: ref(["alpha", "beta"]),
|
||||
candidates: [
|
||||
candidate({ displayName: "Alpha", pubkey: "a".repeat(64) }),
|
||||
candidate({ displayName: "Beta", pubkey: "b".repeat(64) }),
|
||||
],
|
||||
activePersonaIds: new Set(),
|
||||
channelType: "group",
|
||||
});
|
||||
|
||||
assert.equal(debounceTimerRef.current, null);
|
||||
assert.equal(flushed?.type, "match");
|
||||
assert.equal(flushed?.suggestion.displayName, "Beta");
|
||||
assert.equal(flushed?.startIndex, 7);
|
||||
});
|
||||
|
||||
test("flushMentionDebounce returns no-match for a fresh query with no matches", () => {
|
||||
const flushed = flushMentionDebounce({
|
||||
debounceTimerRef: ref(setTimeout(() => {}, 1000)),
|
||||
latestValueRef: ref("@Alpha @zzzq"),
|
||||
latestCursorRef: ref("@Alpha @zzzq".length),
|
||||
searchableNamesLowerRef: ref(["alpha", "beta"]),
|
||||
candidates: [candidate()],
|
||||
activePersonaIds: new Set(),
|
||||
channelType: "group",
|
||||
});
|
||||
|
||||
assert.deepEqual(flushed, { type: "no-match" });
|
||||
});
|
||||
|
||||
test("flushMentionDebounce returns null for an empty fresh query", () => {
|
||||
const flushed = flushMentionDebounce({
|
||||
debounceTimerRef: ref(setTimeout(() => {}, 1000)),
|
||||
latestValueRef: ref("@"),
|
||||
latestCursorRef: ref("@".length),
|
||||
searchableNamesLowerRef: ref(["alpha", "beta"]),
|
||||
candidates: [candidate()],
|
||||
activePersonaIds: new Set(),
|
||||
channelType: "group",
|
||||
});
|
||||
|
||||
assert.equal(flushed, null);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Synchronously flush a pending mention debounce and resolve the correct
|
||||
* top-ranked suggestion. Used by handleMentionKeyDown to close the race
|
||||
* window where Tab/Enter fires before the debounce catches up to typed text.
|
||||
*/
|
||||
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
|
||||
import {
|
||||
type MentionCandidateForRanking,
|
||||
rankMentionCandidates,
|
||||
} from "./mentionRanking";
|
||||
import {
|
||||
mapMentionCandidateToSuggestion,
|
||||
type MentionSuggestionCandidate,
|
||||
} from "./mentionSuggestionMapping";
|
||||
|
||||
type MentionCandidateWithUI = MentionCandidateForRanking &
|
||||
MentionSuggestionCandidate;
|
||||
|
||||
export type FlushMentionDebounceResult =
|
||||
| { type: "match"; suggestion: MentionSuggestion; startIndex: number }
|
||||
| { type: "no-match" };
|
||||
|
||||
/**
|
||||
* Cancel the pending debounce timer, re-detect the prefix query from the
|
||||
* latest editor state, rank candidates, and return the top suggestion — or
|
||||
* null if no valid match is found.
|
||||
*/
|
||||
export function flushMentionDebounce<T extends MentionCandidateWithUI>(opts: {
|
||||
debounceTimerRef: React.MutableRefObject<ReturnType<
|
||||
typeof setTimeout
|
||||
> | null>;
|
||||
latestValueRef: React.RefObject<string>;
|
||||
latestCursorRef: React.RefObject<number>;
|
||||
searchableNamesLowerRef: React.RefObject<string[]>;
|
||||
candidates: readonly T[];
|
||||
activePersonaIds: ReadonlySet<string>;
|
||||
channelType?: ChannelType | null;
|
||||
currentPubkey?: string | null;
|
||||
ownerProfiles?: UserProfileLookup;
|
||||
profiles?: UserProfileLookup;
|
||||
}): FlushMentionDebounceResult | null {
|
||||
if (opts.debounceTimerRef.current !== null) {
|
||||
clearTimeout(opts.debounceTimerRef.current);
|
||||
}
|
||||
opts.debounceTimerRef.current = null;
|
||||
|
||||
const mention = detectPrefixQuery(
|
||||
"@",
|
||||
opts.latestValueRef.current,
|
||||
opts.latestCursorRef.current,
|
||||
opts.searchableNamesLowerRef.current,
|
||||
);
|
||||
|
||||
if (!mention || mention.query.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ranked = rankMentionCandidates(
|
||||
opts.candidates,
|
||||
mention.query,
|
||||
opts.activePersonaIds,
|
||||
);
|
||||
|
||||
if (ranked.length === 0) {
|
||||
return { type: "no-match" };
|
||||
}
|
||||
|
||||
const { candidate, label } = ranked[0];
|
||||
return {
|
||||
type: "match",
|
||||
suggestion: mapMentionCandidateToSuggestion({
|
||||
candidate,
|
||||
label,
|
||||
channelType: opts.channelType,
|
||||
currentPubkey: opts.currentPubkey,
|
||||
ownerProfiles: opts.ownerProfiles,
|
||||
profiles: opts.profiles,
|
||||
}),
|
||||
startIndex: mention.startIndex,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { formatOwnerLabel } from "@/features/profile/lib/identity";
|
||||
import type { ChannelRole, ChannelType } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export type MentionSuggestionCandidate = {
|
||||
kind: "identity" | "persona";
|
||||
pubkey?: string;
|
||||
personaId?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
isAgent: boolean;
|
||||
isMember: boolean;
|
||||
role?: ChannelRole | null;
|
||||
ownerPubkey?: string | null;
|
||||
};
|
||||
|
||||
export function mapMentionCandidateToSuggestion(opts: {
|
||||
candidate: MentionSuggestionCandidate;
|
||||
label: string;
|
||||
channelType?: ChannelType | null;
|
||||
currentPubkey?: string | null;
|
||||
ownerProfiles?: UserProfileLookup;
|
||||
profiles?: UserProfileLookup;
|
||||
}): MentionSuggestion {
|
||||
const {
|
||||
candidate,
|
||||
channelType,
|
||||
currentPubkey,
|
||||
label,
|
||||
ownerProfiles,
|
||||
profiles,
|
||||
} = opts;
|
||||
const ownerLabel = candidate.isAgent
|
||||
? formatOwnerLabel(candidate.ownerPubkey, currentPubkey, ownerProfiles)
|
||||
: null;
|
||||
|
||||
return {
|
||||
pubkey: candidate.pubkey,
|
||||
personaId: candidate.personaId ?? undefined,
|
||||
kind: candidate.kind,
|
||||
displayName: label,
|
||||
avatarUrl:
|
||||
candidate.avatarUrl ??
|
||||
(candidate.pubkey
|
||||
? profiles?.[normalizePubkey(candidate.pubkey)]?.avatarUrl
|
||||
: null) ??
|
||||
null,
|
||||
isAgent: candidate.isAgent,
|
||||
notInChannel: channelType !== "dm" && candidate.isMember === false,
|
||||
ownerLabel,
|
||||
role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null,
|
||||
};
|
||||
}
|
||||
@@ -32,12 +32,13 @@ import type {
|
||||
UserSearchResult,
|
||||
} from "@/shared/api/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { formatOwnerLabel } from "@/features/profile/lib/identity";
|
||||
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
|
||||
import { flushMentionDebounce } from "./flushMentionDebounce";
|
||||
import { hasMention } from "./hasMention";
|
||||
import { rankMentionCandidates } from "./mentionRanking";
|
||||
import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping";
|
||||
|
||||
const MENTION_DEBOUNCE_MS = 120;
|
||||
const MENTION_SUGGESTION_LIMIT = 50;
|
||||
@@ -531,6 +532,7 @@ export function useMentions(
|
||||
);
|
||||
const latestValueRef = React.useRef<string>("");
|
||||
const latestCursorRef = React.useRef<number>(0);
|
||||
const flushedMentionStartIndexRef = React.useRef<number | null>(null);
|
||||
const searchableNamesLowerRef = React.useRef<string[]>(searchableNamesLower);
|
||||
|
||||
// Keep the known-names ref in sync so the debounced callback never reads stale data.
|
||||
@@ -558,35 +560,16 @@ export function useMentions(
|
||||
activePersonaIds,
|
||||
)
|
||||
.slice(0, Math.max(MENTION_SUGGESTION_LIMIT, mentionCandidates.length))
|
||||
.map(({ candidate, label }) => {
|
||||
const ownerLabel = candidate.isAgent
|
||||
? formatOwnerLabel(
|
||||
candidate.ownerPubkey,
|
||||
currentPubkey,
|
||||
ownerProfilesQuery.data?.profiles,
|
||||
)
|
||||
: null;
|
||||
const notInChannel =
|
||||
options?.channelType !== "dm" && candidate.isMember === false;
|
||||
|
||||
return {
|
||||
pubkey: candidate.pubkey,
|
||||
personaId: candidate.personaId,
|
||||
kind: candidate.kind,
|
||||
displayName: label,
|
||||
avatarUrl:
|
||||
candidate.avatarUrl ??
|
||||
(candidate.pubkey
|
||||
? profiles?.[normalizePubkey(candidate.pubkey)]?.avatarUrl
|
||||
: null) ??
|
||||
null,
|
||||
isAgent: candidate.isAgent,
|
||||
notInChannel,
|
||||
ownerLabel,
|
||||
role:
|
||||
!candidate.isAgent && candidate.role === "admin" ? "admin" : null,
|
||||
};
|
||||
});
|
||||
.map(({ candidate, label }) =>
|
||||
mapMentionCandidateToSuggestion({
|
||||
candidate,
|
||||
label,
|
||||
channelType: options?.channelType,
|
||||
currentPubkey,
|
||||
ownerProfiles: ownerProfilesQuery.data?.profiles,
|
||||
profiles,
|
||||
}),
|
||||
);
|
||||
}, [
|
||||
activePersonaIds,
|
||||
currentPubkey,
|
||||
@@ -695,8 +678,11 @@ export function useMentions(
|
||||
setMentionQuery(null);
|
||||
setMentionSelectedIndex(0);
|
||||
|
||||
const startIndex =
|
||||
flushedMentionStartIndexRef.current ?? mentionStartIndex;
|
||||
flushedMentionStartIndexRef.current = null;
|
||||
return {
|
||||
replaceFromOffset: mentionStartIndex,
|
||||
replaceFromOffset: startIndex,
|
||||
replaceToOffset: selectionEnd,
|
||||
insertText,
|
||||
};
|
||||
@@ -917,6 +903,34 @@ export function useMentions(
|
||||
!event.shiftKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
// If a debounce is pending, the suggestions array reflects a stale query.
|
||||
// Flush: re-detect synchronously and re-derive the correct suggestion.
|
||||
if (debounceTimerRef.current !== null) {
|
||||
const flushed = flushMentionDebounce({
|
||||
debounceTimerRef,
|
||||
latestValueRef,
|
||||
latestCursorRef,
|
||||
searchableNamesLowerRef,
|
||||
candidates: mentionCandidates,
|
||||
activePersonaIds,
|
||||
channelType: options?.channelType,
|
||||
currentPubkey,
|
||||
ownerProfiles: ownerProfilesQuery.data?.profiles,
|
||||
profiles,
|
||||
});
|
||||
if (flushed?.type === "match") {
|
||||
flushedMentionStartIndexRef.current = flushed.startIndex;
|
||||
setMentionQuery(null); // reset so dropdown closes
|
||||
return { handled: true, suggestion: flushed.suggestion };
|
||||
}
|
||||
if (flushed?.type === "no-match") {
|
||||
setMentionQuery(null);
|
||||
return { handled: true };
|
||||
}
|
||||
// Plain `@` after flush intentionally falls through to existing suggestions.
|
||||
}
|
||||
|
||||
return { handled: true, suggestion: suggestions[mentionSelectedIndex] };
|
||||
}
|
||||
|
||||
@@ -928,7 +942,17 @@ export function useMentions(
|
||||
|
||||
return { handled: false };
|
||||
},
|
||||
[isMentionOpen, mentionSelectedIndex, suggestions],
|
||||
[
|
||||
activePersonaIds,
|
||||
currentPubkey,
|
||||
isMentionOpen,
|
||||
mentionCandidates,
|
||||
mentionSelectedIndex,
|
||||
options?.channelType,
|
||||
ownerProfilesQuery.data?.profiles,
|
||||
profiles,
|
||||
suggestions,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user