an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
at main 8.2 kB view raw
1import { 2 type $Typed, 3 AppBskyActorDefs, 4 AppBskyEmbedExternal, 5 AppBskyEmbedImages, 6 AppBskyEmbedRecord, 7 AppBskyEmbedRecordWithMedia, 8 AppBskyEmbedVideo, 9 AppBskyFeedPost, 10 AtUri, 11} from "@atproto/api"; 12import { useAtom } from "jotai"; 13import { useMemo } from "react"; 14 15import { imgCDNAtom, videoCDNAtom } from "./atoms"; 16import { useQueryIdentity, useQueryPost, useQueryProfile } from "./useQuery"; 17 18type QueryResultData<T extends (...args: any) => any> = 19 ReturnType<T> extends { data: infer D } | undefined ? D : never; 20 21function asTyped<T extends { $type: string }>(obj: T): $Typed<T> { 22 return obj as $Typed<T>; 23} 24 25export function hydrateEmbedImages( 26 embed: AppBskyEmbedImages.Main, 27 did: string, 28 cdn: string 29): $Typed<AppBskyEmbedImages.View> { 30 return asTyped({ 31 $type: "app.bsky.embed.images#view" as const, 32 images: embed.images 33 .map((img) => { 34 const link = img.image.ref?.["$link"]; 35 if (!link) return null; 36 return { 37 thumb: `https://${cdn}/img/feed_thumbnail/plain/${did}/${link}@jpeg`, 38 fullsize: `https://${cdn}/img/feed_fullsize/plain/${did}/${link}@jpeg`, 39 alt: img.alt || "", 40 aspectRatio: img.aspectRatio, 41 }; 42 }) 43 .filter(Boolean) as AppBskyEmbedImages.ViewImage[], 44 }); 45} 46 47export function hydrateEmbedExternal( 48 embed: AppBskyEmbedExternal.Main, 49 did: string, 50 cdn: string 51): $Typed<AppBskyEmbedExternal.View> { 52 return asTyped({ 53 $type: "app.bsky.embed.external#view" as const, 54 external: { 55 uri: embed.external.uri, 56 title: embed.external.title, 57 description: embed.external.description, 58 thumb: embed.external.thumb?.ref?.$link 59 ? `https://${cdn}/img/feed_thumbnail/plain/${did}/${embed.external.thumb.ref.$link}@jpeg` 60 : undefined, 61 }, 62 }); 63} 64 65export function hydrateEmbedVideo( 66 embed: AppBskyEmbedVideo.Main, 67 did: string, 68 videocdn: string 69): $Typed<AppBskyEmbedVideo.View> { 70 const videoLink = embed.video.ref.$link; 71 return asTyped({ 72 $type: "app.bsky.embed.video#view" as const, 73 playlist: `https://${videocdn}/watch/${did}/${videoLink}/playlist.m3u8`, 74 thumbnail: `https://${videocdn}/watch/${did}/${videoLink}/thumbnail.jpg`, 75 aspectRatio: embed.aspectRatio, 76 cid: videoLink, 77 }); 78} 79 80function hydrateEmbedRecord( 81 embed: AppBskyEmbedRecord.Main, 82 quotedPost: QueryResultData<typeof useQueryPost>, 83 quotedProfile: QueryResultData<typeof useQueryProfile>, 84 quotedIdentity: QueryResultData<typeof useQueryIdentity>, 85 cdn: string 86): $Typed<AppBskyEmbedRecord.View> | undefined { 87 if (!quotedPost || !quotedProfile || !quotedIdentity) { 88 return undefined; 89 } 90 91 const author: $Typed<AppBskyActorDefs.ProfileViewBasic> = asTyped({ 92 $type: "app.bsky.actor.defs#profileViewBasic" as const, 93 did: quotedIdentity.did, 94 handle: quotedIdentity.handle, 95 displayName: quotedProfile.value.displayName ?? quotedIdentity.handle, 96 avatar: quotedProfile.value.avatar?.ref?.$link 97 ? `https://${cdn}/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg` 98 : undefined, 99 viewer: {}, 100 labels: [], 101 }); 102 103 const viewRecord: $Typed<AppBskyEmbedRecord.ViewRecord> = asTyped({ 104 $type: "app.bsky.embed.record#viewRecord" as const, 105 uri: quotedPost.uri, 106 cid: quotedPost.cid, 107 author, 108 value: quotedPost.value, 109 indexedAt: quotedPost.value.createdAt, 110 embeds: quotedPost.value.embed ? [quotedPost.value.embed] : undefined, 111 }); 112 113 return asTyped({ 114 $type: "app.bsky.embed.record#view" as const, 115 record: viewRecord, 116 }); 117} 118 119function hydrateEmbedRecordWithMedia( 120 embed: AppBskyEmbedRecordWithMedia.Main, 121 mediaHydratedEmbed: 122 | $Typed<AppBskyEmbedImages.View> 123 | $Typed<AppBskyEmbedVideo.View> 124 | $Typed<AppBskyEmbedExternal.View>, 125 quotedPost: QueryResultData<typeof useQueryPost>, 126 quotedProfile: QueryResultData<typeof useQueryProfile>, 127 quotedIdentity: QueryResultData<typeof useQueryIdentity>, 128 cdn: string 129): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined { 130 const hydratedRecord = hydrateEmbedRecord( 131 embed.record, 132 quotedPost, 133 quotedProfile, 134 quotedIdentity, 135 cdn 136 ); 137 138 if (!hydratedRecord) return undefined; 139 140 return asTyped({ 141 $type: "app.bsky.embed.recordWithMedia#view" as const, 142 record: hydratedRecord, 143 media: mediaHydratedEmbed, 144 }); 145} 146 147type HydratedEmbedView = 148 | $Typed<AppBskyEmbedImages.View> 149 | $Typed<AppBskyEmbedExternal.View> 150 | $Typed<AppBskyEmbedVideo.View> 151 | $Typed<AppBskyEmbedRecord.View> 152 | $Typed<AppBskyEmbedRecordWithMedia.View>; 153 154export function useHydratedEmbed( 155 embed: AppBskyFeedPost.Record["embed"], 156 postAuthorDid: string | undefined 157) { 158 const recordInfo = useMemo(() => { 159 if (AppBskyEmbedRecordWithMedia.isMain(embed)) { 160 const recordUri = embed.record.record.uri; 161 const quotedAuthorDid = new AtUri(recordUri).hostname; 162 return { recordUri, quotedAuthorDid, isRecordType: true }; 163 } else if (AppBskyEmbedRecord.isMain(embed)) { 164 const recordUri = embed.record.uri; 165 const quotedAuthorDid = new AtUri(recordUri).hostname; 166 return { recordUri, quotedAuthorDid, isRecordType: true }; 167 } 168 return { 169 recordUri: undefined, 170 quotedAuthorDid: undefined, 171 isRecordType: false, 172 }; 173 }, [embed]); 174 175 const { isRecordType, recordUri, quotedAuthorDid } = recordInfo; 176 177 const usequerypostresults = useQueryPost(recordUri); 178 179 const profileUri = quotedAuthorDid 180 ? `at://${quotedAuthorDid}/app.bsky.actor.profile/self` 181 : undefined; 182 183 const { 184 data: quotedProfile, 185 isLoading: isLoadingProfile, 186 error: profileError, 187 } = useQueryProfile(profileUri); 188 189 const [imgcdn] = useAtom(imgCDNAtom); 190 const [videocdn] = useAtom(videoCDNAtom); 191 192 const queryidentityresult = useQueryIdentity(quotedAuthorDid); 193 194 const hydratedEmbed: HydratedEmbedView | undefined = (() => { 195 if (!embed || !postAuthorDid) return undefined; 196 197 if ( 198 isRecordType && 199 (!usequerypostresults?.data || 200 !quotedProfile || 201 !queryidentityresult?.data) 202 ) { 203 return undefined; 204 } 205 206 try { 207 if (AppBskyEmbedImages.isMain(embed)) { 208 return hydrateEmbedImages(embed, postAuthorDid, imgcdn); 209 } else if (AppBskyEmbedExternal.isMain(embed)) { 210 return hydrateEmbedExternal(embed, postAuthorDid, imgcdn); 211 } else if (AppBskyEmbedVideo.isMain(embed)) { 212 return hydrateEmbedVideo(embed, postAuthorDid, videocdn); 213 } else if (AppBskyEmbedRecord.isMain(embed)) { 214 return hydrateEmbedRecord( 215 embed, 216 usequerypostresults?.data, 217 quotedProfile, 218 queryidentityresult?.data, 219 imgcdn 220 ); 221 } else if (AppBskyEmbedRecordWithMedia.isMain(embed)) { 222 let hydratedMedia: 223 | $Typed<AppBskyEmbedImages.View> 224 | $Typed<AppBskyEmbedVideo.View> 225 | $Typed<AppBskyEmbedExternal.View> 226 | undefined; 227 228 if (AppBskyEmbedImages.isMain(embed.media)) { 229 hydratedMedia = hydrateEmbedImages( 230 embed.media, 231 postAuthorDid, 232 imgcdn 233 ); 234 } else if (AppBskyEmbedExternal.isMain(embed.media)) { 235 hydratedMedia = hydrateEmbedExternal( 236 embed.media, 237 postAuthorDid, 238 imgcdn 239 ); 240 } else if (AppBskyEmbedVideo.isMain(embed.media)) { 241 hydratedMedia = hydrateEmbedVideo( 242 embed.media, 243 postAuthorDid, 244 videocdn 245 ); 246 } 247 248 if (hydratedMedia) { 249 return hydrateEmbedRecordWithMedia( 250 embed, 251 hydratedMedia, 252 usequerypostresults?.data, 253 quotedProfile, 254 queryidentityresult?.data, 255 imgcdn 256 ); 257 } 258 } 259 } catch (e) { 260 console.error("Error hydrating embed", e); 261 return undefined; 262 } 263 })(); 264 265 const isLoading = isRecordType 266 ? usequerypostresults?.isLoading || 267 isLoadingProfile || 268 queryidentityresult?.isLoading 269 : false; 270 271 const error = 272 usequerypostresults?.error || profileError || queryidentityresult?.error; 273 274 return { data: hydratedEmbed, isLoading, error }; 275}