diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 77a76a4e458..1a2fd622110 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -161,6 +161,7 @@ export default defineConfig({ "**/settings-section-layout.spec.ts", "**/experimental-features.spec.ts", "**/interactions.spec.ts", + "**/interaction-authoring.spec.ts", "**/agent-provider-dropdowns.spec.ts", "**/agent-lifecycle-feedback.spec.ts", "**/agent-access-warning.spec.ts", diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index ea3c2647865..3724398bbd2 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -6,6 +6,7 @@ import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { ComposerDockBackdrop } from "@/features/messages/ui/ComposerDockBackdrop"; import { ComposerUploadProgressOverlay } from "@/features/messages/ui/ComposerUploadProgressOverlay"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; +import { AskInteractionButton } from "@/features/interactions/AskInteractionButton"; import { ComposerTimeoutBanner } from "@/features/moderation/ui/ComposerTimeoutBanner"; import { useTimeoutState } from "@/features/moderation/lib/timeoutStore"; import { isModerationDm } from "@/features/moderation/lib/moderationDm"; @@ -267,6 +268,16 @@ export const ChannelPane = React.memo(function ChannelPane({ timeoutState.active || isModerationDmChannel || isSending; + // Memoized so the memoized composer does not re-render for a fresh element. + const askInteractionAction = React.useMemo( + () => ( + + ), + [activeChannel?.id, isComposerDisabled], + ); const knownAgentPubkeys = React.useMemo(() => { const pubkeys = new Set(); for (const pubkey of agentPubkeys ?? []) { @@ -778,6 +789,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onSend={handleSendMessage} {...{ profiles, recentMentionPubkeys: recentMentions }} showBackgroundUploadProgress={false} + toolbarExtraActions={askInteractionAction} placeholder={ timeoutState.active ? "You're timed out by community moderators." diff --git a/desktop/src/features/interactions/AskInteractionButton.tsx b/desktop/src/features/interactions/AskInteractionButton.tsx new file mode 100644 index 00000000000..569146f90c1 --- /dev/null +++ b/desktop/src/features/interactions/AskInteractionButton.tsx @@ -0,0 +1,58 @@ +import { ListChecks } from "lucide-react"; +import * as React from "react"; + +import { useFeatureEnabled } from "@/shared/features"; +import { Button } from "@/shared/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { AskInteractionDialog } from "./AskInteractionDialog"; + +/** + * Composer action that opens the prompt authoring dialog. Renders nothing + * unless the "Interaction cards" experimental feature is enabled, so default + * builds keep their toolbar unchanged. + * + * The dialog is pinned to the channel it was opened for: it publishes only + * to that channel, closes if the active channel changes underneath it, and + * its draft is keyed per channel so text written for one channel is never + * carried into another. + */ +export const AskInteractionButton = React.memo(function AskInteractionButton({ + channelId, + disabled = false, +}: { + channelId: string | null; + disabled?: boolean; +}) { + const enabled = useFeatureEnabled("interactions"); + const [openFor, setOpenFor] = React.useState(null); + if (!enabled) return null; + const open = openFor !== null && openFor === channelId; + return ( + <> + + + + + Ask for a decision + + {channelId ? ( + setOpenFor(next ? channelId : null)} + /> + ) : null} + + ); +}); diff --git a/desktop/src/features/interactions/AskInteractionDialog.tsx b/desktop/src/features/interactions/AskInteractionDialog.tsx new file mode 100644 index 00000000000..3a0405209d8 --- /dev/null +++ b/desktop/src/features/interactions/AskInteractionDialog.tsx @@ -0,0 +1,526 @@ +import { Plus, X } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Textarea } from "@/shared/ui/textarea"; +import { + buildPrompt, + type CloseRule, + type DraftField, + type DraftOption, + EXPIRY_PRESETS, + emptyDraft, + type FieldType, + type OptionStyle, + type PromptDraft, + PromptDraftError, + type PromptType, + type ResponderRule, + suggestId, +} from "./authoring"; + +const selectClass = + "h-9 w-full rounded-md border border-input bg-background px-2 text-sm"; +const TYPE_LABELS: Record = { + buttons: "Buttons", + poll: "Poll", + form: "Form", +}; +const FIELD_TYPES: FieldType[] = [ + "text", + "number", + "select", + "boolean", + "date", +]; + +function closeRules(type: PromptType): { value: CloseRule; label: string }[] { + const manual = { value: "manual" as const, label: "When I close it" }; + const expiry = { value: "expiry" as const, label: "At the deadline" }; + if (type === "poll") return [expiry, manual]; + return [ + { value: "first", label: "On the first answer" }, + { value: "quorum:2", label: "After 2 answers" }, + { value: "quorum:3", label: "After 3 answers" }, + { value: "quorum:5", label: "After 5 answers" }, + manual, + expiry, + ]; +} + +/** + * Compose, sign and publish an experimental interaction prompt (kind 40010) + * into the active channel. The relay projects it as an ordinary message for + * clients that do not render cards; see docs/experimental-interactions.md. + */ +export function AskInteractionDialog({ + channelId, + open, + onOpenChange, +}: { + /** The channel this dialog publishes to; fixed for the dialog's lifetime. */ + channelId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [draft, setDraft] = React.useState(() => emptyDraft()); + const [error, setError] = React.useState(null); + const [pending, setPending] = React.useState(false); + const submitting = React.useRef(false); + const formId = React.useId(); + const fid = (name: string) => `${formId}-${name}`; + + const update = (patch: Partial) => { + setError(null); + setDraft((current) => ({ ...current, ...patch })); + }; + const setType = (type: PromptType) => { + setError(null); + setDraft((current) => ({ + ...emptyDraft(type), + text: current.text, + responders: current.responders, + expiresIn: current.expiresIn, + })); + }; + const updateOption = (index: number, patch: Partial) => + update({ + options: draft.options.map((option, i) => { + if (i !== index) return option; + const next = { ...option, ...patch }; + if (patch.label !== undefined && !option.idEdited) { + next.id = suggestId( + patch.label, + draft.options.filter((_, j) => j !== index).map((o) => o.id), + ); + } + return next; + }), + }); + const updateField = (index: number, patch: Partial) => + update({ + fields: draft.fields.map((field, i) => { + if (i !== index) return field; + const next = { ...field, ...patch }; + if (patch.label !== undefined && !field.idEdited) { + next.id = suggestId( + patch.label, + draft.fields.filter((_, j) => j !== index).map((f) => f.id), + ); + } + return next; + }), + }); + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + if (submitting.current) return; + let unsigned: ReturnType; + try { + unsigned = buildPrompt(draft, channelId); + } catch (reason) { + setError( + reason instanceof PromptDraftError + ? reason.message + : "This request cannot be sent.", + ); + return; + } + submitting.current = true; + setPending(true); + setError(null); + try { + const signed = await signRelayEvent(unsigned); + await relayClient.publishEvent( + signed, + "Timed out sending the request.", + "The relay did not accept the request.", + ); + toast.success(draft.type === "poll" ? "Poll started" : "Request sent"); + onOpenChange(false); + setDraft(emptyDraft(draft.type)); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + submitting.current = false; + setPending(false); + } + }; + + const showOptions = draft.type !== "form"; + const showFields = draft.type !== "poll"; + return ( + + +
void submit(e)} className="space-y-4"> + + Ask for a decision + + Members answer with signed responses. Answers are visible to + everyone who can read this channel. Experimental. + + + +
+ Kind of request + {(Object.keys(TYPE_LABELS) as PromptType[]).map((type) => ( + + ))} +
+ +
+ +