feat: add post / upload status
12 unmodified lines131415161718192021222340 unmodified lines12 unmodified linesimport { AtprotoDid } from "@atcute/lexicons/syntax";import { toaster } from "./components/Toaster";import { autoTranscribe } from "./lib/settings";import { preloadModel } from "./lib/transcribe";import { Text } from "~/components/ui/text";import { Link } from "~/components/ui/link";import { HStack } from "styled-system/jsx";
const root = document.getElementById("root");
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {40 unmodified lines1123445678910111212131415161718192021224 unmodified lines2728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808114 unmodified lines9697989910010110210310435 unmodified lines14014114214314414514614714816 unmodified lines1651661671681691701711721731741751761771781791801811821831341351841851861871881892 unmodified lines1921931941951461961971981992006 unmodified lines2072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382394 unmodified linesimport { Component, createSignal, Signal } from "solid-js";import { createSignal, Signal } from "solid-js";
import { CaptionsIcon, SendIcon, XIcon } from "lucide-solid";import { Stack } from "styled-system/jsx";import { HStack, Stack, VStack } from "styled-system/jsx";import { IconButton } from "~/components/ui/icon-button";import { Spinner } from "~/components/ui/spinner";import { Text } from "~/components/ui/text";import { Link } from "~/components/ui/link";
import { parseCanonicalResourceUri } from "@atcute/lexicons/syntax";import { css } from "styled-system/css";import { sendPost } from "~/lib/at";import { sendPost, UploadStatus } from "~/lib/at";import { toaster } from "~/components/Toaster";import { Dialog } from "~/components/ui/dialog";import { Textarea } from "~/components/ui/textarea";import { Account } from "~/lib/accounts";import { Popover } from "./ui/popover";import { Progress } from "./ui/progress";
const PostDialog = (props: { result: Blob; account: Account | undefined;4 unmodified lines const [altText, setAltText] = createSignal<string>( props.initialAltText ?? "", ); const [posting, setPosting] = createSignal(false); const [uploadStatus, setUploadStatus] = createSignal<UploadStatus | null>( null, ); const [open, setOpen] = props.openSignal;
const getStatusMessage = () => { const status = uploadStatus(); if (!status) return "";
switch (status.stage) { case "auth": return "authenticating..."; case "uploading": return "uploading video..."; case "processing": return status.progress ? `processing video... ${Math.round(status.progress)}%` : "processing video..."; case "posting": return "creating post..."; case "complete": return "complete!"; default: return ""; } };
const getProgressValue = () => { const status = uploadStatus(); if (!status) return 0;
switch (status.stage) { case "auth": return 5; case "uploading": return 10; case "processing": return status.progress ? 10 + status.progress * 0.6 : 40; case "posting": return 90; case "complete": return 100; default: return 0; } };
return ( <Dialog.Root open={open()} onOpenChange={(e) => setOpen(e.open)}> <Dialog.Backdrop /> <Dialog.Positioner>14 unmodified lines resize="none" border="none" borderTop="1px solid var(--colors-border-muted)" boxShadow={{ base: "none", _focus: "none" }} disabled={posting()} /> </Stack> <Stack borderTop="1px solid var(--colors-border-muted)"35 unmodified lines <IconButton {...triggerProps()} variant={altText() ? "solid" : "ghost"} size="sm" disabled={posting()} > <CaptionsIcon /> </IconButton> )}16 unmodified lines <IconButton disabled={posting()} onClick={() => { setPosting(true); setUploadStatus(null); sendPost( props.account?.did!, props.result, postContent(), altText(), (status) => setUploadStatus(status), ) .then((result) => { const parsedUri = parseCanonicalResourceUri(result.uri); if (!parsedUri.ok) throw "failed to parse atproto uri"; const { repo, rkey } = parsedUri.value; toaster.create({ title: "post sent", description: ( <> <Text>view post </Text> <HStack gap="1"> <Text>view post</Text> <Link href={`https://bsky.app/profile/${repo}/post/${rkey}`} color={{ base: "colorPalette.text",2 unmodified lines textDecoration={{ _hover: "underline" }} > here </Link> </> </HStack> ), type: "success", }); setOpen(false);6 unmodified lines }); }) .finally(() => { setPosting(false); setUploadStatus(null); }); }} variant="ghost" size="sm" > <SendIcon /> </IconButton> </Stack> {posting() && uploadStatus() && ( <VStack gap="2" p="2" borderTop="1px solid var(--colors-border-muted)" > <Text fontSize="sm" color="fg.muted"> {getStatusMessage()} </Text> <Progress value={getProgressValue()} max={100} colorPalette="blue" /> </VStack> )} </Stack> </Dialog.Content> </Dialog.Positioner> </Dialog.Root>4 unmodified lines30 unmodified lines313233343536373839404142434445464748495051525320 unmodified lines74757677787980818215 unmodified lines98991001011021031041051061 unmodified line1081091101111121131141151164 unmodified lines12112212312412512612712812913013113213313413513613712 unmodified lines15015115215315415515615730 unmodified lines pds: didDoc.data.pds, };};
export type UploadStatus = { stage: "auth" | "uploading" | "processing" | "posting" | "complete"; progress?: number;};
export const sendPost = async ( did: AtprotoDid, blob: Blob, postContent: string, altText?: string, onStatus?: (status: UploadStatus) => void,) => { const login = await getSessionClient(did);
onStatus?.({ stage: "auth" }); const serviceAuthUrl = new URL( `${login.pds}/xrpc/com.atproto.server.getServiceAuth`, ); serviceAuthUrl.searchParams.append(20 unmodified lines
const serviceAuth = await serviceAuthResponse.json(); const token = serviceAuth.token;
onStatus?.({ stage: "uploading" }); const uploadUrl = new URL( "https://video.bsky.app/xrpc/app.bsky.video.uploadVideo", ); uploadUrl.searchParams.append("did", did);15 unmodified lines
const jobStatus = await uploadResponse.json(); let videoBlobRef = jobStatus.blob;
onStatus?.({ stage: "processing" }); while (!videoBlobRef) { await new Promise((resolve) => setTimeout(resolve, 1000));
const statusResponse = await fetch(1 unmodified line );
if (!statusResponse.ok) { const error = await statusResponse.json(); // reuse blob if (error.error === "already_exists" && error.blob) { videoBlobRef = error.blob; break; }4 unmodified lines if (status.jobStatus.blob) { videoBlobRef = status.jobStatus.blob; } else if (status.jobStatus.state === "JOB_STATE_FAILED") { throw `video processing failed: ${status.jobStatus.error || "unknown error"}`; } else if (status.jobStatus.progress !== undefined) { onStatus?.({ stage: "processing", progress: status.jobStatus.progress, }); } }
onStatus?.({ stage: "posting" }); const record: AppBskyFeedPost.Main = { $type: "app.bsky.feed.post", text: postContent, embed: {12 unmodified lines }, });
if (!result.ok) throw `failed to upload post: ${result.data.error}`;
onStatus?.({ stage: "complete" }); return result.data;};