an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
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 if (!quotedPost || !quotedProfile || !quotedIdentity) {
91 const failureViewRecord: $Typed<AppBskyEmbedRecord.ViewNotFound> = asTyped({
92 $type: "app.bsky.embed.record#viewNotFound" as const,
93 uri: embed.record.uri,
94 notFound: true as const,
95 })
96
97 return asTyped({
98 $type: "app.bsky.embed.record#view" as const,
99 record: failureViewRecord,
100 });
101 }
102
103 const author: $Typed<AppBskyActorDefs.ProfileViewBasic> = asTyped({
104 $type: "app.bsky.actor.defs#profileViewBasic" as const,
105 did: quotedIdentity.did,
106 handle: quotedIdentity.handle,
107 displayName: quotedProfile.value.displayName ?? quotedIdentity.handle,
108 avatar: quotedProfile.value.avatar?.ref?.$link
109 ? `https://${cdn}/img/avatar/plain/${quotedIdentity.did}/${quotedProfile.value.avatar.ref.$link}@jpeg`
110 : undefined,
111 viewer: {},
112 labels: [],
113 });
114
115 const viewRecord: $Typed<AppBskyEmbedRecord.ViewRecord> = asTyped({
116 $type: "app.bsky.embed.record#viewRecord" as const,
117 uri: quotedPost.uri,
118 cid: quotedPost.cid,
119 author,
120 value: quotedPost.value,
121 indexedAt: quotedPost.value.createdAt,
122 embeds: quotedPost.value.embed ? [quotedPost.value.embed] : undefined,
123 });
124
125 return asTyped({
126 $type: "app.bsky.embed.record#view" as const,
127 record: viewRecord,
128 });
129}
130
131function hydrateEmbedRecordWithMedia(
132 embed: AppBskyEmbedRecordWithMedia.Main,
133 mediaHydratedEmbed:
134 | $Typed<AppBskyEmbedImages.View>
135 | $Typed<AppBskyEmbedVideo.View>
136 | $Typed<AppBskyEmbedExternal.View>,
137 quotedPost: QueryResultData<typeof useQueryPost>,
138 quotedProfile: QueryResultData<typeof useQueryProfile>,
139 quotedIdentity: QueryResultData<typeof useQueryIdentity>,
140 cdn: string
141): $Typed<AppBskyEmbedRecordWithMedia.View> | undefined {
142 const hydratedRecord = hydrateEmbedRecord(
143 embed.record,
144 quotedPost,
145 quotedProfile,
146 quotedIdentity,
147 cdn
148 );
149
150 if (!hydratedRecord) return undefined;
151
152 return asTyped({
153 $type: "app.bsky.embed.recordWithMedia#view" as const,
154 record: hydratedRecord,
155 media: mediaHydratedEmbed,
156 });
157}
158
159type HydratedEmbedView =
160 | $Typed<AppBskyEmbedImages.View>
161 | $Typed<AppBskyEmbedExternal.View>
162 | $Typed<AppBskyEmbedVideo.View>
163 | $Typed<AppBskyEmbedRecord.View>
164 | $Typed<AppBskyEmbedRecordWithMedia.View>;
165
166export function useHydratedEmbed(
167 embed: AppBskyFeedPost.Record["embed"],
168 postAuthorDid: string | undefined
169) {
170 const recordInfo = useMemo(() => {
171 if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
172 const recordUri = embed.record.record.uri;
173 const quotedAuthorDid = new AtUri(recordUri).hostname;
174 return { recordUri, quotedAuthorDid, isRecordType: true };
175 } else if (AppBskyEmbedRecord.isMain(embed)) {
176 const recordUri = embed.record.uri;
177 const quotedAuthorDid = new AtUri(recordUri).hostname;
178 return { recordUri, quotedAuthorDid, isRecordType: true };
179 }
180 return {
181 recordUri: undefined,
182 quotedAuthorDid: undefined,
183 isRecordType: false,
184 };
185 }, [embed]);
186
187 const { isRecordType, recordUri, quotedAuthorDid } = recordInfo;
188
189 const usequerypostresults = useQueryPost(recordUri);
190
191 const profileUri = quotedAuthorDid
192 ? `at://${quotedAuthorDid}/app.bsky.actor.profile/self`
193 : undefined;
194
195 const {
196 data: quotedProfile,
197 isLoading: isLoadingProfile,
198 error: profileError,
199 } = useQueryProfile(profileUri);
200
201 const [imgcdn] = useAtom(imgCDNAtom);
202 const [videocdn] = useAtom(videoCDNAtom);
203
204 const queryidentityresult = useQueryIdentity(quotedAuthorDid);
205
206 const hydratedEmbed: HydratedEmbedView | undefined = (() => {
207 if (!embed || !postAuthorDid) return undefined;
208
209 // if (
210 // isRecordType &&
211 // (!usequerypostresults?.data ||
212 // !quotedProfile ||
213 // !queryidentityresult?.data)
214 // ) {
215 // return undefined;
216 // }
217 try {
218 if (AppBskyEmbedImages.isMain(embed)) {
219 return hydrateEmbedImages(embed, postAuthorDid, imgcdn);
220 } else if (AppBskyEmbedExternal.isMain(embed)) {
221 return hydrateEmbedExternal(embed, postAuthorDid, imgcdn);
222 } else if (AppBskyEmbedVideo.isMain(embed)) {
223 return hydrateEmbedVideo(embed, postAuthorDid, videocdn);
224 } else if (AppBskyEmbedRecord.isMain(embed)) {
225 return hydrateEmbedRecord(
226 embed,
227 usequerypostresults?.data,
228 quotedProfile,
229 queryidentityresult?.data,
230 imgcdn
231 );
232 } else if (AppBskyEmbedRecordWithMedia.isMain(embed)) {
233 let hydratedMedia:
234 | $Typed<AppBskyEmbedImages.View>
235 | $Typed<AppBskyEmbedVideo.View>
236 | $Typed<AppBskyEmbedExternal.View>
237 | undefined;
238
239 if (AppBskyEmbedImages.isMain(embed.media)) {
240 hydratedMedia = hydrateEmbedImages(
241 embed.media,
242 postAuthorDid,
243 imgcdn
244 );
245 } else if (AppBskyEmbedExternal.isMain(embed.media)) {
246 hydratedMedia = hydrateEmbedExternal(
247 embed.media,
248 postAuthorDid,
249 imgcdn
250 );
251 } else if (AppBskyEmbedVideo.isMain(embed.media)) {
252 hydratedMedia = hydrateEmbedVideo(
253 embed.media,
254 postAuthorDid,
255 videocdn
256 );
257 }
258
259 if (hydratedMedia) {
260 return hydrateEmbedRecordWithMedia(
261 embed,
262 hydratedMedia,
263 usequerypostresults?.data,
264 quotedProfile,
265 queryidentityresult?.data,
266 imgcdn
267 );
268 }
269 }
270 } catch (e) {
271 console.error("Error hydrating embed", e);
272 return undefined;
273 }
274 })();
275
276 const isLoading = isRecordType
277 ? usequerypostresults?.isLoading ||
278 isLoadingProfile ||
279 queryidentityresult?.isLoading
280 : false;
281
282 const error =
283 usequerypostresults?.error || profileError || queryidentityresult?.error;
284
285 return { data: hydratedEmbed, isLoading, error };
286}