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 { 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}