+73
-6
src/components/PostDialog.tsx
+73
-6
src/components/PostDialog.tsx
···
1
-
import { Component, createSignal, Signal } from "solid-js";
2
3
import { CaptionsIcon, SendIcon, XIcon } from "lucide-solid";
4
-
import { Stack } from "styled-system/jsx";
5
import { IconButton } from "~/components/ui/icon-button";
6
import { Spinner } from "~/components/ui/spinner";
7
import { Text } from "~/components/ui/text";
···
9
10
import { parseCanonicalResourceUri } from "@atcute/lexicons/syntax";
11
import { css } from "styled-system/css";
12
-
import { sendPost } from "~/lib/at";
13
import { toaster } from "~/components/Toaster";
14
import { Dialog } from "~/components/ui/dialog";
15
import { Textarea } from "~/components/ui/textarea";
16
import { Account } from "~/lib/accounts";
17
import { Popover } from "./ui/popover";
18
19
const PostDialog = (props: {
20
result: Blob;
···
27
props.initialAltText ?? "",
28
);
29
const [posting, setPosting] = createSignal(false);
30
const [open, setOpen] = props.openSignal;
31
32
return (
33
<Dialog.Root open={open()} onOpenChange={(e) => setOpen(e.open)}>
34
<Dialog.Backdrop />
···
51
border="none"
52
borderTop="1px solid var(--colors-border-muted)"
53
boxShadow={{ base: "none", _focus: "none" }}
54
/>
55
</Stack>
56
<Stack
···
94
{...triggerProps()}
95
variant={altText() ? "solid" : "ghost"}
96
size="sm"
97
>
98
<CaptionsIcon />
99
</IconButton>
···
118
disabled={posting()}
119
onClick={() => {
120
setPosting(true);
121
sendPost(
122
props.account?.did!,
123
props.result,
124
postContent(),
125
altText(),
126
)
127
.then((result) => {
128
const parsedUri = parseCanonicalResourceUri(result.uri);
···
131
toaster.create({
132
title: "post sent",
133
description: (
134
-
<>
135
-
<Text>view post </Text>
136
<Link
137
href={`https://bsky.app/profile/${repo}/post/${rkey}`}
138
color={{
···
143
>
144
here
145
</Link>
146
-
</>
147
),
148
type: "success",
149
});
···
158
})
159
.finally(() => {
160
setPosting(false);
161
});
162
}}
163
variant="ghost"
···
166
<SendIcon />
167
</IconButton>
168
</Stack>
169
</Stack>
170
</Dialog.Content>
171
</Dialog.Positioner>
···
1
+
import { createSignal, Signal } from "solid-js";
2
3
import { CaptionsIcon, SendIcon, XIcon } from "lucide-solid";
4
+
import { HStack, Stack, VStack } from "styled-system/jsx";
5
import { IconButton } from "~/components/ui/icon-button";
6
import { Spinner } from "~/components/ui/spinner";
7
import { Text } from "~/components/ui/text";
···
9
10
import { parseCanonicalResourceUri } from "@atcute/lexicons/syntax";
11
import { css } from "styled-system/css";
12
+
import { sendPost, UploadStatus } from "~/lib/at";
13
import { toaster } from "~/components/Toaster";
14
import { Dialog } from "~/components/ui/dialog";
15
import { Textarea } from "~/components/ui/textarea";
16
import { Account } from "~/lib/accounts";
17
import { Popover } from "./ui/popover";
18
+
import { Progress } from "./ui/progress";
19
20
const PostDialog = (props: {
21
result: Blob;
···
28
props.initialAltText ?? "",
29
);
30
const [posting, setPosting] = createSignal(false);
31
+
const [uploadStatus, setUploadStatus] = createSignal<UploadStatus | null>(
32
+
null,
33
+
);
34
const [open, setOpen] = props.openSignal;
35
36
+
const getStatusMessage = () => {
37
+
const status = uploadStatus();
38
+
if (!status) return "";
39
+
40
+
switch (status.stage) {
41
+
case "auth":
42
+
return "authenticating...";
43
+
case "uploading":
44
+
return "uploading video...";
45
+
case "processing":
46
+
return status.progress
47
+
? `processing video... ${Math.round(status.progress)}%`
48
+
: "processing video...";
49
+
case "posting":
50
+
return "creating post...";
51
+
case "complete":
52
+
return "complete!";
53
+
default:
54
+
return "";
55
+
}
56
+
};
57
+
58
+
const getProgressValue = () => {
59
+
const status = uploadStatus();
60
+
if (!status) return 0;
61
+
62
+
switch (status.stage) {
63
+
case "auth":
64
+
return 5;
65
+
case "uploading":
66
+
return 10;
67
+
case "processing":
68
+
return status.progress ? 10 + status.progress * 0.6 : 40;
69
+
case "posting":
70
+
return 90;
71
+
case "complete":
72
+
return 100;
73
+
default:
74
+
return 0;
75
+
}
76
+
};
77
+
78
return (
79
<Dialog.Root open={open()} onOpenChange={(e) => setOpen(e.open)}>
80
<Dialog.Backdrop />
···
97
border="none"
98
borderTop="1px solid var(--colors-border-muted)"
99
boxShadow={{ base: "none", _focus: "none" }}
100
+
disabled={posting()}
101
/>
102
</Stack>
103
<Stack
···
141
{...triggerProps()}
142
variant={altText() ? "solid" : "ghost"}
143
size="sm"
144
+
disabled={posting()}
145
>
146
<CaptionsIcon />
147
</IconButton>
···
166
disabled={posting()}
167
onClick={() => {
168
setPosting(true);
169
+
setUploadStatus(null);
170
sendPost(
171
props.account?.did!,
172
props.result,
173
postContent(),
174
altText(),
175
+
(status) => setUploadStatus(status),
176
)
177
.then((result) => {
178
const parsedUri = parseCanonicalResourceUri(result.uri);
···
181
toaster.create({
182
title: "post sent",
183
description: (
184
+
<HStack gap="1">
185
+
<Text>view post</Text>
186
<Link
187
href={`https://bsky.app/profile/${repo}/post/${rkey}`}
188
color={{
···
193
>
194
here
195
</Link>
196
+
</HStack>
197
),
198
type: "success",
199
});
···
208
})
209
.finally(() => {
210
setPosting(false);
211
+
setUploadStatus(null);
212
});
213
}}
214
variant="ghost"
···
217
<SendIcon />
218
</IconButton>
219
</Stack>
220
+
{posting() && uploadStatus() && (
221
+
<VStack
222
+
gap="2"
223
+
p="2"
224
+
borderTop="1px solid var(--colors-border-muted)"
225
+
>
226
+
<Text fontSize="sm" color="fg.muted">
227
+
{getStatusMessage()}
228
+
</Text>
229
+
<Progress
230
+
value={getProgressValue()}
231
+
max={100}
232
+
colorPalette="blue"
233
+
/>
234
+
</VStack>
235
+
)}
236
</Stack>
237
</Dialog.Content>
238
</Dialog.Positioner>
+3
src/index.tsx
+3
src/index.tsx
···
14
import { toaster } from "./components/Toaster";
15
import { autoTranscribe } from "./lib/settings";
16
import { preloadModel } from "./lib/transcribe";
17
+
import { Text } from "~/components/ui/text";
18
+
import { Link } from "~/components/ui/link";
19
+
import { HStack } from "styled-system/jsx";
20
21
const root = document.getElementById("root");
22
+18
src/lib/at.ts
+18
src/lib/at.ts
···
32
};
33
};
34
35
export const sendPost = async (
36
did: AtprotoDid,
37
blob: Blob,
38
postContent: string,
39
altText?: string,
40
) => {
41
const login = await getSessionClient(did);
42
43
const serviceAuthUrl = new URL(
44
`${login.pds}/xrpc/com.atproto.server.getServiceAuth`,
45
);
···
68
const serviceAuth = await serviceAuthResponse.json();
69
const token = serviceAuth.token;
70
71
const uploadUrl = new URL(
72
"https://video.bsky.app/xrpc/app.bsky.video.uploadVideo",
73
);
···
91
const jobStatus = await uploadResponse.json();
92
let videoBlobRef = jobStatus.blob;
93
94
while (!videoBlobRef) {
95
await new Promise((resolve) => setTimeout(resolve, 1000));
96
···
100
101
if (!statusResponse.ok) {
102
const error = await statusResponse.json();
103
if (error.error === "already_exists" && error.blob) {
104
videoBlobRef = error.blob;
105
break;
···
112
videoBlobRef = status.jobStatus.blob;
113
} else if (status.jobStatus.state === "JOB_STATE_FAILED") {
114
throw `video processing failed: ${status.jobStatus.error || "unknown error"}`;
115
}
116
}
117
118
const record: AppBskyFeedPost.Main = {
119
$type: "app.bsky.feed.post",
120
text: postContent,
···
135
});
136
137
if (!result.ok) throw `failed to upload post: ${result.data.error}`;
138
return result.data;
139
};
···
32
};
33
};
34
35
+
export type UploadStatus = {
36
+
stage: "auth" | "uploading" | "processing" | "posting" | "complete";
37
+
progress?: number;
38
+
};
39
+
40
export const sendPost = async (
41
did: AtprotoDid,
42
blob: Blob,
43
postContent: string,
44
altText?: string,
45
+
onStatus?: (status: UploadStatus) => void,
46
) => {
47
const login = await getSessionClient(did);
48
49
+
onStatus?.({ stage: "auth" });
50
const serviceAuthUrl = new URL(
51
`${login.pds}/xrpc/com.atproto.server.getServiceAuth`,
52
);
···
75
const serviceAuth = await serviceAuthResponse.json();
76
const token = serviceAuth.token;
77
78
+
onStatus?.({ stage: "uploading" });
79
const uploadUrl = new URL(
80
"https://video.bsky.app/xrpc/app.bsky.video.uploadVideo",
81
);
···
99
const jobStatus = await uploadResponse.json();
100
let videoBlobRef = jobStatus.blob;
101
102
+
onStatus?.({ stage: "processing" });
103
while (!videoBlobRef) {
104
await new Promise((resolve) => setTimeout(resolve, 1000));
105
···
109
110
if (!statusResponse.ok) {
111
const error = await statusResponse.json();
112
+
// reuse blob
113
if (error.error === "already_exists" && error.blob) {
114
videoBlobRef = error.blob;
115
break;
···
122
videoBlobRef = status.jobStatus.blob;
123
} else if (status.jobStatus.state === "JOB_STATE_FAILED") {
124
throw `video processing failed: ${status.jobStatus.error || "unknown error"}`;
125
+
} else if (status.jobStatus.progress !== undefined) {
126
+
onStatus?.({
127
+
stage: "processing",
128
+
progress: status.jobStatus.progress,
129
+
});
130
}
131
}
132
133
+
onStatus?.({ stage: "posting" });
134
const record: AppBskyFeedPost.Main = {
135
$type: "app.bsky.feed.post",
136
text: postContent,
···
151
});
152
153
if (!result.ok) throw `failed to upload post: ${result.data.error}`;
154
+
155
+
onStatus?.({ stage: "complete" });
156
return result.data;
157
};