From 42f02ab853948baae4e96eb9c45bc4368ef7ac6d Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Sun, 25 Jan 2026 12:47:53 +0100 Subject: [PATCH] fixes, add image stuff --- .claude/settings.local.json | 5 + README.md | 9 +- src/lib/UI/Button.svelte | 2 +- src/lib/UI/HandleInput.svelte | 6 +- src/lib/UI/LoginModal.svelte | 145 +++++++++++++++------------- src/lib/UI/SecondaryButton.svelte | 2 +- src/lib/atproto/auth.svelte.ts | 3 +- src/lib/atproto/image-helper.ts | 152 ++++++++++++++++++++++++++++++ src/lib/atproto/index.ts | 2 +- src/lib/atproto/metadata.ts | 15 +-- src/lib/atproto/methods.ts | 28 +++--- src/lib/atproto/settings.ts | 40 +++++--- src/routes/+page.svelte | 7 +- 13 files changed, 308 insertions(+), 108 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 src/lib/atproto/image-helper.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..43e6d8b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "allow": ["Bash(npx tsc:*)"] + } +} diff --git a/README.md b/README.md index 30142f3..e731f70 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,6 @@ npm install @atcute/atproto @atcute/bluesky @atcute/identity-resolver @atcute/le 6. (optionally) set your base in `svelte.config.js` (e.g. for github pages: `base: '/your-repo-name/'`) while keeping it as `''` in development. - ```ts const config = { // ... @@ -93,13 +92,12 @@ const config = { 7. setup the correct permissions (see below) - ## how to use ### set permissions you request on sign-in in `$lib/atproto/settings.ts` (see commented out examples for more info) - add collections to the collections array -- add rpcs to rpcCalls +- rpcs for authenticated proxied requests - blobs for uploading blobs ### change sign up pds @@ -152,3 +150,8 @@ const response = await user.client.get('app.bsky.feed.getActorLikes', { } }); ``` + +## todo + +- check if pds supports prompt=create +- add lexicon stuff diff --git a/src/lib/UI/Button.svelte b/src/lib/UI/Button.svelte index b32d524..69fbc09 100644 --- a/src/lib/UI/Button.svelte +++ b/src/lib/UI/Button.svelte @@ -10,7 +10,7 @@ - - + + + + + + sign in with other account + + - - {/each} - - -
Or new handle
- {/if} - - - {#if !selectedActor} -
+ {/each} +
+ {:else if !selectedActor} +
{ @@ -169,7 +171,7 @@
{:else}
@@ -199,17 +201,28 @@ {/if} {#if error} -

{error}

+

{error}

{/if}
- + {#if showRecentLogins} +
Or login with new handle
+ + + {:else} + + {/if}
- {#if signIn} -
+ {#if signUp} +
Don't have an account?
{ + return new Promise((resolve, reject) => { + const img = new Image(); + const reader = new FileReader(); + + reader.onload = (e) => { + if (!e.target?.result) { + return reject(new Error('Failed to read file.')); + } + img.src = e.target.result as string; + }; + + reader.onerror = (err) => reject(err); + reader.readAsDataURL(file); + + img.onload = () => { + let width = img.width; + let height = img.height; + + // If image is already small enough, return original + if (file.size <= maxSize) { + console.log('skipping compression+resizing, already small enough'); + return resolve({ + blob: file, + aspectRatio: { + width, + height + } + }); + } + + if (width > maxDimension || height > maxDimension) { + if (width > height) { + height = Math.round((maxDimension / width) * height); + width = maxDimension; + } else { + width = Math.round((maxDimension / height) * width); + height = maxDimension; + } + } + + // Create a canvas to draw the image + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return reject(new Error('Failed to get canvas context.')); + ctx.drawImage(img, 0, 0, width, height); + + // Use WebP for both compression and transparency support + let quality = 0.9; + + function attemptCompression() { + canvas.toBlob( + (blob) => { + if (!blob) { + return reject(new Error('Compression failed.')); + } + if (blob.size <= maxSize || quality < 0.3) { + resolve({ + blob, + aspectRatio: { + width, + height + } + }); + } else { + quality -= 0.1; + attemptCompression(); + } + }, + 'image/webp', + quality + ); + } + + attemptCompression(); + }; + + img.onerror = (err) => reject(err); + }); +} + +export async function checkAndUploadImage( + recordWithImage: Record, + key: string = 'image', + // e.g. /api/image-proxy?url= + imageProxy?: string +) { + if (!recordWithImage[key]) return; + + // Already uploaded as blob + if (typeof recordWithImage[key] === 'object' && recordWithImage[key].$type === 'blob') { + return; + } + + if (typeof recordWithImage[key] === 'string' && imageProxy) { + const proxyUrl = imageProxy + encodeURIComponent(recordWithImage[key]); + const response = await fetch(proxyUrl); + if (!response.ok) { + throw Error('failed to get image from image proxy'); + } + + const blob = await response.blob(); + const compressedBlob = await compressImage(blob); + + recordWithImage[key] = await uploadBlob({ blob: compressedBlob.blob }); + + return; + } + + if (recordWithImage[key]?.blob) { + if (recordWithImage[key].objectUrl) { + URL.revokeObjectURL(recordWithImage[key].objectUrl); + } + const compressedBlob = await compressImage(recordWithImage[key].blob); + recordWithImage[key] = await uploadBlob({ blob: compressedBlob.blob }); + } +} + +export function getImageFromRecord( + recordWithImage: Record | undefined, + did: string, + key: string = 'image' +): string | undefined { + if (!recordWithImage?.[key]) return; + + if (typeof recordWithImage[key] === 'object' && recordWithImage[key].$type === 'blob') { + return getCDNImageBlobUrl({ did, blob: recordWithImage[key] }); + } + + if (recordWithImage[key].objectUrl) return recordWithImage[key].objectUrl; + + if (recordWithImage[key].blob) { + recordWithImage[key].objectUrl = URL.createObjectURL(recordWithImage[key].blob); + return recordWithImage[key].objectUrl; + } + + return recordWithImage[key]; +} diff --git a/src/lib/atproto/index.ts b/src/lib/atproto/index.ts index 37b9d42..05df676 100644 --- a/src/lib/atproto/index.ts +++ b/src/lib/atproto/index.ts @@ -14,6 +14,6 @@ export { uploadBlob, describeRepo, getBlobURL, - getImageBlobUrl, + getCDNImageBlobUrl as getImageBlobUrl, searchActorsTypeahead } from './methods'; diff --git a/src/lib/atproto/metadata.ts b/src/lib/atproto/metadata.ts index d2348fd..f68d9ac 100644 --- a/src/lib/atproto/metadata.ts +++ b/src/lib/atproto/metadata.ts @@ -1,22 +1,23 @@ import { resolve } from '$app/paths'; -import { blobs, collections, rpcCalls, SITE } from './settings'; +import { permissions, SITE } from './settings'; function constructScope() { - const repos = collections.map((collection) => 'repo:' + collection).join(' '); + const repos = permissions.collections.map((collection) => 'repo:' + collection).join(' '); let rpcs = ''; - for (const [key, value] of Object.entries(rpcCalls)) { + for (const [key, value] of Object.entries(permissions.rpc ?? {})) { if (Array.isArray(value)) { rpcs += value.map((lxm) => 'rpc?lxm=' + lxm + '&aud=' + key).join(' '); } else { rpcs += 'rpc?lxm=' + value + '&aud=' + key; } } + let blobScope: string | undefined = undefined; - if (Array.isArray(blobs)) { - blobScope = 'blob?' + blobs.map((b) => 'accept=' + b).join('&'); - } else if (blobs) { - blobScope = 'blob:' + blobs; + if (Array.isArray(permissions.blobs)) { + blobScope = 'blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&'); + } else if (permissions.blobs) { + blobScope = 'blob:' + permissions.blobs; } const scope = ['atproto', repos, rpcs, blobScope].filter((v) => v?.trim()).join(' '); diff --git a/src/lib/atproto/methods.ts b/src/lib/atproto/methods.ts index 39f425d..32214d2 100644 --- a/src/lib/atproto/methods.ts +++ b/src/lib/atproto/methods.ts @@ -1,5 +1,6 @@ import type { Did, Handle } from '@atcute/lexicons'; import { user } from './auth.svelte'; +import type { AllowedCollection } from './settings'; import { CompositeDidDocumentResolver, CompositeHandleResolver, @@ -16,7 +17,7 @@ export type Collection = `${string}.${string}.${string}`; export function parseUri(uri: string) { const [did, collection, rkey] = uri.replace('at://', '').split('/'); return { did, collection, rkey } as { - collection: `${string}.${string}.${string}`; + collection: Collection; rkey: string; did: string; }; @@ -85,7 +86,7 @@ export async function listRecords({ did, collection, cursor, - limit = 0, + limit = 100, client }: { did?: Did; @@ -112,7 +113,7 @@ export async function listRecords({ params: { repo: did, collection, - limit: limit || 100, + limit: !limit || limit > 100 ? 100 : limit, cursor: currentCursor } }); @@ -131,7 +132,7 @@ export async function listRecords({ export async function getRecord({ did, collection, - rkey, + rkey = 'self', client }: { did?: Did; @@ -140,7 +141,6 @@ export async function getRecord({ client?: Client; }) { did ??= user.did; - rkey ??= 'self'; if (!collection) { throw new Error('Missing parameters for getRecord'); @@ -164,11 +164,11 @@ export async function getRecord({ export async function putRecord({ collection, - rkey, + rkey = 'self', record }: { - collection: Collection; - rkey: string; + collection: AllowedCollection; + rkey?: string; record: Record; }) { if (!user.client || !user.did) throw new Error('No rpc or did'); @@ -187,7 +187,13 @@ export async function putRecord({ return response; } -export async function deleteRecord({ collection, rkey }: { collection: Collection; rkey: string }) { +export async function deleteRecord({ + collection, + rkey = 'self' +}: { + collection: AllowedCollection; + rkey: string; +}) { if (!user.client || !user.did) throw new Error('No profile or rpc or did'); const response = await user.client.post('com.atproto.repo.deleteRecord', { @@ -258,7 +264,7 @@ export async function getBlobURL({ return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; } -export function getImageBlobUrl({ +export function getCDNImageBlobUrl({ did, blob }: { @@ -270,7 +276,7 @@ export function getImageBlobUrl({ }; }; }) { - return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@jpeg`; + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; } export async function searchActorsTypeahead( diff --git a/src/lib/atproto/settings.ts b/src/lib/atproto/settings.ts index 0efb757..7acd705 100644 --- a/src/lib/atproto/settings.ts +++ b/src/lib/atproto/settings.ts @@ -1,23 +1,37 @@ export const SITE = 'https://flo-bit.dev'; -// optionally add action=create/update/delete to only allow those actions for a collection -export const collections: string[] = ['xyz.statusphere.status']; -// example: only allow create and delete -// export const collections: string[] = ['xyz.statusphere.status?action=create&action=update']; +type Permissions = { + collections: readonly string[]; + rpc: Record; + blobs: readonly string[]; +}; + +export const permissions = { + // collections you can create/delete/update + + // example: only allow create and delete + // collections: ['xyz.statusphere.status?action=create&action=update'], + collections: ['xyz.statusphere.status'], + + // what types of authenticated proxied requests you can make to services -export const rpcCalls: Record = { // example: allow authenticated proxying to bsky appview to get a users liked posts - //'did:web:api.bsky.app#bsky_appview': ['app.bsky.feed.getActorLikes'] - // https://docs.bsky.app/docs/api/app-bsky-feed-get-actor-likes -}; + //rpc: {'did:web:api.bsky.app#bsky_appview': ['app.bsky.feed.getActorLikes']} + rpc: {}, + + // what types of blobs you can upload to a users PDS -export const blobs = [] as string | string[] | undefined; + // example: allowing video and html uploads + // blobs: ['video/*', 'text/html'] + // example: allowing all blob types + // blobs: ['*/*'] + blobs: ['hello'] +} as const satisfies Permissions; -// example: allowing video and html uploads -// export const blobs = ['video/*', 'text/html'] as string | string[] | undefined; +// Extract base collection name (before any query params) +type ExtractCollectionBase = T extends `${infer Base}?${string}` ? Base : T; -// example: allowing all blob types -// export const blobs = ['*/*'] as string | string[] | undefined; +export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>; // which PDS to use for signup // ATTENTION: pds.rip is only for development, all accounts get deleted automatically after a week diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f79951c..53093c1 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,8 +1,13 @@
-- 2.51.2