Hopefully feature-complete Android Bluesky client written in Expo
atproto bluesky
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Record+withMedia embed

SharpMars b85b8f2f b03a2dc1

+176 -4
+176 -4
src/components/EmbedView.tsx
··· 4 4 AppBskyEmbedRecord, 5 5 AppBskyEmbedRecordWithMedia, 6 6 AppBskyEmbedVideo, 7 + AppBskyFeedDefs, 7 8 AppBskyFeedPost, 9 + AppBskyLabelerDefs, 8 10 AppBskyRichtextFacet, 9 11 } from "@atcute/bluesky"; 10 12 import { segmentize } from "@atcute/bluesky-richtext-segmenter"; 11 - import { $type, is } from "@atcute/lexicons"; 13 + import { $type, Did, is } from "@atcute/lexicons"; 12 14 import { FlashList } from "@shopify/flash-list"; 13 15 import { Image } from "expo-image"; 14 - import { View, Text, FlatList, Pressable } from "react-native"; 16 + import { View, Text, FlatList, Pressable, useColorScheme } from "react-native"; 15 17 import VideoPlayer from "./VideoPlayer"; 16 18 import { useMaterial3Theme } from "@pchmn/expo-material3-theme"; 17 19 import { Link } from "expo-router"; 18 20 import DynamicImage from "./DynamicImage"; 21 + import { useQuery } from "@tanstack/react-query"; 22 + import { Client, FetchHandler, ok } from "@atcute/client"; 23 + import { useOAuthSession } from "./SessionProvider"; 19 24 20 25 export default function EmbedView(props: { 21 26 embed?: $type.enforce< ··· 27 32 >; 28 33 recursed?: true; 29 34 }) { 35 + const colorScheme = useColorScheme(); 30 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 + }); 31 68 32 69 const switchOnEmbed = () => { 33 70 switch (props.embed?.$type) { ··· 165 202 </Pressable> 166 203 </Link> 167 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; 168 210 169 - default: 170 - return <Text>{props.embed?.$type}</Text>; 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 + return ( 284 + <View 285 + key={val.src + val.val} 286 + style={{ 287 + flexDirection: "row", 288 + alignItems: "center", 289 + gap: 2, 290 + backgroundColor: theme[colorScheme!].elevation.level2, 291 + paddingVertical: 4, 292 + paddingHorizontal: 4, 293 + borderRadius: 4, 294 + }} 295 + > 296 + <Image 297 + style={{ width: 12, height: 12, borderRadius: 16 }} 298 + source={labeler?.creator.avatar} 299 + /> 300 + <Text 301 + style={{ 302 + color: colorScheme === "light" ? "black" : "white", 303 + fontSize: 10, 304 + fontWeight: 700, 305 + }} 306 + > 307 + {label?.locales.find((lc) => lc.lang === "en")?.name ?? val.val} 308 + </Text> 309 + </View> 310 + ); 311 + })} 312 + </View> 313 + )} 314 + 315 + {(post.value as AppBskyFeedPost.Main).text.trim().length > 0 && ( 316 + <Text 317 + selectable 318 + style={{ fontSize: 16, color: colorScheme === "light" ? "black" : "white", width: "100%" }} 319 + > 320 + {segmentize( 321 + (post.value as AppBskyFeedPost.Main).text, 322 + (post.value as AppBskyFeedPost.Main).facets 323 + ).map((val, i) => { 324 + if (val.features?.at(0)?.$type === "app.bsky.richtext.facet#link") 325 + return ( 326 + <Link key={i} href={(val.features!.at(0)! as any).uri} style={{ color: "#1974D2" }}> 327 + {val.text} 328 + </Link> 329 + ); 330 + 331 + return val.text; 332 + })} 333 + </Text> 334 + )} 335 + {post.embeds && post.embeds.length > 0 && <EmbedView recursed embed={post.embeds[0]} />} 336 + </View> 337 + )} 338 + {record.$type !== "app.bsky.embed.record#viewRecord" && !props.recursed && ( 339 + <Text>{record.$type ?? "undefined"}</Text> 340 + )} 341 + </> 342 + ); 171 343 } 172 344 }; 173 345