Hopefully feature-complete Android Bluesky client written in Expo
atproto bluesky
at main 13 kB view raw
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}