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