Hopefully feature-complete Android Bluesky client written in Expo
atproto
bluesky
1import {
2 AppBskyEmbedExternal,
3 AppBskyEmbedImages,
4 AppBskyEmbedRecord,
5 AppBskyEmbedRecordWithMedia,
6 AppBskyEmbedVideo,
7 AppBskyFeedDefs,
8 AppBskyFeedPost,
9 AppBskyLabelerDefs,
10 AppBskyRichtextFacet,
11} from "@atcute/bluesky";
12import { segmentize } from "@atcute/bluesky-richtext-segmenter";
13import { $type, Did, is } from "@atcute/lexicons";
14import { FlashList } from "@shopify/flash-list";
15import { Image } from "expo-image";
16import { View, Text, FlatList, Pressable, useColorScheme } from "react-native";
17import VideoPlayer from "./VideoPlayer";
18import { useMaterial3Theme } from "@pchmn/expo-material3-theme";
19import { Link } from "expo-router";
20import DynamicImage from "./DynamicImage";
21import { useQuery } from "@tanstack/react-query";
22import { Client, FetchHandler, ok } from "@atcute/client";
23import { useOAuthSession } from "./SessionProvider";
24
25export default function EmbedView(props: {
26 embed?: $type.enforce<
27 | AppBskyEmbedExternal.View
28 | AppBskyEmbedImages.View
29 | AppBskyEmbedRecord.View
30 | AppBskyEmbedRecordWithMedia.View
31 | AppBskyEmbedVideo.View
32 >;
33 recursed?: true;
34}) {
35 const colorScheme = useColorScheme();
36 const { theme } = useMaterial3Theme({ sourceColor: "#f4983c" });
37 const session = useOAuthSession();
38
39 const labelersQuery = useQuery({
40 queryKey: ["labelers"],
41 queryFn: async ({ signal }) => {
42 const wrapper: FetchHandler = async (pathname, init) => {
43 return session.fetchHandler(pathname, init);
44 };
45
46 const client = new Client({
47 handler: wrapper,
48 proxy: { did: "did:web:api.bsky.app", serviceId: "#bsky_appview" },
49 });
50
51 const { preferences } = await ok(client.get("app.bsky.actor.getPreferences", { signal, params: {} }));
52
53 let labelersDids: Did[] = [];
54
55 for (const pref of preferences) {
56 switch (pref.$type) {
57 case "app.bsky.actor.defs#labelersPref":
58 labelersDids = pref.labelers.map((val) => val.did);
59 break;
60 }
61 }
62
63 return await ok(
64 client.get("app.bsky.labeler.getServices", { signal, params: { dids: labelersDids, detailed: true } })
65 );
66 },
67 });
68
69 const switchOnEmbed = () => {
70 switch (props.embed?.$type) {
71 case "app.bsky.embed.images#view":
72 if (props.embed.images.length > 1)
73 return (
74 <FlatList
75 style={{ maxHeight: 256, gap: 8 }}
76 contentContainerStyle={{ gap: 8 }}
77 horizontal
78 data={props.embed.images}
79 renderItem={({ item }) => {
80 return (
81 <Image
82 style={{
83 aspectRatio: item.aspectRatio ? item.aspectRatio?.width / item.aspectRatio?.height : 1,
84 flex: 1,
85 height: "100%",
86 maxHeight: "100%",
87 width: "25%",
88 borderRadius: 16,
89 }}
90 source={item.thumb}
91 alt={item.alt}
92 contentFit="fill"
93 allowDownscaling={false}
94 autoplay
95 />
96 );
97 }}
98 />
99 );
100 else
101 return (
102 <View
103 style={{
104 height: "100%",
105 flex: 1,
106 alignItems: "center",
107 }}
108 >
109 <Image
110 style={{
111 aspectRatio: props.embed.images[0].aspectRatio
112 ? props.embed.images[0].aspectRatio?.width / props.embed.images[0].aspectRatio?.height
113 : 1,
114 maxWidth: "100%",
115 height: "auto",
116 maxHeight: "100%",
117 width: "100%",
118 borderRadius: 16,
119 }}
120 source={props.embed.images[0].fullsize}
121 contentFit="contain"
122 allowDownscaling={false}
123 autoplay
124 />
125 </View>
126 );
127 case "app.bsky.embed.video#view":
128 return <VideoPlayer video={props.embed} />;
129 case "app.bsky.embed.external#view":
130 if (props.embed.external.uri.startsWith("https://media.tenor.com/")) {
131 const params = new URL(props.embed.external.uri).searchParams;
132 const h = parseInt(params.get("hh") ?? "1");
133 const w = parseInt(params.get("ww") ?? "1");
134
135 return (
136 <DynamicImage
137 style={{
138 aspectRatio: w / h,
139 flex: 1,
140 height: "100%",
141 borderRadius: 16,
142 }}
143 source={props.embed.external.uri}
144 contentFit="cover"
145 allowDownscaling={false}
146 autoplay
147 />
148 );
149 } else
150 return (
151 <Link href={props.embed.external.uri} asChild>
152 <Pressable
153 style={{
154 height: "100%",
155 flex: 1,
156 alignItems: "center",
157 borderWidth: 2,
158 borderColor: theme.dark.outline,
159 borderRadius: 16,
160 overflow: "hidden",
161 backgroundColor: theme.dark.elevation.level1,
162 }}
163 >
164 {props.embed.external.thumb && (
165 <Image
166 style={{
167 aspectRatio: 1.90476 / 1,
168 flex: 1,
169 height: "auto",
170 width: "100%",
171 borderBottomWidth: 2,
172 borderColor: theme.dark.outline,
173 }}
174 source={props.embed.external.thumb}
175 contentFit="cover"
176 />
177 )}
178 <View style={{ padding: 8, alignItems: "flex-start", width: "100%" }}>
179 <Text style={{ alignSelf: "flex-start", color: "white", fontWeight: 700 }}>
180 {props.embed.external.title}
181 </Text>
182
183 {props.embed.external.description.trim().length > 0 && (
184 <Text style={{ alignSelf: "flex-start", color: "white", fontSize: 12 }}>
185 {props.embed.external.description}
186 </Text>
187 )}
188 <View
189 style={{
190 backgroundColor: theme.dark.outline,
191 width: "100%",
192 height: 1,
193 borderRadius: 16,
194 marginVertical: 4,
195 }}
196 />
197
198 <Text style={{ alignSelf: "flex-start", color: "white", fontSize: 12 }}>
199 {new URL(props.embed.external.uri).hostname}
200 </Text>
201 </View>
202 </Pressable>
203 </Link>
204 );
205 case "app.bsky.embed.recordWithMedia#view":
206 case "app.bsky.embed.record#view":
207 const record =
208 props.embed.$type === "app.bsky.embed.recordWithMedia#view" ? props.embed.record.record : props.embed.record;
209 const post = record as AppBskyEmbedRecord.ViewRecord;
210
211 return (
212 <>
213 {props.embed.$type === "app.bsky.embed.recordWithMedia#view" && (
214 <EmbedView embed={props.embed.media} recursed />
215 )}
216 {record.$type === "app.bsky.embed.record#viewRecord" && !props.recursed && (
217 <View
218 style={{
219 height: "100%",
220 flex: 1,
221 alignItems: "center",
222 borderWidth: 2,
223 borderColor: theme.dark.outline,
224 borderRadius: 16,
225 overflow: "hidden",
226 backgroundColor: theme.dark.elevation.level1,
227 padding: 8,
228 }}
229 >
230 <View
231 style={{
232 flexDirection: "row",
233 gap: 4,
234 flex: 1,
235 width: "100%",
236 alignItems: "center",
237 }}
238 >
239 <Image source={post.author.avatar} style={{ height: 16, width: 16, borderRadius: 6 }} />
240 <Text
241 style={{ fontWeight: 700, color: "white", flexShrink: 1 }}
242 ellipsizeMode="tail"
243 numberOfLines={1}
244 >
245 {post.author.displayName}
246 </Text>
247 <Text
248 style={{ opacity: 0.75, color: colorScheme === "light" ? "black" : "white", flexShrink: 1 }}
249 ellipsizeMode="tail"
250 numberOfLines={1}
251 >
252 {"@" + post.author.handle}
253 </Text>
254 </View>
255
256 {(post.author.pronouns || (labelersQuery.isSuccess && (post.author.labels?.length ?? 0) > 0)) && (
257 <View style={{ flexDirection: "row", gap: 4, flexWrap: "wrap", width: "100%" }}>
258 {post.author.pronouns && (
259 <Text
260 style={{
261 color: colorScheme === "light" ? "black" : "white",
262 backgroundColor: theme[colorScheme!].elevation.level2,
263 paddingVertical: 4,
264 paddingHorizontal: 4,
265 borderRadius: 4,
266 alignSelf: "flex-start",
267 fontSize: 10,
268 fontWeight: 700,
269 }}
270 >
271 {post.author.pronouns}
272 </Text>
273 )}
274 {labelersQuery.isSuccess &&
275 (post.author.labels ?? [])
276 .filter((val) => val.src !== val.uri.slice(5).split("/")[0])
277 .map((val) => {
278 const labeler = labelersQuery.data.views.find((labeler) => labeler.creator.did === val.src);
279 const labelDefs = (labeler as AppBskyLabelerDefs.LabelerViewDetailed)?.policies
280 .labelValueDefinitions;
281 const label = labelDefs?.find((def) => def.identifier === val.val);
282
283 if (!label) return null;
284
285 return (
286 <View
287 key={val.src + val.val}
288 style={{
289 flexDirection: "row",
290 alignItems: "center",
291 gap: 2,
292 backgroundColor: theme[colorScheme!].elevation.level2,
293 paddingVertical: 4,
294 paddingHorizontal: 4,
295 borderRadius: 4,
296 }}
297 >
298 <Image
299 style={{ width: 12, height: 12, borderRadius: 16 }}
300 source={labeler?.creator.avatar}
301 />
302 <Text
303 style={{
304 color: colorScheme === "light" ? "black" : "white",
305 fontSize: 10,
306 fontWeight: 700,
307 }}
308 >
309 {label?.locales.find((lc) => lc.lang === "en")?.name ?? val.val}
310 </Text>
311 </View>
312 );
313 })
314 .filter((val) => val)}
315 </View>
316 )}
317
318 {(post.value as AppBskyFeedPost.Main).text.trim().length > 0 && (
319 <Text
320 selectable
321 style={{ fontSize: 16, color: colorScheme === "light" ? "black" : "white", width: "100%" }}
322 >
323 {segmentize(
324 (post.value as AppBskyFeedPost.Main).text,
325 (post.value as AppBskyFeedPost.Main).facets
326 ).map((val, i) => {
327 if (val.features?.at(0)?.$type === "app.bsky.richtext.facet#link")
328 return (
329 <Link key={i} href={(val.features!.at(0)! as any).uri} style={{ color: "#1974D2" }}>
330 {val.text}
331 </Link>
332 );
333
334 return val.text;
335 })}
336 </Text>
337 )}
338 {post.embeds && post.embeds.length > 0 && <EmbedView recursed embed={post.embeds[0]} />}
339 </View>
340 )}
341 {record.$type !== "app.bsky.embed.record#viewRecord" && !props.recursed && (
342 <Text>{record.$type ?? "undefined"}</Text>
343 )}
344 </>
345 );
346 }
347 };
348
349 return <>{props.embed && switchOnEmbed()}</>;
350}