mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): enforce agent mention authorization at send boundaries (#5681)
## Summary - allow channel-member remote/headless agents only with current kind `10100` directory evidence, while stale member identities remain hidden - fail closed while managed/relay directories load, error, or background-refetch across channel, forum, and cached autocomplete surfaces - revalidate agent mention authorization immediately before normal sends and message-edit saves, including after deferred uploads - in owner-only builds, fetch fresh authoritative profile ownership at send time and deny missing, changed-owner, or unavailable proofs - preserve human mention tags when agent authorization is revoked or unknown Supersedes #5536 because its contributor-fork head cannot be updated by maintainers. ## Validation Exact head: `7278cdd5fbcee676c7b858ea098503c62eeeff0d` - mandatory pre-push suites passed: desktop check/typecheck/tests, Rust tests, mobile tests, desktop Tauri checks, branch-skew - desktop unit tests: 4,732 passed - focused edit/ownership regressions: 8 passed - focused mention E2E: 5 passed (remote positive, stale-member negative, directory error, pre-send revocation, mid-send revocation) - file-size ratchet passed One first focused E2E batch had a timing-only miss where the send click did not emit; the isolated rerun passed. One separate pre-push attempt hit the existing randomized passphrase separator test; the successful exact-head push reran and passed the mandatory suite. --------- Signed-off-by: JDiz00 <174381550+JDiz00@users.noreply.github.com> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: JDiz00 <174381550+JDiz00@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -3,7 +3,9 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
coalesceAgentAutocompleteCandidates,
|
||||
filterAdmittedMentionPubkeys,
|
||||
filterCachedAgentSuggestions,
|
||||
getAgentMentionAdmission,
|
||||
getMentionableAgentPubkeys,
|
||||
getSharedChannelIds,
|
||||
isAgentIdentityInAllowedList,
|
||||
@@ -258,6 +260,7 @@ test("isAgentIdentityInAllowedList: keeps people and only explicitly allowed age
|
||||
test("shouldHideAgentFromMentions: never hides non-agents", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
ownerOnly: false,
|
||||
isAgent: false,
|
||||
isMember: false,
|
||||
pubkey: PUB_A,
|
||||
@@ -271,6 +274,7 @@ test("shouldHideAgentFromMentions: never hides non-agents", () => {
|
||||
test("shouldHideAgentFromMentions: shows invocable agents even when non-member", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
ownerOnly: false,
|
||||
isAgent: true,
|
||||
isMember: false,
|
||||
pubkey: PUB_A,
|
||||
@@ -284,6 +288,7 @@ test("shouldHideAgentFromMentions: shows invocable agents even when non-member",
|
||||
test("shouldHideAgentFromMentions: hides non-member non-invocable agents", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
ownerOnly: false,
|
||||
isAgent: true,
|
||||
isMember: false,
|
||||
pubkey: PUB_A,
|
||||
@@ -297,6 +302,7 @@ test("shouldHideAgentFromMentions: hides non-member non-invocable agents", () =>
|
||||
test("shouldHideAgentFromMentions: hides member agents with an explicit not-invocable directory entry (Fizz)", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
ownerOnly: false,
|
||||
isAgent: true,
|
||||
isMember: true,
|
||||
pubkey: PUB_A,
|
||||
@@ -307,25 +313,100 @@ test("shouldHideAgentFromMentions: hides member agents with an explicit not-invo
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldHideAgentFromMentions: shows member agents with unknown invocability (not in directory)", () => {
|
||||
test("shouldHideAgentFromMentions: hides member agents without an affirmative directory grant", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
ownerOnly: false,
|
||||
isAgent: true,
|
||||
isMember: true,
|
||||
pubkey: PUB_A,
|
||||
mentionableAgentPubkeys: new Set(),
|
||||
directoryAgentPubkeys: new Set(),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldHideAgentFromMentions: hides unknown member agents while directories load", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
ownerOnly: false,
|
||||
isAgent: true,
|
||||
isMember: true,
|
||||
pubkey: PUB_A,
|
||||
mentionableAgentPubkeys: new Set(),
|
||||
directoryAgentPubkeys: new Set(),
|
||||
directoryReady: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldHideAgentFromMentions: hides mentionable member agents while directories load", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
ownerOnly: false,
|
||||
isAgent: true,
|
||||
isMember: true,
|
||||
pubkey: PUB_A,
|
||||
mentionableAgentPubkeys: new Set([PUB_A]),
|
||||
directoryAgentPubkeys: new Set(),
|
||||
directoryReady: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldHideAgentFromMentions: shows non-agent members while directories load", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
ownerOnly: false,
|
||||
isAgent: false,
|
||||
isMember: true,
|
||||
pubkey: PUB_A,
|
||||
mentionableAgentPubkeys: new Set(),
|
||||
directoryAgentPubkeys: new Set([PUB_A]),
|
||||
directoryReady: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldHideAgentFromMentions: hides unknown member agents after empty directories settle", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
ownerOnly: false,
|
||||
isAgent: true,
|
||||
isMember: true,
|
||||
pubkey: PUB_A,
|
||||
mentionableAgentPubkeys: new Set(),
|
||||
directoryAgentPubkeys: new Set(),
|
||||
directoryReady: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldHideAgentFromMentions: hides agents while owner policy loads", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
isAgent: true,
|
||||
pubkey: PUB_A,
|
||||
mentionableAgentPubkeys: new Set([PUB_A]),
|
||||
directoryReady: true,
|
||||
ownerOnly: undefined,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldHideAgentFromMentions: normalizes the pubkey before lookup", () => {
|
||||
const mixedCase = "Ab".repeat(32);
|
||||
const normalized = mixedCase.toLowerCase();
|
||||
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
ownerOnly: false,
|
||||
isAgent: true,
|
||||
isMember: true,
|
||||
pubkey: mixedCase,
|
||||
@@ -336,6 +417,58 @@ test("shouldHideAgentFromMentions: normalizes the pubkey before lookup", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("getAgentMentionAdmission: owner-only requires current verified ownership", () => {
|
||||
const common = {
|
||||
isAgent: true,
|
||||
isManagedAgent: false,
|
||||
pubkey: PUB_A,
|
||||
currentPubkey: CURRENT_PUBKEY,
|
||||
mentionableAgentPubkeys: new Set([PUB_A]),
|
||||
directoryReady: true,
|
||||
ownerOnly: true,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
getAgentMentionAdmission({ ...common, ownerPubkey: CURRENT_PUBKEY }),
|
||||
"allow",
|
||||
);
|
||||
assert.equal(
|
||||
getAgentMentionAdmission({ ...common, ownerPubkey: OTHER_OWNER_PUBKEY }),
|
||||
"deny",
|
||||
);
|
||||
assert.equal(
|
||||
getAgentMentionAdmission({ ...common, ownerPubkey: null }),
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
|
||||
test("getAgentMentionAdmission: unresolved directory state stays unknown", () => {
|
||||
assert.equal(
|
||||
getAgentMentionAdmission({
|
||||
isAgent: true,
|
||||
isManagedAgent: false,
|
||||
pubkey: PUB_A,
|
||||
currentPubkey: CURRENT_PUBKEY,
|
||||
ownerPubkey: CURRENT_PUBKEY,
|
||||
mentionableAgentPubkeys: new Set([PUB_A]),
|
||||
directoryReady: false,
|
||||
ownerOnly: false,
|
||||
}),
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
|
||||
test("filterAdmittedMentionPubkeys: rechecks agent admission without dropping people", () => {
|
||||
assert.deepEqual(
|
||||
filterAdmittedMentionPubkeys(
|
||||
[PUB_A, PUB_B, PUB_C],
|
||||
new Set([PUB_A, PUB_B]),
|
||||
new Set([PUB_B]),
|
||||
),
|
||||
[PUB_B, PUB_C],
|
||||
);
|
||||
});
|
||||
|
||||
test("coalesceAgentAutocompleteCandidates: keeps agents with the same persona id distinct", () => {
|
||||
const first = makeAgent({ pubkey: PUB_A, personaId: "pinky" });
|
||||
const second = makeAgent({
|
||||
|
||||
@@ -92,37 +92,138 @@ export function isAgentIdentityInAllowedList(
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldHideAgentFromMentions({
|
||||
export type AgentMentionAdmission = "allow" | "deny" | "unknown";
|
||||
|
||||
export function getAgentMentionAdmission({
|
||||
isAgent,
|
||||
isMember,
|
||||
isManagedAgent,
|
||||
pubkey,
|
||||
ownerPubkey,
|
||||
currentPubkey,
|
||||
mentionableAgentPubkeys,
|
||||
directoryAgentPubkeys,
|
||||
directoryReady,
|
||||
ownerOnly,
|
||||
}: {
|
||||
isAgent: boolean;
|
||||
isMember: boolean;
|
||||
isManagedAgent: boolean;
|
||||
pubkey: string;
|
||||
ownerPubkey?: string | null;
|
||||
currentPubkey?: string | null;
|
||||
mentionableAgentPubkeys: ReadonlySet<string>;
|
||||
directoryAgentPubkeys: ReadonlySet<string>;
|
||||
}) {
|
||||
if (!isAgent) return false;
|
||||
directoryReady: boolean;
|
||||
ownerOnly: boolean | undefined;
|
||||
}): AgentMentionAdmission {
|
||||
if (!isAgent) return "allow";
|
||||
if (!directoryReady || ownerOnly === undefined) return "unknown";
|
||||
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
// Invocable => always show.
|
||||
if (mentionableAgentPubkeys.has(normalized)) return false;
|
||||
// Non-member, non-invocable => hide (preserves prior behavior).
|
||||
if (!isMember) return true;
|
||||
// Member (Option B): hide only when we have an explicit not-invocable
|
||||
// signal — a relay directory (kind:10100) entry that excludes us.
|
||||
// Unknown invocability (not in directory) => show.
|
||||
//
|
||||
// NOTE: this assumes `directoryAgentPubkeys` and `mentionableAgentPubkeys`
|
||||
// share the same source query (`relayAgentsQuery.data`), so directory
|
||||
// presence without membership in `mentionableAgentPubkeys` is a real
|
||||
// explicit-exclusion signal. If a future change sources the directory set
|
||||
// from a different query, an agent that's directory-present but whose
|
||||
// mentionability is still loading could be hidden prematurely — keep the
|
||||
// two sets derived from the same query.
|
||||
return directoryAgentPubkeys.has(normalized);
|
||||
if (!mentionableAgentPubkeys.has(normalized)) return "deny";
|
||||
if (!ownerOnly || isManagedAgent) return "allow";
|
||||
if (!ownerPubkey || !currentPubkey) return "unknown";
|
||||
|
||||
return normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey)
|
||||
? "allow"
|
||||
: "deny";
|
||||
}
|
||||
|
||||
export function shouldHideAgentFromMentions({
|
||||
isAgent,
|
||||
isManagedAgent = false,
|
||||
pubkey,
|
||||
ownerPubkey,
|
||||
currentPubkey,
|
||||
mentionableAgentPubkeys,
|
||||
directoryReady = true,
|
||||
ownerOnly,
|
||||
}: {
|
||||
isAgent: boolean;
|
||||
isManagedAgent?: boolean;
|
||||
pubkey: string;
|
||||
ownerPubkey?: string | null;
|
||||
currentPubkey?: string | null;
|
||||
mentionableAgentPubkeys: ReadonlySet<string>;
|
||||
directoryReady?: boolean;
|
||||
ownerOnly: boolean | undefined;
|
||||
}) {
|
||||
return (
|
||||
getAgentMentionAdmission({
|
||||
isAgent,
|
||||
isManagedAgent,
|
||||
pubkey,
|
||||
ownerPubkey,
|
||||
currentPubkey,
|
||||
mentionableAgentPubkeys,
|
||||
directoryReady,
|
||||
ownerOnly,
|
||||
}) !== "allow"
|
||||
);
|
||||
}
|
||||
|
||||
export function getAgentIdentityPubkeys({
|
||||
managedAgentPubkeys,
|
||||
relayAgents,
|
||||
members,
|
||||
profileIsAgent,
|
||||
}: {
|
||||
managedAgentPubkeys: ReadonlySet<string>;
|
||||
relayAgents: readonly { pubkey: string }[];
|
||||
members: readonly {
|
||||
pubkey: string;
|
||||
isAgent?: boolean;
|
||||
role?: string | null;
|
||||
}[];
|
||||
profileIsAgent: (pubkey: string) => boolean;
|
||||
}) {
|
||||
return new Set([
|
||||
...managedAgentPubkeys,
|
||||
...relayAgents.map(({ pubkey }) => normalizePubkey(pubkey)),
|
||||
...members
|
||||
.filter(
|
||||
(member) =>
|
||||
member.isAgent === true ||
|
||||
member.role === "bot" ||
|
||||
profileIsAgent(normalizePubkey(member.pubkey)),
|
||||
)
|
||||
.map(({ pubkey }) => normalizePubkey(pubkey)),
|
||||
]);
|
||||
}
|
||||
|
||||
export function getAdmittedAgentPubkeys(
|
||||
candidates: readonly { pubkey?: string; isAgent?: boolean }[],
|
||||
) {
|
||||
return new Set(
|
||||
candidates.flatMap((candidate) =>
|
||||
candidate.isAgent && candidate.pubkey
|
||||
? [normalizePubkey(candidate.pubkey)]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function rememberSelectedAgentPubkeys(
|
||||
target: Set<string>,
|
||||
selected: readonly { pubkey?: string; isAgent?: boolean }[],
|
||||
selectionIsAgent: boolean,
|
||||
) {
|
||||
for (const candidate of selected) {
|
||||
if (candidate.pubkey && (selectionIsAgent || candidate.isAgent === true)) {
|
||||
target.add(normalizePubkey(candidate.pubkey));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function filterAdmittedMentionPubkeys(
|
||||
pubkeys: readonly string[],
|
||||
agentIdentityPubkeys: ReadonlySet<string>,
|
||||
admittedAgentPubkeys: ReadonlySet<string>,
|
||||
) {
|
||||
return pubkeys.filter((pubkey) => {
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
return (
|
||||
!agentIdentityPubkeys.has(normalized) ||
|
||||
admittedAgentPubkeys.has(normalized)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isAgentMentionChannelType(type?: string | null) {
|
||||
|
||||
@@ -58,6 +58,7 @@ export function ForumComposer({
|
||||
const [isCompactExpanded, setIsCompactExpanded] = React.useState(!compact);
|
||||
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
|
||||
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
|
||||
const [isSubmissionPending, setIsSubmissionPending] = React.useState(false);
|
||||
const [submitMode, setSubmitMode] = React.useState<"primary" | "secondary">(
|
||||
"primary",
|
||||
);
|
||||
@@ -83,6 +84,7 @@ export function ForumComposer({
|
||||
const disabledRef = React.useRef(disabled);
|
||||
const isSendingRef = React.useRef(isSending);
|
||||
const isUploadingRef = React.useRef(media.isUploading);
|
||||
const isSubmissionPendingRef = React.useRef(false);
|
||||
const onSubmitRef = React.useRef(onSubmit);
|
||||
const onSecondarySubmitRef = React.useRef(onSecondarySubmit);
|
||||
const submitModeRef = React.useRef(submitMode);
|
||||
@@ -111,7 +113,7 @@ export function ForumComposer({
|
||||
|
||||
const richText = useRichTextEditor({
|
||||
placeholder,
|
||||
editable: !disabled,
|
||||
editable: !disabled && !isSubmissionPending,
|
||||
mentionNames: mentions.knownNames,
|
||||
channelNames: channelLinks.knownChannelNames,
|
||||
messageLinkChannels: channelLinks.channels,
|
||||
@@ -139,6 +141,7 @@ export function ForumComposer({
|
||||
// Native ProseMirror transactions — no markdown round-trip.
|
||||
const applyMentionInsert = React.useCallback(
|
||||
(suggestion: MentionSuggestion) => {
|
||||
if (isSubmissionPendingRef.current) return;
|
||||
const { cursor } = richText.getPlainTextAndCursor();
|
||||
const { replaceFromOffset, replaceToOffset, insertText } =
|
||||
mentions.insertMention(suggestion, cursor);
|
||||
@@ -157,6 +160,7 @@ export function ForumComposer({
|
||||
|
||||
const applyChannelInsert = React.useCallback(
|
||||
(suggestion: ChannelSuggestion) => {
|
||||
if (isSubmissionPendingRef.current) return;
|
||||
const { cursor } = richText.getPlainTextAndCursor();
|
||||
const { replaceFromOffset, replaceToOffset, insertText } =
|
||||
channelLinks.insertChannel(suggestion, cursor);
|
||||
@@ -175,7 +179,7 @@ export function ForumComposer({
|
||||
|
||||
const insertEmoji = React.useCallback(
|
||||
(emoji: string) => {
|
||||
if (!richText.editor) return;
|
||||
if (isSubmissionPendingRef.current || !richText.editor) return;
|
||||
richText.editor.chain().focus().insertContent(emoji).run();
|
||||
setIsEmojiPickerOpen(false);
|
||||
mentions.clearMentions();
|
||||
@@ -213,7 +217,7 @@ export function ForumComposer({
|
||||
|
||||
// ── Submit ──────────────────────────────────────────────────────────
|
||||
const submitMessage = React.useCallback(
|
||||
(submitter = onSubmitRef.current) => {
|
||||
async (submitter = onSubmitRef.current) => {
|
||||
const trimmed = contentRef.current.trim();
|
||||
const currentPendingImeta = media.pendingImetaRef.current;
|
||||
const hasMedia = currentPendingImeta.length > 0;
|
||||
@@ -222,58 +226,68 @@ export function ForumComposer({
|
||||
(!trimmed && !hasMedia) ||
|
||||
disabledRef.current ||
|
||||
isSendingRef.current ||
|
||||
isUploadingRef.current
|
||||
isUploadingRef.current ||
|
||||
isSubmissionPendingRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pubkeys = mentions.extractMentionPubkeys(trimmed);
|
||||
|
||||
// Reuse the shared send-path builder so forum/notes posts emit the same
|
||||
// body + imeta as chat: generic files become `[filename](url)` links with a
|
||||
// `filename` imeta tag (FileCard renderer), images/video stay inline. Send
|
||||
// semantics use `undefined` for "no attachments" (no imeta tags emitted).
|
||||
const { content: finalContent, mediaTags } = buildOutgoingMessage(
|
||||
trimmed,
|
||||
currentPendingImeta,
|
||||
);
|
||||
|
||||
// Save draft state so we can restore on failure.
|
||||
const savedContent = contentRef.current;
|
||||
const savedImeta = [...currentPendingImeta];
|
||||
|
||||
setContent("");
|
||||
contentRef.current = "";
|
||||
richText.clearContent();
|
||||
media.setPendingImeta([]);
|
||||
mentions.clearMentions();
|
||||
isSubmissionPendingRef.current = true;
|
||||
setIsSubmissionPending(true);
|
||||
mentions.cancelMentionAutocomplete();
|
||||
channelLinks.clearChannels();
|
||||
setIsEmojiPickerOpen(false);
|
||||
try {
|
||||
const pubkeys = await mentions.revalidateMentionPubkeys(
|
||||
mentions.extractMentionPubkeys(trimmed),
|
||||
);
|
||||
|
||||
const result = submitter(finalContent, pubkeys, mediaTags);
|
||||
const completeSubmission = () => {
|
||||
setSubmitMode("primary");
|
||||
if (compact) setIsCompactExpanded(false);
|
||||
};
|
||||
// Reuse the shared send-path builder so forum/notes posts emit the same
|
||||
// body + imeta as chat: generic files become `[filename](url)` links with a
|
||||
// `filename` imeta tag (FileCard renderer), images/video stay inline. Send
|
||||
// semantics use `undefined` for "no attachments" (no imeta tags emitted).
|
||||
const { content: finalContent, mediaTags } = buildOutgoingMessage(
|
||||
trimmed,
|
||||
currentPendingImeta,
|
||||
);
|
||||
|
||||
// If onSubmit returns a promise, restore draft on failure.
|
||||
if (result && typeof result.then === "function") {
|
||||
result.then(completeSubmission).catch(() => {
|
||||
// Save draft state so we can restore on failure.
|
||||
const savedContent = contentRef.current;
|
||||
const savedImeta = [...currentPendingImeta];
|
||||
|
||||
setContent("");
|
||||
contentRef.current = "";
|
||||
richText.clearContent();
|
||||
media.setPendingImeta([]);
|
||||
mentions.clearMentions();
|
||||
channelLinks.clearChannels();
|
||||
setIsEmojiPickerOpen(false);
|
||||
|
||||
try {
|
||||
await submitter(finalContent, pubkeys, mediaTags);
|
||||
setSubmitMode("primary");
|
||||
if (compact) setIsCompactExpanded(false);
|
||||
} catch {
|
||||
setContent(savedContent);
|
||||
contentRef.current = savedContent;
|
||||
richText.setContent(savedContent);
|
||||
media.setPendingImeta(savedImeta);
|
||||
if (compact) setIsCompactExpanded(true);
|
||||
});
|
||||
} else {
|
||||
completeSubmission();
|
||||
}
|
||||
} catch {
|
||||
// Keep the draft intact when authorization refresh fails.
|
||||
} finally {
|
||||
isSubmissionPendingRef.current = false;
|
||||
setIsSubmissionPending(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
compact,
|
||||
media.pendingImetaRef,
|
||||
media.setPendingImeta,
|
||||
mentions.cancelMentionAutocomplete,
|
||||
mentions.extractMentionPubkeys,
|
||||
mentions.revalidateMentionPubkeys,
|
||||
mentions.clearMentions,
|
||||
channelLinks.clearChannels,
|
||||
richText.clearContent,
|
||||
@@ -375,9 +389,16 @@ export function ForumComposer({
|
||||
const sendDisabled = React.useMemo(
|
||||
() =>
|
||||
disabled ||
|
||||
isSubmissionPending ||
|
||||
media.isUploading ||
|
||||
(content.trim().length === 0 && media.pendingImeta.length === 0),
|
||||
[disabled, media.isUploading, content, media.pendingImeta.length],
|
||||
[
|
||||
disabled,
|
||||
isSubmissionPending,
|
||||
media.isUploading,
|
||||
content,
|
||||
media.pendingImeta.length,
|
||||
],
|
||||
);
|
||||
const hasComposerContent =
|
||||
content.trim().length > 0 ||
|
||||
@@ -448,15 +469,30 @@ export function ForumComposer({
|
||||
"relative rounded-2xl border border-input bg-card px-3 py-2 sm:px-4",
|
||||
className,
|
||||
)}
|
||||
inert={isSubmissionPending ? true : undefined}
|
||||
onBlurCapture={handleFormBlur}
|
||||
onDragEnter={(event) => {
|
||||
if (isSubmissionPending) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
expandCompactComposer();
|
||||
media.handleDragEnter(event);
|
||||
}}
|
||||
onDragLeave={media.handleDragLeave}
|
||||
onDragOver={media.handleDragOver}
|
||||
onDrop={(e) => {
|
||||
void media.handleDrop(e);
|
||||
onDragOver={(event) => {
|
||||
if (isSubmissionPending) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
media.handleDragOver(event);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (isSubmissionPending) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
void media.handleDrop(event);
|
||||
}}
|
||||
onFocusCapture={expandCompactComposer}
|
||||
onSubmit={handleSubmit}
|
||||
@@ -466,7 +502,7 @@ export function ForumComposer({
|
||||
<ForumComposerCompactLayout
|
||||
editor={richText.editor}
|
||||
header={header}
|
||||
isSending={isSending}
|
||||
isSending={Boolean(isSending || isSubmissionPending)}
|
||||
onEditorKeyDown={handleEditorKeyDown}
|
||||
sendDisabled={sendDisabled}
|
||||
/>
|
||||
@@ -496,7 +532,15 @@ export function ForumComposer({
|
||||
position={autocompletePosition}
|
||||
/>
|
||||
|
||||
<ForumComposerMediaStatus media={media} />
|
||||
<fieldset
|
||||
className="min-w-0 border-0 p-0"
|
||||
disabled={isSubmissionPending}
|
||||
>
|
||||
<ForumComposerMediaStatus
|
||||
disabled={isSubmissionPending}
|
||||
media={media}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
|
||||
<div
|
||||
@@ -507,14 +551,14 @@ export function ForumComposer({
|
||||
</div>
|
||||
|
||||
<MessageComposerToolbar
|
||||
composerDisabled={disabled ?? false}
|
||||
composerDisabled={Boolean(disabled || isSubmissionPending)}
|
||||
editor={richText.editor}
|
||||
extraActions={
|
||||
onCancel || (onSecondarySubmit && secondarySubmitLabel) ? (
|
||||
<>
|
||||
{onCancel ? (
|
||||
<Button
|
||||
disabled={isSending}
|
||||
disabled={isSending || isSubmissionPending}
|
||||
onClick={onCancel}
|
||||
size="sm"
|
||||
type="button"
|
||||
@@ -531,7 +575,9 @@ export function ForumComposer({
|
||||
submitMode === "secondary" &&
|
||||
"border-amber-500/40 text-amber-700 hover:bg-amber-500/10 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300",
|
||||
)}
|
||||
disabled={disabled || isSending}
|
||||
disabled={
|
||||
disabled || isSending || isSubmissionPending
|
||||
}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -562,10 +608,10 @@ export function ForumComposer({
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
formattingDisabled={disabled ?? false}
|
||||
formattingDisabled={Boolean(disabled || isSubmissionPending)}
|
||||
isEmojiPickerOpen={isEmojiPickerOpen}
|
||||
isFormattingOpen={isFormattingOpen}
|
||||
isSending={isSending ?? false}
|
||||
isSending={Boolean(isSending || isSubmissionPending)}
|
||||
isUploading={media.isUploading}
|
||||
onCaptureSelection={handleToolbarMouseDown}
|
||||
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
|
||||
@@ -579,8 +625,8 @@ export function ForumComposer({
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
{linkEditor.card}
|
||||
{linkEditor.dialog}
|
||||
{!isSubmissionPending && linkEditor.card}
|
||||
{!isSubmissionPending && linkEditor.dialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,17 +19,27 @@ type ComposerMedia = Pick<
|
||||
>;
|
||||
|
||||
type ForumComposerMediaStatusProps = {
|
||||
disabled?: boolean;
|
||||
media: ComposerMedia;
|
||||
};
|
||||
|
||||
export function ForumComposerMediaStatus({
|
||||
disabled = false,
|
||||
media,
|
||||
}: ForumComposerMediaStatusProps) {
|
||||
const handleEditSave = React.useCallback(
|
||||
async (url: string, bytes: Uint8Array) => {
|
||||
if (disabled) return;
|
||||
await media.uploadEditedAttachment(url, bytes);
|
||||
},
|
||||
[media.uploadEditedAttachment],
|
||||
[disabled, media.uploadEditedAttachment],
|
||||
);
|
||||
const guard = React.useCallback(
|
||||
<T,>(callback: (value: T) => void) =>
|
||||
(value: T) => {
|
||||
if (!disabled) callback(value);
|
||||
},
|
||||
[disabled],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -39,6 +49,7 @@ export function ForumComposerMediaStatus({
|
||||
Upload failed: {media.uploadState.message}
|
||||
<button
|
||||
className="ml-2 underline"
|
||||
disabled={disabled}
|
||||
onClick={() => media.setUploadState({ status: "idle" })}
|
||||
type="button"
|
||||
>
|
||||
@@ -52,10 +63,10 @@ export function ForumComposerMediaStatus({
|
||||
<ComposerAttachments
|
||||
attachments={media.pendingImeta}
|
||||
isUploading={media.isUploading}
|
||||
onCancelUpload={media.cancelUpload}
|
||||
onCancelUpload={guard(media.cancelUpload)}
|
||||
onEditSave={handleEditSave}
|
||||
onRemove={media.removeAttachment}
|
||||
onRevert={media.revertAttachment}
|
||||
onRemove={guard(media.removeAttachment)}
|
||||
onRevert={guard(media.revertAttachment)}
|
||||
originalUrlByUrl={media.originalUrlByUrl}
|
||||
uploadingCount={media.uploadingCount}
|
||||
uploadingPreviews={media.uploadingPreviews}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { revalidateAgentMentionPubkeys } from "./agentMentionRevalidation.ts";
|
||||
|
||||
const CURRENT = "a".repeat(64);
|
||||
const AGENT = "b".repeat(64);
|
||||
const HUMAN = "c".repeat(64);
|
||||
const OTHER_OWNER = "d".repeat(64);
|
||||
|
||||
function options(refetchOwnerProfiles) {
|
||||
return {
|
||||
pubkeys: [HUMAN, AGENT],
|
||||
agentPubkeys: new Set([AGENT]),
|
||||
currentPubkey: CURRENT,
|
||||
eligibilityScope: { type: "channel", channelId: "general" },
|
||||
sharedChannelIds: new Set(["general"]),
|
||||
ownerOnly: true,
|
||||
ownerPolicyError: null,
|
||||
refetchManagedAgents: async () => ({ data: [], error: null }),
|
||||
refetchRelayAgents: async () => ({
|
||||
data: [
|
||||
{
|
||||
pubkey: AGENT,
|
||||
respondTo: "anyone",
|
||||
respondToAllowlist: [],
|
||||
channelIds: ["general"],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
refetchOwnerProfiles,
|
||||
};
|
||||
}
|
||||
|
||||
test("owner-only revalidation admits an agent only from a fresh same-owner proof", async () => {
|
||||
const requested = [];
|
||||
const result = await revalidateAgentMentionPubkeys(
|
||||
options(async (pubkeys) => {
|
||||
requested.push(...pubkeys);
|
||||
return {
|
||||
profiles: { [AGENT]: { ownerPubkey: CURRENT } },
|
||||
missing: [],
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(requested, [AGENT]);
|
||||
assert.deepEqual(result, [HUMAN, AGENT]);
|
||||
});
|
||||
|
||||
for (const [name, refetchOwnerProfiles] of [
|
||||
["revoked owner proof", async () => ({ profiles: {}, missing: [AGENT] })],
|
||||
[
|
||||
"changed owner proof",
|
||||
async () => ({
|
||||
profiles: { [AGENT]: { ownerPubkey: OTHER_OWNER } },
|
||||
missing: [],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"owner profile query error",
|
||||
async () => {
|
||||
throw new Error("relay unavailable");
|
||||
},
|
||||
],
|
||||
]) {
|
||||
test(`owner-only revalidation fails closed on ${name}`, async () => {
|
||||
assert.deepEqual(
|
||||
await revalidateAgentMentionPubkeys(options(refetchOwnerProfiles)),
|
||||
[HUMAN],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
filterAdmittedMentionPubkeys,
|
||||
getAgentMentionAdmission,
|
||||
getMentionableAgentPubkeys,
|
||||
type AgentEligibilityScope,
|
||||
} from "@/features/agents/lib/agentAutocompleteEligibility";
|
||||
import { evictUsersBatchEntries } from "@/features/profile/hooks";
|
||||
import { getUsersBatch } from "@/shared/api/tauriProfiles";
|
||||
import type {
|
||||
ManagedAgent,
|
||||
RelayAgent,
|
||||
UsersBatchResponse,
|
||||
} from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
type DirectoryResult<T> = {
|
||||
data: T | undefined;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
export async function revalidateAgentMentionPubkeys({
|
||||
pubkeys,
|
||||
agentPubkeys,
|
||||
currentPubkey,
|
||||
eligibilityScope,
|
||||
sharedChannelIds,
|
||||
ownerOnly,
|
||||
ownerPolicyError,
|
||||
refetchManagedAgents,
|
||||
refetchRelayAgents,
|
||||
refetchOwnerProfiles,
|
||||
}: {
|
||||
pubkeys: readonly string[];
|
||||
agentPubkeys: ReadonlySet<string>;
|
||||
currentPubkey: string | null;
|
||||
eligibilityScope: AgentEligibilityScope;
|
||||
sharedChannelIds: ReadonlySet<string>;
|
||||
ownerOnly: boolean | undefined;
|
||||
ownerPolicyError: Error | null;
|
||||
refetchManagedAgents: () => Promise<DirectoryResult<ManagedAgent[]>>;
|
||||
refetchRelayAgents: () => Promise<DirectoryResult<RelayAgent[]>>;
|
||||
refetchOwnerProfiles: (pubkeys: string[]) => Promise<UsersBatchResponse>;
|
||||
}) {
|
||||
const requestedAgentPubkeys = new Set(
|
||||
pubkeys.map(normalizePubkey).filter((pubkey) => agentPubkeys.has(pubkey)),
|
||||
);
|
||||
if (requestedAgentPubkeys.size === 0) {
|
||||
return [...pubkeys];
|
||||
}
|
||||
|
||||
const [managedResult, relayResult, ownerProfiles] = await Promise.all([
|
||||
refetchManagedAgents(),
|
||||
refetchRelayAgents(),
|
||||
ownerOnly
|
||||
? refetchOwnerProfiles([...requestedAgentPubkeys]).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
if (
|
||||
managedResult.error !== null ||
|
||||
relayResult.error !== null ||
|
||||
managedResult.data === undefined ||
|
||||
relayResult.data === undefined ||
|
||||
ownerOnly === undefined ||
|
||||
ownerPolicyError !== null ||
|
||||
(ownerOnly && ownerProfiles === null)
|
||||
) {
|
||||
return filterAdmittedMentionPubkeys(pubkeys, agentPubkeys, new Set());
|
||||
}
|
||||
|
||||
const managedPubkeys = new Set(
|
||||
managedResult.data.map((agent) => normalizePubkey(agent.pubkey)),
|
||||
);
|
||||
const mentionablePubkeys = getMentionableAgentPubkeys({
|
||||
currentPubkey,
|
||||
eligibilityScope,
|
||||
managedAgentPubkeys: managedPubkeys,
|
||||
relayAgents: relayResult.data,
|
||||
sharedChannelIds,
|
||||
});
|
||||
const admittedPubkeys = new Set(
|
||||
[...agentPubkeys].filter(
|
||||
(pubkey) =>
|
||||
getAgentMentionAdmission({
|
||||
isAgent: true,
|
||||
isManagedAgent: managedPubkeys.has(pubkey),
|
||||
pubkey,
|
||||
ownerPubkey: ownerProfiles?.profiles[pubkey]?.ownerPubkey,
|
||||
currentPubkey,
|
||||
mentionableAgentPubkeys: mentionablePubkeys,
|
||||
directoryReady: true,
|
||||
ownerOnly,
|
||||
}) === "allow",
|
||||
),
|
||||
);
|
||||
return filterAdmittedMentionPubkeys(pubkeys, agentPubkeys, admittedPubkeys);
|
||||
}
|
||||
|
||||
export function useAgentMentionRevalidation({
|
||||
agentPubkeys,
|
||||
getSelectedAgentPubkeys,
|
||||
currentPubkey,
|
||||
eligibilityScope,
|
||||
sharedChannelIds,
|
||||
ownerOnly,
|
||||
ownerPolicyError,
|
||||
refetchManagedAgents,
|
||||
refetchRelayAgents,
|
||||
}: {
|
||||
agentPubkeys: ReadonlySet<string>;
|
||||
getSelectedAgentPubkeys: () => ReadonlySet<string>;
|
||||
currentPubkey: string | null;
|
||||
eligibilityScope: AgentEligibilityScope;
|
||||
sharedChannelIds: ReadonlySet<string>;
|
||||
ownerOnly: boolean | undefined;
|
||||
ownerPolicyError: Error | null;
|
||||
refetchManagedAgents: () => Promise<DirectoryResult<ManagedAgent[]>>;
|
||||
refetchRelayAgents: () => Promise<DirectoryResult<RelayAgent[]>>;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const refetchOwnerProfiles = React.useCallback(
|
||||
async (pubkeys: string[]) => {
|
||||
evictUsersBatchEntries(queryClient, pubkeys);
|
||||
return getUsersBatch(pubkeys);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
return React.useCallback(
|
||||
(pubkeys: readonly string[]) =>
|
||||
revalidateAgentMentionPubkeys({
|
||||
pubkeys,
|
||||
agentPubkeys: new Set([...agentPubkeys, ...getSelectedAgentPubkeys()]),
|
||||
currentPubkey,
|
||||
eligibilityScope,
|
||||
sharedChannelIds,
|
||||
ownerOnly,
|
||||
ownerPolicyError,
|
||||
refetchManagedAgents,
|
||||
refetchRelayAgents,
|
||||
refetchOwnerProfiles,
|
||||
}),
|
||||
[
|
||||
agentPubkeys,
|
||||
currentPubkey,
|
||||
eligibilityScope,
|
||||
getSelectedAgentPubkeys,
|
||||
ownerOnly,
|
||||
ownerPolicyError,
|
||||
refetchManagedAgents,
|
||||
refetchOwnerProfiles,
|
||||
refetchRelayAgents,
|
||||
sharedChannelIds,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,30 @@
|
||||
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
|
||||
import type { AgentPersona, AgentTeam, ChannelRole } from "@/shared/api/types";
|
||||
import type {
|
||||
AgentPersona,
|
||||
AgentTeam,
|
||||
ChannelRole,
|
||||
UserSearchResult,
|
||||
} from "@/shared/api/types";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export function formatSearchUserDisplayName(user: UserSearchResult) {
|
||||
return user.displayName?.trim() || user.nip05Handle?.trim() || null;
|
||||
}
|
||||
|
||||
export function formatSearchUserSecondaryLabel(user: UserSearchResult) {
|
||||
const displayName = user.displayName?.trim();
|
||||
const nip05Handle = user.nip05Handle?.trim();
|
||||
return displayName && nip05Handle ? nip05Handle : null;
|
||||
}
|
||||
|
||||
export function appendUniqueName(current: string[], name: string): string[] {
|
||||
return current.some(
|
||||
(candidate) => candidate.toLowerCase() === name.toLowerCase(),
|
||||
)
|
||||
? current
|
||||
: [...current, name];
|
||||
}
|
||||
|
||||
export type TeamMentionMember = {
|
||||
displayName: string;
|
||||
kind: "identity" | "persona";
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useRelayAgentsQuery,
|
||||
useTeamsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/useAgentAccessOwnerOnly";
|
||||
import {
|
||||
useChannelMembersQuery,
|
||||
useChannelsQuery,
|
||||
@@ -14,11 +15,14 @@ import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomple
|
||||
import {
|
||||
coalesceAgentAutocompleteCandidates,
|
||||
coalesceAutocompleteCandidatesByKey,
|
||||
filterAdmittedMentionPubkeys,
|
||||
filterCachedAgentSuggestions,
|
||||
getAdmittedAgentPubkeys,
|
||||
getAgentIdentityPubkeys,
|
||||
getMentionableAgentPubkeys,
|
||||
getSharedChannelIds,
|
||||
isAgentIdentityInAllowedList,
|
||||
isAgentMentionChannelType,
|
||||
rememberSelectedAgentPubkeys,
|
||||
shouldHideAgentFromMentions,
|
||||
uniqueAutocompleteLabels,
|
||||
} from "@/features/agents/lib/agentAutocompleteEligibility";
|
||||
@@ -32,20 +36,23 @@ import type {
|
||||
AgentPersona,
|
||||
ChannelMember,
|
||||
ChannelType,
|
||||
UserSearchResult,
|
||||
} from "@/shared/api/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
|
||||
import { flushMentionDebounce } from "./flushMentionDebounce";
|
||||
import { useAgentMentionRevalidation } from "./agentMentionRevalidation";
|
||||
import { hasMention } from "./hasMention";
|
||||
import { extractMentionPubkeys } from "./extractMentionPubkeys";
|
||||
import { useDraftMentionRouting } from "./useDraftMentionRouting";
|
||||
import { rankMentionCandidates } from "./mentionRanking";
|
||||
import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping";
|
||||
import {
|
||||
appendUniqueName,
|
||||
buildTeamMentionCandidates,
|
||||
formatSearchUserDisplayName,
|
||||
formatSearchUserSecondaryLabel,
|
||||
formatTeamMention,
|
||||
globalSearchIdentityKey,
|
||||
type MentionCandidate,
|
||||
@@ -60,25 +67,6 @@ export type PersonaMentionTarget = {
|
||||
type UseMentionsOptions = {
|
||||
channelType?: ChannelType | null;
|
||||
};
|
||||
function formatSearchUserDisplayName(user: UserSearchResult) {
|
||||
return user.displayName?.trim() || user.nip05Handle?.trim() || null;
|
||||
}
|
||||
function formatSearchUserSecondaryLabel(user: UserSearchResult) {
|
||||
const displayName = user.displayName?.trim();
|
||||
const nip05Handle = user.nip05Handle?.trim();
|
||||
if (displayName && nip05Handle) {
|
||||
return nip05Handle;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function appendUniqueName(current: string[], name: string): string[] {
|
||||
return current.some(
|
||||
(candidate) => candidate.toLowerCase() === name.toLowerCase(),
|
||||
)
|
||||
? current
|
||||
: [...current, name];
|
||||
}
|
||||
|
||||
export function useMentions(
|
||||
channelId: string | null,
|
||||
externalMembers?: ChannelMember[],
|
||||
@@ -94,6 +82,7 @@ export function useMentions(
|
||||
const [selectedAgentMentionNames, setSelectedAgentMentionNames] =
|
||||
React.useState<string[]>([]);
|
||||
const selectedAgentMentionNamesRef = React.useRef<string[]>([]);
|
||||
const selectedAgentMentionPubkeysRef = React.useRef<Set<string>>(new Set());
|
||||
selectedAgentMentionNamesRef.current = selectedAgentMentionNames;
|
||||
const mentionMapRef = React.useRef<Map<string, string>>(new Map());
|
||||
const personaMentionMapRef = React.useRef<Map<string, string>>(new Map());
|
||||
@@ -112,18 +101,22 @@ export function useMentions(
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const personasQuery = usePersonasQuery();
|
||||
const teamsQuery = useTeamsQuery();
|
||||
const agentAccessOwnerOnlyQuery = useAgentAccessOwnerOnlyQuery();
|
||||
const managedAgentDirectoryReady =
|
||||
managedAgentsQuery.data !== undefined ||
|
||||
!managedAgentsQuery.isLoading ||
|
||||
managedAgentsQuery.error !== null;
|
||||
managedAgentsQuery.data !== undefined &&
|
||||
managedAgentsQuery.error === null &&
|
||||
!managedAgentsQuery.isFetching;
|
||||
const relayAgentDirectoryReady =
|
||||
relayAgentsQuery.data !== undefined ||
|
||||
!relayAgentsQuery.isLoading ||
|
||||
relayAgentsQuery.error !== null;
|
||||
const canSearchGlobalUsers =
|
||||
canSearchGlobalPeople &&
|
||||
managedAgentDirectoryReady &&
|
||||
relayAgentDirectoryReady;
|
||||
relayAgentsQuery.data !== undefined &&
|
||||
relayAgentsQuery.error === null &&
|
||||
!relayAgentsQuery.isFetching;
|
||||
const ownerPolicyReady =
|
||||
agentAccessOwnerOnlyQuery.data !== undefined &&
|
||||
agentAccessOwnerOnlyQuery.error === null &&
|
||||
!agentAccessOwnerOnlyQuery.isFetching;
|
||||
const agentDirectoriesReady =
|
||||
managedAgentDirectoryReady && relayAgentDirectoryReady && ownerPolicyReady;
|
||||
const canSearchGlobalUsers = canSearchGlobalPeople && agentDirectoriesReady;
|
||||
const userSearchQuery = useInfiniteUserSearchQuery(mentionQuery ?? "", {
|
||||
allowEmpty: true,
|
||||
enabled: canSearchGlobalUsers && mentionQuery !== null,
|
||||
@@ -183,15 +176,6 @@ export function useMentions(
|
||||
),
|
||||
[relayAgentsQuery.data],
|
||||
);
|
||||
const directoryAgentPubkeys = React.useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(relayAgentsQuery.data ?? []).map((agent) =>
|
||||
normalizePubkey(agent.pubkey),
|
||||
),
|
||||
),
|
||||
[relayAgentsQuery.data],
|
||||
);
|
||||
const sharedChannelIds = React.useMemo(
|
||||
() => getSharedChannelIds(channelsQuery.data),
|
||||
[channelsQuery.data],
|
||||
@@ -231,7 +215,10 @@ export function useMentions(
|
||||
}
|
||||
return lookup;
|
||||
}, [managedAgentsQuery.data, personasQuery.data]);
|
||||
const knownAgentPubkeys = mentionableAgentPubkeys;
|
||||
const knownAgentPubkeys = React.useMemo(
|
||||
() => new Set([...mentionableAgentPubkeys, ...managedAgentPubkeys]),
|
||||
[managedAgentPubkeys, mentionableAgentPubkeys],
|
||||
);
|
||||
const activePersonas = React.useMemo(
|
||||
() => (personasQuery.data ?? []).filter((persona) => persona.isActive),
|
||||
[personasQuery.data],
|
||||
@@ -249,24 +236,36 @@ export function useMentions(
|
||||
new Set((members ?? []).map((member) => normalizePubkey(member.pubkey))),
|
||||
[members],
|
||||
);
|
||||
const agentIdentityPubkeys = React.useMemo(
|
||||
() =>
|
||||
getAgentIdentityPubkeys({
|
||||
managedAgentPubkeys,
|
||||
relayAgents: relayAgentsQuery.data ?? [],
|
||||
members: members ?? [],
|
||||
profileIsAgent: (pubkey) => profiles?.[pubkey]?.isAgent === true,
|
||||
}),
|
||||
[managedAgentPubkeys, members, profiles, relayAgentsQuery.data],
|
||||
);
|
||||
const mentionCandidates = React.useMemo<MentionCandidate[]>(() => {
|
||||
const candidatesByPubkey = new Map<string, MentionCandidate>();
|
||||
|
||||
const addCandidate = (candidate: MentionCandidate & { pubkey: string }) => {
|
||||
const pubkey = normalizePubkey(candidate.pubkey);
|
||||
if (isArchivedDiscovery(pubkey)) {
|
||||
return;
|
||||
}
|
||||
if (!isAgentIdentityInAllowedList(candidate, mentionableAgentPubkeys)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
shouldHideAgentFromMentions({
|
||||
isAgent: candidate.isAgent === true,
|
||||
isMember: candidate.isMember === true,
|
||||
isManagedAgent: candidate.isManagedAgent === true,
|
||||
pubkey,
|
||||
ownerPubkey: candidate.ownerPubkey,
|
||||
currentPubkey,
|
||||
mentionableAgentPubkeys,
|
||||
directoryAgentPubkeys,
|
||||
directoryReady:
|
||||
candidate.isManagedAgent === true
|
||||
? managedAgentDirectoryReady
|
||||
: relayAgentDirectoryReady,
|
||||
ownerOnly: agentAccessOwnerOnlyQuery.data,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
@@ -276,7 +275,6 @@ export function useMentions(
|
||||
candidatesByPubkey.set(pubkey, { ...candidate, pubkey });
|
||||
return;
|
||||
}
|
||||
|
||||
candidatesByPubkey.set(pubkey, {
|
||||
...current,
|
||||
avatarUrl: current.avatarUrl ?? candidate.avatarUrl ?? null,
|
||||
@@ -341,7 +339,6 @@ export function useMentions(
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const agent of relayAgentsQuery.data ?? []) {
|
||||
const pubkey = normalizePubkey(agent.pubkey);
|
||||
addCandidate({
|
||||
@@ -356,7 +353,6 @@ export function useMentions(
|
||||
isAgent: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const agent of managedAgentsQuery.data ?? []) {
|
||||
addCandidate({
|
||||
kind: "identity",
|
||||
@@ -371,7 +367,6 @@ export function useMentions(
|
||||
ownerPubkey: currentPubkey,
|
||||
});
|
||||
}
|
||||
|
||||
if (canSearchGlobalUsers) {
|
||||
for (const user of userSearchResults) {
|
||||
const pubkey = normalizePubkey(user.pubkey);
|
||||
@@ -396,7 +391,6 @@ export function useMentions(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const personaCandidates: MentionCandidate[] = activePersonas
|
||||
.filter((persona) => !managedAgentPersonaIds.has(persona.id))
|
||||
.map((persona) => ({
|
||||
@@ -408,7 +402,6 @@ export function useMentions(
|
||||
isAgent: true,
|
||||
}))
|
||||
.filter((candidate) => candidate.displayName.trim().length > 0);
|
||||
|
||||
return coalesceAgentAutocompleteCandidates(
|
||||
coalesceAutocompleteCandidatesByKey(
|
||||
[...candidatesByPubkey.values(), ...personaCandidates],
|
||||
@@ -423,11 +416,12 @@ export function useMentions(
|
||||
}, [
|
||||
activePersonaById,
|
||||
activePersonas,
|
||||
agentAccessOwnerOnlyQuery.data,
|
||||
userSearchResults,
|
||||
canSearchGlobalUsers,
|
||||
currentPubkey,
|
||||
directoryAgentPubkeys,
|
||||
isArchivedDiscovery,
|
||||
managedAgentDirectoryReady,
|
||||
managedAgentNamesByPubkey,
|
||||
managedAgentPersonaIds,
|
||||
managedAgentPersonaIdsByPubkey,
|
||||
@@ -437,10 +431,14 @@ export function useMentions(
|
||||
mentionableAgentPubkeys,
|
||||
personaNameByPubkey,
|
||||
profiles,
|
||||
relayAgentDirectoryReady,
|
||||
relayAgentNamesByPubkey,
|
||||
relayAgentsQuery.data,
|
||||
]);
|
||||
|
||||
const admittedAgentPubkeys = React.useMemo(
|
||||
() => getAdmittedAgentPubkeys(mentionCandidates),
|
||||
[mentionCandidates],
|
||||
);
|
||||
const mentionCandidatesWithTeams = React.useMemo(
|
||||
() => [
|
||||
...mentionCandidates,
|
||||
@@ -452,7 +450,6 @@ export function useMentions(
|
||||
],
|
||||
[mentionCandidates, personasQuery.data, teamsQuery.data],
|
||||
);
|
||||
|
||||
const ownerPubkeys = React.useMemo(
|
||||
() => [
|
||||
...new Set(
|
||||
@@ -466,7 +463,6 @@ export function useMentions(
|
||||
const ownerProfilesQuery = useUsersBatchQuery(ownerPubkeys, {
|
||||
enabled: ownerPubkeys.length > 0,
|
||||
});
|
||||
|
||||
const searchableNames = React.useMemo(
|
||||
() => uniqueAutocompleteLabels(mentionCandidatesWithTeams),
|
||||
[mentionCandidatesWithTeams],
|
||||
@@ -651,6 +647,11 @@ export function useMentions(
|
||||
(suggestion.pubkey
|
||||
? knownAgentPubkeys.has(normalizePubkey(suggestion.pubkey))
|
||||
: false);
|
||||
rememberSelectedAgentPubkeys(
|
||||
selectedAgentMentionPubkeysRef.current,
|
||||
selectedMentions,
|
||||
isAgentMention,
|
||||
);
|
||||
if (isAgentMention) {
|
||||
setSelectedAgentMentionNames((current) => {
|
||||
const known = new Set(current.map((name) => name.toLowerCase()));
|
||||
@@ -794,15 +795,40 @@ export function useMentions(
|
||||
);
|
||||
|
||||
const extractMentionPubkeysForCurrentMentions = React.useCallback(
|
||||
(text: string): string[] =>
|
||||
extractMentionPubkeys({
|
||||
(text: string): string[] => {
|
||||
const extracted = extractMentionPubkeys({
|
||||
text,
|
||||
selectedMentions: mentionMapRef.current,
|
||||
selectedDisplayNames: personaMentionMapRef.current.keys(),
|
||||
memberCandidates: mentionCandidates,
|
||||
}),
|
||||
[mentionCandidates],
|
||||
});
|
||||
return filterAdmittedMentionPubkeys(
|
||||
extracted,
|
||||
new Set([
|
||||
...agentIdentityPubkeys,
|
||||
...selectedAgentMentionPubkeysRef.current,
|
||||
]),
|
||||
admittedAgentPubkeys,
|
||||
);
|
||||
},
|
||||
[admittedAgentPubkeys, agentIdentityPubkeys, mentionCandidates],
|
||||
);
|
||||
const getSelectedAgentPubkeys = React.useRef(
|
||||
() => selectedAgentMentionPubkeysRef.current,
|
||||
).current;
|
||||
const revalidateMentionPubkeys = useAgentMentionRevalidation({
|
||||
agentPubkeys: agentIdentityPubkeys,
|
||||
getSelectedAgentPubkeys,
|
||||
currentPubkey,
|
||||
eligibilityScope: mentionChannelId
|
||||
? { type: "channel", channelId: mentionChannelId }
|
||||
: { type: "managed-only" },
|
||||
sharedChannelIds,
|
||||
ownerOnly: agentAccessOwnerOnlyQuery.data,
|
||||
ownerPolicyError: agentAccessOwnerOnlyQuery.error,
|
||||
refetchManagedAgents: managedAgentsQuery.refetch,
|
||||
refetchRelayAgents: relayAgentsQuery.refetch,
|
||||
});
|
||||
|
||||
const extractMentionPersonas = React.useCallback(
|
||||
(text: string): PersonaMentionTarget[] => {
|
||||
@@ -838,12 +864,12 @@ export function useMentions(
|
||||
setMentionQuery(null);
|
||||
setMentionSelectedIndex(0);
|
||||
}, []);
|
||||
|
||||
const clearMentions = React.useCallback(() => {
|
||||
cancelMentionAutocomplete();
|
||||
mentionMapRef.current.clear();
|
||||
personaMentionMapRef.current.clear();
|
||||
selectedAgentMentionNamesRef.current = [];
|
||||
selectedAgentMentionPubkeysRef.current.clear();
|
||||
setSelectedMentionNames([]);
|
||||
setSelectedAgentMentionNames([]);
|
||||
}, [cancelMentionAutocomplete]);
|
||||
@@ -946,6 +972,7 @@ export function useMentions(
|
||||
clearMentions,
|
||||
extractMentionPersonas,
|
||||
extractMentionPubkeys: extractMentionPubkeysForCurrentMentions,
|
||||
revalidateMentionPubkeys,
|
||||
getDraftMentionRefs,
|
||||
getMentionDisplayName,
|
||||
handleMentionKeyDown,
|
||||
|
||||
@@ -516,9 +516,7 @@ function MessageComposerImpl({
|
||||
// Edit mode
|
||||
if (editTargetRef.current && onEditSaveRef.current) {
|
||||
if (isEditSubmissionLocked) return;
|
||||
// No empty-edit guard here: clearing an edit to empty (no text, no
|
||||
// attachments) flows through to onEditSave as empty content, which
|
||||
// deletes the message instead of publishing it (see handleEditSave).
|
||||
// Empty edits delete the message through handleEditSave.
|
||||
await submitMessageEdit({
|
||||
content: trimmed,
|
||||
editTargetId: editTargetRef.current.id,
|
||||
@@ -551,6 +549,7 @@ function MessageComposerImpl({
|
||||
setSpoileredAttachmentUrls(draft.spoileredAttachmentUrls);
|
||||
},
|
||||
restoreMentionRefs: mentions.restoreDraftMentionRefs,
|
||||
revalidateMentionPubkeys: mentions.revalidateMentionPubkeys,
|
||||
shouldRestoreComposer: () => canRestoreEditDraftRef.current,
|
||||
setDeferredUploadPending: setDeferredEditPending,
|
||||
setUploadError: (message) =>
|
||||
@@ -638,6 +637,7 @@ function MessageComposerImpl({
|
||||
effectiveDraftKey,
|
||||
mentions.getDraftMentionRefs,
|
||||
mentions.restoreDraftMentionRefs,
|
||||
mentions.revalidateMentionPubkeys,
|
||||
]);
|
||||
submitMessageRef.current = submitMessage;
|
||||
// ── Auto-submit on draft send ────────────────────────────────────────────
|
||||
|
||||
@@ -29,6 +29,7 @@ function baseOptions(
|
||||
queuedAttachments: [],
|
||||
restoreComposer: () => {},
|
||||
restoreMentionRefs: () => {},
|
||||
revalidateMentionPubkeys: async (pubkeys) => [...pubkeys],
|
||||
setDeferredUploadPending: () => {},
|
||||
setUploadError: () => {},
|
||||
shouldRestoreComposer: () => true,
|
||||
@@ -81,3 +82,63 @@ test("edit save uses edit-target refs that resolve after edit-open", async () =>
|
||||
eventId: "event-id",
|
||||
});
|
||||
});
|
||||
|
||||
test("edit save revalidates added mentions immediately before save", async () => {
|
||||
const agent = "c".repeat(64);
|
||||
const calls = [];
|
||||
await submitMessageEdit({
|
||||
...baseOptions(async (_content, _tags, mentionPubkeys) => {
|
||||
calls.push(["save", mentionPubkeys]);
|
||||
}),
|
||||
content: "hello @Agent",
|
||||
originalContent: "hello",
|
||||
extractMentionPubkeys: (content) =>
|
||||
content.includes("@Agent") ? [agent] : [],
|
||||
revalidateMentionPubkeys: async (pubkeys) => {
|
||||
calls.push(["revalidate", pubkeys]);
|
||||
return [];
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
["revalidate", [agent]],
|
||||
["save", []],
|
||||
]);
|
||||
});
|
||||
|
||||
test("edit upload pause revalidates revoked mentions only after upload completes", async () => {
|
||||
const agent = "d".repeat(64);
|
||||
const calls = [];
|
||||
let completeUpload;
|
||||
await submitMessageEdit({
|
||||
...baseOptions(async (_content, _tags, mentionPubkeys) => {
|
||||
calls.push(["save", mentionPubkeys]);
|
||||
}),
|
||||
content: "hello @Agent",
|
||||
originalContent: "hello",
|
||||
extractMentionPubkeys: (content) =>
|
||||
content.includes("@Agent") ? [agent] : [],
|
||||
queuedAttachments: [
|
||||
{
|
||||
file: new File(["image"], "image.png", { type: "image/png" }),
|
||||
id: 1,
|
||||
spoilered: false,
|
||||
},
|
||||
],
|
||||
enqueueUpload: ({ onComplete }) => {
|
||||
completeUpload = () => onComplete([], new AbortController().signal);
|
||||
return {};
|
||||
},
|
||||
revalidateMentionPubkeys: async (pubkeys) => {
|
||||
calls.push(["revalidate", pubkeys]);
|
||||
return [];
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
await completeUpload();
|
||||
assert.deepEqual(calls, [
|
||||
["revalidate", [agent]],
|
||||
["save", []],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ type SubmitMessageEditOptions = Omit<
|
||||
extractMentionPubkeys: (content: string) => string[];
|
||||
getMentionRefs: (content: string) => DraftMentionRef[];
|
||||
editTargetId: string;
|
||||
enqueueUpload?: typeof enqueueBackgroundMediaUpload;
|
||||
editTarget: Pick<
|
||||
MessageComposerEditTarget,
|
||||
"mentionRefs" | "unresolvedMentionPubkeys"
|
||||
@@ -39,6 +40,7 @@ type SubmitMessageEditOptions = Omit<
|
||||
ownerPubkey: string | null;
|
||||
restoreComposer: (draft: EditDraft) => void;
|
||||
restoreMentionRefs: (refs: DraftMentionRef[]) => void;
|
||||
revalidateMentionPubkeys: (pubkeys: readonly string[]) => Promise<string[]>;
|
||||
shouldRestoreComposer: () => boolean;
|
||||
setDeferredUploadPending: (isPending: boolean) => void;
|
||||
save: (
|
||||
@@ -55,6 +57,7 @@ export async function submitMessageEdit({
|
||||
content,
|
||||
customEmoji,
|
||||
editTargetId,
|
||||
enqueueUpload = enqueueBackgroundMediaUpload,
|
||||
editTarget,
|
||||
extractMentionPubkeys,
|
||||
getMentionRefs,
|
||||
@@ -64,6 +67,7 @@ export async function submitMessageEdit({
|
||||
queuedAttachments,
|
||||
restoreComposer,
|
||||
restoreMentionRefs,
|
||||
revalidateMentionPubkeys,
|
||||
setDeferredUploadPending,
|
||||
shouldRestoreComposer,
|
||||
save,
|
||||
@@ -122,11 +126,19 @@ export async function submitMessageEdit({
|
||||
],
|
||||
);
|
||||
if (signal?.aborted) return;
|
||||
await save(finalContent, outgoingTags, addedMentionPubkeys, editTargetId);
|
||||
const revalidatedMentionPubkeys =
|
||||
await revalidateMentionPubkeys(addedMentionPubkeys);
|
||||
if (signal?.aborted) return;
|
||||
await save(
|
||||
finalContent,
|
||||
outgoingTags,
|
||||
revalidatedMentionPubkeys,
|
||||
editTargetId,
|
||||
);
|
||||
};
|
||||
|
||||
if (hasQueuedAttachments) {
|
||||
enqueueBackgroundMediaUpload({
|
||||
enqueueUpload({
|
||||
attachments: draft.queuedAttachments,
|
||||
onComplete: async (uploaded, signal) => {
|
||||
try {
|
||||
|
||||
@@ -240,7 +240,6 @@ export function useMentionSendFlow({
|
||||
startAgentMutation,
|
||||
],
|
||||
);
|
||||
|
||||
const createMentionedPersonaAgents = React.useCallback(
|
||||
async (trimmed: string, capturedChannelId: string) => {
|
||||
const personaMentions = mentions.extractMentionPersonas(trimmed);
|
||||
@@ -251,7 +250,6 @@ export function useMentionSendFlow({
|
||||
pubkeys: [] as string[],
|
||||
};
|
||||
}
|
||||
|
||||
const runtimes = await getAvailableRuntimes();
|
||||
const defaultRuntime = runtimes[0] ?? null;
|
||||
const errors: string[] = [];
|
||||
@@ -260,13 +258,11 @@ export function useMentionSendFlow({
|
||||
const seenPersonaIds = new Set<string>();
|
||||
const shouldProvisionForDm =
|
||||
channelType === "dm" && Boolean(onPrepareSendChannel);
|
||||
|
||||
for (const { displayName, persona } of personaMentions) {
|
||||
if (seenPersonaIds.has(persona.id)) {
|
||||
continue;
|
||||
}
|
||||
seenPersonaIds.add(persona.id);
|
||||
|
||||
const { runtime } = resolvePersonaRuntime(
|
||||
persona.runtime,
|
||||
runtimes,
|
||||
@@ -276,7 +272,6 @@ export function useMentionSendFlow({
|
||||
errors.push(`${displayName}: No agent runtime available.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const input: CreateChannelManagedAgentInput & {
|
||||
channelId: string;
|
||||
@@ -309,7 +304,6 @@ export function useMentionSendFlow({
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agents,
|
||||
errors,
|
||||
@@ -404,8 +398,15 @@ export function useMentionSendFlow({
|
||||
};
|
||||
let uploadStarted = false;
|
||||
try {
|
||||
const admittedMentionPubkeys = uniqueNormalizedPubkeys(
|
||||
await mentions.revalidateMentionPubkeys(mentionPubkeys),
|
||||
);
|
||||
if (!isMountedRef.current) return persistPreflightDraft();
|
||||
const admittedMentionPubkeySet = new Set(admittedMentionPubkeys);
|
||||
const readyAgentPubkeys = new Set(
|
||||
(draft.readyAgentPubkeys ?? []).map(normalizePubkey),
|
||||
uniqueNormalizedPubkeys(draft.readyAgentPubkeys ?? []).filter(
|
||||
(pubkey) => admittedMentionPubkeySet.has(pubkey),
|
||||
),
|
||||
);
|
||||
const managedAgentsByPubkey = await getManagedAgentsByPubkey();
|
||||
if (!isMountedRef.current) {
|
||||
@@ -415,8 +416,7 @@ export function useMentionSendFlow({
|
||||
for (const agent of draft.preparedManagedAgents ?? []) {
|
||||
managedAgentsByPubkey.set(normalizePubkey(agent.pubkey), agent);
|
||||
}
|
||||
const normalizedMentionPubkeys =
|
||||
uniqueNormalizedPubkeys(mentionPubkeys);
|
||||
const normalizedMentionPubkeys = admittedMentionPubkeys;
|
||||
const managedMentionPubkeys = normalizedMentionPubkeys.filter(
|
||||
(pubkey) => managedAgentsByPubkey.has(pubkey),
|
||||
);
|
||||
@@ -463,7 +463,6 @@ export function useMentionSendFlow({
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (preparedAgentPubkeys.length > 0 && sendChannelId) {
|
||||
try {
|
||||
await invokeTauri("sync_agents_to_active_huddle", {
|
||||
@@ -480,13 +479,11 @@ export function useMentionSendFlow({
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveExplicitAgentPubkeys =
|
||||
filterEffectiveExplicitAgentPubkeys(
|
||||
draft.explicitAgentPubkeys,
|
||||
mentionPubkeys,
|
||||
);
|
||||
|
||||
const send = onSendRef.current;
|
||||
const persistCanceledDraft = () => {
|
||||
if (!draft.recoveryDraftKey) return;
|
||||
@@ -560,23 +557,28 @@ export function useMentionSendFlow({
|
||||
outgoingTags ?? [],
|
||||
);
|
||||
if (signal?.aborted) return;
|
||||
const revalidatedMentionPubkeys =
|
||||
await mentions.revalidateMentionPubkeys(mentionPubkeys);
|
||||
if (signal?.aborted) return;
|
||||
const revalidatedExplicitAgentPubkeys =
|
||||
filterEffectiveExplicitAgentPubkeys(
|
||||
draft.explicitAgentPubkeys,
|
||||
revalidatedMentionPubkeys,
|
||||
);
|
||||
await send(
|
||||
finalContent,
|
||||
mentionPubkeys,
|
||||
revalidatedMentionPubkeys,
|
||||
finalOutgoingTags,
|
||||
sendChannelId,
|
||||
draft.capturedThreadContext,
|
||||
);
|
||||
if (signal?.aborted) return;
|
||||
if (effectiveExplicitAgentPubkeys.length > 0) {
|
||||
// Promote only explicitly authored agents that remained effective
|
||||
// for this successful send. "Send without inviting" removes its
|
||||
// excluded recipients here as well as from event routing.
|
||||
if (revalidatedExplicitAgentPubkeys.length > 0) {
|
||||
onSuccessfulExplicitAgentAudience?.({
|
||||
channelId: sendChannelId ?? draft.capturedChannelId ?? "",
|
||||
expectedGeneration: draft.audienceGeneration,
|
||||
expectedRevision: draft.audienceRevision,
|
||||
explicitAgentPubkeys: effectiveExplicitAgentPubkeys,
|
||||
explicitAgentPubkeys: revalidatedExplicitAgentPubkeys,
|
||||
});
|
||||
}
|
||||
if (draft.sentDraftKey) {
|
||||
@@ -612,7 +614,6 @@ export function useMentionSendFlow({
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the sent body directly with its final post-send state before
|
||||
// the async network send starts. This avoids an intermediate blank frame
|
||||
// for persistent audiences while preserving the ordinary empty state.
|
||||
@@ -647,6 +648,7 @@ export function useMentionSendFlow({
|
||||
ensureManagedAgentMentionsReady,
|
||||
getManagedAgentsByPubkey,
|
||||
mentions.isAgentPubkey,
|
||||
mentions.revalidateMentionPubkeys,
|
||||
onPrepareSendChannel,
|
||||
onSendRef,
|
||||
onSuccessfulExplicitAgentAudience,
|
||||
@@ -873,38 +875,37 @@ export function useMentionSendFlow({
|
||||
);
|
||||
void completeSend(pendingNonMemberSend, mentionPubkeys, outgoingTags);
|
||||
}, [completeSend, pendingNonMemberSend]);
|
||||
|
||||
const handleInviteNonMembers = React.useCallback(() => {
|
||||
if (!pendingNonMemberSend) return;
|
||||
// The dialog hides Invite in this case; this guards the keyboard/programmatic
|
||||
// path so we surface the reason instead of a raw relay rejection.
|
||||
if (!canInviteNonMembers) {
|
||||
setNonMemberPromptError(PRIVATE_CHANNEL_ADD_DENIED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
const invitedPubkeys = new Set(
|
||||
pendingNonMemberSend.nonMemberPubkeys.map(normalizePubkey),
|
||||
);
|
||||
const mentionPubkeys = uniqueNormalizedPubkeys([
|
||||
...pendingNonMemberSend.mentionPubkeys,
|
||||
...pendingNonMemberSend.nonMemberPubkeys,
|
||||
]);
|
||||
const outgoingTags = (pendingNonMemberSend.outgoingTags ?? []).filter(
|
||||
(tag) =>
|
||||
tag[0] !== MENTION_REFERENCE_TAG ||
|
||||
!invitedPubkeys.has(normalizePubkey(tag[1] ?? "")),
|
||||
);
|
||||
|
||||
setNonMemberPromptError(null);
|
||||
void (async () => {
|
||||
const mentionPubkeys = uniqueNormalizedPubkeys(
|
||||
await mentions.revalidateMentionPubkeys([
|
||||
...pendingNonMemberSend.mentionPubkeys,
|
||||
...pendingNonMemberSend.nonMemberPubkeys,
|
||||
]),
|
||||
);
|
||||
const admittedMentionPubkeys = new Set(mentionPubkeys);
|
||||
const originalNonMemberPubkeys = new Set(
|
||||
pendingNonMemberSend.nonMemberPubkeys.map(normalizePubkey),
|
||||
);
|
||||
const nonMemberPubkeys = [...originalNonMemberPubkeys].filter(
|
||||
admittedMentionPubkeys.has.bind(admittedMentionPubkeys),
|
||||
);
|
||||
const outgoingTags = (pendingNonMemberSend.outgoingTags ?? []).filter(
|
||||
(tag) =>
|
||||
tag[0] !== MENTION_REFERENCE_TAG ||
|
||||
!originalNonMemberPubkeys.has(normalizePubkey(tag[1] ?? "")),
|
||||
);
|
||||
const managedAgentsByPubkey = await getManagedAgentsByPubkey();
|
||||
if (!isMountedRef.current) return;
|
||||
const peoplePubkeys: string[] = [];
|
||||
const relayAgentPubkeys: string[] = [];
|
||||
|
||||
for (const pubkey of uniqueNormalizedPubkeys(
|
||||
pendingNonMemberSend.nonMemberPubkeys,
|
||||
)) {
|
||||
for (const pubkey of nonMemberPubkeys) {
|
||||
if (managedAgentsByPubkey.has(pubkey)) {
|
||||
continue;
|
||||
}
|
||||
@@ -960,6 +961,7 @@ export function useMentionSendFlow({
|
||||
completeSend,
|
||||
getManagedAgentsByPubkey,
|
||||
mentions.isAgentPubkey,
|
||||
mentions.revalidateMentionPubkeys,
|
||||
pendingNonMemberSend,
|
||||
]);
|
||||
|
||||
|
||||
@@ -293,6 +293,8 @@ type E2eConfig = {
|
||||
personaSharePublicationStatuses?: Array<"published" | "queued">;
|
||||
teams?: MockTeamSeed[];
|
||||
relayAgents?: MockRelayAgentSeed[];
|
||||
/** Reject successive relay-agent directory reads, then resume. */
|
||||
relayAgentListErrors?: (string | null)[];
|
||||
/** Native-like huddle state seeded from authoritative role-bearing membership. */
|
||||
huddle?: MockHuddleSeed;
|
||||
agentListDelayMs?: number;
|
||||
@@ -7463,6 +7465,8 @@ async function handleListRelayAgents(
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawRelayAgent[]> {
|
||||
await delayAgentList(config);
|
||||
const error = config?.mock?.relayAgentListErrors?.shift();
|
||||
if (error) throw new Error(error);
|
||||
syncMockRelayAgentsFromManagedAgents();
|
||||
return mockRelayAgents.map(cloneRelayAgent);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,22 @@ async function readOutgoingMentionPubkeys(
|
||||
content: string,
|
||||
) {
|
||||
return page.evaluate((expectedContent) => {
|
||||
const signedEvent = (
|
||||
window as Window & {
|
||||
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{
|
||||
content?: string;
|
||||
tags?: string[][];
|
||||
}>;
|
||||
}
|
||||
).__BUZZ_E2E_SIGNED_EVENTS__?.find(
|
||||
(event) => event.content === expectedContent,
|
||||
);
|
||||
if (signedEvent) {
|
||||
return (signedEvent.tags ?? [])
|
||||
.filter((tag) => tag[0] === "p" && tag[1])
|
||||
.map((tag) => tag[1]);
|
||||
}
|
||||
|
||||
const entries =
|
||||
(
|
||||
window as Window & {
|
||||
@@ -84,6 +100,25 @@ async function readOutgoingMentionPubkeys(
|
||||
).__BUZZ_E2E_COMMAND_LOG__ ?? [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.command === "send_channel_message") {
|
||||
const payload = entry.payload as
|
||||
| { content?: string; mentionPubkeys?: string[] }
|
||||
| undefined;
|
||||
if (payload?.content === expectedContent) {
|
||||
return payload.mentionPubkeys ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.command === "sign_event") {
|
||||
const unsignedEvent = entry.payload as
|
||||
| { content?: string; tags?: string[][] }
|
||||
| undefined;
|
||||
if (unsignedEvent?.content !== expectedContent) continue;
|
||||
return (unsignedEvent.tags ?? [])
|
||||
.filter((tag) => tag[0] === "p" && tag[1])
|
||||
.map((tag) => tag[1]);
|
||||
}
|
||||
|
||||
if (entry.command !== "plugin:websocket|send") continue;
|
||||
const data = (
|
||||
entry.payload as { message?: { data?: string } } | undefined
|
||||
@@ -969,6 +1004,74 @@ test("relay-only shared agents appear in forum mentions", async ({ page }) => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("forum sends revalidate relay-agent authorization before signing", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
deferredComposerUploads: true,
|
||||
uploadDescriptors: [
|
||||
{
|
||||
url: `https://mock.relay/media/${"f".repeat(64)}.pdf`,
|
||||
sha256: "f".repeat(64),
|
||||
size: 12345,
|
||||
type: "application/pdf",
|
||||
uploaded: Math.floor(Date.now() / 1000),
|
||||
filename: "forum-race.pdf",
|
||||
},
|
||||
],
|
||||
relayAgents: [
|
||||
{
|
||||
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
||||
name: "quinn",
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
|
||||
channelNames: ["watercooler"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-watercooler").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
|
||||
await page.getByRole("button", { name: "Start a new post..." }).click();
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill("@quinn");
|
||||
await page.getByTestId("mention-autocomplete").getByText("quinn").click();
|
||||
await page.keyboard.type("hello");
|
||||
await page.getByRole("button", { name: "Attach file" }).click();
|
||||
const removeAttachment = page.getByRole("button", {
|
||||
name: "Remove attachment",
|
||||
});
|
||||
await expect(removeAttachment).toBeVisible();
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.agentListDelayMs = 1_000;
|
||||
window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(100).fill(
|
||||
"mock forum directory revoked before send",
|
||||
);
|
||||
});
|
||||
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect(input).toHaveAttribute("contenteditable", "false");
|
||||
await expect(removeAttachment).toBeDisabled();
|
||||
await removeAttachment.evaluate((button: HTMLButtonElement) =>
|
||||
button.click(),
|
||||
);
|
||||
await expect(removeAttachment).toBeVisible();
|
||||
await input.focus();
|
||||
await page.keyboard.type(" later edit");
|
||||
await expect(input).toContainText("@quinn hello");
|
||||
await expect(input).not.toContainText("later edit");
|
||||
|
||||
const outgoingContent = `@quinn hello\n[forum-race.pdf](https://mock.relay/media/${"f".repeat(64)}.pdf)`;
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, outgoingContent))
|
||||
.not.toBeNull();
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, outgoingContent))
|
||||
.not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
||||
});
|
||||
|
||||
test("relay-only allowlisted agents are visible in channel mentions", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -995,6 +1098,271 @@ test("relay-only allowlisted agents are visible in channel mentions", async ({
|
||||
await expect(dropdown.getByText("agent")).toBeVisible();
|
||||
});
|
||||
|
||||
test("relay-agent directory errors fail closed and recover after a fresh fetch", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
relayAgentListErrors: ["mock directory unavailable", null],
|
||||
relayAgents: [
|
||||
{
|
||||
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
||||
name: "quinn",
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
|
||||
channelNames: ["general"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill("@quinn");
|
||||
await expect(autocomplete(page)).toHaveCount(0);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
||||
queryKey: ["relay-agents"],
|
||||
});
|
||||
});
|
||||
await expect(autocomplete(page).getByText("quinn")).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.agentListDelayMs = 1_000;
|
||||
void window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
|
||||
queryKey: ["relay-agents"],
|
||||
});
|
||||
});
|
||||
await expect(autocomplete(page).getByText("quinn")).toHaveCount(0);
|
||||
await expect(autocomplete(page).getByText("quinn")).toBeVisible();
|
||||
});
|
||||
|
||||
test("relay-only allowlisted agents emit a p tag when sent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
relayAgents: [
|
||||
{
|
||||
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
||||
name: "quinn",
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
|
||||
channelNames: ["general"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill("@quinn");
|
||||
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
||||
await expect(quinnRow).toBeVisible();
|
||||
await quinnRow.click();
|
||||
await page.keyboard.type("hello");
|
||||
await expect(input).toHaveText("@quinn hello");
|
||||
await page.getByTestId("send-message").click();
|
||||
await page.getByRole("button", { name: "Invite", exact: true }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
||||
.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
||||
});
|
||||
|
||||
test("selected relay agents revoked before send emit no p tag", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
relayAgents: [
|
||||
{
|
||||
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
||||
name: "quinn",
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
|
||||
channelNames: ["general"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill("@quinn");
|
||||
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
||||
await expect(quinnRow).toBeVisible();
|
||||
await quinnRow.click();
|
||||
await page.keyboard.type("hello");
|
||||
|
||||
await page.evaluate(async () => {
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(5).fill(
|
||||
"mock directory revoked",
|
||||
);
|
||||
const queryClient = window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as {
|
||||
invalidateQueries: (filters: {
|
||||
queryKey: readonly unknown[];
|
||||
}) => Promise<void>;
|
||||
getQueryState: (
|
||||
queryKey: readonly unknown[],
|
||||
) => { status?: string } | undefined;
|
||||
};
|
||||
await queryClient.invalidateQueries({ queryKey: ["relay-agents"] });
|
||||
if (queryClient.getQueryState(["relay-agents"])?.status !== "error") {
|
||||
throw new Error(
|
||||
"relay-agent directory refetch did not enter error state",
|
||||
);
|
||||
}
|
||||
});
|
||||
const baselineCommands = await readCommandLog(page);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
||||
.not.toBeNull();
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
||||
.not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
||||
const commands = await readCommandLog(page);
|
||||
for (const command of [
|
||||
"add_channel_members",
|
||||
"start_managed_agent",
|
||||
"attach_managed_agent",
|
||||
"sync_agents_to_active_huddle",
|
||||
]) {
|
||||
expect(commandCount(commands, command)).toBe(
|
||||
commandCount(baselineCommands, command),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("selected relay agents revoked after the invite prompt cause no side effects", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
relayAgents: [
|
||||
{
|
||||
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
||||
name: "quinn",
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
|
||||
channelNames: ["general"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill("@quinn");
|
||||
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
||||
await expect(quinnRow).toBeVisible();
|
||||
await quinnRow.click();
|
||||
await page.keyboard.type("hello");
|
||||
await page.getByTestId("send-message").click();
|
||||
const inviteButton = page.getByRole("button", {
|
||||
name: "Invite",
|
||||
exact: true,
|
||||
});
|
||||
await expect(inviteButton).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(5).fill(
|
||||
"mock directory revoked after invite prompt",
|
||||
);
|
||||
});
|
||||
const baselineCommands = await readCommandLog(page);
|
||||
await inviteButton.click();
|
||||
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
||||
.not.toBeNull();
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
||||
.not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
||||
const commands = await readCommandLog(page);
|
||||
for (const command of [
|
||||
"add_channel_members",
|
||||
"start_managed_agent",
|
||||
"attach_managed_agent",
|
||||
"sync_agents_to_active_huddle",
|
||||
]) {
|
||||
expect(commandCount(commands, command)).toBe(
|
||||
commandCount(baselineCommands, command),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("selected relay agents revoked during send emit no p tag", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
relayAgents: [
|
||||
{
|
||||
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
||||
name: "quinn",
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
|
||||
channelNames: ["general"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill("@quinn");
|
||||
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
|
||||
await expect(quinnRow).toBeVisible();
|
||||
await quinnRow.click();
|
||||
await page.keyboard.type("hello");
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.agentListDelayMs = 300;
|
||||
});
|
||||
await page.getByTestId("send-message").click();
|
||||
await page.getByRole("button", { name: "Invite", exact: true }).click();
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E__.mock ??= {};
|
||||
window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(100).fill(
|
||||
"mock directory revoked mid-send",
|
||||
);
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
||||
.not.toBeNull();
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
|
||||
.not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
|
||||
});
|
||||
|
||||
test("owner-only builds hide other-owned relay agents", async ({ page }) => {
|
||||
await installMockBridge(page, {
|
||||
ownerOnlyAccessBuild: true,
|
||||
searchProfiles: [
|
||||
{
|
||||
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
||||
displayName: "quinn",
|
||||
ownerPubkey: TEST_IDENTITIES.outsider.pubkey,
|
||||
isAgent: true,
|
||||
},
|
||||
],
|
||||
relayAgents: [
|
||||
{
|
||||
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
|
||||
name: "quinn",
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
|
||||
channelNames: ["general"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.getByTestId("message-input").fill("@quinn");
|
||||
|
||||
await expect(autocomplete(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("relay-only allowlisted agents stay hidden outside their channel", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -250,6 +250,8 @@ type MockBridgeOptions = {
|
||||
personaSharePublicationStatuses?: Array<"published" | "queued">;
|
||||
teams?: MockTeamSeed[];
|
||||
relayAgents?: MockRelayAgentSeed[];
|
||||
/** Reject successive relay-agent directory reads, then resume. */
|
||||
relayAgentListErrors?: (string | null)[];
|
||||
/** Delay both managed and relay agent directory reads. */
|
||||
agentListDelayMs?: number;
|
||||
createManagedAgentDelayMs?: number;
|
||||
|
||||
Reference in New Issue
Block a user