this repo has no description
0
fork

Configure Feed

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

rich text and better embeds

+213 -117
+13 -1
apps/expo/src/app/(app)/(home)/profile.tsx
··· 1 1 import { ActivityIndicator, ScrollView, View } from "react-native"; 2 - import { useQuery } from "@tanstack/react-query"; 2 + import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 3 3 4 4 import { ProfileInfo } from "../../../components/profile-info"; 5 5 import { useAuthedAgent } from "../../../lib/agent"; ··· 12 12 actor: agent.session.handle, 13 13 }); 14 14 return profile.data; 15 + }); 16 + 17 + const profilePosts = useInfiniteQuery({ 18 + queryKey: ["profile", agent.session.handle, "posts"], 19 + queryFn: async ({ pageParam }) => { 20 + const timeline = await agent.getAuthorFeed({ 21 + actor: agent.session.handle, 22 + cursor: pageParam as string | undefined, 23 + }); 24 + return timeline.data; 25 + }, 26 + getNextPageParam: (lastPage) => lastPage.cursor, 15 27 }); 16 28 17 29 return (
+1 -1
apps/expo/src/app/(app)/(home)/timeline.tsx
··· 52 52 onEndReachedThreshold={0.5} 53 53 onEndReached={() => void timeline.fetchNextPage()} 54 54 data={timeline.data.pages.flatMap((page) => page.feed)} 55 - estimatedItemSize={110} 55 + estimatedItemSize={367} 56 56 renderItem={({ item }) => <FeedPost item={item} />} 57 57 keyExtractor={(item) => item.post.uri} 58 58 />
+2 -2
apps/expo/src/app/(app)/profile/[handle]/index.tsx
··· 1 1 import { ActivityIndicator, ScrollView, View } from "react-native"; 2 - import { Stack, useSearchParams } from "expo-router"; 2 + import { Stack, useLocalSearchParams } from "expo-router"; 3 3 import { useQuery } from "@tanstack/react-query"; 4 4 5 5 import { ProfileInfo } from "../../../../components/profile-info"; 6 6 import { useAuthedAgent } from "../../../../lib/agent"; 7 7 8 8 export default function ProfilePage() { 9 - const { handle } = useSearchParams() as { handle: string }; 9 + const { handle } = useLocalSearchParams() as { handle: string }; 10 10 const agent = useAuthedAgent(); 11 11 12 12 const profile = useQuery(["profile", handle], async () => {
+10 -5
apps/expo/src/app/(app)/profile/[handle]/post/[id].tsx
··· 1 1 import { ActivityIndicator, ScrollView, Text, View } from "react-native"; 2 - import { Stack, useSearchParams } from "expo-router"; 2 + import { Stack, useLocalSearchParams } from "expo-router"; 3 3 import { type AppBskyFeedDefs } from "@atproto/api"; 4 4 import { useQuery } from "@tanstack/react-query"; 5 5 ··· 7 7 import { useAuthedAgent } from "../../../../../lib/agent"; 8 8 9 9 export default function PostPage() { 10 - const { handle, id } = useSearchParams() as { id: string; handle: string }; 10 + const { handle, id } = useLocalSearchParams() as { 11 + id: string; 12 + handle: string; 13 + }; 11 14 const agent = useAuthedAgent(); 12 15 13 16 const thread = useQuery(["profile", handle, "post", id], async () => { 14 - const { 15 - data: { did }, 16 - } = await agent.resolveHandle({ handle }); 17 + let did = handle; 18 + if (!did.startsWith("did:")) { 19 + const { data } = await agent.resolveHandle({ handle }); 20 + did = data.did; 21 + } 17 22 const uri = `at://${did}/app.bsky.feed.post/${id}`; 18 23 const postThread = await agent.getPostThread({ uri }); 19 24 return postThread.data.thread;
+72 -91
apps/expo/src/components/embed.tsx
··· 9 9 } from "react-native"; 10 10 import { Link } from "expo-router"; 11 11 import { 12 - type AppBskyEmbedExternal, 13 - type AppBskyEmbedImages, 14 - type AppBskyEmbedRecord, 15 - type AppBskyEmbedRecordWithMedia, 16 - type AppBskyFeedPost, 12 + AppBskyEmbedExternal, 13 + AppBskyEmbedImages, 14 + AppBskyEmbedRecord, 15 + AppBskyEmbedRecordWithMedia, 16 + AppBskyFeedPost, 17 + type AppBskyActorDefs, 18 + type AppBskyFeedDefs, 17 19 } from "@atproto/api"; 20 + 21 + import { assert } from "../lib/utils/assert"; 18 22 19 23 function useImageAspectRatio(imageUrl: string) { 20 24 const [aspectRatio, setAspectRatio] = useState(1); ··· 39 43 return aspectRatio; 40 44 } 41 45 42 - type EmbeddedImage = { 43 - $type: "app.bsky.embed.images#view"; 44 - } & AppBskyEmbedImages.View; 45 - 46 - type EmbeddedExternal = { 47 - $type: "app.bsky.embed.external#view"; 48 - } & AppBskyEmbedExternal.View; 49 - 50 - type EmbeddedRecord = { 51 - $type: "app.bsky.embed.record#view"; 52 - } & AppBskyEmbedRecord.View; 53 - 54 - type EmbeddedRecordWithMedia = { 55 - $type: "app.bsky.embed.record#viewWithMedia"; 56 - } & AppBskyEmbedRecordWithMedia.View; 57 - 58 - export type PostEmbed = 59 - | EmbeddedImage 60 - | EmbeddedExternal 61 - | EmbeddedRecord 62 - | EmbeddedRecordWithMedia; 63 - 64 46 interface Props { 65 - content: PostEmbed; 47 + content: AppBskyFeedDefs.FeedViewPost["post"]["embed"]; 48 + truncate?: boolean; 66 49 } 67 50 68 - export const Embed = ({ content }: Props) => { 51 + export const Embed = ({ content, truncate = true }: Props) => { 69 52 try { 70 - switch (content.$type) { 71 - case "app.bsky.embed.images#view": 72 - return <ImageEmbed content={content} />; 73 - case "app.bsky.embed.external#view": 74 - return ( 75 - <TouchableOpacity 76 - onPress={() => void Linking.openURL(content.external.uri)} 77 - className="my-1.5 rounded border border-neutral-300 p-2" 78 - > 79 - <Text className="text-base font-semibold" numberOfLines={2}> 80 - {content.external.title || content.external.uri} 81 - </Text> 53 + // Case 1: Image 54 + if (AppBskyEmbedImages.isView(content)) { 55 + assert(AppBskyEmbedImages.validateView(content)); 56 + return <ImageEmbed content={content} />; 57 + } 58 + 59 + // Case 2: External link 60 + if (AppBskyEmbedExternal.isView(content)) { 61 + assert(AppBskyEmbedExternal.validateView(content)); 62 + return ( 63 + <TouchableOpacity 64 + onPress={() => void Linking.openURL(content.external.uri)} 65 + className="my-1.5 rounded border border-neutral-300 p-2" 66 + > 67 + <Text className="text-base font-semibold" numberOfLines={2}> 68 + {content.external.title || content.external.uri} 69 + </Text> 70 + 71 + <Text className="text-sm text-neutral-400" numberOfLines={1}> 72 + {content.external.uri} 73 + </Text> 74 + </TouchableOpacity> 75 + ); 76 + } 82 77 83 - <Text className="text-sm text-neutral-400" numberOfLines={1}> 84 - {content.external.uri} 85 - </Text> 86 - </TouchableOpacity> 87 - ); 88 - case "app.bsky.embed.record#view": 89 - // may break - TODO figure this out 90 - const record = content.record as AppBskyEmbedRecord.ViewRecord; 91 - const value = record.value as { 92 - $type: "app.bsky.feed.post"; 93 - } & AppBskyFeedPost.Record; 94 - let postContent = null; 78 + // Case 3: Record (quote or linked post) 79 + let record: AppBskyEmbedRecord.View["record"] | null = null; 80 + let media: AppBskyEmbedRecordWithMedia.View["media"] | null = null; 95 81 96 - switch (value.$type) { 97 - case "app.bsky.feed.post": 98 - postContent = ( 99 - <Text className="mt-1 text-base leading-5" numberOfLines={4}> 100 - {value.text} 101 - </Text> 102 - ); 103 - break; 104 - default: 105 - console.warn("Unsupported nested embed type", content); 106 - postContent = ( 107 - <Text className="mt-1 text-base italic"> 108 - Unsupported nested embed type 109 - </Text> 110 - ); 111 - } 82 + if (AppBskyEmbedRecord.isView(content)) { 83 + assert(AppBskyEmbedRecord.validateView(content)); 84 + record = content.record; 85 + } 112 86 113 - return ( 114 - <PostEmbed author={record.author} uri={record.uri}> 115 - {postContent} 116 - </PostEmbed> 117 - ); 118 - case "app.bsky.embed.record#viewWithMedia": 119 - // may break - TODO figure this out 120 - const recordWithMedia = 121 - content.record as unknown as AppBskyEmbedRecord.ViewRecord; 87 + if (AppBskyEmbedRecordWithMedia.isView(content)) { 88 + assert(AppBskyEmbedRecordWithMedia.validateView(content)); 89 + record = content.record.record; 90 + media = content.media; 91 + } 122 92 123 - console.log(JSON.stringify(recordWithMedia, null, 2)); 93 + if (record !== null) { 94 + // record can either be ViewRecord or ViewNotFound 95 + if (!AppBskyEmbedRecord.isViewRecord(record)) 96 + throw new Error("Not found"); 97 + assert(AppBskyEmbedRecord.validateViewRecord(record)); 124 98 125 - return ( 126 - <PostEmbed 127 - author={recordWithMedia.author} 128 - uri={recordWithMedia.uri} 129 - ></PostEmbed> 130 - ); 99 + if (!AppBskyFeedPost.isRecord(record.value)) 100 + throw new Error("An error occurred"); 101 + assert(AppBskyFeedPost.validateRecord(record.value)); 131 102 132 - default: 133 - console.info("Unsupported embed type", content); 134 - throw new Error("Unsupported embed type"); 103 + return ( 104 + <PostEmbed author={record.author} uri={record.uri}> 105 + <Text 106 + className="mt-1 text-base leading-5" 107 + numberOfLines={truncate ? 4 : undefined} 108 + > 109 + {record.value.text} 110 + </Text> 111 + {media && <Embed content={media} />} 112 + </PostEmbed> 113 + ); 135 114 } 115 + 116 + throw new Error("Unsupported embed type"); 136 117 } catch (err) { 137 118 console.error("Error rendering embed", content, err); 138 119 return ( 139 120 <View className="my-1.5 rounded bg-neutral-100 p-2"> 140 - <Text className="text-center">Unsupported embed type</Text> 121 + <Text className="text-center">{(err as Error).message}</Text> 141 122 </View> 142 123 ); 143 124 } 144 125 }; 145 126 146 - const ImageEmbed = ({ content }: { content: EmbeddedImage }) => { 127 + const ImageEmbed = ({ content }: { content: AppBskyEmbedImages.View }) => { 147 128 const aspectRatio = useImageAspectRatio(content.images[0]!.thumb); 148 129 switch (content.images.length) { 149 130 case 0: ··· 216 197 uri, 217 198 children, 218 199 }: React.PropsWithChildren<{ 219 - author: AppBskyEmbedRecord.ViewRecord["author"]; 200 + author: AppBskyActorDefs.ProfileViewBasic; 220 201 uri: string; 221 202 }>) => { 222 203 const profileHref = `/profile/${author.handle}`;
+16 -10
apps/expo/src/components/feed-post.tsx
··· 1 1 import { Image, Text, TouchableOpacity, View } from "react-native"; 2 2 import { Link } from "expo-router"; 3 - import { type AppBskyFeedDefs, type AppBskyFeedPost } from "@atproto/api"; 3 + import { AppBskyFeedPost, type AppBskyFeedDefs } from "@atproto/api"; 4 4 import { Heart, MessageSquare, Repeat, User } from "lucide-react-native"; 5 5 6 6 import { useLike, useRepost } from "../lib/hooks"; 7 + import { assert } from "../lib/utils/assert"; 7 8 import { cx } from "../lib/utils/cx"; 8 9 import { timeSince } from "../lib/utils/time"; 9 - import { Embed, type PostEmbed } from "./embed"; 10 + import { Embed } from "./embed"; 11 + import { RichText } from "./rich-text"; 10 12 11 13 interface Props { 12 14 item: AppBskyFeedDefs.FeedViewPost; ··· 21 23 22 24 const postHref = `${profileHref}/post/${item.post.uri.split("/").pop()}`; 23 25 26 + if (!AppBskyFeedPost.isRecord(item.post.record)) { 27 + return null; 28 + } 29 + 30 + assert(AppBskyFeedPost.validateRecord(item.post.record)); 31 + 32 + // TODO - don't nest feedposts! 33 + 24 34 return ( 25 - <> 35 + <View> 26 36 {item.reply?.parent && ( 27 37 <FeedPost item={{ post: item.reply.parent }} hasReply /> 28 38 )} ··· 86 96 {/* text content */} 87 97 <Link href={postHref} asChild> 88 98 <TouchableOpacity> 89 - <Text className="text-base leading-6"> 90 - {(item.post.record as AppBskyFeedPost.Record).text} 91 - </Text> 99 + <RichText value={item.post.record.text} /> 92 100 </TouchableOpacity> 93 101 </Link> 94 102 {/* embeds */} 95 - {item.post.embed && ( 96 - <Embed content={item.post.embed as PostEmbed} /> 97 - )} 103 + {item.post.embed && <Embed content={item.post.embed} />} 98 104 {/* actions */} 99 105 <View className="mt-2 flex-row justify-between"> 100 106 <TouchableOpacity className="flex-row items-center gap-2"> ··· 138 144 </View> 139 145 </View> 140 146 </View> 141 - </> 147 + </View> 142 148 ); 143 149 }; 144 150
+13 -7
apps/expo/src/components/post.tsx
··· 1 1 import { Image, Text, TouchableOpacity, View } from "react-native"; 2 2 import { Link } from "expo-router"; 3 - import { type AppBskyFeedDefs, type AppBskyFeedPost } from "@atproto/api"; 3 + import { AppBskyFeedPost, type AppBskyFeedDefs } from "@atproto/api"; 4 4 import { Heart, MessageSquare, Repeat, User } from "lucide-react-native"; 5 5 6 6 import { useLike, useRepost } from "../lib/hooks"; 7 + import { assert } from "../lib/utils/assert"; 7 8 import { cx } from "../lib/utils/cx"; 8 9 import { timeSince } from "../lib/utils/time"; 9 - import { Embed, type PostEmbed } from "./embed"; 10 + import { Embed } from "./embed"; 11 + import { RichText } from "./rich-text"; 10 12 11 13 interface Props { 12 14 post: AppBskyFeedDefs.ThreadViewPost["post"]; ··· 19 21 20 22 const profileHref = `/profile/${post.author.handle}`; 21 23 24 + if (!AppBskyFeedPost.isRecord(post.record)) { 25 + return null; 26 + } 27 + 28 + assert(AppBskyFeedPost.validateRecord(post.record)); 29 + 22 30 return ( 23 31 <View 24 32 className={cx( ··· 27 35 )} 28 36 > 29 37 <Link href={profileHref} asChild> 30 - <TouchableOpacity className="flex-row"> 38 + <TouchableOpacity className="mb-2 flex-row"> 31 39 {post.author.avatar ? ( 32 40 <Image 33 41 source={{ uri: post.author.avatar }} ··· 60 68 </TouchableOpacity> 61 69 </Link> 62 70 {/* text content */} 63 - <Text className="mt-3 text-lg leading-6"> 64 - {(post.record as AppBskyFeedPost.Record).text} 65 - </Text> 71 + <RichText value={post.record.text} size="lg" /> 66 72 {/* embeds */} 67 - {post.embed && <Embed content={post.embed as PostEmbed} />} 73 + {post.embed && <Embed content={post.embed} truncate={false} />} 68 74 {/* actions */} 69 75 <View className="mt-4 flex-row justify-between"> 70 76 <TouchableOpacity className="flex-row items-center gap-2">
+81
apps/expo/src/components/rich-text.tsx
··· 1 + import { Fragment } from "react"; 2 + import { Linking, Text } from "react-native"; 3 + import { useRouter } from "expo-router"; 4 + import { RichText as RichTextHelper } from "@atproto/api"; 5 + import { useQuery } from "@tanstack/react-query"; 6 + 7 + import { useAuthedAgent } from "../lib/agent"; 8 + import { cx } from "../lib/utils/cx"; 9 + 10 + interface Props { 11 + value: string; 12 + size?: "sm" | "base" | "lg"; 13 + } 14 + 15 + export const RichText = ({ value, size = "base" }: Props) => { 16 + const agent = useAuthedAgent(); 17 + const router = useRouter(); 18 + 19 + const { data: segments } = useQuery({ 20 + queryKey: ["richtext", value], 21 + queryFn: async () => { 22 + const rt = new RichTextHelper({ text: value }); 23 + await rt.detectFacets(agent); 24 + const parts = []; 25 + for (const segment of rt.segments()) { 26 + if (segment.isLink()) { 27 + parts.push({ 28 + text: segment.text, 29 + component: ( 30 + <Text 31 + className="text-blue-500" 32 + onPress={(evt) => { 33 + evt.stopPropagation(); 34 + void Linking.openURL(segment.link!.uri); 35 + }} 36 + > 37 + {segment.text} 38 + </Text> 39 + ), 40 + }); 41 + } else if (segment.isMention()) { 42 + parts.push({ 43 + text: segment.text, 44 + component: ( 45 + <Text 46 + className="text-blue-500" 47 + onPress={(evt) => { 48 + evt.stopPropagation(); 49 + router.push(`/profile/${segment.mention!.did}`); 50 + }} 51 + > 52 + {segment.text} 53 + </Text> 54 + ), 55 + }); 56 + } else { 57 + parts.push({ 58 + text: segment.text, 59 + component: segment.text, 60 + }); 61 + } 62 + } 63 + return parts; 64 + }, 65 + }); 66 + 67 + if (!segments) return null; 68 + 69 + return ( 70 + <Text 71 + className={cx("text-base", { 72 + "text-sm": size === "sm", 73 + "text-lg leading-6": size === "lg", 74 + })} 75 + > 76 + {segments.map(({ text, component }, i) => ( 77 + <Fragment key={`${i}+${text}`}>{component}</Fragment> 78 + ))} 79 + </Text> 80 + ); 81 + };
+5
apps/expo/src/lib/utils/assert.ts
··· 1 + export function assert({ success }: { success: boolean }) { 2 + if (!success) { 3 + throw new Error("Assertion failed"); 4 + } 5 + }