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