Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at main 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}