Something went wrong. Try again.
Hopefully feature-complete Android Bluesky client written in Expo
atproto bluesky
Something went wrong. Try again.
10 kB · 260 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261import { AppBskyFeedDefs, AppBskyFeedPost } from "@atcute/bluesky";import { Client, FetchHandler, ok } from "@atcute/client";import { useQuery } from "@tanstack/react-query";import { Pressable, Text, View } from "react-native";import { useShareSheet } from "./ShareSheetProvider";import { useOAuthSession } from "./SessionProvider";import { useMaterial3Theme } from "@pchmn/expo-material3-theme";import { useColorScheme } from "../hooks/useColorScheme";import { Did } from "@atcute/lexicons";import { Image } from "expo-image";import { formatDateShort, formatNumberOrEmpty } from "../misc/formatting";import EmbedView from "./EmbedView";import { AnimatedMaterialSymbolsFilled, MaterialSymbols } from "./MaterialSymbols";import { ZoomIn, ZoomOut } from "react-native-reanimated";import { usePostInteractions } from "../hooks/usePostInteractions";import { useFocusEffect } from "expo-router";import { useRecyclingState } from "@legendapp/list";
export default function ThreadedReply(props: { post: AppBskyFeedDefs.ThreadViewPost; layer?: number; hasSibling?: boolean;}) { const colorScheme = useColorScheme(); const { theme } = useMaterial3Theme({ sourceColor: "#f4983c" });
const MAX_LAYER = 4; const layer = props.layer ?? 0; const replies = props.post.replies ?? []; if (layer >= MAX_LAYER) return <></>;
return ( <View style={{ flexDirection: "row" }}> {layer > 0 && ( <View style={{ width: 16, }} > <View style={{ width: 6, height: 18, borderColor: theme[colorScheme].outlineVariant, borderLeftWidth: 1, borderBottomWidth: 1, borderBottomLeftRadius: 6, position: "absolute", right: -9.75, top: -4, }} /> {props.hasSibling && ( <View style={{ width: 1, height: "100%", backgroundColor: theme[colorScheme].outlineVariant, position: "absolute", top: -4, left: layer * 16 + 3.75, }} /> )} </View> )} <View style={{ flexGrow: 1 }}> <Reply post={props.post.post} hasReply={replies.length > 0 && props.layer !== MAX_LAYER - 1} /> {(props.post.replies ?? []) .filter((val) => val.$type === "app.bsky.feed.defs#threadViewPost") .map((val, i, arr) => ( <ThreadedReply key={val.post.uri} post={val} layer={layer + 1} hasSibling={arr.length - 1 !== i} /> ))} </View> </View> );}
function Reply(props: { post: AppBskyFeedDefs.PostView; hasReply: boolean }) { const colorScheme = useColorScheme(); const { theme } = useMaterial3Theme({ sourceColor: "#f4983c" }); const session = useOAuthSession(); const shareSheet = useShareSheet(); const { likeState, likeCount, repostedState, bookmarkedState, toggleBookmark, toggleLike, update } = usePostInteractions(props.post);
useFocusEffect(() => { update(); });
const labelersQuery = useQuery({ queryKey: ["labelers"], queryFn: async ({ signal }) => { const wrapper: FetchHandler = async (pathname, init) => { return session.fetchHandler(pathname, init); };
const client = new Client({ handler: wrapper, proxy: { did: "did:web:api.bsky.app", serviceId: "#bsky_appview" }, });
const { preferences } = await ok(client.get("app.bsky.actor.getPreferences", { signal, params: {} }));
let labelersDids: Did[] = [];
for (const pref of preferences) { switch (pref.$type) { case "app.bsky.actor.defs#labelersPref": labelersDids = pref.labelers.map((val) => val.did); break; } }
return await ok( client.get("app.bsky.labeler.getServices", { signal, params: { dids: labelersDids, detailed: true } }) ); }, });
return ( <View style={{ paddingHorizontal: 12, paddingVertical: 4, flexDirection: "row", gap: 4, flexGrow: 1 }}> <View style={{ alignItems: "center", gap: 4 }}> <Image source={props.post.author.avatar} style={{ width: 16, height: 16, borderRadius: 16 }} /> {props.hasReply && ( <View style={{ backgroundColor: theme[colorScheme].outlineVariant, width: 1, flexGrow: 1 }} /> )} </View> <View style={{ flex: 1 }}> <View style={{ flexDirection: "row", gap: 4 }}> <Text style={{ color: colorScheme === "light" ? "black" : "white", fontWeight: 700, flexShrink: 1, overflow: "hidden", }} ellipsizeMode="tail" numberOfLines={1} > {props.post.author.displayName} </Text> <Text style={{ color: colorScheme === "light" ? "black" : "white", opacity: 0.75, flexShrink: 1, overflow: "hidden", }} ellipsizeMode="tail" numberOfLines={1} > {"@" + props.post.author.handle} </Text> <Text style={{ color: colorScheme === "light" ? "black" : "white", opacity: 0.75, }} > • </Text> <Text style={{ color: colorScheme === "light" ? "black" : "white", opacity: 0.75, }} > {formatDateShort(new Date((props.post.record as AppBskyFeedPost.Main).createdAt))} </Text> </View> <Text style={{ color: colorScheme === "light" ? "black" : "white", marginBottom: 4, }} > {(props.post.record as AppBskyFeedPost.Main).text} </Text> {props.post.embed && <EmbedView embed={props.post.embed} />} <View style={{ flexDirection: "row", justifyContent: "space-between", paddingTop: 4 }}> <View style={{ flexDirection: "row", gap: 12 }}> <View style={{ flexDirection: "row", gap: 4 }}> <Pressable android_ripple={{ color: theme[colorScheme].primary + "60", foreground: true, borderless: true }} > <MaterialSymbols color={theme[colorScheme].inverseSurface} size={18} name="chat_bubble" /> </Pressable> <Text style={{ color: colorScheme === "light" ? "black" : "white", width: 32 }}> {formatNumberOrEmpty(props.post.replyCount ?? 0)} </Text> </View> <View style={{ flexDirection: "row", gap: 4 }}> <Pressable android_ripple={{ color: theme[colorScheme].primary + "60", foreground: true, borderless: true }} > <MaterialSymbols color={theme[colorScheme].inverseSurface} size={18} name="loop" /> </Pressable> <Text style={{ color: colorScheme === "light" ? "black" : "white", width: 32 }}> {formatNumberOrEmpty( (props.post.repostCount ?? 0) + (props.post.quoteCount ?? 0) + (repostedState ? 1 : 0) )} </Text> </View> <View style={{ flexDirection: "row", gap: 4 }}> <Pressable android_ripple={{ color: theme[colorScheme].primary + "60", foreground: true, borderless: true }} onPress={() => toggleLike()} > <MaterialSymbols color={theme[colorScheme].inverseSurface} size={18} name="favorite" /> {likeState && ( <AnimatedMaterialSymbolsFilled entering={ZoomIn.springify(200).dampingRatio(0.75).mass(4).overshootClamping(1)} exiting={ZoomOut.springify(200).dampingRatio(0.75).mass(4).overshootClamping(1)} color={theme[colorScheme].primary} size={18} name="favorite" style={{ position: "absolute" }} /> )} </Pressable> <Text style={{ color: colorScheme === "light" ? "black" : "white", width: 32 }}> {formatNumberOrEmpty(likeCount)} </Text> </View> </View> <View style={{ flexDirection: "row", gap: 16, paddingRight: 8 }}> <Pressable android_ripple={{ color: theme[colorScheme].primary + "60", foreground: true, borderless: true }} onPress={() => toggleBookmark()} > <MaterialSymbols color={theme[colorScheme].inverseSurface} size={18} name="bookmark" /> {bookmarkedState && ( <AnimatedMaterialSymbolsFilled entering={ZoomIn.springify(200).dampingRatio(0.75).mass(4).overshootClamping(1)} exiting={ZoomOut.springify(200).dampingRatio(0.75).mass(4).overshootClamping(1)} color={theme[colorScheme].primary} size={18} name="bookmark" style={{ position: "absolute" }} /> )} </Pressable> <Pressable android_ripple={{ color: theme[colorScheme].primary + "60", foreground: true, borderless: true }} onPress={() => shareSheet.current.present({ uri: props.post.uri, did: props.post.author.did })} > <MaterialSymbols color={theme[colorScheme].inverseSurface} size={18} name="share" /> </Pressable> <Pressable android_ripple={{ color: theme[colorScheme].primary + "60", foreground: true, borderless: true }} > <MaterialSymbols color={theme[colorScheme].inverseSurface} size={18} name="more_horiz" /> </Pressable> </View> </View> </View> </View> );}