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.

Pass 2 on post composer (making it functional)

+1239 -26
+11
bun.lock
··· 8 8 "@atcute/atproto": "^3.1.10", 9 9 "@atcute/bluesky": "^3.2.20", 10 10 "@atcute/bluesky-richtext-segmenter": "^3.0.0", 11 + "@atcute/cbor": "^2.3.2", 12 + "@atcute/cid": "^2.4.1", 11 13 "@atcute/client": "^4.1.1", 12 14 "@atcute/tid": "^1.1.2", 13 15 "@atproto/oauth-client-expo": "^0.0.10", ··· 32 34 "expo-haptics": "~55.0.8", 33 35 "expo-image": "~55.0.6", 34 36 "expo-linking": "~55.0.7", 37 + "expo-paste-input": "^0.1.10", 35 38 "expo-router": "~55.0.4", 36 39 "expo-secure-store": "~55.0.8", 37 40 "expo-splash-screen": "~55.0.10", ··· 67 70 68 71 "@atcute/bluesky-richtext-segmenter": ["@atcute/bluesky-richtext-segmenter@3.0.0", "", {}, "sha512-NhZTUKtFpeBBbILwAcxj5u4RobIoHOmGw3CAaaEFNebKYSvmTecrXJ7XufHw5DFOUdr8SiKXQVRQxGAxulMNWg=="], 69 72 73 + "@atcute/cbor": ["@atcute/cbor@2.3.2", "", { "dependencies": { "@atcute/cid": "^2.4.1", "@atcute/multibase": "^1.1.8", "@atcute/uint8array": "^1.1.1" } }, "sha512-xP2SORSau/VVI00x2V4BjwIkHr6EQ7l/MXEOPaa4LGYtePFc4gnD4L1yN10dT5NEuUnvGEuCh6arLB7gz1smVQ=="], 74 + 75 + "@atcute/cid": ["@atcute/cid@2.4.1", "", { "dependencies": { "@atcute/multibase": "^1.1.8", "@atcute/uint8array": "^1.1.1" } }, "sha512-bwhna69RCv7yetXudtj+2qrMPYvhhIQqvJz6YUpUS98v7OdF3X2dnye9Nig2NDrklZcuyOsu7sQo7GOykJXRLQ=="], 76 + 70 77 "@atcute/client": ["@atcute/client@4.2.1", "", { "dependencies": { "@atcute/identity": "^1.1.3", "@atcute/lexicons": "^1.2.6" } }, "sha512-ZBFM2pW075JtgGFu5g7HHZBecrClhlcNH8GVP9Zz1aViWR+cjjBsTpeE63rJs+FCOHFYlirUyo5L8SGZ4kMINw=="], 71 78 72 79 "@atcute/identity": ["@atcute/identity@1.1.3", "", { "dependencies": { "@atcute/lexicons": "^1.2.4", "@badrap/valita": "^0.4.6" } }, "sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng=="], 73 80 74 81 "@atcute/lexicons": ["@atcute/lexicons@1.2.9", "", { "dependencies": { "@atcute/uint8array": "^1.1.1", "@atcute/util-text": "^1.1.1", "@standard-schema/spec": "^1.1.0", "esm-env": "^1.2.2" } }, "sha512-/RRHm2Cw9o8Mcsrq0eo8fjS9okKYLGfuFwrQ0YoP/6sdSDsXshaTLJsvLlcUcaDaSJ1YFOuHIo3zr2Om2F/16g=="], 82 + 83 + "@atcute/multibase": ["@atcute/multibase@1.2.0", "", { "dependencies": { "@atcute/uint8array": "^1.1.1" } }, "sha512-ZK2GRra+qIYq9nNuQB52m2ul0hOmCQEtPobGfTSUxm7pF0OGEkWGkWHugFhNEDVzHzTwPxHp6VGotdZFue4lYQ=="], 75 84 76 85 "@atcute/tid": ["@atcute/tid@1.1.2", "", { "dependencies": { "@atcute/time-ms": "^1.2.2" } }, "sha512-bmPuOX/TOfcm/vsK9vM98spjkcx2wgd9S2PeK5oLgEr8IbNRPq7iMCAPzOL1nu5XAW3LlkOYQEbYRcw5vcQ37w=="], 77 86 ··· 992 1001 "expo-modules-autolinking": ["expo-modules-autolinking@55.0.8", "", { "dependencies": { "@expo/require-utils": "^55.0.2", "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-nrWB1pkNp7bR8ECUTgYUiJ2Pyh6AvxCBXZ+lyPlfl1TzEIGhwU1Yqr+d78eJDueXaW+9zKeE0HqrTZoLS3ve4A=="], 993 1002 994 1003 "expo-modules-core": ["expo-modules-core@55.0.14", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-eAerOnnhbZitUAKbY7B61kIudiabAz/m/oMGINms2+GeY1DRhdvrm5aAkhkHHmykPrg58PPryXtmF14YAYWViw=="], 1004 + 1005 + "expo-paste-input": ["expo-paste-input@0.1.10", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-TkAauK1eIq7+vk2SA39trmDO6dLfFLQL+09KIVZh//3XWs8gHC5ezH3vjfjr7xj9xi67amyeiDCErP5caTDIKg=="], 995 1006 996 1007 "expo-router": ["expo-router@55.0.4", "", { "dependencies": { "@expo/metro-runtime": "^55.0.6", "@expo/schema-utils": "^55.0.2", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.10.1", "@react-navigation/native": "^7.1.28", "@react-navigation/native-stack": "^7.10.1", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^55.0.7", "expo-image": "^55.0.6", "expo-server": "^55.0.6", "expo-symbols": "^55.0.5", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.2.1", "semver": "~7.6.3", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "use-latest-callback": "^0.2.1", "vaul": "^1.1.2" }, "peerDependencies": { "@expo/log-box": "55.0.7", "@react-navigation/drawer": "^7.7.2", "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^55.0.7", "expo-linking": "^55.0.7", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@react-navigation/drawer", "@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-wLKxc9l3IaE96UJFvwXKi2YYYjYK/VUttwAwcnljaUA2dLgDruNGmjsBS9A+g3aK3lt2/JJRu+cec7ZLJ9r6Wg=="], 997 1008
+3
package.json
··· 14 14 "@atcute/atproto": "^3.1.10", 15 15 "@atcute/bluesky": "^3.2.20", 16 16 "@atcute/bluesky-richtext-segmenter": "^3.0.0", 17 + "@atcute/cbor": "^2.3.2", 18 + "@atcute/cid": "^2.4.1", 17 19 "@atcute/client": "^4.1.1", 18 20 "@atcute/tid": "^1.1.2", 19 21 "@atproto/oauth-client-expo": "^0.0.10", ··· 38 40 "expo-haptics": "~55.0.8", 39 41 "expo-image": "~55.0.6", 40 42 "expo-linking": "~55.0.7", 43 + "expo-paste-input": "^0.1.10", 41 44 "expo-router": "~55.0.4", 42 45 "expo-secure-store": "~55.0.8", 43 46 "expo-splash-screen": "~55.0.10",
+305 -26
src/app/(authenticated)/new-post.tsx
··· 3 3 import { useColorScheme } from "@/src/hooks/useColorScheme"; 4 4 import { Client, FetchHandler, ok } from "@atcute/client"; 5 5 import { useMaterial3Theme } from "@pchmn/expo-material3-theme"; 6 - import { useQuery } from "@tanstack/react-query"; 6 + import { useMutation, useQuery } from "@tanstack/react-query"; 7 7 import { Image } from "expo-image"; 8 - import { Stack } from "expo-router"; 9 - import { Pressable, Text, View } from "react-native"; 8 + import { Stack, useRouter } from "expo-router"; 9 + import { useEffect, useMemo, useRef, useState } from "react"; 10 + import { ActivityIndicator, Pressable, ScrollView, Text, TextInput, View } from "react-native"; 10 11 import { useSafeAreaInsets } from "react-native-safe-area-context"; 12 + import { TextInputWrapper } from "expo-paste-input"; 13 + import { tokenize, MentionToken } from "@/src/misc/richtextParser"; 14 + import RichtextBuilder from "@/src/misc/richtextBuilder"; 15 + import { GenericUri } from "@atcute/lexicons"; 16 + import { publishThread } from "@/src/misc/postThread"; 17 + import Animated, { FadeInDown, FadeOutDown } from "react-native-reanimated"; 11 18 12 19 export default function NewPost() { 13 20 const insets = useSafeAreaInsets(); 14 21 const colorScheme = useColorScheme(); 15 22 const { theme } = useMaterial3Theme({ sourceColor: "#f4983c" }); 16 23 const session = useOAuthSession(); 24 + const router = useRouter(); 17 25 18 26 const profileQuery = useQuery({ 19 27 queryKey: ["profile", session.did], 20 - queryFn: async ({ queryKey, signal }) => { 28 + queryFn: async ({ signal }) => { 21 29 const wrapper: FetchHandler = async (pathname, init) => { 22 30 return session.fetchHandler(pathname, init); 23 31 }; ··· 31 39 }, 32 40 }); 33 41 42 + const inputRef = useRef<TextInput>(null!); 43 + const [inputValue, setInputValue] = useState<ReturnType<typeof tokenize>>(); 44 + const [selection, setSelection] = useState<{ start: number; end: number }>({ start: 0, end: 0 }); 45 + 46 + useEffect(() => { 47 + if (mentionBeingWritten) { 48 + typeaheadQuery.refetch(); 49 + } 50 + }); 51 + 52 + const rawText = (inputValue?.tokens ?? []) 53 + .map((val) => { 54 + switch (val.type) { 55 + case "escape": 56 + return val.escaped; 57 + case "link": 58 + return val.children.map((val2) => val2.raw).join(""); 59 + default: 60 + return val.raw; 61 + } 62 + }) 63 + .join(""); 64 + 65 + const charCount = rawText.length; 66 + const notAllowedToPost = rawText.trim().length === 0; 67 + const mentionBeingWritten = useMemo(() => { 68 + if (selection.start !== selection.end) return undefined; 69 + 70 + return ( 71 + (inputValue?.tokens?.find( 72 + (val) => val.type === "mention" && selection.start >= val.pos && selection.start <= val.pos + val.raw.length 73 + ) as MentionToken) ?? undefined 74 + ); 75 + }, [inputValue, selection]); 76 + 77 + const typeaheadQuery = useQuery({ 78 + enabled: false, 79 + queryKey: ["mentionTypeahead", mentionBeingWritten?.handle ?? ""], 80 + queryFn: async ({ signal, queryKey }) => { 81 + const wrapper: FetchHandler = async (pathname, init) => { 82 + return session.fetchHandler(pathname, init); 83 + }; 84 + 85 + const client = new Client({ 86 + handler: wrapper, 87 + proxy: { did: "did:web:api.bsky.app", serviceId: "#bsky_appview" }, 88 + }); 89 + 90 + const res = await ok( 91 + client.get("app.bsky.actor.searchActorsTypeahead", { 92 + signal, 93 + params: { limit: 4, q: queryKey[1] }, 94 + }) 95 + ); 96 + 97 + return res; 98 + }, 99 + }); 100 + 101 + const postMutation = useMutation({ 102 + mutationFn: async () => { 103 + const wrapper: FetchHandler = async (pathname, init) => { 104 + return session.fetchHandler(pathname, init); 105 + }; 106 + 107 + const client = new Client({ 108 + handler: wrapper, 109 + proxy: { did: "did:web:api.bsky.app", serviceId: "#bsky_appview" }, 110 + }); 111 + 112 + const rtb = new RichtextBuilder(); 113 + for (const token of inputValue?.tokens ?? []) { 114 + switch (token.type) { 115 + case "text": 116 + case "mention": 117 + rtb.addText(token.raw); 118 + break; 119 + case "cashtag": 120 + case "topic": 121 + rtb.addTag(token.raw, token.name); 122 + break; 123 + case "filledMention": 124 + rtb.addMention(token.raw, token.did); 125 + break; 126 + case "autolink": 127 + rtb.addLink(token.url, token.url as GenericUri); 128 + break; 129 + case "link": 130 + const text = token.children.map((val) => val.raw).join(""); 131 + rtb.addLink(text, token.url as GenericUri); 132 + break; 133 + case "escape": 134 + rtb.addText(token.escaped); 135 + break; 136 + } 137 + } 138 + 139 + return await publishThread(client, { author: session.did, posts: [{ content: rtb }] }); 140 + }, 141 + }); 142 + 143 + const [textInputBottomPos, setTextInputBottomPos] = useState(-1); 144 + 34 145 return ( 35 146 <View style={{ flex: 1, backgroundColor: theme[colorScheme].background }}> 36 147 <Stack.Screen ··· 49 160 backgroundColor: theme[colorScheme].surfaceContainer, 50 161 flexDirection: "row", 51 162 justifyContent: "space-between", 163 + alignItems: "center", 52 164 }} 53 165 > 54 166 <View style={{ overflow: "hidden", borderRadius: 48, width: 48, height: 48 }}> 55 167 <Pressable 56 168 style={{ flex: 1, justifyContent: "center", alignItems: "center", borderRadius: 48 }} 57 169 android_ripple={{ color: theme[colorScheme].inverseSurface + "46", foreground: true }} 170 + onPress={() => router.back()} 58 171 > 59 172 <MaterialSymbols color={theme[colorScheme].onSurface} name="close" size={26} /> 60 173 </Pressable> 61 174 </View> 62 - <View style={{ overflow: "hidden", borderRadius: 32 }}> 175 + <View style={{ overflow: "hidden", borderRadius: 20, maxHeight: 40 }}> 63 176 <Pressable 177 + disabled={notAllowedToPost || postMutation.isPending} 64 178 style={{ 65 179 flex: 1, 66 180 justifyContent: "center", 67 181 alignItems: "center", 68 182 flexDirection: "row", 69 183 gap: 4, 70 - paddingVertical: 4, 71 - paddingHorizontal: 8, 184 + paddingLeft: 14, 185 + paddingRight: 8, 72 186 }} 73 187 android_ripple={{ color: theme[colorScheme].inverseSurface + "46", foreground: true }} 188 + onPress={async () => { 189 + if (postMutation.isPending) return; 190 + await postMutation.mutateAsync(); 191 + router.back(); 192 + }} 74 193 > 75 - <Text style={{ color: theme[colorScheme].primary, fontSize: 16, fontWeight: 500 }}>Post</Text> 76 - <MaterialSymbols color={theme[colorScheme].primary} name="send" size={26} /> 194 + <Text 195 + style={{ 196 + color: notAllowedToPost ? theme[colorScheme].onSurface : theme[colorScheme].primary, 197 + fontSize: 16, 198 + fontWeight: 500, 199 + opacity: notAllowedToPost ? 0.38 : 1, 200 + }} 201 + > 202 + Post 203 + </Text> 204 + {postMutation.isPending ? ( 205 + <ActivityIndicator size={26} color={theme[colorScheme].primary} /> 206 + ) : ( 207 + <MaterialSymbols 208 + style={{ 209 + opacity: notAllowedToPost ? 0.38 : 1, 210 + }} 211 + color={notAllowedToPost ? theme[colorScheme].onSurface : theme[colorScheme].primary} 212 + name="send" 213 + size={26} 214 + /> 215 + )} 216 + 217 + <View 218 + style={{ 219 + display: notAllowedToPost ? "flex" : "none", 220 + position: "absolute", 221 + width: "1000%", 222 + height: "1000%", 223 + backgroundColor: theme[colorScheme].onSurface, 224 + opacity: 0.1, 225 + }} 226 + /> 77 227 </Pressable> 78 228 </View> 79 229 </View> ··· 81 231 }, 82 232 }} 83 233 /> 84 - {profileQuery.isSuccess && ( 85 - <View style={{ width: "100%", padding: 12, flexDirection: "row", gap: 8 }}> 86 - <Image source={profileQuery.data.avatar} style={{ width: 48, height: 48, borderRadius: 12 }} /> 87 - <View style={{ flex: 1, justifyContent: "center" }}> 88 - <Text style={{ fontSize: 16, fontWeight: 500, color: colorScheme === "light" ? "black" : "white" }}> 89 - {profileQuery.data.displayName} 90 - </Text> 91 - <Text style={{ color: colorScheme === "light" ? "black" : "white", opacity: 0.75 }}> 92 - {"@" + profileQuery.data.handle} 93 - </Text> 234 + <ScrollView> 235 + {profileQuery.isSuccess && ( 236 + <View style={{ width: "100%", padding: 12, paddingBottom: 8, flexDirection: "row", gap: 8 }}> 237 + <Image source={profileQuery.data.avatar} style={{ width: 48, height: 48, borderRadius: 12 }} /> 238 + <View style={{ flex: 1, justifyContent: "center" }}> 239 + <Text style={{ fontSize: 16, fontWeight: 500, color: colorScheme === "light" ? "black" : "white" }}> 240 + {profileQuery.data.displayName} 241 + </Text> 242 + <Text style={{ color: colorScheme === "light" ? "black" : "white", opacity: 0.75 }}> 243 + {"@" + profileQuery.data.handle} 244 + </Text> 245 + </View> 94 246 </View> 95 - </View> 96 - )} 97 - <View style={{ flex: 1, paddingVertical: 4, paddingHorizontal: 12 }}> 98 - <Text style={{ color: "white", opacity: 0.75, fontSize: 16 }}>Type something fun</Text> 99 - </View> 247 + )} 248 + <Pressable onPress={() => inputRef.current.blur()} style={{ flex: 1, paddingHorizontal: 12 }}> 249 + <Pressable style={{ flex: 1 }} onPress={() => inputRef.current.focus()}> 250 + <TextInputWrapper onPaste={(ev) => console.log(ev)}> 251 + <TextInput 252 + onLayout={(ev) => { 253 + setTextInputBottomPos(ev.nativeEvent.layout.y + ev.nativeEvent.layout.height + insets.top); 254 + }} 255 + editable={!postMutation.isPending} 256 + onChangeText={(text) => { 257 + setInputValue(tokenize(text, inputValue?.mentions ?? [])); 258 + }} 259 + style={{ fontSize: 16, width: "100%", flexGrow: 1 }} 260 + ref={inputRef} 261 + multiline 262 + placeholder="Type something fun" 263 + placeholderTextColor={theme[colorScheme].onSurfaceVariant} 264 + cursorColor={theme[colorScheme].primary} 265 + selectionHandleColor={theme[colorScheme].primary} 266 + scrollEnabled={false} 267 + onSelectionChange={(ev) => setSelection(ev.nativeEvent.selection)} 268 + autoCorrect={false} 269 + > 270 + {inputValue?.tokens?.map((val, i) => { 271 + const isLink = 272 + val.type === "filledMention" || 273 + val.type === "topic" || 274 + val.type === "autolink" || 275 + val.type === "cashtag"; 276 + 277 + const textColor = isLink ? theme[colorScheme].primary : colorScheme === "light" ? "black" : "white"; 278 + 279 + return ( 280 + <Text key={i} style={{ color: textColor }}> 281 + {(() => { 282 + if (val.type === "link") { 283 + return ( 284 + <> 285 + <Text style={{ opacity: 0.75 }}>[</Text> 286 + {val.children.map((val) => val.raw).join("")} 287 + <Text style={{ opacity: 0.75 }}>]</Text> 288 + <Text style={{ opacity: 0.75 }}>(</Text> 289 + <Text style={{ color: val.isValidUrl ? theme[colorScheme].primary : undefined }}> 290 + {val.url} 291 + </Text> 292 + <Text style={{ opacity: 0.75 }}>)</Text> 293 + </> 294 + ); 295 + } 296 + 297 + if (val.type === "escape") { 298 + return ( 299 + <> 300 + <Text style={{ opacity: 0.75 }}>{val.raw[0]}</Text> 301 + {val.raw.slice(1)} 302 + </> 303 + ); 304 + } 305 + 306 + return val.raw; 307 + })()} 308 + </Text> 309 + ); 310 + })} 311 + </TextInput> 312 + </TextInputWrapper> 313 + </Pressable> 314 + </Pressable> 315 + </ScrollView> 100 316 <View 101 317 style={{ 102 318 width: "100%", ··· 109 325 justifyContent: "space-between", 110 326 }} 111 327 > 112 - <View style={{ flexDirection: "row", gap: 4 }}> 328 + <View style={{ flexDirection: "row", gap: 2 }}> 113 329 <View style={{ overflow: "hidden", borderRadius: 48, width: 48, height: 48 }}> 114 330 <Pressable 115 331 style={{ flex: 1, justifyContent: "center", alignItems: "center", borderRadius: 48 }} ··· 161 377 <Text style={{ color: theme[colorScheme].onSurface, fontWeight: 500 }}>English</Text> 162 378 </Pressable> 163 379 </View> 164 - <Text style={{ color: theme[colorScheme].onSurface, fontWeight: 500, fontSize: 16 }}>300</Text> 380 + <Text style={{ color: theme[colorScheme].onSurface, fontWeight: 500, fontSize: 16 }}>{300 - charCount}</Text> 165 381 </View> 166 382 </View> 383 + {mentionBeingWritten && typeaheadQuery.data && ( 384 + <Animated.View 385 + entering={FadeInDown.duration(300).springify()} 386 + exiting={FadeOutDown.duration(150).springify()} 387 + style={{ 388 + position: "absolute", 389 + width: "100%", 390 + top: textInputBottomPos, 391 + paddingTop: 16, 392 + padding: 8, 393 + flexGrow: 1, 394 + }} 395 + > 396 + <View 397 + style={{ 398 + backgroundColor: theme[colorScheme].surfaceContainer, 399 + flex: 1, 400 + borderRadius: 8, 401 + outlineWidth: 2, 402 + outlineColor: theme[colorScheme].outline, 403 + overflow: "hidden", 404 + }} 405 + > 406 + {typeaheadQuery.data.actors.length === 0 && ( 407 + <View style={{ padding: 8 }}> 408 + <Text style={{ color: colorScheme === "light" ? "black" : "white" }}>No results</Text> 409 + </View> 410 + )} 411 + {typeaheadQuery.data.actors.map((val, i) => { 412 + return ( 413 + <Pressable 414 + key={i} 415 + style={{ flexDirection: "row", alignContent: "center", padding: 8, gap: 8 }} 416 + android_ripple={{ foreground: true, color: theme[colorScheme].onSurfaceVariant + "50" }} 417 + onPress={() => { 418 + if (mentionBeingWritten) { 419 + const newMentions = [ 420 + ...(inputValue?.mentions ?? []), 421 + { pos: mentionBeingWritten.pos, profile: val }, 422 + ]; 423 + mentionBeingWritten.raw = mentionBeingWritten.raw.slice(0, 1) + val.handle; 424 + mentionBeingWritten.handle = val.handle; 425 + 426 + const rawText = (inputValue?.tokens ?? []) 427 + .map((val) => { 428 + return val.raw; 429 + }) 430 + .join(""); 431 + 432 + setInputValue(tokenize(rawText, newMentions)); 433 + } 434 + }} 435 + > 436 + <Image source={val.avatar} style={{ width: 24, height: 24, borderRadius: 8 }} /> 437 + <Text style={{ verticalAlign: "middle", color: colorScheme === "light" ? "black" : "white" }}> 438 + {val.handle} 439 + </Text> 440 + </Pressable> 441 + ); 442 + })} 443 + </View> 444 + </Animated.View> 445 + )} 167 446 </View> 168 447 ); 169 448 }
+508
src/misc/postThread.ts
··· 1 + import type * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs"; 2 + import type * as ComAtprotoRepoApplyWrites from "@atcute/atproto/types/repo/applyWrites"; 3 + import type * as ComAtprotoRepoStrongRef from "@atcute/atproto/types/repo/strongRef"; 4 + import type * as AppBskyEmbedExternal from "@atcute/bluesky/types/app/embed/external"; 5 + import type * as AppBskyEmbedImages from "@atcute/bluesky/types/app/embed/images"; 6 + import type * as AppBskyEmbedRecord from "@atcute/bluesky/types/app/embed/record"; 7 + import type * as AppBskyEmbedVideo from "@atcute/bluesky/types/app/embed/video"; 8 + import type * as AppBskyFeedDefs from "@atcute/bluesky/types/app/feed/defs"; 9 + import type * as AppBskyFeedPost from "@atcute/bluesky/types/app/feed/post"; 10 + import type * as AppBskyFeedThreadgate from "@atcute/bluesky/types/app/feed/threadgate"; 11 + import { ClientResponseError, ok, type Client } from "@atcute/client"; 12 + import type { $type, Blob as AtBlob, CanonicalResourceUri, ResourceUri, Did, GenericUri } from "@atcute/lexicons"; 13 + import type * as AppBskyRichtextFacet from "@atcute/bluesky/types/app/richtext/facet"; 14 + import * as TID from "@atcute/tid"; 15 + import { encode } from "@atcute/cbor"; 16 + import { create, toString } from "@atcute/cid"; 17 + 18 + export async function serializeRecordCid(record: { $type: string }): Promise<string> { 19 + const bytes = encode(record); 20 + 21 + const cid = await create(0x71, bytes); 22 + const serialized = toString(cid); 23 + 24 + return serialized; 25 + } 26 + 27 + let lastTimestamp: number = 0; 28 + 29 + /** 30 + * Return the current time, make sure that each call never returns same value 31 + * so that posts sent at the same time from different accounts don't end up 32 + * colliding with each other potentially causing them to not be shown. 33 + */ 34 + function getNow(threadSize: number): number { 35 + let timestamp = Math.max(Date.now(), lastTimestamp); 36 + lastTimestamp = timestamp + 2 + threadSize; 37 + 38 + return timestamp; 39 + } 40 + 41 + export interface MediaAspectRatio { 42 + width: number; 43 + height: number; 44 + } 45 + 46 + export interface PostExternalEmbed { 47 + type: "external"; 48 + uri: GenericUri; 49 + title: string; 50 + description?: string; 51 + thumbnail?: Blob | AtBlob; 52 + labels?: string[]; 53 + } 54 + 55 + export interface ComposedImage { 56 + blob: Blob | AtBlob; 57 + alt?: string; 58 + aspectRatio?: MediaAspectRatio; 59 + } 60 + 61 + export interface PostImageEmbed { 62 + type: "image"; 63 + images: ComposedImage[]; 64 + labels?: string[]; 65 + } 66 + 67 + export interface PostVideoEmbed { 68 + type: "video"; 69 + blob: Blob | AtBlob; 70 + alt?: string; 71 + aspectRatio?: MediaAspectRatio; 72 + labels?: string[]; 73 + } 74 + 75 + export type PostMediaEmbed = PostExternalEmbed | PostImageEmbed | PostVideoEmbed; 76 + 77 + export interface PostFeedEmbed { 78 + type: "feed"; 79 + uri: ResourceUri; 80 + cid?: string; 81 + } 82 + 83 + export interface PostListEmbed { 84 + type: "list"; 85 + uri: ResourceUri; 86 + cid?: string; 87 + } 88 + 89 + export interface PostQuoteEmbed { 90 + type: "quote"; 91 + uri: ResourceUri; 92 + cid?: string; 93 + } 94 + 95 + export interface PostStarterpackEmbed { 96 + type: "starterpack"; 97 + uri: ResourceUri; 98 + cid?: string; 99 + } 100 + 101 + export type PostRecordEmbed = PostFeedEmbed | PostListEmbed | PostQuoteEmbed | PostStarterpackEmbed; 102 + 103 + /** Embed in a post, can contain media and links to other records */ 104 + export interface PostEmbed { 105 + media?: PostMediaEmbed; 106 + record?: PostRecordEmbed; 107 + } 108 + 109 + export interface ComposedPost { 110 + languages?: string[]; 111 + content: { 112 + text: string; 113 + facets?: AppBskyRichtextFacet.Main[]; 114 + }; 115 + embed?: PostEmbed; 116 + } 117 + 118 + export interface ComposedThreadgate { 119 + follows?: boolean; 120 + mentions?: boolean; 121 + listUris?: ResourceUri[]; 122 + } 123 + 124 + export interface ComposedThread { 125 + client: Client; 126 + signal?: AbortSignal; 127 + author: Did; 128 + createdAt?: string | number | Date; 129 + reply?: string | AppBskyFeedDefs.PostView | AppBskyEmbedRecord.ViewRecord; 130 + gate?: ComposedThreadgate; 131 + languages?: string[]; 132 + posts: ComposedPost[]; 133 + } 134 + 135 + /** 136 + * Create post records and publish them 137 + * @param client An authenticated Bluesky RPC client 138 + * @param thread Composed thread 139 + * @returns An array of post records that were published 140 + */ 141 + export async function publishThread( 142 + client: Client, 143 + thread: Omit<ComposedThread, "client"> 144 + ): Promise<$type.enforce<ComAtprotoRepoApplyWrites.Create>[]> { 145 + const records = await createThread({ ...thread, client: client }); 146 + 147 + await ok( 148 + client.post("com.atproto.repo.applyWrites", { 149 + signal: thread.signal, 150 + input: { 151 + repo: thread.author, 152 + writes: records, 153 + }, 154 + }) 155 + ); 156 + 157 + return records; 158 + } 159 + 160 + async function createThread(thread: ComposedThread): Promise<$type.enforce<ComAtprotoRepoApplyWrites.Create>[]> { 161 + const client = thread.client; 162 + const signal = thread.signal; 163 + 164 + const did = thread.author; 165 + const posts = thread.posts; 166 + const threadgate = thread.gate; 167 + const languages = thread.languages; 168 + 169 + const writes: $type.enforce<ComAtprotoRepoApplyWrites.Create>[] = []; 170 + 171 + const now = thread.createdAt !== undefined ? new Date(thread.createdAt) : new Date(getNow(posts.length)); 172 + assert(!Number.isNaN(now.getTime()), `provided createdAt value is invalid`); 173 + 174 + let reply: AppBskyFeedPost.ReplyRef | undefined; 175 + let rkey: string | undefined; 176 + 177 + if (thread.reply) { 178 + let post = thread.reply; 179 + 180 + if (typeof post === "string") { 181 + // AT-URI being passed 182 + post = await getPost(post as ResourceUri); 183 + } 184 + 185 + let root: ComAtprotoRepoStrongRef.Main | undefined; 186 + let ref: ComAtprotoRepoStrongRef.Main; 187 + 188 + if ("record" in post) { 189 + // AppBskyFeedDefs.PostView being passed 190 + 191 + root = (post.record as AppBskyFeedPost.Main).reply?.root; 192 + ref = { uri: post.uri, cid: post.cid }; 193 + } else if ("value" in post) { 194 + // AppBskyEmbedRecord.ViewRecord being passed 195 + 196 + root = (post.value as AppBskyFeedPost.Main).reply?.root; 197 + ref = { uri: post.uri, cid: post.cid }; 198 + } else { 199 + assert(false, `Unexpected end of code`); 200 + } 201 + 202 + reply = { 203 + root: root ? { uri: root.uri, cid: root.cid } : ref, 204 + parent: ref, 205 + }; 206 + } 207 + 208 + assert(!reply || !threadgate, `threadgate and reply are mutually exclusive`); 209 + 210 + for (let idx = 0, len = posts.length; idx < len; idx++) { 211 + // Get the record key for this post 212 + rkey = TID.createRaw(now.getTime(), Math.floor(Math.random() * 1024)); 213 + 214 + const post = posts[idx]; 215 + const uri: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`; 216 + 217 + // Resolve embeds 218 + let embed: AppBskyFeedPost.Main["embed"]; 219 + if (post.embed !== undefined) { 220 + embed = await resolveEmbed(post.embed); 221 + } 222 + 223 + // Get the self-labels 224 + const labels = getEmbedLabels(post.embed); 225 + let selfLabels: $type.enforce<ComAtprotoLabelDefs.SelfLabels> | undefined; 226 + 227 + if (labels?.length) { 228 + selfLabels = { 229 + $type: "com.atproto.label.defs#selfLabels", 230 + values: labels.map((val) => ({ val })), 231 + }; 232 + } 233 + 234 + // Now form the record 235 + const content = post.content; 236 + 237 + const record: AppBskyFeedPost.Main = { 238 + $type: "app.bsky.feed.post", 239 + createdAt: now.toISOString(), 240 + text: content.text, 241 + facets: content.facets, 242 + reply: reply, 243 + embed: embed, 244 + langs: post.languages ?? languages, 245 + labels: selfLabels, 246 + tags: ["DuskskyApp"], 247 + }; 248 + 249 + writes.push({ 250 + $type: "com.atproto.repo.applyWrites#create", 251 + collection: "app.bsky.feed.post", 252 + rkey: rkey, 253 + value: record, 254 + }); 255 + 256 + // If this is the first post, and we have a threadgate set, create one now. 257 + if (idx === 0 && threadgate) { 258 + const threadgateRecord: AppBskyFeedThreadgate.Main = { 259 + $type: "app.bsky.feed.threadgate", 260 + createdAt: now.toISOString(), 261 + post: uri, 262 + allow: resolveThreadgate(threadgate), 263 + }; 264 + 265 + writes.push({ 266 + $type: "com.atproto.repo.applyWrites#create", 267 + collection: "app.bsky.feed.threadgate", 268 + rkey: rkey, 269 + value: threadgateRecord, 270 + }); 271 + } 272 + 273 + if (idx !== len - 1) { 274 + // Retrieve the next reply reference 275 + const serialized = await serializeRecordCid(record); 276 + 277 + const ref: ComAtprotoRepoStrongRef.Main = { 278 + cid: serialized, 279 + uri: uri, 280 + }; 281 + 282 + reply = { 283 + root: reply ? reply.root : ref, 284 + parent: ref, 285 + }; 286 + 287 + // Posts are not guaranteed to be shown in the correct order if they are 288 + // all posted with the same timestamp. 289 + now.setMilliseconds(now.getMilliseconds() + 1); 290 + } 291 + } 292 + 293 + return writes; 294 + 295 + async function resolveEmbed(embed: PostEmbed): Promise<AppBskyFeedPost.Main["embed"] | undefined> { 296 + const { media, record } = embed; 297 + 298 + if (media && record) { 299 + return { 300 + $type: "app.bsky.embed.recordWithMedia", 301 + media: await resolveMediaEmbed(media), 302 + record: await resolveRecordEmbed(record), 303 + }; 304 + } else if (media) { 305 + return resolveMediaEmbed(media); 306 + } else if (record) { 307 + return resolveRecordEmbed(record); 308 + } 309 + 310 + return; 311 + 312 + async function resolveMediaEmbed( 313 + embed: PostMediaEmbed 314 + ): Promise<$type.enforce<AppBskyEmbedExternal.Main | AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main>> { 315 + const type = embed.type; 316 + 317 + if (type === "external") { 318 + const rawThumb = embed.thumbnail; 319 + let thumb: AtBlob<any> | undefined; 320 + 321 + if (rawThumb !== undefined) { 322 + if (rawThumb instanceof Blob) { 323 + thumb = await uploadBlob(rawThumb); 324 + } else { 325 + thumb = rawThumb; 326 + } 327 + } 328 + 329 + return { 330 + $type: "app.bsky.embed.external", 331 + external: { 332 + uri: embed.uri, 333 + title: embed.title, 334 + description: embed.description ?? "", 335 + thumb: thumb, 336 + }, 337 + }; 338 + } 339 + 340 + if (type === "image") { 341 + const images: AppBskyEmbedImages.Image[] = []; 342 + 343 + for (const image of embed.images) { 344 + const aspectRatio = image.aspectRatio; 345 + const rawBlob = image.blob; 346 + let blob: AtBlob<any>; 347 + 348 + if (rawBlob instanceof Blob) { 349 + blob = await uploadBlob(rawBlob); 350 + } else { 351 + blob = rawBlob; 352 + } 353 + 354 + images.push({ 355 + image: blob, 356 + alt: image.alt ?? "", 357 + aspectRatio: aspectRatio ? { width: aspectRatio.width, height: aspectRatio.height } : undefined, 358 + }); 359 + } 360 + 361 + return { 362 + $type: "app.bsky.embed.images", 363 + images: images, 364 + }; 365 + } 366 + 367 + if (type === "video") { 368 + const aspectRatio = embed.aspectRatio; 369 + const rawBlob = embed.blob; 370 + let blob: AtBlob<any> | undefined; 371 + 372 + if (rawBlob instanceof Blob) { 373 + blob = await uploadBlob(rawBlob); 374 + } else { 375 + blob = rawBlob; 376 + } 377 + 378 + return { 379 + $type: "app.bsky.embed.video", 380 + video: blob, 381 + alt: embed.alt ?? "", 382 + aspectRatio: aspectRatio ? { width: aspectRatio.width, height: aspectRatio.height } : undefined, 383 + }; 384 + } 385 + 386 + assert(false, `Unexpected end of code`); 387 + } 388 + 389 + async function resolveRecordEmbed(embed: PostRecordEmbed): Promise<$type.enforce<AppBskyEmbedRecord.Main>> { 390 + const uri = embed.uri; 391 + let cid = embed.cid; 392 + 393 + if (cid === undefined) { 394 + const type = embed.type; 395 + 396 + if (type === "quote") { 397 + const post = await getPost(uri); 398 + 399 + cid = post.cid; 400 + } else if (type === "feed") { 401 + const data = await ok( 402 + client.get("app.bsky.feed.getFeedGenerator", { 403 + signal: signal, 404 + params: { feed: uri }, 405 + }) 406 + ); 407 + 408 + cid = data.view.cid; 409 + } else if (type === "list") { 410 + const data = await ok( 411 + client.get("app.bsky.graph.getList", { 412 + signal: signal, 413 + params: { list: uri, limit: 1 }, 414 + }) 415 + ); 416 + 417 + cid = data.list.cid; 418 + } else if (type === "starterpack") { 419 + const data = await ok( 420 + client.get("app.bsky.graph.getStarterPack", { 421 + signal: signal, 422 + params: { starterPack: uri }, 423 + }) 424 + ); 425 + 426 + cid = data.starterPack.cid; 427 + } else { 428 + assert(false, `Unexpected end of code`); 429 + } 430 + } 431 + 432 + return { 433 + $type: "app.bsky.embed.record", 434 + record: { 435 + uri: uri, 436 + cid: cid, 437 + }, 438 + }; 439 + } 440 + } 441 + 442 + async function uploadBlob(blob: Blob): Promise<AtBlob> { 443 + const data = await ok( 444 + client.post("com.atproto.repo.uploadBlob", { 445 + signal: signal, 446 + input: blob, 447 + }) 448 + ); 449 + 450 + return data.blob; 451 + } 452 + 453 + async function getPost(uri: ResourceUri): Promise<AppBskyFeedDefs.PostView> { 454 + const data = await ok( 455 + client.get("app.bsky.feed.getPosts", { 456 + signal: signal, 457 + params: { 458 + uris: [uri], 459 + }, 460 + }) 461 + ); 462 + 463 + const post = data.posts[0]; 464 + if (!post) { 465 + throw new ClientResponseError({ 466 + status: 400, 467 + data: { error: "NotFound", message: `Post not found: ${uri}` }, 468 + }); 469 + } 470 + 471 + return post; 472 + } 473 + } 474 + 475 + function resolveThreadgate(gate: ComposedThreadgate): AppBskyFeedThreadgate.Main["allow"] { 476 + const rules: AppBskyFeedThreadgate.Main["allow"] = []; 477 + 478 + if (gate.follows) { 479 + rules.push({ $type: "app.bsky.feed.threadgate#followingRule" }); 480 + } 481 + if (gate.mentions) { 482 + rules.push({ $type: "app.bsky.feed.threadgate#mentionRule" }); 483 + } 484 + 485 + for (const listUri of gate.listUris ?? []) { 486 + rules.push({ $type: "app.bsky.feed.threadgate#listRule", list: listUri }); 487 + } 488 + 489 + return rules; 490 + } 491 + 492 + function getEmbedLabels(embed: PostEmbed | undefined): string[] | undefined { 493 + const media = embed?.media; 494 + 495 + if (media !== undefined) { 496 + const type = media.type; 497 + 498 + if (type === "image" || type === "external") { 499 + return media.labels; 500 + } 501 + } 502 + } 503 + 504 + function assert(condition: boolean, message: string): asserts condition { 505 + if (!condition) { 506 + throw new Error(message); 507 + } 508 + }
+142
src/misc/richtextBuilder.ts
··· 1 + //copied from @atcute/bluesky-richtext-builder 2 + import type { AppBskyRichtextFacet } from "@atcute/bluesky"; 3 + import type { Did, GenericUri } from "@atcute/lexicons"; 4 + import { getUtf8Length } from "@atcute/uint8array"; 5 + 6 + type UnwrapArray<T> = T extends (infer V)[] ? V : never; 7 + 8 + /** Facet interface, `app.bsky.richtext.facet#main` from the lexicon */ 9 + type Facet = AppBskyRichtextFacet.Main; 10 + /** Feature union type from Facet['features'] */ 11 + type FacetFeature = UnwrapArray<Facet["features"]>; 12 + 13 + /** Resulting rich text */ 14 + export interface BakedRichtext { 15 + text: string; 16 + facets: Facet[]; 17 + } 18 + 19 + /** Builder for constructing Bluesky rich texts */ 20 + class RichtextBuilder { 21 + // Even-numbered are substrings, odd-numbered are facets 22 + // This way we'll avoid taking the hit on calculating UTF-8 indices up until 23 + // a facet is actually being inserted. 24 + #segments: (string | Facet)[] = [""]; 25 + 26 + /** Resulting composed text */ 27 + get text(): string { 28 + const segments = this.#segments; 29 + let str = ""; 30 + 31 + for (let idx = 0, len = segments.length; idx < len; idx += 2) { 32 + str += segments[idx] as string; 33 + } 34 + 35 + return str; 36 + } 37 + 38 + /** Resulting composed facets */ 39 + get facets(): Facet[] { 40 + const segments = this.#segments; 41 + const facets: Facet[] = []; 42 + 43 + for (let idx = 1, len = segments.length; idx < len; idx += 2) { 44 + facets.push(segments[idx] as Facet); 45 + } 46 + 47 + return facets; 48 + } 49 + 50 + /** Retrieve the composed rich text */ 51 + build(): BakedRichtext { 52 + return { 53 + text: this.text, 54 + facets: this.facets, 55 + }; 56 + } 57 + 58 + /** Clone rich text builder instance */ 59 + clone(): RichtextBuilder { 60 + const instance = new RichtextBuilder(); 61 + instance.#segments = this.#segments.slice(0); 62 + 63 + return instance; 64 + } 65 + 66 + /** 67 + * Add plain text to the rich text 68 + * @param text The plain text 69 + * @returns The builder instance, for chaining 70 + */ 71 + addText(text: string): this { 72 + const segments = this.#segments; 73 + segments[segments.length - 1] += text; 74 + 75 + return this; 76 + } 77 + 78 + /** 79 + * Add decorated text to the rich text 80 + * @param text The text itself 81 + * @param feature Feature to imbue on the text 82 + * @returns The builder instance, for chaining 83 + */ 84 + addDecoratedText(text: string, feature: FacetFeature): this { 85 + const segments = this.#segments; 86 + const last = segments.length - 1; 87 + 88 + // Calculate the starting index 89 + let start = 0; 90 + 91 + start += getUtf8Length(segments[last] as string); 92 + if (last !== 0) { 93 + start += (segments[last - 1] as Facet).index.byteEnd; 94 + } 95 + 96 + const byteLength = getUtf8Length(text); 97 + 98 + const facet: Facet = { 99 + index: { 100 + byteStart: start, 101 + byteEnd: start + byteLength, 102 + }, 103 + features: [feature], 104 + }; 105 + 106 + segments[last] += text; 107 + segments.push(facet, ""); 108 + return this; 109 + } 110 + 111 + /** 112 + * Add link to the rich text 113 + * @param text Text of the link 114 + * @param uri Valid URL, for example: https://example.com 115 + * @returns The builder instance, for chaining 116 + */ 117 + addLink(text: string, uri: GenericUri): this { 118 + return this.addDecoratedText(text, { $type: "app.bsky.richtext.facet#link", uri: uri }); 119 + } 120 + 121 + /** 122 + * Mention a user in rich text 123 + * @param text Text of the mention, usually in the form of `@handle` 124 + * @param did Valid DID, for example: did:plc:ia76kvnndjutgedggx2ibrem 125 + * @returns The builder instance, for chaining 126 + */ 127 + addMention(text: string, did: Did): this { 128 + return this.addDecoratedText(text, { $type: "app.bsky.richtext.facet#mention", did: did }); 129 + } 130 + 131 + /** 132 + * Add inline hashtag to the rich text 133 + * @param text Text to display 134 + * @param tag The tag, without the pound prefix 135 + * @returns The builder instance, for chaining 136 + */ 137 + addTag(text: string, tag: string): this { 138 + return this.addDecoratedText(text, { $type: "app.bsky.richtext.facet#tag", tag: tag }); 139 + } 140 + } 141 + 142 + export default RichtextBuilder;
+270
src/misc/richtextParser.ts
··· 1 + //copied from @atcute/bluesky-richtext-parser 2 + import { AppBskyActorDefs } from "@atcute/bluesky"; 3 + import { Did } from "@atcute/lexicons"; 4 + 5 + const ESCAPE_RE = /^\\([^0-9A-Za-z\s])/; 6 + 7 + const MENTION_RE = /^[@@]([a-zA-Z0-9-\.]+)($|\s|\p{P})/u; 8 + 9 + const TOPIC_RE = /^(?:#(?!\ufe0f|\u20e3)|#)([\p{N}]*[\p{L}\p{M}\p{Pc}][\p{L}\p{M}\p{Pc}\p{N}]*)($|\s|\p{P})/u; 10 + 11 + const CASHTAG_RE = /^[$$]([A-Za-z][A-Za-z0-9]{0,7})($|\s|\p{P})/u; 12 + 13 + const AUTOLINK_RE = /^https?:\/\/[\S]+/; 14 + 15 + const trimAutolink = (url: string): string => { 16 + let end = url.length; 17 + 18 + while (end > 0) { 19 + const code = url.charCodeAt(end - 1); 20 + if (code === 46 || code === 44 || code === 59) { 21 + end -= 1; 22 + continue; 23 + } 24 + 25 + break; 26 + } 27 + 28 + if (end > 0 && url.charCodeAt(end - 1) === 41 && url.lastIndexOf("(", end - 1) === -1) { 29 + end -= 1; 30 + } 31 + 32 + return end === url.length ? url : url.slice(0, end); 33 + }; 34 + 35 + const LINK_RE = 36 + /^\[((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*)\]\(\s*<?((?:\([^)]*\)|[^\s\\]|\\.)*?)>?(?:\s+['"]([^]*?)['"])?\s*\)/; 37 + const UNESCAPE_URL_RE = /\\([^0-9A-Za-z\s])/g; 38 + 39 + const TEXT_RE = /^[^]+?(?:(?=$|[~*_`:\\[]|https?:\/\/)|(?<=\s|[(){}/\\[\]\-|:;'".,=+])(?=[@@##$$]))/; 40 + 41 + export interface EscapeToken { 42 + type: "escape"; 43 + raw: string; 44 + escaped: string; 45 + } 46 + 47 + export interface MentionToken { 48 + type: "mention"; 49 + pos: number; 50 + raw: string; 51 + handle: string; 52 + } 53 + 54 + export interface FilledMentionToken { 55 + type: "filledMention"; 56 + pos: number; 57 + raw: string; 58 + handle: string; 59 + did: Did; 60 + } 61 + 62 + export interface TopicToken { 63 + type: "topic"; 64 + raw: string; 65 + name: string; 66 + } 67 + 68 + export interface CashtagToken { 69 + type: "cashtag"; 70 + raw: string; 71 + name: string; 72 + } 73 + 74 + export interface AutolinkToken { 75 + type: "autolink"; 76 + raw: string; 77 + url: string; 78 + } 79 + 80 + export interface LinkToken { 81 + type: "link"; 82 + raw: string; 83 + url: string; 84 + isValidUrl: boolean; 85 + children: Token[]; 86 + } 87 + 88 + export interface TextToken { 89 + type: "text"; 90 + raw: string; 91 + content: string; 92 + } 93 + 94 + export type Token = 95 + | EscapeToken 96 + | FilledMentionToken 97 + | MentionToken 98 + | TopicToken 99 + | CashtagToken 100 + | AutolinkToken 101 + | LinkToken 102 + | TextToken; 103 + 104 + const tokenizeEscape = (src: string): EscapeToken | undefined => { 105 + const match = ESCAPE_RE.exec(src); 106 + if (match) { 107 + return { 108 + type: "escape", 109 + raw: match[0], 110 + escaped: match[1], 111 + }; 112 + } 113 + }; 114 + 115 + const tokenizeMention = ( 116 + src: string, 117 + pos: number, 118 + usedMentions: { pos: number; profile: AppBskyActorDefs.ProfileViewBasic }[], 119 + mentions?: { pos: number; profile: AppBskyActorDefs.ProfileViewBasic }[] 120 + ): MentionToken | FilledMentionToken | undefined => { 121 + const match = MENTION_RE.exec(src); 122 + if (match && match[2] !== "@") { 123 + const suffix = match[2].length; 124 + 125 + if (mentions) { 126 + const mentionedProfile = mentions.find((val) => val.pos === pos && val.profile.handle === match[1]); 127 + if (mentionedProfile) { 128 + usedMentions.push(mentionedProfile); 129 + return { 130 + type: "filledMention", 131 + pos, 132 + raw: suffix > 0 ? match[0].slice(0, -suffix) : match[0], 133 + handle: match[1], 134 + did: mentionedProfile.profile.did, 135 + }; 136 + } 137 + } 138 + 139 + return { 140 + type: "mention", 141 + pos, 142 + raw: suffix > 0 ? match[0].slice(0, -suffix) : match[0], 143 + handle: match[1], 144 + }; 145 + } 146 + }; 147 + 148 + const tokenizeTopic = (src: string): TopicToken | undefined => { 149 + const match = TOPIC_RE.exec(src); 150 + if (match && match[2] !== "#") { 151 + const suffix = match[2].length; 152 + 153 + return { 154 + type: "topic", 155 + raw: suffix > 0 ? match[0].slice(0, -suffix) : match[0], 156 + name: match[1], 157 + }; 158 + } 159 + }; 160 + 161 + const tokenizeCashtag = (src: string): CashtagToken | undefined => { 162 + const match = CASHTAG_RE.exec(src); 163 + if (match && match[2] !== "$") { 164 + const suffix = match[2].length; 165 + 166 + return { 167 + type: "cashtag", 168 + raw: suffix > 0 ? match[0].slice(0, -suffix) : match[0], 169 + name: match[1], 170 + }; 171 + } 172 + }; 173 + 174 + const tokenizeAutolink = (src: string): AutolinkToken | undefined => { 175 + const match = AUTOLINK_RE.exec(src); 176 + if (match) { 177 + const url = trimAutolink(match[0]); 178 + 179 + return { 180 + type: "autolink", 181 + raw: url, 182 + url: url, 183 + }; 184 + } 185 + }; 186 + 187 + const tokenizeLink = (src: string): LinkToken | undefined => { 188 + const match = LINK_RE.exec(src); 189 + 190 + if (match) { 191 + return { 192 + type: "link", 193 + raw: match[0], 194 + url: match[2].replace(UNESCAPE_URL_RE, "$1"), 195 + isValidUrl: !!AUTOLINK_RE.exec(match[2]), 196 + children: tokenize(match[1]).tokens, 197 + }; 198 + } 199 + }; 200 + 201 + const tokenizeText = (src: string): TextToken | undefined => { 202 + const match = TEXT_RE.exec(src); 203 + if (match) { 204 + return { 205 + type: "text", 206 + raw: match[0], 207 + content: match[0], 208 + }; 209 + } 210 + }; 211 + 212 + export const tokenize = ( 213 + src: string, 214 + mentions?: { pos: number; profile: AppBskyActorDefs.ProfileViewBasic }[] 215 + ): { tokens: Token[]; mentions: { pos: number; profile: AppBskyActorDefs.ProfileViewBasic }[] } => { 216 + const tokens: Token[] = []; 217 + 218 + let last: Token | undefined; 219 + let token: Token | undefined; 220 + let currPos = 0; 221 + let usedMentions: NonNullable<typeof mentions> = []; 222 + 223 + while (src) { 224 + last = token; 225 + const first = src.charCodeAt(0); 226 + 227 + if (first === 92) { 228 + token = tokenizeEscape(src); 229 + } else if (first === 104) { 230 + token = tokenizeAutolink(src); 231 + } else if (first === 64 || first === 65312) { 232 + token = tokenizeMention(src, currPos, usedMentions, mentions); 233 + } else if (first === 35 || first === 65283) { 234 + token = tokenizeTopic(src); 235 + } else if (first === 36 || first === 65284) { 236 + token = tokenizeCashtag(src); 237 + } else if (first === 91) { 238 + token = tokenizeLink(src); 239 + } else { 240 + token = undefined; 241 + } 242 + 243 + if (token) { 244 + src = src.slice(token.raw.length); 245 + currPos += token.raw.length; 246 + tokens.push(token); 247 + continue; 248 + } 249 + 250 + if ((token = tokenizeText(src))) { 251 + src = src.slice(token.raw.length); 252 + currPos += token.raw.length; 253 + 254 + if (last && last.type === "text") { 255 + last.raw += token.raw; 256 + token = last; 257 + } else { 258 + tokens.push(token); 259 + } 260 + 261 + continue; 262 + } 263 + 264 + if (src) { 265 + throw new Error(`infinite loop encountered`); 266 + } 267 + } 268 + 269 + return { tokens, mentions: usedMentions }; 270 + };