Skip to content

Commit 6858c35

Browse files
loganjLarry
andcommitted
feat(desktop): invite owned agents from standalone forums
Reuse phase-aware preparation, authorized add and final destination authorization; retain selected drafts on cancellation and failure. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson <loganj@squareup.com>
1 parent d9d1647 commit 6858c35

6 files changed

Lines changed: 495 additions & 8 deletions

File tree

desktop/playwright.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export default defineConfig({
7373
"**/composer-tooltip-dismiss.spec.ts",
7474
"**/mentions.spec.ts",
7575
"**/remote-owned-mentions.spec.ts",
76+
"**/forum-agent-invitation.spec.ts",
7677
"**/team-mentions.spec.ts",
7778
"**/persistent-agent-audience.spec.ts",
7879
"**/relay-reconnect.spec.ts",

desktop/src/features/forum/ui/ForumComposer.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { useLinkEditor } from "@/features/messages/lib/useLinkEditor";
2121
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
2222
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
2323
import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar";
24+
import { NonMemberMentionDialog } from "@/features/messages/ui/NonMemberMentionDialog";
2425
import { Button } from "@/shared/ui/button";
2526
import { cn } from "@/shared/lib/cn";
2627
import {
@@ -35,6 +36,7 @@ import { ForumComposerAutocompletes } from "./ForumComposerAutocompletes";
3536
import { ForumComposerCompactLayout } from "./ForumComposerCompactLayout";
3637
import { ForumComposerMediaStatus } from "./ForumComposerMediaStatus";
3738
import { useCompactComposerInteractions } from "./useCompactComposerInteractions";
39+
import { useForumMentionPreparation } from "./useForumMentionPreparation";
3840

3941
export function ForumComposer({
4042
channelId = null,
@@ -74,6 +76,8 @@ export function ForumComposer({
7476
}, [compact]);
7577

7678
const mentions = useMentions(channelId, members, profiles, { channelType });
79+
const { prepareMentionPubkeys, nonMemberPromptProps } =
80+
useForumMentionPreparation(channelId, channelType, mentions);
7781
const channelLinks = useChannelLinks();
7882
const media = useMediaUpload();
7983
const { handlePaperclipClick, handleToolbarMouseDown, shouldIgnoreBlur } =
@@ -240,9 +244,11 @@ export function ForumComposer({
240244
channelLinks.clearChannels();
241245
setIsEmojiPickerOpen(false);
242246
try {
243-
const pubkeys = await mentions.revalidateMentionPubkeys(
247+
const pubkeys = await prepareMentionPubkeys(
244248
mentions.extractMentionPubkeys(trimmed),
249+
trimmed,
245250
);
251+
if (pubkeys === null) return;
246252

247253
// Reuse the shared send-path builder so forum/notes posts emit the same
248254
// body + imeta as chat: generic files become `[filename](url)` links with a
@@ -290,7 +296,7 @@ export function ForumComposer({
290296
media.setPendingImeta,
291297
mentions.cancelMentionAutocomplete,
292298
mentions.extractMentionPubkeys,
293-
mentions.revalidateMentionPubkeys,
299+
prepareMentionPubkeys,
294300
mentions.clearMentions,
295301
channelLinks.clearChannels,
296302
richText.clearContent,
@@ -633,6 +639,7 @@ export function ForumComposer({
633639
</>
634640
)}
635641
</form>
642+
<NonMemberMentionDialog {...nonMemberPromptProps} />
636643
{!isSubmissionPending && linkEditor.card}
637644
{!isSubmissionPending && linkEditor.dialog}
638645
</>
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import * as React from "react";
2+
import { useAddChannelMembersMutation } from "@/features/channels/hooks";
3+
import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/channelMemberAdmission";
4+
import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers";
5+
import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
6+
import type { ChannelType } from "@/shared/api/types";
7+
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
8+
9+
type PendingInvite = {
10+
channelId: string;
11+
pubkeys: string[];
12+
nonMemberPubkeys: string[];
13+
intendedAgentPubkeys: string[];
14+
resolve: (invited: boolean) => void;
15+
};
16+
17+
/** Adapt the normal mention Invite dialog and authorized add to standalone forums. */
18+
export function useForumMentionPreparation(
19+
channelId: string | null,
20+
channelType: ChannelType | null | undefined,
21+
mentions: UseMentionsResult,
22+
) {
23+
const addMembers = useAddChannelMembersMutation(channelId);
24+
const canInvite = useCanAddChannelMembers(channelId);
25+
const [pending, setPending] = React.useState<PendingInvite | null>(null);
26+
const [error, setError] = React.useState<string | null>(null);
27+
const [isInviting, setIsInviting] = React.useState(false);
28+
const pendingRef = React.useRef<PendingInvite | null>(null);
29+
const invitingRef = React.useRef(false);
30+
const activeChannelRef = React.useRef(channelId);
31+
activeChannelRef.current = channelId;
32+
const mountedRef = React.useRef(false);
33+
34+
const dismiss = React.useCallback(() => {
35+
const draft = pendingRef.current;
36+
pendingRef.current = null;
37+
setPending(null);
38+
setError(null);
39+
draft?.resolve(false);
40+
}, []);
41+
42+
React.useEffect(() => {
43+
mountedRef.current = true;
44+
return () => {
45+
mountedRef.current = false;
46+
pendingRef.current?.resolve(false);
47+
pendingRef.current = null;
48+
};
49+
}, []);
50+
React.useEffect(() => {
51+
if (pendingRef.current?.channelId !== channelId) dismiss();
52+
}, [channelId, dismiss]);
53+
54+
const prepareMentionPubkeys = React.useCallback(
55+
async (pubkeys: string[], content: string) => {
56+
const capturedChannelId = channelId;
57+
const intendedAgentPubkeys = [
58+
...pubkeys.filter(mentions.isAgentPubkey),
59+
...mentions
60+
.getDraftMentionRefs(content)
61+
.filter((ref) => ref.isAgent)
62+
.map((ref) => ref.pubkey),
63+
];
64+
const agentPubkeys = new Set(intendedAgentPubkeys.map(normalizePubkey));
65+
// Local managed-agent lifecycle and channel-less/notes surfaces are not
66+
// part of this adapter. Relay-only agents use the same bot add as chat.
67+
const nonMemberPubkeys =
68+
capturedChannelId &&
69+
channelType === "forum" &&
70+
mentions.hasResolvedMembers
71+
? [...new Set(pubkeys.map(normalizePubkey))].filter(
72+
(pubkey) =>
73+
agentPubkeys.has(pubkey) &&
74+
!mentions.isManagedAgentPubkey(pubkey) &&
75+
!mentions.memberPubkeys.has(pubkey),
76+
)
77+
: [];
78+
if (capturedChannelId && nonMemberPubkeys.length > 0) {
79+
const invited = await new Promise<boolean>((resolve) => {
80+
const draft = {
81+
channelId: capturedChannelId,
82+
pubkeys,
83+
nonMemberPubkeys,
84+
intendedAgentPubkeys,
85+
resolve,
86+
};
87+
pendingRef.current = draft;
88+
setError(null);
89+
setPending(draft);
90+
});
91+
if (!invited) return null;
92+
}
93+
if (!mountedRef.current || activeChannelRef.current !== capturedChannelId)
94+
return null;
95+
// The add mutation awaits membership invalidation. Publication still
96+
// requires a fresh authoritative directory/membership/policy read.
97+
const validated = await mentions.revalidateMentionPubkeys(
98+
pubkeys,
99+
capturedChannelId,
100+
{ phase: "publish", intendedAgentPubkeys },
101+
);
102+
return mountedRef.current &&
103+
activeChannelRef.current === capturedChannelId
104+
? validated
105+
: null;
106+
},
107+
[channelId, channelType, mentions],
108+
);
109+
110+
const invite = React.useCallback(async () => {
111+
const draft = pendingRef.current;
112+
if (!draft || invitingRef.current) return;
113+
if (!canInvite) {
114+
setError(PRIVATE_CHANNEL_ADD_DENIED_MESSAGE);
115+
return;
116+
}
117+
const isCurrent = () =>
118+
mountedRef.current &&
119+
activeChannelRef.current === draft.channelId &&
120+
pendingRef.current === draft;
121+
invitingRef.current = true;
122+
setIsInviting(true);
123+
setError(null);
124+
try {
125+
// Preparation admits eligible owned nonmembers, not arbitrary targets.
126+
// Never use publication's membership gate before the authorized add.
127+
await mentions.revalidateMentionPubkeys(draft.pubkeys, draft.channelId, {
128+
phase: "prepare",
129+
intendedAgentPubkeys: draft.intendedAgentPubkeys,
130+
});
131+
if (!isCurrent()) return;
132+
const result = await addMembers.mutateAsync({
133+
channelId: draft.channelId,
134+
pubkeys: draft.nonMemberPubkeys,
135+
role: "bot",
136+
});
137+
if (!isCurrent()) return;
138+
if (result.errors.length > 0) {
139+
setError(result.errors.map((failure) => failure.error).join("; "));
140+
return;
141+
}
142+
pendingRef.current = null;
143+
setPending(null);
144+
draft.resolve(true);
145+
} catch (failure) {
146+
if (isCurrent())
147+
setError(
148+
failure instanceof Error
149+
? failure.message
150+
: "Could not invite members.",
151+
);
152+
} finally {
153+
invitingRef.current = false;
154+
if (mountedRef.current) setIsInviting(false);
155+
}
156+
}, [addMembers.mutateAsync, canInvite, mentions.revalidateMentionPubkeys]);
157+
158+
return {
159+
prepareMentionPubkeys,
160+
nonMemberPromptProps: {
161+
canInvite,
162+
error,
163+
isInvitePending: isInviting,
164+
names: (pending?.nonMemberPubkeys ?? []).map(
165+
(pubkey) =>
166+
mentions.getMentionDisplayName(pubkey) ?? truncatePubkey(pubkey),
167+
),
168+
onDismiss: dismiss,
169+
onInvite: () => void invite(),
170+
open: pending !== null,
171+
},
172+
};
173+
}

desktop/src/features/messages/ui/NonMemberMentionDialog.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ type NonMemberMentionDialogProps = {
1616
isInvitePending: boolean;
1717
names: string[];
1818
onDismiss: () => void;
19-
onDoNothing: () => void;
19+
/** Omit when publication requires the intended recipients to be invited. */
20+
onDoNothing?: () => void;
2021
onInvite: () => void;
2122
open: boolean;
2223
};
@@ -48,9 +49,13 @@ export function NonMemberMentionDialog({
4849
<AlertDialogDescription>
4950
{names.join(", ")} {names.length === 1 ? "is" : "are"} not in this
5051
channel.{" "}
51-
{canInvite
52-
? "Invite them to the channel, or send without inviting them."
53-
: `${PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} You can still send without inviting them.`}
52+
{onDoNothing
53+
? canInvite
54+
? "Invite them to the channel, or send without inviting them."
55+
: `${PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} You can still send without inviting them.`
56+
: canInvite
57+
? "Invite them to the channel, or cancel to keep your draft."
58+
: PRIVATE_CHANNEL_ADD_DENIED_MESSAGE}
5459
</AlertDialogDescription>
5560
</AlertDialogHeader>
5661
{error ? (
@@ -61,12 +66,16 @@ export function NonMemberMentionDialog({
6166
<AlertDialogFooter>
6267
<Button
6368
disabled={isInvitePending}
64-
onClick={onDoNothing}
69+
onClick={onDoNothing ?? onDismiss}
6570
size="sm"
6671
type="button"
6772
variant="outline"
6873
>
69-
{canInvite ? "Do nothing" : "Send anyway"}
74+
{onDoNothing
75+
? canInvite
76+
? "Do nothing"
77+
: "Send anyway"
78+
: "Cancel"}
7079
</Button>
7180
{canInvite ? (
7281
<Button

0 commit comments

Comments
 (0)