Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
120
fork

Configure Feed

Select the types of activity you want to include in your feed.

at a876aae44ea07494ebea9727350aa060b81f317b 263 lines 7.4 kB view raw
1import { 2 type AppBskyFeedDefs, 3 type AppBskyGraphDefs, 4 type BskyAgent, 5 type ComAtprotoRepoStrongRef, 6} from '@atproto/api' 7import {AtUri} from '@atproto/api' 8 9import {POST_IMG_MAX} from '#/lib/constants' 10import {getLinkMeta} from '#/lib/link-meta/link-meta' 11import {resolveShortLink} from '#/lib/link-meta/resolve-short-link' 12import {downloadAndResize} from '#/lib/media/manip' 13import { 14 createStarterPackUri, 15 parseStarterPackUri, 16} from '#/lib/strings/starter-pack' 17import { 18 convertBskyAppUrlIfNeeded, 19 isBskyCustomFeedUrl, 20 isBskyListUrl, 21 isBskyPostUrl, 22 isBskyStarterPackUrl, 23 isBskyStartUrl, 24 isShortLink, 25 makeRecordUri, 26} from '#/lib/strings/url-helpers' 27import {type ComposerImage} from '#/state/gallery' 28import {createComposerImage} from '#/state/gallery' 29import {type Gif} from '#/state/queries/tenor' 30import {createGIFDescription} from '../gif-alt-text' 31 32type ResolvedExternalLink = { 33 type: 'external' 34 uri: string 35 title: string 36 description: string 37 thumb: ComposerImage | undefined 38} 39 40type ResolvedPostRecord = { 41 type: 'record' 42 record: ComAtprotoRepoStrongRef.Main 43 kind: 'post' 44 view: AppBskyFeedDefs.PostView 45} 46 47type ResolvedFeedRecord = { 48 type: 'record' 49 record: ComAtprotoRepoStrongRef.Main 50 kind: 'feed' 51 view: AppBskyFeedDefs.GeneratorView 52} 53 54type ResolvedListRecord = { 55 type: 'record' 56 record: ComAtprotoRepoStrongRef.Main 57 kind: 'list' 58 view: AppBskyGraphDefs.ListView 59} 60 61type ResolvedStarterPackRecord = { 62 type: 'record' 63 record: ComAtprotoRepoStrongRef.Main 64 kind: 'starter-pack' 65 view: AppBskyGraphDefs.StarterPackView 66} 67 68export type ResolvedLink = 69 | ResolvedExternalLink 70 | ResolvedPostRecord 71 | ResolvedFeedRecord 72 | ResolvedListRecord 73 | ResolvedStarterPackRecord 74 75export class EmbeddingDisabledError extends Error { 76 constructor() { 77 super('Embedding is disabled for this record') 78 } 79} 80 81export async function resolveLink( 82 agent: BskyAgent, 83 uri: string, 84): Promise<ResolvedLink> { 85 let resolvedUri = uri 86 if (isShortLink(resolvedUri)) { 87 resolvedUri = await resolveShortLink(resolvedUri) 88 } 89 if (isBskyPostUrl(uri)) { 90 uri = convertBskyAppUrlIfNeeded(uri) 91 const [_0, user, _1, rkey] = uri.split('/').filter(Boolean) 92 const recordUri = makeRecordUri(user, 'app.bsky.feed.post', rkey) 93 const post = await getPost({uri: recordUri}) 94 if (post.viewer?.embeddingDisabled) { 95 throw new EmbeddingDisabledError() 96 } 97 return { 98 type: 'record', 99 record: { 100 cid: post.cid, 101 uri: post.uri, 102 }, 103 kind: 'post', 104 view: post, 105 } 106 } 107 if (isBskyCustomFeedUrl(resolvedUri)) { 108 resolvedUri = convertBskyAppUrlIfNeeded(resolvedUri) 109 const [_0, handleOrDid, _1, rkey] = resolvedUri.split('/').filter(Boolean) 110 const did = await fetchDid(handleOrDid) 111 const feed = makeRecordUri(did, 'app.bsky.feed.generator', rkey) 112 const res = await agent.app.bsky.feed.getFeedGenerator({feed}) 113 return { 114 type: 'record', 115 record: { 116 uri: res.data.view.uri, 117 cid: res.data.view.cid, 118 }, 119 kind: 'feed', 120 view: res.data.view, 121 } 122 } 123 if (isBskyListUrl(resolvedUri)) { 124 resolvedUri = convertBskyAppUrlIfNeeded(resolvedUri) 125 const [_0, handleOrDid, _1, rkey] = resolvedUri.split('/').filter(Boolean) 126 const did = await fetchDid(handleOrDid) 127 const list = makeRecordUri(did, 'app.bsky.graph.list', rkey) 128 const res = await agent.app.bsky.graph.getList({list}) 129 return { 130 type: 'record', 131 record: { 132 uri: res.data.list.uri, 133 cid: res.data.list.cid, 134 }, 135 kind: 'list', 136 view: res.data.list, 137 } 138 } 139 if (isBskyStartUrl(resolvedUri) || isBskyStarterPackUrl(resolvedUri)) { 140 const parsed = parseStarterPackUri(resolvedUri) 141 if (!parsed) { 142 throw new Error( 143 'Unexpectedly called getStarterPackAsEmbed with a non-starterpack url', 144 ) 145 } 146 const did = await fetchDid(parsed.name) 147 const starterPack = createStarterPackUri({did, rkey: parsed.rkey}) 148 const res = await agent.app.bsky.graph.getStarterPack({starterPack}) 149 return { 150 type: 'record', 151 record: { 152 uri: res.data.starterPack.uri, 153 cid: res.data.starterPack.cid, 154 }, 155 kind: 'starter-pack', 156 view: res.data.starterPack, 157 } 158 } 159 return resolveExternal(agent, resolvedUri) 160 161 // Forked from useGetPost. TODO: move into RQ. 162 async function getPost({uri}: {uri: string}) { 163 const urip = new AtUri(uri) 164 if (!urip.host.startsWith('did:')) { 165 const res = await agent.resolveHandle({ 166 handle: urip.host, 167 }) 168 // @ts-expect-error TODO new-sdk-migration 169 urip.host = res.data.did 170 } 171 const res = await agent.getPosts({ 172 uris: [urip.toString()], 173 }) 174 if (res.success && res.data.posts[0]) { 175 return res.data.posts[0] 176 } 177 throw new Error('getPost: post not found') 178 } 179 180 // Forked from useFetchDid. TODO: move into RQ. 181 async function fetchDid(handleOrDid: string) { 182 let identifier = handleOrDid 183 if (!identifier.startsWith('did:')) { 184 const res = await agent.resolveHandle({handle: identifier}) 185 identifier = res.data.did 186 } 187 return identifier 188 } 189} 190 191export async function resolveGif( 192 agent: BskyAgent, 193 gif: Gif, 194): Promise<ResolvedExternalLink> { 195 const gifUrl = gif.media_formats.gif.url 196 const params = new URLSearchParams() 197 params.set('hh', String(gif.media_formats.gif.dims[1])) 198 params.set('ww', String(gif.media_formats.gif.dims[0])) 199 200 // For Klipy GIFs, embed video format slugs so parseKlipyGif can 201 // swap to the right format per platform at render time. Klipy uses 202 // different filename slugs per format (unlike Tenor where format is 203 // encoded in the URL ID), so this info must travel with the URL. 204 try { 205 const url = new URL(gifUrl) 206 if (url.hostname === 'static.klipy.com') { 207 const mp4Slug = getFileSlug(gif.media_formats.mp4?.url) 208 const webmSlug = getFileSlug(gif.media_formats.webm?.url) 209 if (mp4Slug) params.set('mp4', mp4Slug) 210 if (webmSlug) params.set('webm', webmSlug) 211 } 212 } catch {} 213 214 const uri = `${gifUrl}?${params.toString()}` 215 const altText = gif.content_description || gif.title 216 return { 217 type: 'external', 218 uri, 219 title: altText, 220 description: createGIFDescription(altText), 221 thumb: await imageToThumb(gif.media_formats.preview.url), 222 } 223} 224 225function getFileSlug(url: string | undefined): string | undefined { 226 if (!url) return undefined 227 const filename = url.split('/').pop() 228 if (!filename) return undefined 229 const dotIndex = filename.lastIndexOf('.') 230 return dotIndex > 0 ? filename.slice(0, dotIndex) : undefined 231} 232 233async function resolveExternal( 234 agent: BskyAgent, 235 uri: string, 236): Promise<ResolvedExternalLink> { 237 const result = await getLinkMeta(agent, uri) 238 return { 239 type: 'external', 240 uri: result.url, 241 title: result.title ?? '', 242 description: result.description ?? '', 243 thumb: result.image ? await imageToThumb(result.image) : undefined, 244 } 245} 246 247export async function imageToThumb( 248 imageUri: string, 249): Promise<ComposerImage | undefined> { 250 try { 251 const img = await downloadAndResize({ 252 uri: imageUri, 253 width: POST_IMG_MAX.width, 254 height: POST_IMG_MAX.height, 255 mode: 'contain', 256 maxSize: POST_IMG_MAX.size, 257 timeout: 15e3, 258 }) 259 if (img) { 260 return await createComposerImage(img) 261 } 262 } catch {} 263}