+1
-1
src/components/BskyPost.svelte
+1
-1
src/components/BskyPost.svelte
···
161
161
rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10
162
162
"
163
163
style="color: {color};"
164
-
onclick={() => router.navigate(`/profile/${did}`)}
164
+
onclick={() => ((profileOpen = false), router.navigate(`/profile/${did}`))}
165
165
>
166
166
<ProfilePicture {client} {did} size={8} />
167
167
+15
-17
src/components/EmbedMedia.svelte
+15
-17
src/components/EmbedMedia.svelte
···
27
27
height: (i.aspectRatio?.height ?? 3) * sizeFactor
28
28
};
29
29
const cid = i.image.ref.$link;
30
-
const isPreview = cid.startsWith('blob:');
31
30
return {
32
31
...size,
33
-
src: isPreview ? cid : img('feed_fullsize', did, cid),
32
+
src: img('feed_fullsize', did, cid),
34
33
thumbnail: {
35
-
src: isPreview ? cid : img('feed_thumbnail', did, cid),
34
+
src: img('feed_thumbnail', did, cid),
36
35
...size
37
36
}
38
37
};
39
38
})}
40
-
<PhotoSwipeGallery {images} />
39
+
{#if images.length > 0}
40
+
<PhotoSwipeGallery {images} />
41
+
{/if}
41
42
{:else if embed.$type === 'app.bsky.embed.video'}
42
43
{#if isBlob(embed.video)}
43
-
{@const cid = embed.video.ref.$link}
44
-
{@const isPreview = cid.startsWith('blob:')}
45
-
{#if isPreview}
46
-
<!-- svelte-ignore a11y_media_has_caption -->
47
-
<video class="rounded-sm" src={cid} controls></video>
48
-
{:else}
49
-
{#await resolveDidDoc(did) then didDoc}
50
-
{#if didDoc.ok}
51
-
<!-- svelte-ignore a11y_media_has_caption -->
52
-
<video class="rounded-sm" src={blob(didDoc.value.pds, did, cid)} controls></video>
53
-
{/if}
54
-
{/await}
55
-
{/if}
44
+
{#await resolveDidDoc(did) then didDoc}
45
+
{#if didDoc.ok}
46
+
<!-- svelte-ignore a11y_media_has_caption -->
47
+
<video
48
+
class="rounded-sm"
49
+
src={blob(didDoc.value.pds, did, embed.video.ref.$link)}
50
+
controls
51
+
></video>
52
+
{/if}
53
+
{/await}
56
54
{/if}
57
55
{/if}
58
56
</div>
+379
-42
src/components/PostComposer.svelte
+379
-42
src/components/PostComposer.svelte
···
12
12
import Icon from '@iconify/svelte';
13
13
import ProfilePicture from './ProfilePicture.svelte';
14
14
import type { AppBskyEmbedMedia } from '$lib/at/types';
15
+
import { SvelteMap } from 'svelte/reactivity';
16
+
import { handles } from '$lib/state.svelte';
15
17
18
+
type UploadState =
19
+
| { state: 'uploading'; progress: number }
20
+
| { state: 'uploaded'; blob: AtpBlob<string> }
21
+
| { state: 'error'; message: string };
16
22
export type FocusState = 'null' | 'focused';
17
23
export type State = {
18
24
focus: FocusState;
···
20
26
quoting?: PostWithUri;
21
27
replying?: PostWithUri;
22
28
attachedMedia?: AppBskyEmbedMedia;
29
+
blobsState: SvelteMap<string, UploadState>;
23
30
};
24
31
25
32
interface Props {
···
28
35
_state: State;
29
36
}
30
37
31
-
let { client, onPostSent, _state = $bindable({ focus: 'null', text: '' }) }: Props = $props();
38
+
let { client, onPostSent, _state = $bindable() }: Props = $props();
32
39
33
40
const isFocused = $derived(_state.focus === 'focused');
34
41
···
36
43
client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)'
37
44
);
38
45
46
+
const uploadVideo = async (blobUrl: string, mimeType: string) => {
47
+
const blob = await (await fetch(blobUrl)).blob();
48
+
return await client.uploadVideo(blob, mimeType, (status) => {
49
+
if (status.stage === 'uploading' && status.progress !== undefined) {
50
+
_state.blobsState.set(blobUrl, { state: 'uploading', progress: status.progress * 0.5 });
51
+
} else if (status.stage === 'processing' && status.progress !== undefined) {
52
+
_state.blobsState.set(blobUrl, {
53
+
state: 'uploading',
54
+
progress: 0.5 + status.progress * 0.5
55
+
});
56
+
}
57
+
});
58
+
};
59
+
const uploadImage = async (blobUrl: string) => {
60
+
const blob = await (await fetch(blobUrl)).blob();
61
+
return await client.uploadBlob(blob, (progress) => {
62
+
_state.blobsState.set(blobUrl, { state: 'uploading', progress });
63
+
});
64
+
};
65
+
39
66
const post = async (text: string): Promise<Result<PostWithUri, string>> => {
40
67
const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({
41
68
$type: 'com.atproto.repo.strongRef',
···
50
77
const images = _state.attachedMedia.images;
51
78
let uploadedImages: typeof images = [];
52
79
for (const image of images) {
53
-
const blobUrl = (image.image as AtpBlob<string>).ref.$link;
54
-
const blob = await (await fetch(blobUrl)).blob();
55
-
const result = await client.uploadBlob(blob);
56
-
if (!result.ok) return result;
80
+
const upload = _state.blobsState.get((image.image as AtpBlob<string>).ref.$link);
81
+
if (!upload || upload.state !== 'uploaded') continue;
57
82
uploadedImages.push({
58
83
...image,
59
-
image: result.value
84
+
image: upload.blob
60
85
});
61
86
}
62
-
media = {
63
-
..._state.attachedMedia,
64
-
$type: 'app.bsky.embed.images',
65
-
images: uploadedImages
66
-
};
87
+
if (uploadedImages.length > 0)
88
+
media = {
89
+
..._state.attachedMedia,
90
+
$type: 'app.bsky.embed.images',
91
+
images: uploadedImages
92
+
};
67
93
} else if (_state.attachedMedia?.$type === 'app.bsky.embed.video') {
68
-
const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link;
69
-
const blob = await (await fetch(blobUrl)).blob();
70
-
const result = await client.uploadVideo(blob);
71
-
if (!result.ok) return result;
72
-
media = {
73
-
..._state.attachedMedia,
74
-
$type: 'app.bsky.embed.video',
75
-
video: result.value
76
-
};
94
+
const upload = _state.blobsState.get(
95
+
(_state.attachedMedia.video as AtpBlob<string>).ref.$link
96
+
);
97
+
if (upload && upload.state === 'uploaded')
98
+
media = {
99
+
..._state.attachedMedia,
100
+
$type: 'app.bsky.embed.video',
101
+
video: upload.blob
102
+
};
77
103
}
104
+
console.log('media', media);
78
105
79
106
const record: AppBskyFeedPost.Main = {
80
107
$type: 'app.bsky.feed.post',
···
123
150
});
124
151
};
125
152
126
-
let info = $state('');
153
+
let posting = $state(false);
154
+
let postError = $state('');
127
155
let textareaEl: HTMLTextAreaElement | undefined = $state();
156
+
let fileInputEl: HTMLInputElement | undefined = $state();
157
+
let selectingFile = $state(false);
128
158
129
159
const unfocus = () => (_state.focus = 'null');
130
160
161
+
const handleFileSelect = (event: Event) => {
162
+
selectingFile = false;
163
+
164
+
const input = event.target as HTMLInputElement;
165
+
const files = input.files;
166
+
if (!files || files.length === 0) return;
167
+
168
+
const existingImages =
169
+
_state.attachedMedia?.$type === 'app.bsky.embed.images' ? _state.attachedMedia.images : [];
170
+
171
+
let newImages = [...existingImages];
172
+
let hasVideo = false;
173
+
174
+
for (let i = 0; i < files.length; i++) {
175
+
const file = files[i];
176
+
const isVideo = file.type.startsWith('video/');
177
+
const isImage = file.type.startsWith('image/');
178
+
179
+
if (!isVideo && !isImage) {
180
+
postError = 'unsupported file type';
181
+
continue;
182
+
}
183
+
184
+
if (isVideo) {
185
+
if (existingImages.length > 0 || newImages.length > 0) {
186
+
postError = 'cannot mix images and video';
187
+
continue;
188
+
}
189
+
const blobUrl = URL.createObjectURL(file);
190
+
_state.attachedMedia = {
191
+
$type: 'app.bsky.embed.video',
192
+
video: {
193
+
$type: 'blob',
194
+
ref: { $link: blobUrl },
195
+
mimeType: file.type,
196
+
size: file.size
197
+
}
198
+
};
199
+
hasVideo = true;
200
+
break;
201
+
} else if (isImage) {
202
+
if (newImages.length >= 4) {
203
+
postError = 'max 4 images allowed';
204
+
break;
205
+
}
206
+
const blobUrl = URL.createObjectURL(file);
207
+
newImages.push({
208
+
image: {
209
+
$type: 'blob',
210
+
ref: { $link: blobUrl },
211
+
mimeType: file.type,
212
+
size: file.size
213
+
},
214
+
alt: '',
215
+
aspectRatio: undefined
216
+
});
217
+
}
218
+
}
219
+
220
+
if (!hasVideo && newImages.length > 0) {
221
+
_state.attachedMedia = {
222
+
$type: 'app.bsky.embed.images',
223
+
images: newImages
224
+
};
225
+
}
226
+
227
+
const handleUpload = (blobUrl: string, blob: Result<AtpBlob<string>, string>) => {
228
+
if (blob.ok) _state.blobsState.set(blobUrl, { state: 'uploaded', blob: blob.value });
229
+
else _state.blobsState.set(blobUrl, { state: 'error', message: blob.error });
230
+
};
231
+
232
+
const media = _state.attachedMedia;
233
+
if (media?.$type == 'app.bsky.embed.images') {
234
+
for (const image of media.images) {
235
+
const blobUrl = (image.image as AtpBlob<string>).ref.$link;
236
+
uploadImage(blobUrl).then((r) => handleUpload(blobUrl, r));
237
+
}
238
+
} else if (media?.$type === 'app.bsky.embed.video') {
239
+
const blobUrl = (media.video as AtpBlob<string>).ref.$link;
240
+
uploadVideo(blobUrl, media.video.mimeType).then((r) => handleUpload(blobUrl, r));
241
+
}
242
+
243
+
input.value = '';
244
+
};
245
+
246
+
const removeMedia = () => {
247
+
if (_state.attachedMedia?.$type === 'app.bsky.embed.video') {
248
+
const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link;
249
+
_state.blobsState.delete(blobUrl);
250
+
}
251
+
_state.attachedMedia = undefined;
252
+
};
253
+
254
+
const removeMediaAtIndex = (index: number) => {
255
+
if (_state.attachedMedia?.$type !== 'app.bsky.embed.images') return;
256
+
const imageToRemove = _state.attachedMedia.images[index];
257
+
const blobUrl = (imageToRemove.image as AtpBlob<string>).ref.$link;
258
+
_state.blobsState.delete(blobUrl);
259
+
260
+
const images = _state.attachedMedia.images.filter((_, i) => i !== index);
261
+
_state.attachedMedia = images.length > 0 ? { ..._state.attachedMedia, images } : undefined;
262
+
};
263
+
131
264
const doPost = () => {
132
265
if (_state.text.length === 0 || _state.text.length > 300) return;
133
266
134
-
post(_state.text).then((res) => {
135
-
if (res.ok) {
136
-
onPostSent(res.value);
137
-
_state.text = '';
138
-
info = 'posted!';
139
-
unfocus();
140
-
setTimeout(() => (info = ''), 800);
141
-
} else {
142
-
info = res.error;
143
-
setTimeout(() => (info = ''), 3000);
144
-
}
145
-
});
267
+
postError = '';
268
+
posting = true;
269
+
post(_state.text)
270
+
.then((res) => {
271
+
if (res.ok) {
272
+
onPostSent(res.value);
273
+
_state.text = '';
274
+
_state.attachedMedia = undefined;
275
+
_state.blobsState.clear();
276
+
unfocus();
277
+
} else {
278
+
postError = res.error;
279
+
}
280
+
})
281
+
.finally(() => {
282
+
posting = false;
283
+
});
146
284
};
147
285
148
286
$effect(() => {
149
-
if (!client.atcute) info = 'not logged in';
150
287
document.documentElement.style.setProperty('--acc-color', color);
151
288
if (isFocused && textareaEl) textareaEl.focus();
152
289
});
···
169
306
{#snippet attachmentIndicator(post: PostWithUri, type: 'quoting' | 'replying')}
170
307
{@const parsedUri = expect(parseCanonicalResourceUri(post.uri))}
171
308
{@const color = generateColorForDid(parsedUri.repo)}
309
+
{@const id = handles.get(parsedUri.repo) ?? parsedUri.repo}
172
310
<div
173
311
class="flex shrink-0 items-center gap-1.5 rounded-sm border py-0.5 pr-0.5 pl-1 text-xs font-bold transition-all"
174
312
style="
···
176
314
border-color: {color};
177
315
color: {color};
178
316
"
179
-
title={type === 'replying' ? `replying to @${parsedUri.repo}` : `quoting @${parsedUri.repo}`}
317
+
title={type === 'replying' ? `replying to ${id}` : `quoting ${id}`}
180
318
>
181
319
<span class="truncate text-sm font-normal opacity-90">
182
320
{type === 'replying' ? 'replying to' : 'quoting'}
···
201
339
{/if}
202
340
{/snippet}
203
341
342
+
{#snippet uploadControls(blobUrl: string, remove: () => void)}
343
+
{@const upload = _state.blobsState.get(blobUrl)}
344
+
{#if upload !== undefined && upload.state === 'uploading'}
345
+
<div
346
+
class="absolute top-2 right-2 z-10 flex items-center gap-2 rounded-sm bg-black/70 p-1.5 text-sm backdrop-blur-sm"
347
+
>
348
+
<div class="flex justify-center">
349
+
<div
350
+
class="h-5 w-5 animate-spin rounded-full border-4 border-t-transparent"
351
+
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
352
+
></div>
353
+
</div>
354
+
<span class="font-medium">{Math.round(upload.progress * 100)}%</span>
355
+
</div>
356
+
{:else}
357
+
<div class="absolute top-2 right-2 z-10 flex items-center gap-1">
358
+
{#if upload !== undefined && upload.state === 'error'}
359
+
<span
360
+
class="rounded-sm bg-black/70 p-1.5 px-1 text-sm font-bold text-red-500 backdrop-blur-sm"
361
+
>{upload.message}</span
362
+
>
363
+
{/if}
364
+
<button
365
+
onclick={(e) => {
366
+
e.preventDefault();
367
+
e.stopPropagation();
368
+
remove();
369
+
}}
370
+
onmousedown={(e) => e.preventDefault()}
371
+
class="rounded-sm bg-black/70 p-1.5 backdrop-blur-sm {upload?.state !== 'error'
372
+
? 'opacity-0 transition-opacity group-hover:opacity-100'
373
+
: ''}"
374
+
>
375
+
{#if upload?.state === 'error'}
376
+
<Icon
377
+
class="text-red-500 group-hover:hidden"
378
+
icon="heroicons:exclamation-circle-16-solid"
379
+
width={20}
380
+
/>
381
+
{/if}
382
+
<Icon
383
+
class={upload?.state === 'error' ? 'hidden group-hover:block' : ''}
384
+
icon="heroicons:x-mark-16-solid"
385
+
width={20}
386
+
/>
387
+
</button>
388
+
</div>
389
+
{/if}
390
+
{/snippet}
391
+
392
+
{#snippet mediaPreview(embed: AppBskyEmbedMedia)}
393
+
{#if embed.$type === 'app.bsky.embed.images'}
394
+
<div class="image-preview-grid" data-total={embed.images.length}>
395
+
{#each embed.images as image, idx (idx)}
396
+
{@const blobUrl = (image.image as AtpBlob<string>).ref.$link}
397
+
<div class="image-preview-item group">
398
+
<img src={blobUrl} alt="" />
399
+
{@render uploadControls(blobUrl, () => removeMediaAtIndex(idx))}
400
+
</div>
401
+
{/each}
402
+
</div>
403
+
{:else if embed.$type === 'app.bsky.embed.video'}
404
+
{@const blobUrl = (embed.video as AtpBlob<string>).ref.$link}
405
+
<div
406
+
class="group relative max-h-[30vh] overflow-hidden rounded-sm"
407
+
style="aspect-ratio: 16/10;"
408
+
>
409
+
<!-- svelte-ignore a11y_media_has_caption -->
410
+
<video src={blobUrl} controls class="h-full w-full"></video>
411
+
{@render uploadControls(blobUrl, removeMedia)}
412
+
</div>
413
+
{/if}
414
+
{/snippet}
415
+
204
416
{#snippet composer(replying?: PostWithUri, quoting?: PostWithUri)}
417
+
{@const hasIncompleteUpload = _state.blobsState
418
+
.values()
419
+
.some((s) => s.state === 'uploading' || s.state === 'error')}
205
420
<div class="flex items-center gap-2">
421
+
<input
422
+
bind:this={fileInputEl}
423
+
type="file"
424
+
accept="image/*,video/*"
425
+
multiple
426
+
onchange={handleFileSelect}
427
+
oncancel={() => (selectingFile = false)}
428
+
class="hidden"
429
+
/>
430
+
<button
431
+
onclick={(e) => {
432
+
e.preventDefault();
433
+
e.stopPropagation();
434
+
selectingFile = true;
435
+
fileInputEl?.click();
436
+
}}
437
+
onmousedown={(e) => e.preventDefault()}
438
+
disabled={_state.attachedMedia?.$type === 'app.bsky.embed.video' ||
439
+
(_state.attachedMedia?.$type === 'app.bsky.embed.images' &&
440
+
_state.attachedMedia.images.length >= 4)}
441
+
class="rounded-sm p-1.5 transition-all duration-150 enabled:hover:scale-110 disabled:cursor-not-allowed disabled:opacity-50"
442
+
style="background: color-mix(in srgb, {color} 15%, transparent); color: {color};"
443
+
title="attach media"
444
+
>
445
+
<Icon icon="heroicons:photo-16-solid" width={20} />
446
+
</button>
447
+
{#if postError.length > 0}
448
+
<div class="group flex items-center gap-2 truncate rounded-sm bg-red-500 p-1.5">
449
+
<button onclick={() => (postError = '')}>
450
+
<Icon
451
+
class="group-hover:hidden"
452
+
icon="heroicons:exclamation-circle-16-solid"
453
+
width={20}
454
+
/>
455
+
<Icon class="hidden group-hover:block" icon="heroicons:x-mark-16-solid" width={20} />
456
+
</button>
457
+
<span title={postError} class="truncate text-sm font-bold">{postError}</span>
458
+
</div>
459
+
{/if}
206
460
<div class="grow"></div>
461
+
{#if posting}
462
+
<div
463
+
class="h-6 w-6 animate-spin rounded-full border-4 border-t-transparent"
464
+
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
465
+
></div>
466
+
{/if}
207
467
<span
208
-
class="text-sm font-medium"
468
+
class="text-sm font-medium text-nowrap"
209
469
style="color: color-mix(in srgb, {_state.text.length > 300
210
470
? '#ef4444'
211
471
: 'var(--nucleus-fg)'} 53%, transparent);"
···
213
473
{_state.text.length} / 300
214
474
</span>
215
475
<button
476
+
onmousedown={(e) => e.preventDefault()}
216
477
onclick={doPost}
217
-
disabled={_state.text.length === 0 || _state.text.length > 300}
218
-
class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed! disabled:opacity-50 disabled:hover:scale-100"
478
+
disabled={(!_state.attachedMedia && _state.text.length === 0) ||
479
+
_state.text.length > 300 ||
480
+
hasIncompleteUpload}
481
+
class="action-button border-none px-4 py-1.5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed! disabled:opacity-50 disabled:hover:scale-100"
219
482
style="background: color-mix(in srgb, {color} 87%, transparent);"
220
483
>
221
484
post
···
238
501
bind:this={textareaEl}
239
502
bind:value={_state.text}
240
503
onfocus={() => (_state.focus = 'focused')}
241
-
onblur={unfocus}
504
+
onblur={() => (!selectingFile ? unfocus() : null)}
242
505
onkeydown={(event) => {
243
506
if (event.key === 'Escape') unfocus();
244
507
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
···
248
511
class="col-start-1 row-start-1 field-sizing-content min-h-[5lh] w-full resize-none overflow-hidden bg-transparent text-wrap break-all whitespace-pre-wrap text-transparent caret-(--nucleus-fg) placeholder:text-(--nucleus-fg)/45"
249
512
></textarea>
250
513
</div>
514
+
{#if _state.attachedMedia}
515
+
{@render mediaPreview(_state.attachedMedia)}
516
+
{/if}
251
517
{#if quoting}
252
518
{@render attachedPost(quoting, 'quoting')}
253
519
{/if}
···
274
540
border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);"
275
541
>
276
542
<div class="w-full p-1 px-2">
277
-
{#if info.length > 0}
543
+
{#if !client.atcute}
278
544
<div
279
545
class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis"
280
546
style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};"
281
547
>
282
-
{info}
548
+
not logged in
283
549
</div>
284
550
{:else}
285
-
<div class="flex flex-col gap-2">
551
+
<div class="flex flex-col gap-1">
286
552
{#if _state.focus === 'focused'}
287
553
{@render composer(_state.replying, _state.quoting)}
288
554
{:else}
···
346
612
347
613
textarea:focus {
348
614
@apply border-none! [box-shadow:none]! outline-none!;
615
+
}
616
+
617
+
/* Image preview grid - based on PhotoSwipeGallery */
618
+
.image-preview-grid {
619
+
display: grid;
620
+
gap: 2px;
621
+
border-radius: 4px;
622
+
overflow: hidden;
623
+
width: 100%;
624
+
max-height: 30vh;
625
+
}
626
+
627
+
.image-preview-item {
628
+
width: 100%;
629
+
height: 100%;
630
+
display: block;
631
+
position: relative;
632
+
overflow: hidden;
633
+
border-radius: 4px;
634
+
}
635
+
636
+
.image-preview-item > img {
637
+
width: 100%;
638
+
height: 100%;
639
+
object-fit: cover;
640
+
}
641
+
642
+
/* Single image: natural aspect ratio */
643
+
.image-preview-grid[data-total='1'] {
644
+
display: block;
645
+
height: auto;
646
+
width: 100%;
647
+
border-radius: 0;
648
+
}
649
+
650
+
.image-preview-grid[data-total='1'] .image-preview-item {
651
+
width: 100%;
652
+
height: auto;
653
+
display: block;
654
+
border-radius: 4px;
655
+
}
656
+
657
+
.image-preview-grid[data-total='1'] .image-preview-item > img {
658
+
width: 100%;
659
+
height: auto;
660
+
max-height: 60vh;
661
+
object-fit: contain;
662
+
}
663
+
664
+
/* 2 Images: Split vertically */
665
+
.image-preview-grid[data-total='2'] {
666
+
grid-template-columns: 1fr 1fr;
667
+
grid-template-rows: 1fr;
668
+
aspect-ratio: 16/9;
669
+
}
670
+
671
+
/* 3 Images: 1 Big (left), 2 Small (stacked right) */
672
+
.image-preview-grid[data-total='3'] {
673
+
grid-template-columns: 1fr 1fr;
674
+
grid-template-rows: 1fr 1fr;
675
+
aspect-ratio: 16/9;
676
+
}
677
+
.image-preview-grid[data-total='3'] .image-preview-item:first-child {
678
+
grid-row: span 2;
679
+
}
680
+
681
+
/* 4 Images: 2x2 Grid */
682
+
.image-preview-grid[data-total='4'] {
683
+
grid-template-columns: 1fr 1fr;
684
+
grid-template-rows: 1fr 1fr;
685
+
aspect-ratio: 16/9;
349
686
}
350
687
</style>
+91
-69
src/lib/at/client.ts
+91
-69
src/lib/at/client.ts
···
29
29
import * as v from '@atcute/lexicons/validations';
30
30
import { MiniDocQuery, type MiniDoc } from './slingshot';
31
31
import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation';
32
-
import type { Records } from '@atcute/lexicons/ambient';
32
+
import type { Records, XRPCProcedures } from '@atcute/lexicons/ambient';
33
33
import { cache as rawCache } from '$lib/cache';
34
34
import { AppBskyActorProfile } from '@atcute/bluesky';
35
35
import { WebSocket } from '@soffinal/websocket';
36
36
import type { Notification } from './stardust';
37
-
import { get } from 'svelte/store';
38
-
import { settings } from '$lib/settings';
39
37
import type { OAuthUserAgent } from '@atcute/oauth-browser-client';
40
38
import { timestampFromCursor, toCanonicalUri, toResourceUri } from '$lib';
41
-
42
-
export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot);
43
-
export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust);
44
-
export const constellationUrl: URL = new URL(get(settings).endpoints.constellation);
39
+
import { constellationUrl, slingshotUrl, spacedustUrl } from '.';
45
40
46
41
export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output };
47
42
···
82
77
83
78
const cache = cacheWithRecords;
84
79
85
-
const wrapBlobWithProgress = (
86
-
blob: Blob,
87
-
onProgress: (uploaded: number, total: number) => void
88
-
): ReadableStream<Uint8Array> => {
89
-
const totalSize = blob.size;
90
-
let uploaded = 0;
80
+
export const xhrPost = (
81
+
url: string,
82
+
body: Blob | File,
83
+
headers: Record<string, string> = {},
84
+
onProgress?: (uploaded: number, total: number) => void
85
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
86
+
): Promise<Result<any, { error: string; message: string }>> => {
87
+
return new Promise((resolve) => {
88
+
const xhr = new XMLHttpRequest();
89
+
xhr.open('POST', url);
91
90
92
-
return new ReadableStream({
93
-
start: async (controller) => {
94
-
const reader = blob.stream().getReader();
91
+
if (onProgress && xhr.upload) {
92
+
xhr.upload.onprogress = (event: ProgressEvent) => {
93
+
if (event.lengthComputable) {
94
+
onProgress(event.loaded, event.total);
95
+
}
96
+
};
97
+
}
95
98
96
-
const push = async () => {
97
-
const { done, value } = await reader.read();
99
+
Object.keys(headers).forEach((key) => {
100
+
xhr.setRequestHeader(key, headers[key]);
101
+
});
98
102
99
-
if (done) {
100
-
controller.close();
101
-
return;
102
-
}
103
+
xhr.onload = () => {
104
+
if (xhr.status >= 200 && xhr.status < 300) {
105
+
resolve(ok(JSON.parse(xhr.responseText)));
106
+
} else {
107
+
resolve(err(JSON.parse(xhr.responseText)));
108
+
}
109
+
};
103
110
104
-
uploaded += value.byteLength;
105
-
onProgress(uploaded, totalSize);
111
+
xhr.onerror = () => {
112
+
resolve(err({ error: 'xhr_error', message: 'network error' }));
113
+
};
106
114
107
-
controller.enqueue(value);
108
-
await push();
109
-
};
115
+
xhr.onabort = () => {
116
+
resolve(err({ error: 'xhr_error', message: 'upload aborted' }));
117
+
};
110
118
111
-
await push();
112
-
}
119
+
xhr.send(body);
113
120
});
114
121
};
115
122
···
293
300
return results;
294
301
}
295
302
296
-
async uploadBlob(
297
-
blob: Blob,
298
-
onProgress?: (progress: number) => void
299
-
): Promise<Result<AtpBlob<string>, string>> {
300
-
if (!this.atcute) return err('not authenticated');
301
-
const input = wrapBlobWithProgress(blob, (uploaded, total) => onProgress?.(uploaded / total));
302
-
const res = await this.atcute.post('com.atproto.repo.uploadBlob', { input });
303
-
if (!res.ok) return err(`upload failed: ${res.data.error}`);
304
-
return ok(res.data.blob);
305
-
}
306
-
307
-
async uploadVideo(
308
-
blob: Blob,
309
-
onStatus?: (status: UploadStatus) => void
310
-
): Promise<Result<AtpBlob<string>, string>> {
303
+
async getServiceAuth(lxm: keyof XRPCProcedures, exp: number): Promise<Result<string, string>> {
311
304
if (!this.atcute || !this.user) return err('not authenticated');
312
-
313
-
onStatus?.({ stage: 'auth' });
314
-
const serviceAuthUrl = new URL(`${this.user.pds}/xrpc/com.atproto.server.getServiceAuth`);
315
-
serviceAuthUrl.searchParams.append('aud', this.user.pds.replace('https://', 'did:web:'));
305
+
const serviceAuthUrl = new URL(`${this.user.pds}xrpc/com.atproto.server.getServiceAuth`);
306
+
serviceAuthUrl.searchParams.append(
307
+
'aud',
308
+
this.user.pds.replace('https://', 'did:web:').slice(0, -1)
309
+
);
316
310
serviceAuthUrl.searchParams.append('lxm', 'com.atproto.repo.uploadBlob');
317
-
serviceAuthUrl.searchParams.append('exp', (Math.floor(Date.now() / 1000) + 60 * 30).toString()); // 30 minutes
311
+
serviceAuthUrl.searchParams.append('exp', exp.toString()); // 30 minutes
318
312
319
313
const serviceAuthResponse = await this.atcute.handler(
320
314
`${serviceAuthUrl.pathname}${serviceAuthUrl.search}`,
···
326
320
const error = await serviceAuthResponse.text();
327
321
return err(`failed to get service auth: ${error}`);
328
322
}
329
-
330
323
const serviceAuth = await serviceAuthResponse.json();
331
-
const token = serviceAuth.token;
324
+
return ok(serviceAuth.token);
325
+
}
326
+
327
+
async uploadBlob(
328
+
blob: Blob,
329
+
onProgress?: (progress: number) => void
330
+
): Promise<Result<AtpBlob<string>, string>> {
331
+
if (!this.atcute || !this.user) return err('not authenticated');
332
+
const tokenResult = await this.getServiceAuth(
333
+
'com.atproto.repo.uploadBlob',
334
+
Math.floor(Date.now() / 1000) + 60
335
+
);
336
+
if (!tokenResult.ok) return tokenResult;
337
+
const result = await xhrPost(
338
+
`${this.user.pds}xrpc/com.atproto.repo.uploadBlob`,
339
+
blob,
340
+
{ authorization: `Bearer ${tokenResult.value}` },
341
+
(uploaded, total) => onProgress?.(uploaded / total)
342
+
);
343
+
if (!result.ok) return err(`upload failed: ${result.error.message}`);
344
+
return ok(result.value);
345
+
}
346
+
347
+
async uploadVideo(
348
+
blob: Blob,
349
+
mimeType: string,
350
+
onStatus?: (status: UploadStatus) => void
351
+
): Promise<Result<AtpBlob<string>, string>> {
352
+
if (!this.atcute || !this.user) return err('not authenticated');
353
+
354
+
onStatus?.({ stage: 'auth' });
355
+
const tokenResult = await this.getServiceAuth(
356
+
'com.atproto.repo.uploadBlob',
357
+
Math.floor(Date.now() / 1000) + 60 * 30
358
+
);
359
+
if (!tokenResult.ok) return tokenResult;
332
360
333
361
onStatus?.({ stage: 'uploading' });
334
362
const uploadUrl = new URL('https://video.bsky.app/xrpc/app.bsky.video.uploadVideo');
335
363
uploadUrl.searchParams.append('did', this.user.did);
336
-
uploadUrl.searchParams.append('name', 'video.mp4');
364
+
uploadUrl.searchParams.append('name', 'video');
337
365
338
-
const body = wrapBlobWithProgress(blob, (uploaded, total) =>
339
-
onStatus?.({ stage: 'uploading', progress: uploaded / total })
340
-
);
341
-
const uploadResponse = await fetch(uploadUrl.toString(), {
342
-
method: 'POST',
343
-
headers: {
344
-
Authorization: `Bearer ${token}`,
345
-
'Content-Type': 'video/mp4'
366
+
const uploadResult = await xhrPost(
367
+
uploadUrl.toString(),
368
+
blob,
369
+
{
370
+
Authorization: `Bearer ${tokenResult.value}`,
371
+
'Content-Type': mimeType
346
372
},
347
-
body
348
-
});
349
-
if (!uploadResponse.ok) {
350
-
const error = await uploadResponse.text();
351
-
return err(`failed to upload video: ${error}`);
352
-
}
353
-
354
-
const jobStatus = await uploadResponse.json();
373
+
(uploaded, total) => onStatus?.({ stage: 'uploading', progress: uploaded / total })
374
+
);
375
+
if (!uploadResult.ok) return err(`failed to upload video: ${uploadResult.error.message}`);
376
+
const jobStatus = uploadResult.value;
355
377
let videoBlobRef: AtpBlob<string> = jobStatus.blob;
356
378
357
379
onStatus?.({ stage: 'processing' });
···
380
402
} else if (status.jobStatus.progress !== undefined) {
381
403
onStatus?.({
382
404
stage: 'processing',
383
-
progress: status.jobStatus.progress
405
+
progress: status.jobStatus.progress / 100
384
406
});
385
407
}
386
408
}
+6
src/lib/at/index.ts
+6
src/lib/at/index.ts
···
1
+
import { settings } from '$lib/settings';
2
+
import { get } from 'svelte/store';
3
+
4
+
export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot);
5
+
export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust);
6
+
export const constellationUrl: URL = new URL(get(settings).endpoints.constellation);
+1
-1
src/lib/at/oauth.ts
+1
-1
src/lib/at/oauth.ts
···
14
14
WebDidDocumentResolver,
15
15
XrpcHandleResolver
16
16
} from '@atcute/identity-resolver';
17
-
import { slingshotUrl } from './client';
18
17
import type { ActorIdentifier } from '@atcute/lexicons';
19
18
import { err, ok, type Result } from '$lib/result';
20
19
import type { AtprotoDid } from '@atcute/lexicons/syntax';
21
20
import { clientId, oauthMetadata, redirectUri } from '$lib/oauth';
21
+
import { slingshotUrl } from '.';
22
22
23
23
configureOAuth({
24
24
metadata: {
+2
-1
src/lib/oauth.ts
+2
-1
src/lib/oauth.ts
···
7
7
client_uri: domain,
8
8
logo_uri: `${domain}/favicon.png`,
9
9
redirect_uris: [`${domain}/`],
10
-
scope: 'atproto repo:*?action=create&action=update&action=delete blob:*/*',
10
+
scope:
11
+
'atproto repo:*?action=create&action=update&action=delete rpc:com.atproto.repo.uploadBlob?aud=* blob:*/*',
11
12
grant_types: ['authorization_code', 'refresh_token'],
12
13
response_types: ['code'],
13
14
token_endpoint_auth_method: 'none',
+1
src/lib/result.ts
+1
src/lib/result.ts
+6
-1
src/routes/[...catchall]/+page.svelte
+6
-1
src/routes/[...catchall]/+page.svelte
···
32
32
import { JetstreamSubscription } from '@atcute/jetstream';
33
33
import { settings } from '$lib/settings';
34
34
import type { Sort } from '$lib/following';
35
+
import { SvelteMap } from 'svelte/reactivity';
35
36
36
37
const { data: loadData }: PageProps = $props();
37
38
···
83
84
else animClass = 'animate-fade-in-scale';
84
85
});
85
86
86
-
let postComposerState = $state<PostComposerState>({ focus: 'null', text: '' });
87
+
let postComposerState = $state<PostComposerState>({
88
+
focus: 'null',
89
+
text: '',
90
+
blobsState: new SvelteMap()
91
+
});
87
92
let showScrollToTop = $state(false);
88
93
const handleScroll = () => {
89
94
if (currentRoute.path === '/' || currentRoute.path === '/profile/:actor')
+1
-2
src/routes/[...catchall]/+page.ts
+1
-2
src/routes/[...catchall]/+page.ts
···
1
-
import { replaceState } from '$app/navigation';
2
1
import { addAccount, loggingIn } from '$lib/accounts';
3
2
import { AtpClient } from '$lib/at/client';
4
3
import { flow, sessions } from '$lib/at/oauth';
···
24
23
const currentUrl = new URL(window.location.href);
25
24
// scrub history so auth state cant be replayed
26
25
try {
27
-
replaceState('', '/');
26
+
history.replaceState(null, '', '/');
28
27
} catch {
29
28
// if router was unitialized then we probably dont need to scrub anyway
30
29
// so its fine