replies timeline only, appview-less bluesky client

implement media attachments

ptr.pet bad994e7 a13ea2e7

verified
+1 -1
src/components/BskyPost.svelte
··· 161 rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10 162 " 163 style="color: {color};" 164 - onclick={() => router.navigate(`/profile/${did}`)} 165 > 166 <ProfilePicture {client} {did} size={8} /> 167
··· 161 rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10 162 " 163 style="color: {color};" 164 + onclick={() => ((profileOpen = false), router.navigate(`/profile/${did}`))} 165 > 166 <ProfilePicture {client} {did} size={8} /> 167
+15 -17
src/components/EmbedMedia.svelte
··· 27 height: (i.aspectRatio?.height ?? 3) * sizeFactor 28 }; 29 const cid = i.image.ref.$link; 30 - const isPreview = cid.startsWith('blob:'); 31 return { 32 ...size, 33 - src: isPreview ? cid : img('feed_fullsize', did, cid), 34 thumbnail: { 35 - src: isPreview ? cid : img('feed_thumbnail', did, cid), 36 ...size 37 } 38 }; 39 })} 40 - <PhotoSwipeGallery {images} /> 41 {:else if embed.$type === 'app.bsky.embed.video'} 42 {#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} 56 {/if} 57 {/if} 58 </div>
··· 27 height: (i.aspectRatio?.height ?? 3) * sizeFactor 28 }; 29 const cid = i.image.ref.$link; 30 return { 31 ...size, 32 + src: img('feed_fullsize', did, cid), 33 thumbnail: { 34 + src: img('feed_thumbnail', did, cid), 35 ...size 36 } 37 }; 38 })} 39 + {#if images.length > 0} 40 + <PhotoSwipeGallery {images} /> 41 + {/if} 42 {:else if embed.$type === 'app.bsky.embed.video'} 43 {#if isBlob(embed.video)} 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} 54 {/if} 55 {/if} 56 </div>
+379 -42
src/components/PostComposer.svelte
··· 12 import Icon from '@iconify/svelte'; 13 import ProfilePicture from './ProfilePicture.svelte'; 14 import type { AppBskyEmbedMedia } from '$lib/at/types'; 15 16 export type FocusState = 'null' | 'focused'; 17 export type State = { 18 focus: FocusState; ··· 20 quoting?: PostWithUri; 21 replying?: PostWithUri; 22 attachedMedia?: AppBskyEmbedMedia; 23 }; 24 25 interface Props { ··· 28 _state: State; 29 } 30 31 - let { client, onPostSent, _state = $bindable({ focus: 'null', text: '' }) }: Props = $props(); 32 33 const isFocused = $derived(_state.focus === 'focused'); 34 ··· 36 client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)' 37 ); 38 39 const post = async (text: string): Promise<Result<PostWithUri, string>> => { 40 const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({ 41 $type: 'com.atproto.repo.strongRef', ··· 50 const images = _state.attachedMedia.images; 51 let uploadedImages: typeof images = []; 52 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; 57 uploadedImages.push({ 58 ...image, 59 - image: result.value 60 }); 61 } 62 - media = { 63 - ..._state.attachedMedia, 64 - $type: 'app.bsky.embed.images', 65 - images: uploadedImages 66 - }; 67 } 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 - }; 77 } 78 79 const record: AppBskyFeedPost.Main = { 80 $type: 'app.bsky.feed.post', ··· 123 }); 124 }; 125 126 - let info = $state(''); 127 let textareaEl: HTMLTextAreaElement | undefined = $state(); 128 129 const unfocus = () => (_state.focus = 'null'); 130 131 const doPost = () => { 132 if (_state.text.length === 0 || _state.text.length > 300) return; 133 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 - }); 146 }; 147 148 $effect(() => { 149 - if (!client.atcute) info = 'not logged in'; 150 document.documentElement.style.setProperty('--acc-color', color); 151 if (isFocused && textareaEl) textareaEl.focus(); 152 }); ··· 169 {#snippet attachmentIndicator(post: PostWithUri, type: 'quoting' | 'replying')} 170 {@const parsedUri = expect(parseCanonicalResourceUri(post.uri))} 171 {@const color = generateColorForDid(parsedUri.repo)} 172 <div 173 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 style=" ··· 176 border-color: {color}; 177 color: {color}; 178 " 179 - title={type === 'replying' ? `replying to @${parsedUri.repo}` : `quoting @${parsedUri.repo}`} 180 > 181 <span class="truncate text-sm font-normal opacity-90"> 182 {type === 'replying' ? 'replying to' : 'quoting'} ··· 201 {/if} 202 {/snippet} 203 204 {#snippet composer(replying?: PostWithUri, quoting?: PostWithUri)} 205 <div class="flex items-center gap-2"> 206 <div class="grow"></div> 207 <span 208 - class="text-sm font-medium" 209 style="color: color-mix(in srgb, {_state.text.length > 300 210 ? '#ef4444' 211 : 'var(--nucleus-fg)'} 53%, transparent);" ··· 213 {_state.text.length} / 300 214 </span> 215 <button 216 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" 219 style="background: color-mix(in srgb, {color} 87%, transparent);" 220 > 221 post ··· 238 bind:this={textareaEl} 239 bind:value={_state.text} 240 onfocus={() => (_state.focus = 'focused')} 241 - onblur={unfocus} 242 onkeydown={(event) => { 243 if (event.key === 'Escape') unfocus(); 244 if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost(); ··· 248 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 ></textarea> 250 </div> 251 {#if quoting} 252 {@render attachedPost(quoting, 'quoting')} 253 {/if} ··· 274 border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);" 275 > 276 <div class="w-full p-1 px-2"> 277 - {#if info.length > 0} 278 <div 279 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 280 style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};" 281 > 282 - {info} 283 </div> 284 {:else} 285 - <div class="flex flex-col gap-2"> 286 {#if _state.focus === 'focused'} 287 {@render composer(_state.replying, _state.quoting)} 288 {:else} ··· 346 347 textarea:focus { 348 @apply border-none! [box-shadow:none]! outline-none!; 349 } 350 </style>
··· 12 import Icon from '@iconify/svelte'; 13 import ProfilePicture from './ProfilePicture.svelte'; 14 import type { AppBskyEmbedMedia } from '$lib/at/types'; 15 + import { SvelteMap } from 'svelte/reactivity'; 16 + import { handles } from '$lib/state.svelte'; 17 18 + type UploadState = 19 + | { state: 'uploading'; progress: number } 20 + | { state: 'uploaded'; blob: AtpBlob<string> } 21 + | { state: 'error'; message: string }; 22 export type FocusState = 'null' | 'focused'; 23 export type State = { 24 focus: FocusState; ··· 26 quoting?: PostWithUri; 27 replying?: PostWithUri; 28 attachedMedia?: AppBskyEmbedMedia; 29 + blobsState: SvelteMap<string, UploadState>; 30 }; 31 32 interface Props { ··· 35 _state: State; 36 } 37 38 + let { client, onPostSent, _state = $bindable() }: Props = $props(); 39 40 const isFocused = $derived(_state.focus === 'focused'); 41 ··· 43 client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)' 44 ); 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 + 66 const post = async (text: string): Promise<Result<PostWithUri, string>> => { 67 const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({ 68 $type: 'com.atproto.repo.strongRef', ··· 77 const images = _state.attachedMedia.images; 78 let uploadedImages: typeof images = []; 79 for (const image of images) { 80 + const upload = _state.blobsState.get((image.image as AtpBlob<string>).ref.$link); 81 + if (!upload || upload.state !== 'uploaded') continue; 82 uploadedImages.push({ 83 ...image, 84 + image: upload.blob 85 }); 86 } 87 + if (uploadedImages.length > 0) 88 + media = { 89 + ..._state.attachedMedia, 90 + $type: 'app.bsky.embed.images', 91 + images: uploadedImages 92 + }; 93 } else if (_state.attachedMedia?.$type === 'app.bsky.embed.video') { 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 + }; 103 } 104 + console.log('media', media); 105 106 const record: AppBskyFeedPost.Main = { 107 $type: 'app.bsky.feed.post', ··· 150 }); 151 }; 152 153 + let posting = $state(false); 154 + let postError = $state(''); 155 let textareaEl: HTMLTextAreaElement | undefined = $state(); 156 + let fileInputEl: HTMLInputElement | undefined = $state(); 157 + let selectingFile = $state(false); 158 159 const unfocus = () => (_state.focus = 'null'); 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 + 264 const doPost = () => { 265 if (_state.text.length === 0 || _state.text.length > 300) return; 266 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 + }); 284 }; 285 286 $effect(() => { 287 document.documentElement.style.setProperty('--acc-color', color); 288 if (isFocused && textareaEl) textareaEl.focus(); 289 }); ··· 306 {#snippet attachmentIndicator(post: PostWithUri, type: 'quoting' | 'replying')} 307 {@const parsedUri = expect(parseCanonicalResourceUri(post.uri))} 308 {@const color = generateColorForDid(parsedUri.repo)} 309 + {@const id = handles.get(parsedUri.repo) ?? parsedUri.repo} 310 <div 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" 312 style=" ··· 314 border-color: {color}; 315 color: {color}; 316 " 317 + title={type === 'replying' ? `replying to ${id}` : `quoting ${id}`} 318 > 319 <span class="truncate text-sm font-normal opacity-90"> 320 {type === 'replying' ? 'replying to' : 'quoting'} ··· 339 {/if} 340 {/snippet} 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 + 416 {#snippet composer(replying?: PostWithUri, quoting?: PostWithUri)} 417 + {@const hasIncompleteUpload = _state.blobsState 418 + .values() 419 + .some((s) => s.state === 'uploading' || s.state === 'error')} 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} 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} 467 <span 468 + class="text-sm font-medium text-nowrap" 469 style="color: color-mix(in srgb, {_state.text.length > 300 470 ? '#ef4444' 471 : 'var(--nucleus-fg)'} 53%, transparent);" ··· 473 {_state.text.length} / 300 474 </span> 475 <button 476 + onmousedown={(e) => e.preventDefault()} 477 onclick={doPost} 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" 482 style="background: color-mix(in srgb, {color} 87%, transparent);" 483 > 484 post ··· 501 bind:this={textareaEl} 502 bind:value={_state.text} 503 onfocus={() => (_state.focus = 'focused')} 504 + onblur={() => (!selectingFile ? unfocus() : null)} 505 onkeydown={(event) => { 506 if (event.key === 'Escape') unfocus(); 507 if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost(); ··· 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" 512 ></textarea> 513 </div> 514 + {#if _state.attachedMedia} 515 + {@render mediaPreview(_state.attachedMedia)} 516 + {/if} 517 {#if quoting} 518 {@render attachedPost(quoting, 'quoting')} 519 {/if} ··· 540 border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);" 541 > 542 <div class="w-full p-1 px-2"> 543 + {#if !client.atcute} 544 <div 545 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 546 style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};" 547 > 548 + not logged in 549 </div> 550 {:else} 551 + <div class="flex flex-col gap-1"> 552 {#if _state.focus === 'focused'} 553 {@render composer(_state.replying, _state.quoting)} 554 {:else} ··· 612 613 textarea:focus { 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; 686 } 687 </style>
+91 -69
src/lib/at/client.ts
··· 29 import * as v from '@atcute/lexicons/validations'; 30 import { MiniDocQuery, type MiniDoc } from './slingshot'; 31 import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation'; 32 - import type { Records } from '@atcute/lexicons/ambient'; 33 import { cache as rawCache } from '$lib/cache'; 34 import { AppBskyActorProfile } from '@atcute/bluesky'; 35 import { WebSocket } from '@soffinal/websocket'; 36 import type { Notification } from './stardust'; 37 - import { get } from 'svelte/store'; 38 - import { settings } from '$lib/settings'; 39 import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 40 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); 45 46 export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output }; 47 ··· 82 83 const cache = cacheWithRecords; 84 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; 91 92 - return new ReadableStream({ 93 - start: async (controller) => { 94 - const reader = blob.stream().getReader(); 95 96 - const push = async () => { 97 - const { done, value } = await reader.read(); 98 99 - if (done) { 100 - controller.close(); 101 - return; 102 - } 103 104 - uploaded += value.byteLength; 105 - onProgress(uploaded, totalSize); 106 107 - controller.enqueue(value); 108 - await push(); 109 - }; 110 111 - await push(); 112 - } 113 }); 114 }; 115 ··· 293 return results; 294 } 295 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>> { 311 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:')); 316 serviceAuthUrl.searchParams.append('lxm', 'com.atproto.repo.uploadBlob'); 317 - serviceAuthUrl.searchParams.append('exp', (Math.floor(Date.now() / 1000) + 60 * 30).toString()); // 30 minutes 318 319 const serviceAuthResponse = await this.atcute.handler( 320 `${serviceAuthUrl.pathname}${serviceAuthUrl.search}`, ··· 326 const error = await serviceAuthResponse.text(); 327 return err(`failed to get service auth: ${error}`); 328 } 329 - 330 const serviceAuth = await serviceAuthResponse.json(); 331 - const token = serviceAuth.token; 332 333 onStatus?.({ stage: 'uploading' }); 334 const uploadUrl = new URL('https://video.bsky.app/xrpc/app.bsky.video.uploadVideo'); 335 uploadUrl.searchParams.append('did', this.user.did); 336 - uploadUrl.searchParams.append('name', 'video.mp4'); 337 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' 346 }, 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(); 355 let videoBlobRef: AtpBlob<string> = jobStatus.blob; 356 357 onStatus?.({ stage: 'processing' }); ··· 380 } else if (status.jobStatus.progress !== undefined) { 381 onStatus?.({ 382 stage: 'processing', 383 - progress: status.jobStatus.progress 384 }); 385 } 386 }
··· 29 import * as v from '@atcute/lexicons/validations'; 30 import { MiniDocQuery, type MiniDoc } from './slingshot'; 31 import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation'; 32 + import type { Records, XRPCProcedures } from '@atcute/lexicons/ambient'; 33 import { cache as rawCache } from '$lib/cache'; 34 import { AppBskyActorProfile } from '@atcute/bluesky'; 35 import { WebSocket } from '@soffinal/websocket'; 36 import type { Notification } from './stardust'; 37 import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 38 import { timestampFromCursor, toCanonicalUri, toResourceUri } from '$lib'; 39 + import { constellationUrl, slingshotUrl, spacedustUrl } from '.'; 40 41 export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output }; 42 ··· 77 78 const cache = cacheWithRecords; 79 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); 90 91 + if (onProgress && xhr.upload) { 92 + xhr.upload.onprogress = (event: ProgressEvent) => { 93 + if (event.lengthComputable) { 94 + onProgress(event.loaded, event.total); 95 + } 96 + }; 97 + } 98 99 + Object.keys(headers).forEach((key) => { 100 + xhr.setRequestHeader(key, headers[key]); 101 + }); 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 + }; 110 111 + xhr.onerror = () => { 112 + resolve(err({ error: 'xhr_error', message: 'network error' })); 113 + }; 114 115 + xhr.onabort = () => { 116 + resolve(err({ error: 'xhr_error', message: 'upload aborted' })); 117 + }; 118 119 + xhr.send(body); 120 }); 121 }; 122 ··· 300 return results; 301 } 302 303 + async getServiceAuth(lxm: keyof XRPCProcedures, exp: number): Promise<Result<string, string>> { 304 if (!this.atcute || !this.user) return err('not authenticated'); 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 + ); 310 serviceAuthUrl.searchParams.append('lxm', 'com.atproto.repo.uploadBlob'); 311 + serviceAuthUrl.searchParams.append('exp', exp.toString()); // 30 minutes 312 313 const serviceAuthResponse = await this.atcute.handler( 314 `${serviceAuthUrl.pathname}${serviceAuthUrl.search}`, ··· 320 const error = await serviceAuthResponse.text(); 321 return err(`failed to get service auth: ${error}`); 322 } 323 const serviceAuth = await serviceAuthResponse.json(); 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; 360 361 onStatus?.({ stage: 'uploading' }); 362 const uploadUrl = new URL('https://video.bsky.app/xrpc/app.bsky.video.uploadVideo'); 363 uploadUrl.searchParams.append('did', this.user.did); 364 + uploadUrl.searchParams.append('name', 'video'); 365 366 + const uploadResult = await xhrPost( 367 + uploadUrl.toString(), 368 + blob, 369 + { 370 + Authorization: `Bearer ${tokenResult.value}`, 371 + 'Content-Type': mimeType 372 }, 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; 377 let videoBlobRef: AtpBlob<string> = jobStatus.blob; 378 379 onStatus?.({ stage: 'processing' }); ··· 402 } else if (status.jobStatus.progress !== undefined) { 403 onStatus?.({ 404 stage: 'processing', 405 + progress: status.jobStatus.progress / 100 406 }); 407 } 408 }
+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
··· 14 WebDidDocumentResolver, 15 XrpcHandleResolver 16 } from '@atcute/identity-resolver'; 17 - import { slingshotUrl } from './client'; 18 import type { ActorIdentifier } from '@atcute/lexicons'; 19 import { err, ok, type Result } from '$lib/result'; 20 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 21 import { clientId, oauthMetadata, redirectUri } from '$lib/oauth'; 22 23 configureOAuth({ 24 metadata: {
··· 14 WebDidDocumentResolver, 15 XrpcHandleResolver 16 } from '@atcute/identity-resolver'; 17 import type { ActorIdentifier } from '@atcute/lexicons'; 18 import { err, ok, type Result } from '$lib/result'; 19 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 20 import { clientId, oauthMetadata, redirectUri } from '$lib/oauth'; 21 + import { slingshotUrl } from '.'; 22 23 configureOAuth({ 24 metadata: {
+2 -1
src/lib/oauth.ts
··· 7 client_uri: domain, 8 logo_uri: `${domain}/favicon.png`, 9 redirect_uris: [`${domain}/`], 10 - scope: 'atproto repo:*?action=create&action=update&action=delete blob:*/*', 11 grant_types: ['authorization_code', 'refresh_token'], 12 response_types: ['code'], 13 token_endpoint_auth_method: 'none',
··· 7 client_uri: domain, 8 logo_uri: `${domain}/favicon.png`, 9 redirect_uris: [`${domain}/`], 10 + scope: 11 + 'atproto repo:*?action=create&action=update&action=delete rpc:com.atproto.repo.uploadBlob?aud=* blob:*/*', 12 grant_types: ['authorization_code', 'refresh_token'], 13 response_types: ['code'], 14 token_endpoint_auth_method: 'none',
+1
src/lib/result.ts
··· 12 return { ok: true, value }; 13 }; 14 export const err = <E>(error: E): Err<E> => { 15 return { ok: false, error }; 16 }; 17 export const expect = <T, E>(v: Result<T, E>, msg: string = 'expected result to not be error:') => {
··· 12 return { ok: true, value }; 13 }; 14 export const err = <E>(error: E): Err<E> => { 15 + console.error(error); 16 return { ok: false, error }; 17 }; 18 export const expect = <T, E>(v: Result<T, E>, msg: string = 'expected result to not be error:') => {
+6 -1
src/routes/[...catchall]/+page.svelte
··· 32 import { JetstreamSubscription } from '@atcute/jetstream'; 33 import { settings } from '$lib/settings'; 34 import type { Sort } from '$lib/following'; 35 36 const { data: loadData }: PageProps = $props(); 37 ··· 83 else animClass = 'animate-fade-in-scale'; 84 }); 85 86 - let postComposerState = $state<PostComposerState>({ focus: 'null', text: '' }); 87 let showScrollToTop = $state(false); 88 const handleScroll = () => { 89 if (currentRoute.path === '/' || currentRoute.path === '/profile/:actor')
··· 32 import { JetstreamSubscription } from '@atcute/jetstream'; 33 import { settings } from '$lib/settings'; 34 import type { Sort } from '$lib/following'; 35 + import { SvelteMap } from 'svelte/reactivity'; 36 37 const { data: loadData }: PageProps = $props(); 38 ··· 84 else animClass = 'animate-fade-in-scale'; 85 }); 86 87 + let postComposerState = $state<PostComposerState>({ 88 + focus: 'null', 89 + text: '', 90 + blobsState: new SvelteMap() 91 + }); 92 let showScrollToTop = $state(false); 93 const handleScroll = () => { 94 if (currentRoute.path === '/' || currentRoute.path === '/profile/:actor')
+1 -2
src/routes/[...catchall]/+page.ts
··· 1 - import { replaceState } from '$app/navigation'; 2 import { addAccount, loggingIn } from '$lib/accounts'; 3 import { AtpClient } from '$lib/at/client'; 4 import { flow, sessions } from '$lib/at/oauth'; ··· 24 const currentUrl = new URL(window.location.href); 25 // scrub history so auth state cant be replayed 26 try { 27 - replaceState('', '/'); 28 } catch { 29 // if router was unitialized then we probably dont need to scrub anyway 30 // so its fine
··· 1 import { addAccount, loggingIn } from '$lib/accounts'; 2 import { AtpClient } from '$lib/at/client'; 3 import { flow, sessions } from '$lib/at/oauth'; ··· 23 const currentUrl = new URL(window.location.href); 24 // scrub history so auth state cant be replayed 25 try { 26 + history.replaceState(null, '', '/'); 27 } catch { 28 // if router was unitialized then we probably dont need to scrub anyway 29 // so its fine