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}