Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 99 additions & 8 deletions frontend/src/app/dashboard/create/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState } from "react";
import { useEffect, useState } from "react";
import { Card, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
Expand All @@ -9,10 +9,20 @@ import { useWalletStore } from "@/stores/wallet-store";
import { useBlockHeight } from "@/hooks/use-block-height";
import { useStacksTx } from "@/hooks/use-stacks-tx";
import { useTokenBalance } from "@/hooks/use-token-balance";
import { buildCreateStreamTx } from "@/lib/stacks";
import { buildCreateStreamTx, getSenderStreams } from "@/lib/stacks";
import {
PREFILL_KEYS,
buildStreamSetupLink,
hasPrefill,
parseStreamPrefill,
type StreamPrefill,
} from "@/lib/stream-setup-link";
import { useWorkspace } from "@/hooks/use-workspace";
import { LinkStreamButton } from "@/components/stream/link-stream-button";
import {
DEFAULT_TOKEN,
DURATION_UNITS,
NETWORK,
EXPLORER_BASE,
BLOCKS_PER_HOUR,
toRawAmount,
Expand All @@ -26,18 +36,43 @@ import {
type TokenSelection,
} from "@/components/stream/token-selector";
import { toast } from "sonner";
import { Zap, ArrowRight, Info, Loader2, CheckCircle2 } from "lucide-react";
import { Zap, ArrowRight, Info, Loader2, CheckCircle2, Copy } from "lucide-react";

export default function CreateStreamPage() {
const { address, isConnected } = useWalletStore();
const { blockHeight } = useBlockHeight();
const { execute, isPending, isConfirming, txId, status, error, reset } = useStacksTx();

const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
const [durationValue, setDurationValue] = useState("30");
const [durationUnit, setDurationUnit] = useState<DurationUnit>("days");
const [memo, setMemo] = useState("");
// A prepared setup link fills the form once, on first render. The wallet
// owner still reviews every field and signs; see lib/stream-setup-link.ts.
const [prefill] = useState<StreamPrefill>(() =>
typeof window === "undefined" ? {} : parseStreamPrefill(window.location.search, NETWORK)
);
const fromLink = hasPrefill(prefill);
const [recipient, setRecipient] = useState(prefill.recipient ?? "");
const [amount, setAmount] = useState(prefill.amount ?? "");
const [durationValue, setDurationValue] = useState(prefill.durationValue ?? "30");
const [durationUnit, setDurationUnit] = useState<DurationUnit>(prefill.durationUnit ?? "days");
const [memo, setMemo] = useState(prefill.memo ?? "");
const [createdStreamId, setCreatedStreamId] = useState<number | null>(null);
const { workspace } = useWorkspace();

// Strip the setup values from the address bar once the form is showing, so
// a refresh does not refill it over edits. Waiting for a wallet means a
// refresh before connecting still keeps the link. `token` is left for the
// token selector, which applies and strips it itself.
useEffect(() => {
if (!isConnected) return;
const params = new URLSearchParams(window.location.search);
if (!PREFILL_KEYS.some((key) => params.has(key))) return;
PREFILL_KEYS.forEach((key) => params.delete(key));
const rest = params.toString();
window.history.replaceState(
null,
"",
rest ? `${window.location.pathname}?${rest}` : window.location.pathname
);
}, [isConnected]);
// The selection carries chain-verified metadata (assetName + decimals read
// from the contract), which is what the transaction builder needs. Starting
// on the curated default means the form is usable immediately; a `?token=`
Expand Down Expand Up @@ -140,6 +175,14 @@ export default function CreateStreamPage() {

if (result?.confirmed) {
toast.success("Stream created and confirmed on-chain!");
// The wallet result carries no stream id. Stream ids only increase, so
// the sender's highest id is the stream this transaction just created.
try {
const ids = await getSenderStreams(address);
setCreatedStreamId(ids.length > 0 ? Math.max(...ids) : null);
} catch {
setCreatedStreamId(null);
}
setRecipient("");
setAmount("");
setDurationValue("30");
Expand All @@ -152,6 +195,23 @@ export default function CreateStreamPage() {

const isSubmitting = isPending || isConfirming;

async function copySetupLink() {
const link = buildStreamSetupLink(window.location.origin, {
token: selectedToken.resolved.contractId,
recipient,
amount,
durationValue,
durationUnit,
memo,
});
try {
await navigator.clipboard.writeText(link);
toast.success("Setup link copied. Send it to the person who will sign.");
} catch {
toast.error("Could not copy. Your browser blocked clipboard access.");
}
}

return (
<div className="max-w-2xl mx-auto">
<Card>
Expand All @@ -162,6 +222,13 @@ export default function CreateStreamPage() {
</CardDescription>

<form onSubmit={handleSubmit} className="mt-6 space-y-5">
{fromLink && status !== "success" && (
<div className="rounded-xl border border-brand-500/30 bg-brand-500/5 p-4 text-sm text-zinc-300">
This form was filled in from a setup link. Check the recipient, the token and the
amount before you sign.
</div>
)}

<Input
label="Recipient Address"
placeholder="ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM"
Expand Down Expand Up @@ -347,6 +414,19 @@ export default function CreateStreamPage() {
Your stream is now active. View it on the{" "}
<a href="/dashboard/streams" className="text-brand-400 underline">Manage Streams</a> page.
</p>
{workspace?.isActive && createdStreamId !== null && (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-emerald-500/20 pt-3">
<p className="text-xs text-zinc-400">
Link stream #{createdStreamId} to {workspace.name} so it counts on your
organisation&apos;s public record.
</p>
<LinkStreamButton
streamId={createdStreamId}
workspaceName={workspace.name}
onLinked={() => setCreatedStreamId(null)}
/>
</div>
)}
<a
href={`${EXPLORER_BASE}/txid/${txId}`}
target="_blank"
Expand Down Expand Up @@ -385,6 +465,17 @@ export default function CreateStreamPage() {
</>
)}
</Button>
{status !== "success" && (
<button
type="button"
onClick={copySetupLink}
disabled={isSubmitting}
className="mx-auto flex items-center gap-1.5 text-xs text-zinc-500 transition-colors hover:text-brand-400 disabled:opacity-50"
>
<Copy className="h-3.5 w-3.5" />
Copy setup link for someone else to sign
</button>
)}
</form>
</Card>
</div>
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/app/dashboard/streams/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
import { formatTxError } from "@/lib/utils";
import type { StreamData } from "@/lib/stacks";
import { TopUpDialog } from "@/components/stream/top-up-dialog";
import { LinkStreamButton } from "@/components/stream/link-stream-button";
import { useWorkspace, useTrackedStreams } from "@/hooks/use-workspace";
import { STREAM_STATUS } from "@/lib/constants";
import { toast } from "sonner";
import Link from "next/link";
Expand All @@ -43,6 +45,12 @@ export default function ManageStreamsPage() {
const { execute, isPending, isConfirming } = useStacksTx();
const [filter, setFilter] = useState<FilterValue>("all");
const [topUpTarget, setTopUpTarget] = useState<{ id: number; stream: StreamData } | null>(null);
const { workspace } = useWorkspace();
const canLink = workspace?.isActive === true;
const { tracked, refresh: refreshTracked } = useTrackedStreams(
streams.map((s) => s.id),
canLink
);

const filteredStreams =
filter === "all"
Expand Down Expand Up @@ -127,6 +135,20 @@ export default function ManageStreamsPage() {
stream={stream}
perspective="sender"
actionLoading={isPending || isConfirming}
footer={
canLink && tracked && !tracked.has(stream.id) ? (
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-zinc-500">
Not yet counted on {workspace!.name}&apos;s public record.
</p>
<LinkStreamButton
streamId={stream.id}
workspaceName={workspace!.name}
onLinked={refreshTracked}
/>
</div>
) : undefined
}
onPause={async () => {
const result = await execute(buildPauseStreamTx(stream.id));
if (result?.confirmed) {
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/components/stream/link-stream-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import { Link2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useStacksTx } from "@/hooks/use-stacks-tx";
import { buildTrackStreamTx } from "@/lib/stacks";
import { formatTxError } from "@/lib/utils";

/**
* Links one stream to the sender's organisation, so it counts on the
* organisation's public record. Render it only for an unlinked stream whose
* sender has an active workspace; the contract refuses anything else.
*/
export function LinkStreamButton({
streamId,
workspaceName,
onLinked,
size = "sm",
}: {
streamId: number;
workspaceName: string;
onLinked: () => void;
size?: "sm" | "md";
}) {
const { execute, isWorking } = useStacksTx();

async function link() {
const result = await execute(buildTrackStreamTx(streamId));
if (result?.confirmed) {
toast.success(`Stream #${streamId} now counts for ${workspaceName}`);
onLinked();
} else if (result && !result.confirmed) {
toast.error(formatTxError("Could not link the stream", result));
}
}

return (
<Button variant="outline" size={size} onClick={link} loading={isWorking}>
<Link2 className="h-3.5 w-3.5" />
Link to {workspaceName}
</Button>
);
}
5 changes: 5 additions & 0 deletions frontend/src/components/stream/stream-card.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import type { ReactNode } from "react";
import { Card } from "@/components/ui/card";
import { Badge, streamStatusToBadge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
Expand Down Expand Up @@ -33,6 +34,8 @@ interface StreamCardProps {
onClaimPartial?: () => void;
onExpire?: () => void;
actionLoading?: boolean;
/** Extra row under the actions, e.g. linking the stream to an organisation. */
footer?: ReactNode;
}

export function StreamCard({
Expand All @@ -49,6 +52,7 @@ export function StreamCard({
onClaimPartial,
onExpire,
actionLoading,
footer,
}: StreamCardProps) {
const blockHeight = useAppStore((s) => s.currentBlockHeight);
// Resolve the stream's own token. getTokenConfigByContractId previously
Expand Down Expand Up @@ -245,6 +249,7 @@ export function StreamCard({
</div>
)}
</div>
{footer && <div className="mt-4 border-t border-border/60 pt-4">{footer}</div>}
</Card>
);
}
46 changes: 46 additions & 0 deletions frontend/src/hooks/use-workspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"use client";

import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { getDao, isStreamTracked, type DaoData } from "@/lib/stacks";
import { useWalletStore } from "@/stores/wallet-store";

/** The connected wallet's registered organisation, or null if it has none. */
export function useWorkspace(): { workspace: DaoData | null; isLoading: boolean } {
const address = useWalletStore((s) => s.address);
const query = useQuery({
queryKey: ["workspace", address],
queryFn: () => getDao(address!),
enabled: !!address,
staleTime: 60_000,
});
return { workspace: query.data ?? null, isLoading: query.isLoading };
}

/**
* Which of `streamIds` are linked to the connected wallet's organisation.
* Only queried when the wallet has an active workspace, since nothing else can
* be linked.
*/
export function useTrackedStreams(streamIds: readonly number[], enabled: boolean) {
const address = useWalletStore((s) => s.address);
const queryClient = useQueryClient();
const key = ["tracked-streams", address, [...streamIds].sort((a, b) => a - b).join(",")];

const query = useQuery({
queryKey: key,
queryFn: async () => {
const flags = await Promise.all(streamIds.map((id) => isStreamTracked(address!, id)));
return new Set(streamIds.filter((_, i) => flags[i]));
},
enabled: enabled && !!address && streamIds.length > 0,
staleTime: 60_000,
});

const refresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["tracked-streams", address] });
queryClient.invalidateQueries({ queryKey: ["workspace", address] });
}, [queryClient, address]);

return { tracked: query.data ?? null, isLoading: query.isLoading, refresh };
}
33 changes: 33 additions & 0 deletions frontend/src/lib/stacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,20 @@ export async function isRegisteredDao(admin: string): Promise<boolean> {
return result.value === true;
}

/**
* Whether `streamId` is linked to the organisation registered by `admin`.
* Linking is what makes a stream count on the organisation's public record
* (its stream total on /organisations), so the UI offers it for unlinked ones.
*/
export async function isStreamTracked(admin: string, streamId: number): Promise<boolean> {
const result = await callReadOnly(
STREAM_FACTORY_CONTRACT,
"is-stream-tracked",
[principalCV(admin), uintCV(streamId)]
);
return result.value === true;
}

// ============================================================================
// Transaction builders (return openContractCall options)
// ============================================================================
Expand Down Expand Up @@ -526,6 +540,25 @@ export function buildExpireStreamTx(params: {
};
}

/**
* Link a stream to the sender's organisation (stream-factory `track-stream`).
* Moves no tokens, so deny mode with no post-conditions is exact. The contract
* refuses if the caller is not the stream's sender, has no active workspace,
* or already linked it.
*/
export function buildTrackStreamTx(streamId: number) {
const [factoryAddr, factoryName] = splitContract(STREAM_FACTORY_CONTRACT);
return {
contractAddress: factoryAddr,
contractName: factoryName,
functionName: "track-stream",
functionArgs: [uintCV(streamId)],
postConditionMode: PostConditionMode.Deny,
postConditions: [],
network: getNetwork(),
};
}

export function buildRegisterDaoTx(name: string) {
const [factAddr, factName] = splitContract(STREAM_FACTORY_CONTRACT);
return {
Expand Down
Loading
Loading