implement media attachments
159 unmodified linesExpand all160161162163164164165166167168292 unmodified linesExpand all159 unmodified linesExpand all flex min-w-0 items-center gap-2 font-bold {isOnPostComposer ? 'contrast-125' : ''} rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10 " style="color: {color};" onclick={() => router.navigate(`/profile/${did}`)} onclick={() => ((profileOpen = false), router.navigate(`/profile/${did}`))} > <ProfilePicture {client} {did} size={8} />
{#if profile}292 unmodified linesExpand all25 unmodified lines26272829303031333233353435363738394040414243434445464748495051525354554445464748495051525354555625 unmodified lines width: (i.aspectRatio?.width ?? 4) * sizeFactor, height: (i.aspectRatio?.height ?? 3) * sizeFactor }; const cid = i.image.ref.$link; const isPreview = cid.startsWith('blob:'); return { ...size, src: isPreview ? cid : img('feed_fullsize', did, cid), src: img('feed_fullsize', did, cid), thumbnail: { src: isPreview ? cid : img('feed_thumbnail', did, cid), src: img('feed_thumbnail', did, cid), ...size } }; })} {#if images.length > 0} <PhotoSwipeGallery {images} /> <PhotoSwipeGallery {images} /> {/if} {:else if embed.$type === 'app.bsky.embed.video'} {#if isBlob(embed.video)} {@const cid = embed.video.ref.$link} {@const isPreview = cid.startsWith('blob:')} {#if isPreview} <!-- svelte-ignore a11y_media_has_caption --> <video class="rounded-sm" src={cid} controls></video> {:else} {#await resolveDidDoc(did) then didDoc} {#if didDoc.ok} <!-- svelte-ignore a11y_media_has_caption --> <video class="rounded-sm" src={blob(didDoc.value.pds, did, cid)} controls></video> {/if} {/await} {/if} {#await resolveDidDoc(did) then didDoc} {#if didDoc.ok} <!-- svelte-ignore a11y_media_has_caption --> <video class="rounded-sm" src={blob(didDoc.value.pds, did, embed.video.ref.$link)} controls ></video> {/if} {/await} {/if} {/if}</div>10 unmodified lines1112131415161718192021222324252627282930313233343536373138394041424344454647484950515253545556575859606162636465666768696 unmodified lines7677787953545556808182835984858687626364656688899091929368697071727374757694959697989910010110210310410510610710840 unmodified lines14915015115212615315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526613413513613713813914014114214314414526726826927027127227327427527627727827928028128228328428528614928728828929014 unmodified lines30530630730830931031131231331431531617931731831932032116 unmodified lines33833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646720846846947047147247347447547647721721847847948048148248348448514 unmodified lines5005015025032415045055065075081 unmodified line51051151251351451551651751851952018 unmodified lines53954054154227754354454554654728254854955028555155255355455556 unmodified lines61261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668710 unmodified lines import { tokenize } from '$lib/richtext/parser'; import Icon from '@iconify/svelte'; import ProfilePicture from './ProfilePicture.svelte'; import type { AppBskyEmbedMedia } from '$lib/at/types'; import { SvelteMap } from 'svelte/reactivity'; import { handles } from '$lib/state.svelte';
type UploadState = | { state: 'uploading'; progress: number } | { state: 'uploaded'; blob: AtpBlob<string> } | { state: 'error'; message: string }; export type FocusState = 'null' | 'focused'; export type State = { focus: FocusState; text: string; quoting?: PostWithUri; replying?: PostWithUri; attachedMedia?: AppBskyEmbedMedia; blobsState: SvelteMap<string, UploadState>; };
interface Props { client: AtpClient; onPostSent: (post: PostWithUri) => void; _state: State; }
let { client, onPostSent, _state = $bindable({ focus: 'null', text: '' }) }: Props = $props(); let { client, onPostSent, _state = $bindable() }: Props = $props();
const isFocused = $derived(_state.focus === 'focused');
const color = $derived( client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)' );
const uploadVideo = async (blobUrl: string, mimeType: string) => { const blob = await (await fetch(blobUrl)).blob(); return await client.uploadVideo(blob, mimeType, (status) => { if (status.stage === 'uploading' && status.progress !== undefined) { _state.blobsState.set(blobUrl, { state: 'uploading', progress: status.progress * 0.5 }); } else if (status.stage === 'processing' && status.progress !== undefined) { _state.blobsState.set(blobUrl, { state: 'uploading', progress: 0.5 + status.progress * 0.5 }); } }); }; const uploadImage = async (blobUrl: string) => { const blob = await (await fetch(blobUrl)).blob(); return await client.uploadBlob(blob, (progress) => { _state.blobsState.set(blobUrl, { state: 'uploading', progress }); }); };
const post = async (text: string): Promise<Result<PostWithUri, string>> => { const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({ $type: 'com.atproto.repo.strongRef', cid: p.cid!,6 unmodified lines if (_state.attachedMedia?.$type === 'app.bsky.embed.images') { const images = _state.attachedMedia.images; let uploadedImages: typeof images = []; for (const image of images) { const blobUrl = (image.image as AtpBlob<string>).ref.$link; const blob = await (await fetch(blobUrl)).blob(); const result = await client.uploadBlob(blob); if (!result.ok) return result; const upload = _state.blobsState.get((image.image as AtpBlob<string>).ref.$link); if (!upload || upload.state !== 'uploaded') continue; uploadedImages.push({ ...image, image: result.value image: upload.blob }); } if (uploadedImages.length > 0) media = { ..._state.attachedMedia, $type: 'app.bsky.embed.images', images: uploadedImages }; media = { ..._state.attachedMedia, $type: 'app.bsky.embed.images', images: uploadedImages }; } else if (_state.attachedMedia?.$type === 'app.bsky.embed.video') { const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link; const blob = await (await fetch(blobUrl)).blob(); const result = await client.uploadVideo(blob); if (!result.ok) return result; media = { ..._state.attachedMedia, $type: 'app.bsky.embed.video', video: result.value }; const upload = _state.blobsState.get( (_state.attachedMedia.video as AtpBlob<string>).ref.$link ); if (upload && upload.state === 'uploaded') media = { ..._state.attachedMedia, $type: 'app.bsky.embed.video', video: upload.blob }; } console.log('media', media);
const record: AppBskyFeedPost.Main = { $type: 'app.bsky.feed.post', text: rt.text,40 unmodified lines record }); };
let info = $state(''); let posting = $state(false); let postError = $state(''); let textareaEl: HTMLTextAreaElement | undefined = $state(); let fileInputEl: HTMLInputElement | undefined = $state(); let selectingFile = $state(false);
const unfocus = () => (_state.focus = 'null');
const handleFileSelect = (event: Event) => { selectingFile = false;
const input = event.target as HTMLInputElement; const files = input.files; if (!files || files.length === 0) return;
const existingImages = _state.attachedMedia?.$type === 'app.bsky.embed.images' ? _state.attachedMedia.images : [];
let newImages = [...existingImages]; let hasVideo = false;
for (let i = 0; i < files.length; i++) { const file = files[i]; const isVideo = file.type.startsWith('video/'); const isImage = file.type.startsWith('image/');
if (!isVideo && !isImage) { postError = 'unsupported file type'; continue; }
if (isVideo) { if (existingImages.length > 0 || newImages.length > 0) { postError = 'cannot mix images and video'; continue; } const blobUrl = URL.createObjectURL(file); _state.attachedMedia = { $type: 'app.bsky.embed.video', video: { $type: 'blob', ref: { $link: blobUrl }, mimeType: file.type, size: file.size } }; hasVideo = true; break; } else if (isImage) { if (newImages.length >= 4) { postError = 'max 4 images allowed'; break; } const blobUrl = URL.createObjectURL(file); newImages.push({ image: { $type: 'blob', ref: { $link: blobUrl }, mimeType: file.type, size: file.size }, alt: '', aspectRatio: undefined }); } }
if (!hasVideo && newImages.length > 0) { _state.attachedMedia = { $type: 'app.bsky.embed.images', images: newImages }; }
const handleUpload = (blobUrl: string, blob: Result<AtpBlob<string>, string>) => { if (blob.ok) _state.blobsState.set(blobUrl, { state: 'uploaded', blob: blob.value }); else _state.blobsState.set(blobUrl, { state: 'error', message: blob.error }); };
const media = _state.attachedMedia; if (media?.$type == 'app.bsky.embed.images') { for (const image of media.images) { const blobUrl = (image.image as AtpBlob<string>).ref.$link; uploadImage(blobUrl).then((r) => handleUpload(blobUrl, r)); } } else if (media?.$type === 'app.bsky.embed.video') { const blobUrl = (media.video as AtpBlob<string>).ref.$link; uploadVideo(blobUrl, media.video.mimeType).then((r) => handleUpload(blobUrl, r)); }
input.value = ''; };
const removeMedia = () => { if (_state.attachedMedia?.$type === 'app.bsky.embed.video') { const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link; _state.blobsState.delete(blobUrl); } _state.attachedMedia = undefined; };
const removeMediaAtIndex = (index: number) => { if (_state.attachedMedia?.$type !== 'app.bsky.embed.images') return; const imageToRemove = _state.attachedMedia.images[index]; const blobUrl = (imageToRemove.image as AtpBlob<string>).ref.$link; _state.blobsState.delete(blobUrl);
const images = _state.attachedMedia.images.filter((_, i) => i !== index); _state.attachedMedia = images.length > 0 ? { ..._state.attachedMedia, images } : undefined; };
const doPost = () => { if (_state.text.length === 0 || _state.text.length > 300) return;
post(_state.text).then((res) => { if (res.ok) { onPostSent(res.value); _state.text = ''; info = 'posted!'; unfocus(); setTimeout(() => (info = ''), 800); } else { info = res.error; setTimeout(() => (info = ''), 3000); } }); postError = ''; posting = true; post(_state.text) .then((res) => { if (res.ok) { onPostSent(res.value); _state.text = ''; _state.attachedMedia = undefined; _state.blobsState.clear(); unfocus(); } else { postError = res.error; } }) .finally(() => { posting = false; }); };
$effect(() => { if (!client.atcute) info = 'not logged in'; document.documentElement.style.setProperty('--acc-color', color); if (isFocused && textareaEl) textareaEl.focus(); });</script>14 unmodified lines
{#snippet attachmentIndicator(post: PostWithUri, type: 'quoting' | 'replying')} {@const parsedUri = expect(parseCanonicalResourceUri(post.uri))} {@const color = generateColorForDid(parsedUri.repo)} {@const id = handles.get(parsedUri.repo) ?? parsedUri.repo} <div 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" style=" background: color-mix(in srgb, {color} 10%, transparent); border-color: {color}; color: {color}; " title={type === 'replying' ? `replying to @${parsedUri.repo}` : `quoting @${parsedUri.repo}`} title={type === 'replying' ? `replying to ${id}` : `quoting ${id}`} > <span class="truncate text-sm font-normal opacity-90"> {type === 'replying' ? 'replying to' : 'quoting'} </span>16 unmodified lines <br /> {/if}{/snippet}
{#snippet uploadControls(blobUrl: string, remove: () => void)} {@const upload = _state.blobsState.get(blobUrl)} {#if upload !== undefined && upload.state === 'uploading'} <div 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" > <div class="flex justify-center"> <div class="h-5 w-5 animate-spin rounded-full border-4 border-t-transparent" style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" ></div> </div> <span class="font-medium">{Math.round(upload.progress * 100)}%</span> </div> {:else} <div class="absolute top-2 right-2 z-10 flex items-center gap-1"> {#if upload !== undefined && upload.state === 'error'} <span class="rounded-sm bg-black/70 p-1.5 px-1 text-sm font-bold text-red-500 backdrop-blur-sm" >{upload.message}</span > {/if} <button onclick={(e) => { e.preventDefault(); e.stopPropagation(); remove(); }} onmousedown={(e) => e.preventDefault()} class="rounded-sm bg-black/70 p-1.5 backdrop-blur-sm {upload?.state !== 'error' ? 'opacity-0 transition-opacity group-hover:opacity-100' : ''}" > {#if upload?.state === 'error'} <Icon class="text-red-500 group-hover:hidden" icon="heroicons:exclamation-circle-16-solid" width={20} /> {/if} <Icon class={upload?.state === 'error' ? 'hidden group-hover:block' : ''} icon="heroicons:x-mark-16-solid" width={20} /> </button> </div> {/if}{/snippet}
{#snippet mediaPreview(embed: AppBskyEmbedMedia)} {#if embed.$type === 'app.bsky.embed.images'} <div class="image-preview-grid" data-total={embed.images.length}> {#each embed.images as image, idx (idx)} {@const blobUrl = (image.image as AtpBlob<string>).ref.$link} <div class="image-preview-item group"> <img src={blobUrl} alt="" /> {@render uploadControls(blobUrl, () => removeMediaAtIndex(idx))} </div> {/each} </div> {:else if embed.$type === 'app.bsky.embed.video'} {@const blobUrl = (embed.video as AtpBlob<string>).ref.$link} <div class="group relative max-h-[30vh] overflow-hidden rounded-sm" style="aspect-ratio: 16/10;" > <!-- svelte-ignore a11y_media_has_caption --> <video src={blobUrl} controls class="h-full w-full"></video> {@render uploadControls(blobUrl, removeMedia)} </div> {/if}{/snippet}
{#snippet composer(replying?: PostWithUri, quoting?: PostWithUri)} {@const hasIncompleteUpload = _state.blobsState .values() .some((s) => s.state === 'uploading' || s.state === 'error')} <div class="flex items-center gap-2"> <input bind:this={fileInputEl} type="file" accept="image/*,video/*" multiple onchange={handleFileSelect} oncancel={() => (selectingFile = false)} class="hidden" /> <button onclick={(e) => { e.preventDefault(); e.stopPropagation(); selectingFile = true; fileInputEl?.click(); }} onmousedown={(e) => e.preventDefault()} disabled={_state.attachedMedia?.$type === 'app.bsky.embed.video' || (_state.attachedMedia?.$type === 'app.bsky.embed.images' && _state.attachedMedia.images.length >= 4)} class="rounded-sm p-1.5 transition-all duration-150 enabled:hover:scale-110 disabled:cursor-not-allowed disabled:opacity-50" style="background: color-mix(in srgb, {color} 15%, transparent); color: {color};" title="attach media" > <Icon icon="heroicons:photo-16-solid" width={20} /> </button> {#if postError.length > 0} <div class="group flex items-center gap-2 truncate rounded-sm bg-red-500 p-1.5"> <button onclick={() => (postError = '')}> <Icon class="group-hover:hidden" icon="heroicons:exclamation-circle-16-solid" width={20} /> <Icon class="hidden group-hover:block" icon="heroicons:x-mark-16-solid" width={20} /> </button> <span title={postError} class="truncate text-sm font-bold">{postError}</span> </div> {/if} <div class="grow"></div> {#if posting} <div class="h-6 w-6 animate-spin rounded-full border-4 border-t-transparent" style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" ></div> {/if} <span class="text-sm font-medium" class="text-sm font-medium text-nowrap" style="color: color-mix(in srgb, {_state.text.length > 300 ? '#ef4444' : 'var(--nucleus-fg)'} 53%, transparent);" > {_state.text.length} / 300 </span> <button onmousedown={(e) => e.preventDefault()} onclick={doPost} disabled={_state.text.length === 0 || _state.text.length > 300} class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed! disabled:opacity-50 disabled:hover:scale-100" disabled={(!_state.attachedMedia && _state.text.length === 0) || _state.text.length > 300 || hasIncompleteUpload} 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" style="background: color-mix(in srgb, {color} 87%, transparent);" > post </button>14 unmodified lines <textarea bind:this={textareaEl} bind:value={_state.text} onfocus={() => (_state.focus = 'focused')} onblur={unfocus} onblur={() => (!selectingFile ? unfocus() : null)} onkeydown={(event) => { if (event.key === 'Escape') unfocus(); if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost(); }}1 unmodified line rows="4" 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" ></textarea> </div> {#if _state.attachedMedia} {@render mediaPreview(_state.attachedMedia)} {/if} {#if quoting} {@render attachedPost(quoting, 'quoting')} {/if} </div>18 unmodified lines : `color-mix(in srgb, color-mix(in srgb, var(--nucleus-bg) 85%, ${color}) 70%, transparent)`}; border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);" > <div class="w-full p-1 px-2"> {#if info.length > 0} {#if !client.atcute} <div class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};" > {info} not logged in </div> {:else} <div class="flex flex-col gap-2"> <div class="flex flex-col gap-1"> {#if _state.focus === 'focused'} {@render composer(_state.replying, _state.quoting)} {:else} <!-- svelte-ignore a11y_no_static_element_interactions -->56 unmodified lines
textarea:focus { @apply border-none! [box-shadow:none]! outline-none!; }
/* Image preview grid - based on PhotoSwipeGallery */ .image-preview-grid { display: grid; gap: 2px; border-radius: 4px; overflow: hidden; width: 100%; max-height: 30vh; }
.image-preview-item { width: 100%; height: 100%; display: block; position: relative; overflow: hidden; border-radius: 4px; }
.image-preview-item > img { width: 100%; height: 100%; object-fit: cover; }
/* Single image: natural aspect ratio */ .image-preview-grid[data-total='1'] { display: block; height: auto; width: 100%; border-radius: 0; }
.image-preview-grid[data-total='1'] .image-preview-item { width: 100%; height: auto; display: block; border-radius: 4px; }
.image-preview-grid[data-total='1'] .image-preview-item > img { width: 100%; height: auto; max-height: 60vh; object-fit: contain; }
/* 2 Images: Split vertically */ .image-preview-grid[data-total='2'] { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr; aspect-ratio: 16/9; }
/* 3 Images: 1 Big (left), 2 Small (stacked right) */ .image-preview-grid[data-total='3'] { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; aspect-ratio: 16/9; } .image-preview-grid[data-total='3'] .image-preview-item:first-child { grid-row: span 2; }
/* 4 Images: 2x2 Grid */ .image-preview-grid[data-total='4'] { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; aspect-ratio: 16/9; }</style>5 unmodified lines6789101011121314159 unmodified lines5 unmodified lines client_name: 'nucleus', client_uri: domain, logo_uri: `${domain}/favicon.png`, redirect_uris: [`${domain}/`], scope: 'atproto repo:*?action=create&action=update&action=delete blob:*/*', scope: 'atproto repo:*?action=create&action=update&action=delete rpc:com.atproto.repo.uploadBlob?aud=* blob:*/*', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', application_type: 'web',9 unmodified lines10 unmodified lines11121314151617181913 unmodified lines10 unmodified linesexport const ok = <T>(value: T): Ok<T> => { return { ok: true, value };};export const err = <E>(error: E): Err<E> => { console.error(error); return { ok: false, error };};export const expect = <T, E>(v: Result<T, E>, msg: string = 'expected result to not be error:') => { if (v.ok) {13 unmodified lines27 unmodified lines28293031323233343536373837383940424344454142434431 unmodified lines767778798586878889908081828384858687888990929394919293949596979896979910010110299100101102103104105106107108109110104105111112113114107108109115116117118111112119120121122123175 unmodified linesExpand all2993003013022962972982993003013023033043053063073083093103033043123133143153053063073083093103173113123133143153 unmodified lines31932032132232932333132432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236333636436533833934034134234334434536636736836937037137234734834935035135235335437337437537637737837938020 unmodified lines401402403404383405406407408409101 unmodified linesExpand all27 unmodified lines} from '@atcute/lexicons/validations';import * as v from '@atcute/lexicons/validations';import { MiniDocQuery, type MiniDoc } from './slingshot';import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation';import type { Records } from '@atcute/lexicons/ambient';import type { Records, XRPCProcedures } from '@atcute/lexicons/ambient';import { cache as rawCache } from '$lib/cache';import { AppBskyActorProfile } from '@atcute/bluesky';import { WebSocket } from '@soffinal/websocket';import type { Notification } from './stardust';import { get } from 'svelte/store';import { settings } from '$lib/settings';import type { OAuthUserAgent } from '@atcute/oauth-browser-client';import { timestampFromCursor, toCanonicalUri, toResourceUri } from '$lib';import { constellationUrl, slingshotUrl, spacedustUrl } from '.';
export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot);export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust);export const constellationUrl: URL = new URL(get(settings).endpoints.constellation);
export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output };
const cacheWithHandles = rawCache.define( 'resolveHandle',31 unmodified lines});
const cache = cacheWithRecords;
const wrapBlobWithProgress = ( blob: Blob, onProgress: (uploaded: number, total: number) => void): ReadableStream<Uint8Array> => { const totalSize = blob.size; let uploaded = 0;export const xhrPost = ( url: string, body: Blob | File, headers: Record<string, string> = {}, onProgress?: (uploaded: number, total: number) => void // eslint-disable-next-line @typescript-eslint/no-explicit-any): Promise<Result<any, { error: string; message: string }>> => { return new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.open('POST', url);
return new ReadableStream({ start: async (controller) => { const reader = blob.stream().getReader(); if (onProgress && xhr.upload) { xhr.upload.onprogress = (event: ProgressEvent) => { if (event.lengthComputable) { onProgress(event.loaded, event.total); } }; }
const push = async () => { const { done, value } = await reader.read(); Object.keys(headers).forEach((key) => { xhr.setRequestHeader(key, headers[key]); });
if (done) { controller.close(); return; } xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(ok(JSON.parse(xhr.responseText))); } else { resolve(err(JSON.parse(xhr.responseText))); } };
uploaded += value.byteLength; onProgress(uploaded, totalSize); xhr.onerror = () => { resolve(err({ error: 'xhr_error', message: 'network error' })); };
controller.enqueue(value); await push(); }; xhr.onabort = () => { resolve(err({ error: 'xhr_error', message: 'upload aborted' })); };
await push(); } xhr.send(body); });};
export type UploadStatus =175 unmodified linesExpand all
return results; }
async uploadBlob( blob: Blob, onProgress?: (progress: number) => void ): Promise<Result<AtpBlob<string>, string>> { if (!this.atcute) return err('not authenticated'); const input = wrapBlobWithProgress(blob, (uploaded, total) => onProgress?.(uploaded / total)); const res = await this.atcute.post('com.atproto.repo.uploadBlob', { input }); if (!res.ok) return err(`upload failed: ${res.data.error}`); return ok(res.data.blob); }
async uploadVideo( blob: Blob, onStatus?: (status: UploadStatus) => void ): Promise<Result<AtpBlob<string>, string>> { async getServiceAuth(lxm: keyof XRPCProcedures, exp: number): Promise<Result<string, string>> { if (!this.atcute || !this.user) return err('not authenticated');
onStatus?.({ stage: 'auth' }); const serviceAuthUrl = new URL(`${this.user.pds}/xrpc/com.atproto.server.getServiceAuth`); serviceAuthUrl.searchParams.append('aud', this.user.pds.replace('https://', 'did:web:')); const serviceAuthUrl = new URL(`${this.user.pds}xrpc/com.atproto.server.getServiceAuth`); serviceAuthUrl.searchParams.append( 'aud', this.user.pds.replace('https://', 'did:web:').slice(0, -1) ); serviceAuthUrl.searchParams.append('lxm', 'com.atproto.repo.uploadBlob'); serviceAuthUrl.searchParams.append('exp', (Math.floor(Date.now() / 1000) + 60 * 30).toString()); // 30 minutes serviceAuthUrl.searchParams.append('exp', exp.toString()); // 30 minutes
const serviceAuthResponse = await this.atcute.handler( `${serviceAuthUrl.pathname}${serviceAuthUrl.search}`, {3 unmodified lines if (!serviceAuthResponse.ok) { const error = await serviceAuthResponse.text(); return err(`failed to get service auth: ${error}`); }
const serviceAuth = await serviceAuthResponse.json(); const token = serviceAuth.token; return ok(serviceAuth.token); }
async uploadBlob( blob: Blob, onProgress?: (progress: number) => void ): Promise<Result<AtpBlob<string>, string>> { if (!this.atcute || !this.user) return err('not authenticated'); const tokenResult = await this.getServiceAuth( 'com.atproto.repo.uploadBlob', Math.floor(Date.now() / 1000) + 60 ); if (!tokenResult.ok) return tokenResult; const result = await xhrPost( `${this.user.pds}xrpc/com.atproto.repo.uploadBlob`, blob, { authorization: `Bearer ${tokenResult.value}` }, (uploaded, total) => onProgress?.(uploaded / total) ); if (!result.ok) return err(`upload failed: ${result.error.message}`); return ok(result.value); }
async uploadVideo( blob: Blob, mimeType: string, onStatus?: (status: UploadStatus) => void ): Promise<Result<AtpBlob<string>, string>> { if (!this.atcute || !this.user) return err('not authenticated');
onStatus?.({ stage: 'auth' }); const tokenResult = await this.getServiceAuth( 'com.atproto.repo.uploadBlob', Math.floor(Date.now() / 1000) + 60 * 30 ); if (!tokenResult.ok) return tokenResult;
onStatus?.({ stage: 'uploading' }); const uploadUrl = new URL('https://video.bsky.app/xrpc/app.bsky.video.uploadVideo'); uploadUrl.searchParams.append('did', this.user.did); uploadUrl.searchParams.append('name', 'video.mp4'); uploadUrl.searchParams.append('name', 'video');
const body = wrapBlobWithProgress(blob, (uploaded, total) => onStatus?.({ stage: 'uploading', progress: uploaded / total }) ); const uploadResponse = await fetch(uploadUrl.toString(), { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'video/mp4' const uploadResult = await xhrPost( uploadUrl.toString(), blob, { Authorization: `Bearer ${tokenResult.value}`, 'Content-Type': mimeType }, body }); if (!uploadResponse.ok) { const error = await uploadResponse.text(); return err(`failed to upload video: ${error}`); }
const jobStatus = await uploadResponse.json(); (uploaded, total) => onStatus?.({ stage: 'uploading', progress: uploaded / total }) ); if (!uploadResult.ok) return err(`failed to upload video: ${uploadResult.error.message}`); const jobStatus = uploadResult.value; let videoBlobRef: AtpBlob<string> = jobStatus.blob;
onStatus?.({ stage: 'processing' }); while (!videoBlobRef) {20 unmodified lines return err(`video processing failed: ${status.jobStatus.error || 'unknown error'}`); } else if (status.jobStatus.progress !== undefined) { onStatus?.({ stage: 'processing', progress: status.jobStatus.progress progress: status.jobStatus.progress / 100 }); } }
101 unmodified linesExpand all123456import { settings } from '$lib/settings';import { get } from 'svelte/store';
export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot);export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust);export const constellationUrl: URL = new URL(get(settings).endpoints.constellation);12 unmodified lines131415161717181920212223242566 unmodified lines12 unmodified lines PlcDidDocumentResolver, WebDidDocumentResolver, XrpcHandleResolver} from '@atcute/identity-resolver';import { slingshotUrl } from './client';import type { ActorIdentifier } from '@atcute/lexicons';import { err, ok, type Result } from '$lib/result';import type { AtprotoDid } from '@atcute/lexicons/syntax';import { clientId, oauthMetadata, redirectUri } from '$lib/oauth';import { slingshotUrl } from '.';
configureOAuth({ metadata: { client_id: clientId,66 unmodified lines30 unmodified lines31323334353637383943 unmodified lines8384858686878889909192939495265 unmodified linesExpand all30 unmodified lines import type { PageProps } from './+page'; import { JetstreamSubscription } from '@atcute/jetstream'; import { settings } from '$lib/settings'; import type { Sort } from '$lib/following'; import { SvelteMap } from 'svelte/reactivity';
const { data: loadData }: PageProps = $props();
const currentRoute = $derived(router.current);43 unmodified lines else if (router.direction === 'left') animClass = 'animate-slide-in-left'; else animClass = 'animate-fade-in-scale'; });
let postComposerState = $state<PostComposerState>({ focus: 'null', text: '' }); let postComposerState = $state<PostComposerState>({ focus: 'null', text: '', blobsState: new SvelteMap() }); let showScrollToTop = $state(false); const handleScroll = () => { if (currentRoute.path === '/' || currentRoute.path === '/profile/:actor') showScrollToTop = window.scrollY > 300;265 unmodified linesExpand all