this repo has no description
0
fork

Configure Feed

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

improve timeline + wip other screens

+510 -291
+41
apps/expo/src/app/(app)/(home)/_layout.tsx
··· 1 + import { Stack, Tabs } from "expo-router"; 2 + import { Cloudy, User } from "lucide-react-native"; 3 + 4 + import { useAgent } from "../../../lib/agent"; 5 + 6 + export default function AppLayout() { 7 + const agent = useAgent(); 8 + return ( 9 + <> 10 + <Stack.Screen 11 + options={{ 12 + headerTitle: agent.session?.handle, 13 + animation: "none", 14 + headerBackTitleVisible: false, 15 + }} 16 + /> 17 + <Tabs screenOptions={{ headerShown: false }}> 18 + <Tabs.Screen 19 + name="timeline" 20 + options={{ 21 + title: "Timeline", 22 + tabBarShowLabel: false, 23 + tabBarIcon({ focused }) { 24 + return <Cloudy color={focused ? "#505050" : "#9b9b9b"} />; 25 + }, 26 + }} 27 + /> 28 + <Tabs.Screen 29 + name="profile" 30 + options={{ 31 + title: "Profile", 32 + tabBarShowLabel: false, 33 + tabBarIcon({ focused }) { 34 + return <User color={focused ? "#505050" : "#9b9b9b"} />; 35 + }, 36 + }} 37 + /> 38 + </Tabs> 39 + </> 40 + ); 41 + }
+8
apps/expo/src/app/(app)/(home)/profile.tsx
··· 1 + import { Text } from "react-native"; 2 + 3 + import { useAgent } from "../../../lib/agent"; 4 + 5 + export default function ProfilePage() { 6 + const agent = useAgent(); 7 + return <Text>{agent.session?.handle}</Text>; 8 + }
+63
apps/expo/src/app/(app)/(home)/timeline.tsx
··· 1 + import { ActivityIndicator, Text, View } from "react-native"; 2 + import { FlashList } from "@shopify/flash-list"; 3 + import { useInfiniteQuery } from "@tanstack/react-query"; 4 + 5 + import { Button } from "../../../components/button"; 6 + import { FeedPost } from "../../../components/feed-post"; 7 + import { useAuthedAgent } from "../../../lib/agent"; 8 + 9 + export default function Timeline() { 10 + const agent = useAuthedAgent(); 11 + const timeline = useInfiniteQuery({ 12 + queryKey: ["timeline"], 13 + queryFn: async ({ pageParam }) => { 14 + const timeline = await agent.getTimeline({ 15 + cursor: pageParam as string | undefined, 16 + }); 17 + return timeline.data; 18 + }, 19 + getNextPageParam: (lastPage) => lastPage.cursor, 20 + }); 21 + 22 + switch (timeline.status) { 23 + case "loading": 24 + return ( 25 + <View className="flex-1 items-center justify-center"> 26 + <ActivityIndicator size="large" /> 27 + </View> 28 + ); 29 + 30 + case "error": 31 + return ( 32 + <View className="flex-1 items-center justify-center p-4"> 33 + <Text className="text-center text-xl"> 34 + {(timeline.error as Error).message || "An error occurred"} 35 + </Text> 36 + <Button variant="outline" onPress={() => void timeline.refetch()}> 37 + Retry 38 + </Button> 39 + </View> 40 + ); 41 + 42 + case "success": 43 + return ( 44 + <FlashList 45 + onRefresh={() => { 46 + if (!timeline.isRefetching) void timeline.refetch(); 47 + }} 48 + refreshing={timeline.isRefetching} 49 + onEndReachedThreshold={0.5} 50 + onEndReached={() => void timeline.fetchNextPage()} 51 + data={timeline.data.pages.flatMap((page) => page.feed)} 52 + estimatedItemSize={110} 53 + renderItem={({ item }) => ( 54 + // <View className="h-56 w-full border-b bg-white"> 55 + // <Text>{item.post.author.displayName}</Text> 56 + // </View> 57 + <FeedPost item={item} /> 58 + )} 59 + keyExtractor={(item) => item.post.uri} 60 + /> 61 + ); 62 + } 63 + }
-15
apps/expo/src/app/(app)/_layout.tsx
··· 1 - import { Stack, Tabs } from "expo-router"; 2 - 3 - import { useAgent } from "../../lib/agent"; 4 - 5 - export default function AppLayout() { 6 - const agent = useAgent(); 7 - return ( 8 - <> 9 - <Stack.Screen 10 - options={{ headerTitle: agent.session?.handle, animation: "none" }} 11 - /> 12 - <Tabs screenOptions={{ headerShown: false }} /> 13 - </> 14 - ); 15 - }
+14
apps/expo/src/app/(app)/compose.tsx
··· 1 + import { Text, TextInput, View } from "react-native"; 2 + import { Stack } from "expo-router"; 3 + 4 + export default function ComposeModal() { 5 + return ( 6 + <View className="flex-1 p-4"> 7 + <Stack.Screen 8 + options={{ headerTitle: "Compose a new post", presentation: "modal" }} 9 + /> 10 + <Text>Write in here ig</Text> 11 + <TextInput className="mt-2 p-2" numberOfLines={3} multiline /> 12 + </View> 13 + ); 14 + }
+40
apps/expo/src/app/(app)/profile/[handle]/index.tsx
··· 1 + import { Image, ScrollView, Text } from "react-native"; 2 + import { SafeAreaView } from "react-native-safe-area-context"; 3 + import { Stack, useSearchParams } from "expo-router"; 4 + import { useQuery } from "@tanstack/react-query"; 5 + 6 + import { useAuthedAgent } from "../../../../lib/agent"; 7 + 8 + export default function ProfilePage() { 9 + const { handle } = useSearchParams() as { handle: string }; 10 + const agent = useAuthedAgent(); 11 + 12 + const profile = useQuery(["profile", handle], async () => { 13 + const profile = await agent.getProfile({ 14 + actor: handle, 15 + }); 16 + return profile.data; 17 + }); 18 + 19 + if (!profile.data) return null; 20 + 21 + return ( 22 + <ScrollView> 23 + <Stack.Screen 24 + options={{ 25 + headerTransparent: true, 26 + headerTitle: "", 27 + headerStyle: { 28 + backgroundColor: "transparent", 29 + }, 30 + }} 31 + /> 32 + <Image 33 + source={{ uri: profile.data.banner }} 34 + className="h-48 w-full" 35 + alt="banner image" 36 + /> 37 + <Text>{JSON.stringify(profile.data, null, 2)}</Text> 38 + </ScrollView> 39 + ); 40 + }
+31
apps/expo/src/app/(app)/profile/[handle]/post/[id].tsx
··· 1 + import { ScrollView, Text } from "react-native"; 2 + import { Stack, useSearchParams } from "expo-router"; 3 + import { useQuery } from "@tanstack/react-query"; 4 + 5 + import { useAuthedAgent } from "../../../../../lib/agent"; 6 + 7 + export default function PostPage() { 8 + const { handle, id } = useSearchParams() as { id: string; handle: string }; 9 + const agent = useAuthedAgent(); 10 + 11 + const post = useQuery(["profile", handle, "post", id], async () => { 12 + const { 13 + data: { did }, 14 + } = await agent.resolveHandle({ handle }); 15 + console.log(did); 16 + const uri = `at://${did}/app.bsky.feed.post/${id}`; 17 + const post = await agent.getPostThread({ uri }); 18 + return post.data; 19 + }); 20 + 21 + return ( 22 + <ScrollView> 23 + <Stack.Screen options={{ headerTitle: "Post" }} /> 24 + <Text> 25 + {handle} 26 + {id} 27 + {JSON.stringify(post.data, null, 2)} 28 + </Text> 29 + </ScrollView> 30 + ); 31 + }
-275
apps/expo/src/app/(app)/timeline.tsx
··· 1 - import { useState } from "react"; 2 - import { 3 - ActivityIndicator, 4 - Image, 5 - Linking, 6 - Text, 7 - TouchableOpacity, 8 - View, 9 - } from "react-native"; 10 - import { Tabs } from "expo-router"; 11 - import { type AppBskyFeedPost } from "@atproto/api"; 12 - import { type FeedViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 13 - import { FlashList } from "@shopify/flash-list"; 14 - import { useInfiniteQuery, useMutation } from "@tanstack/react-query"; 15 - import { Cloudy, Heart, MessageSquare, Repeat } from "lucide-react-native"; 16 - 17 - import { Button } from "../../components/button"; 18 - import { useAuthedAgent } from "../../lib/agent"; 19 - 20 - function Timeline() { 21 - const agent = useAuthedAgent(); 22 - const timeline = useInfiniteQuery({ 23 - queryKey: ["timeline"], 24 - queryFn: async ({ pageParam }) => { 25 - const timeline = await agent.getTimeline({ 26 - limit: 5, 27 - cursor: pageParam as string | undefined, 28 - }); 29 - return timeline.data; 30 - }, 31 - getNextPageParam: (lastPage) => lastPage.cursor, 32 - }); 33 - 34 - switch (timeline.status) { 35 - case "loading": 36 - return ( 37 - <View className="flex-1 items-center justify-center"> 38 - <ActivityIndicator size="large" /> 39 - </View> 40 - ); 41 - 42 - case "error": 43 - return ( 44 - <View className="flex-1 items-center justify-center p-4"> 45 - <Text className="text-center text-xl"> 46 - {(timeline.error as Error).message || "An error occurred"} 47 - </Text> 48 - <Button variant="outline" onPress={() => void timeline.refetch()}> 49 - Retry 50 - </Button> 51 - </View> 52 - ); 53 - 54 - case "success": 55 - return ( 56 - <FlashList 57 - onRefresh={() => { 58 - if (!timeline.isRefetching) void timeline.refetch(); 59 - }} 60 - refreshing={timeline.isRefetching} 61 - onEndReachedThreshold={0.5} 62 - onEndReached={() => void timeline.fetchNextPage()} 63 - className="flex-1" 64 - data={timeline.data.pages.flatMap((page) => page.feed)} 65 - estimatedItemSize={111} 66 - renderItem={({ item }) => <Post item={item} />} 67 - /> 68 - ); 69 - } 70 - } 71 - 72 - export default function TimelinePage() { 73 - return ( 74 - <> 75 - <Tabs.Screen 76 - options={{ 77 - tabBarButton: () => ( 78 - <View className="flex-1 items-center justify-center"> 79 - <Cloudy color="#5e5e5e" /> 80 - </View> 81 - ), 82 - }} 83 - /> 84 - <Timeline /> 85 - </> 86 - ); 87 - } 88 - 89 - const Post = ({ item }: { item: FeedViewPost }) => { 90 - const agent = useAuthedAgent(); 91 - 92 - const [liked, setLiked] = useState(!!item.post.viewer?.like); 93 - const [likeUri, setLikeUri] = useState(item.post.viewer?.like); 94 - const [reposted, setReposted] = useState(!!item.post.viewer?.repost); 95 - const [repostUri, setRepostUri] = useState(item.post.viewer?.repost); 96 - 97 - const toggleLike = useMutation({ 98 - mutationKey: ["like", item.post.uri], 99 - mutationFn: async () => { 100 - if (!likeUri) { 101 - try { 102 - setLiked(true); 103 - const like = await agent.like(item.post.uri, item.post.cid); 104 - setLikeUri(like.uri); 105 - } catch (err) { 106 - setLiked(false); 107 - console.log(err); 108 - } 109 - } else { 110 - try { 111 - setLiked(false); 112 - await agent.deleteLike(likeUri); 113 - setLikeUri(undefined); 114 - } catch (err) { 115 - setLiked(true); 116 - console.log(err); 117 - } 118 - } 119 - }, 120 - }); 121 - 122 - const toggleRepost = useMutation({ 123 - mutationKey: ["repost", item.post.uri], 124 - mutationFn: async () => { 125 - if (!repostUri) { 126 - try { 127 - setReposted(true); 128 - const repost = await agent.repost(item.post.uri, item.post.cid); 129 - setRepostUri(repost.uri); 130 - } catch (err) { 131 - setReposted(false); 132 - console.log(err); 133 - } 134 - } else { 135 - try { 136 - setReposted(false); 137 - await agent.deleteRepost(repostUri); 138 - setRepostUri(undefined); 139 - } catch (err) { 140 - setReposted(true); 141 - console.log(err); 142 - } 143 - } 144 - }, 145 - }); 146 - 147 - return ( 148 - <View 149 - className="gap-2 border border-b border-neutral-200 bg-white px-4 pb-5 pt-1" 150 - // onLayout={(x) => console.log(x.nativeEvent.layout)} 151 - > 152 - <View className="flex-row items-center"> 153 - {item.post.author.avatar && ( 154 - <Image 155 - source={{ uri: item.post.author.avatar }} 156 - alt={item.post.author.handle} 157 - className="mr-2 h-6 w-6 rounded-full" 158 - /> 159 - )} 160 - <Text className="w-full text-base" numberOfLines={1}> 161 - {item.post.author.displayName}{" "} 162 - <Text className="text-neutral-400">@{item.post.author.handle}</Text> 163 - </Text> 164 - </View> 165 - {/* text content */} 166 - <Text className="text-base"> 167 - {(item.post.record as AppBskyFeedPost.Record).text} 168 - </Text> 169 - {/* embeds */} 170 - {item.post.embed && <Embed content={item.post.embed as PostEmbed} />} 171 - {/* actions */} 172 - <View className="flex-row justify-between"> 173 - <TouchableOpacity className="flex-row items-center gap-2"> 174 - <MessageSquare size={16} color="#1C1C1E" /> 175 - <Text>{item.post.replyCount}</Text> 176 - </TouchableOpacity> 177 - <TouchableOpacity 178 - disabled={toggleRepost.isLoading} 179 - onPress={() => toggleRepost.mutate()} 180 - className="flex-row items-center gap-2" 181 - > 182 - <Repeat size={16} color={reposted ? "#2563eb" : "#1C1C1E"} /> 183 - <Text 184 - style={{ 185 - color: reposted ? "#2563eb" : "#1C1C1E", 186 - }} 187 - > 188 - {(item.post.repostCount ?? 0) + 189 - (reposted && repostUri !== item.post.viewer?.repost ? 1 : 0)} 190 - </Text> 191 - </TouchableOpacity> 192 - <TouchableOpacity 193 - disabled={toggleLike.isLoading} 194 - onPress={() => toggleLike.mutate()} 195 - className="flex-row items-center gap-2" 196 - > 197 - <Heart 198 - size={16} 199 - fill={liked ? "#dc2626" : "transparent"} 200 - color={liked ? "#dc2626" : "#1C1C1E"} 201 - /> 202 - <Text 203 - style={{ 204 - color: liked ? "#dc2626" : "#1C1C1E", 205 - }} 206 - > 207 - {(item.post.likeCount ?? 0) + 208 - (liked && likeUri !== item.post.viewer?.like ? 1 : 0)} 209 - </Text> 210 - </TouchableOpacity> 211 - <View className="w-8" /> 212 - </View> 213 - </View> 214 - ); 215 - }; 216 - 217 - type PostEmbed = 218 - | { 219 - $type: "app.bsky.embed.images#view"; 220 - images: { 221 - alt: string; 222 - fullsize: string; 223 - thumb: string; 224 - }[]; 225 - } 226 - | { 227 - $type: "app.bsky.embed.external#view"; 228 - external: { 229 - description: string; 230 - thumb: string; 231 - title: string; 232 - uri: string; 233 - }; 234 - }; 235 - 236 - const Embed = ({ content }: { content: PostEmbed }) => { 237 - switch (content.$type) { 238 - case "app.bsky.embed.images#view": 239 - switch (content.images.length) { 240 - case 0: 241 - return null; 242 - case 1: 243 - default: 244 - const image = content.images[0]!; 245 - return ( 246 - <Image 247 - source={{ uri: image.thumb }} 248 - alt={image.alt} 249 - className="my-1.5 aspect-video w-full rounded" 250 - /> 251 - ); 252 - } 253 - case "app.bsky.embed.external#view": 254 - return ( 255 - <TouchableOpacity 256 - onPress={() => void Linking.openURL(content.external.uri)} 257 - className="my-1.5 rounded border p-2" 258 - > 259 - <Text className="text-base" numberOfLines={2}> 260 - {content.external.title} 261 - </Text> 262 - <Text className="text-sm text-neutral-400" numberOfLines={1}> 263 - {content.external.uri} 264 - </Text> 265 - </TouchableOpacity> 266 - ); 267 - default: 268 - console.info("Unsupported embed type", content); 269 - return ( 270 - <View className="my-1.5 rounded bg-neutral-100 p-2"> 271 - <Text className="text-center">Unsupported embed type</Text> 272 - </View> 273 - ); 274 - } 275 - };
+1
apps/expo/src/app/_layout.tsx
··· 110 110 {loading && <SplashScreen />} 111 111 <Stack 112 112 screenOptions={{ 113 + headerShown: true, 113 114 headerStyle: { 114 115 backgroundColor: "#fff", 115 116 },
-1
apps/expo/src/app/index.tsx
··· 17 17 return ( 18 18 <View className="flex-1"> 19 19 <StatusBar style="light" /> 20 - <Stack.Screen options={{ headerShown: false }} /> 21 20 <ImageBackground className="flex-1" source={background}> 22 21 <SafeAreaView className="flex-1 items-stretch justify-between p-4"> 23 22 <Text className="mx-auto mt-16 text-6xl font-bold text-white">
+97
apps/expo/src/components/embed.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { Image, Linking, Text, TouchableOpacity, View } from "react-native"; 3 + 4 + function useImageAspectRatio(imageUrl: string) { 5 + const [aspectRatio, setAspectRatio] = useState(1); 6 + 7 + useEffect(() => { 8 + if (!imageUrl) { 9 + return; 10 + } 11 + 12 + let isValid = true; 13 + Image.getSize(imageUrl, (width, height) => { 14 + if (isValid) { 15 + setAspectRatio(width / height); 16 + } 17 + }); 18 + 19 + return () => { 20 + isValid = false; 21 + }; 22 + }, [imageUrl]); 23 + 24 + return aspectRatio; 25 + } 26 + 27 + type EmbeddedImage = { 28 + $type: "app.bsky.embed.images#view"; 29 + images: { 30 + alt: string; 31 + fullsize: string; 32 + thumb: string; 33 + }[]; 34 + }; 35 + 36 + type EmbeddedExternal = { 37 + $type: "app.bsky.embed.external#view"; 38 + external: { 39 + description: string; 40 + thumb: string; 41 + title: string; 42 + uri: string; 43 + }; 44 + }; 45 + 46 + export type PostEmbed = EmbeddedImage | EmbeddedExternal; 47 + 48 + interface Props { 49 + content: PostEmbed; 50 + } 51 + 52 + export const Embed = ({ content }: Props) => { 53 + switch (content.$type) { 54 + case "app.bsky.embed.images#view": 55 + return <ImageEmbed content={content} />; 56 + case "app.bsky.embed.external#view": 57 + return ( 58 + <TouchableOpacity 59 + onPress={() => void Linking.openURL(content.external.uri)} 60 + className="my-1.5 rounded border p-2" 61 + > 62 + <Text className="text-base" numberOfLines={2}> 63 + {content.external.title} 64 + </Text> 65 + <Text className="text-sm text-neutral-400" numberOfLines={1}> 66 + {content.external.uri} 67 + </Text> 68 + </TouchableOpacity> 69 + ); 70 + default: 71 + console.info("Unsupported embed type", content); 72 + return ( 73 + <View className="my-1.5 rounded bg-neutral-100 p-2"> 74 + <Text className="text-center">Unsupported embed type</Text> 75 + </View> 76 + ); 77 + } 78 + }; 79 + 80 + const ImageEmbed = ({ content }: { content: EmbeddedImage }) => { 81 + const aspectRatio = useImageAspectRatio(content.images[0]!.thumb); 82 + switch (content.images.length) { 83 + case 0: 84 + return null; 85 + case 1: 86 + default: 87 + const image = content.images[0]!; 88 + return ( 89 + <Image 90 + source={{ uri: image.thumb }} 91 + alt={image.alt} 92 + className="my-1.5 w-full rounded" 93 + style={{ aspectRatio }} 94 + /> 95 + ); 96 + } 97 + };
+188
apps/expo/src/components/feed-post.tsx
··· 1 + import { useState } from "react"; 2 + import { Image, Text, TouchableOpacity, View } from "react-native"; 3 + import { Link } from "expo-router"; 4 + import { type AppBskyFeedPost } from "@atproto/api"; 5 + import { type FeedViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 6 + import { useMutation } from "@tanstack/react-query"; 7 + import { Heart, MessageSquare, Repeat, User } from "lucide-react-native"; 8 + 9 + import { useAuthedAgent } from "../lib/agent"; 10 + import { cx } from "../lib/utils/cx"; 11 + import { timeSince } from "../lib/utils/time"; 12 + import { Embed, type PostEmbed } from "./embed"; 13 + 14 + interface Props { 15 + item: FeedViewPost; 16 + hasReply?: boolean; 17 + } 18 + 19 + export const FeedPost = ({ item, hasReply = false }: Props) => { 20 + const agent = useAuthedAgent(); 21 + 22 + const [liked, setLiked] = useState(!!item.post.viewer?.like); 23 + const [likeUri, setLikeUri] = useState(item.post.viewer?.like); 24 + const [reposted, setReposted] = useState(!!item.post.viewer?.repost); 25 + const [repostUri, setRepostUri] = useState(item.post.viewer?.repost); 26 + 27 + const toggleLike = useMutation({ 28 + mutationKey: ["like", item.post.uri], 29 + mutationFn: async () => { 30 + if (!likeUri) { 31 + try { 32 + setLiked(true); 33 + const like = await agent.like(item.post.uri, item.post.cid); 34 + setLikeUri(like.uri); 35 + } catch (err) { 36 + setLiked(false); 37 + console.log(err); 38 + } 39 + } else { 40 + try { 41 + setLiked(false); 42 + await agent.deleteLike(likeUri); 43 + setLikeUri(undefined); 44 + } catch (err) { 45 + setLiked(true); 46 + console.log(err); 47 + } 48 + } 49 + }, 50 + }); 51 + 52 + const toggleRepost = useMutation({ 53 + mutationKey: ["repost", item.post.uri], 54 + mutationFn: async () => { 55 + if (!repostUri) { 56 + try { 57 + setReposted(true); 58 + const repost = await agent.repost(item.post.uri, item.post.cid); 59 + setRepostUri(repost.uri); 60 + } catch (err) { 61 + setReposted(false); 62 + console.log(err); 63 + } 64 + } else { 65 + try { 66 + setReposted(false); 67 + await agent.deleteRepost(repostUri); 68 + setRepostUri(undefined); 69 + } catch (err) { 70 + setReposted(true); 71 + console.log(err); 72 + } 73 + } 74 + }, 75 + }); 76 + 77 + const profileHref = `/profile/${item.post.author.handle}`; 78 + 79 + const postHref = `${profileHref}/post/${item.post.uri.split("/").pop()}`; 80 + 81 + return ( 82 + <> 83 + {item.reply?.parent && ( 84 + <FeedPost item={{ post: item.reply.parent }} hasReply /> 85 + )} 86 + <View 87 + className={cx( 88 + "flex-row bg-white px-2 pt-2", 89 + item.reply?.parent && "pt-0", 90 + !hasReply && "border-b border-b-neutral-200", 91 + )} 92 + // onLayout={(x) => console.log(x.nativeEvent.layout)} 93 + > 94 + {/* left col */} 95 + <View className="flex flex-col items-center px-2"> 96 + <Link href={profileHref} asChild> 97 + <TouchableOpacity> 98 + {item.post.author.avatar ? ( 99 + <Image 100 + source={{ uri: item.post.author.avatar }} 101 + alt={item.post.author.handle} 102 + className="h-12 w-12 rounded-full" 103 + /> 104 + ) : ( 105 + <View className="h-12 w-12 items-center justify-center rounded-full bg-neutral-100"> 106 + <User size={32} color="#1C1C1E" /> 107 + </View> 108 + )} 109 + </TouchableOpacity> 110 + </Link> 111 + <Link href={postHref} asChild> 112 + <TouchableOpacity className="w-full grow items-center"> 113 + {hasReply && <View className="w-1 grow bg-neutral-200" />} 114 + </TouchableOpacity> 115 + </Link> 116 + </View> 117 + 118 + {/* right col */} 119 + <View className="flex-1 pb-2.5 pl-1 pr-2"> 120 + <Link href={profileHref} asChild> 121 + <TouchableOpacity className="flex-row items-center"> 122 + <Text className="w-full text-base" numberOfLines={1}> 123 + {item.post.author.displayName}{" "} 124 + <Text className="text-neutral-400" numberOfLines={1}> 125 + @{item.post.author.handle} 126 + </Text> 127 + {/* get age of post - e.g. 5m */} 128 + <Text> · {timeSince(new Date(item.post.indexedAt))}</Text> 129 + </Text> 130 + </TouchableOpacity> 131 + </Link> 132 + {/* text content */} 133 + <Link href={postHref} asChild> 134 + <TouchableOpacity> 135 + <Text className="text-base"> 136 + {(item.post.record as AppBskyFeedPost.Record).text} 137 + </Text> 138 + </TouchableOpacity> 139 + </Link> 140 + {/* embeds */} 141 + {item.post.embed && <Embed content={item.post.embed as PostEmbed} />} 142 + {/* actions */} 143 + <View className="mt-2 flex-row justify-between"> 144 + <TouchableOpacity className="flex-row items-center gap-2"> 145 + <MessageSquare size={16} color="#1C1C1E" /> 146 + <Text>{item.post.replyCount}</Text> 147 + </TouchableOpacity> 148 + <TouchableOpacity 149 + disabled={toggleRepost.isLoading} 150 + onPress={() => toggleRepost.mutate()} 151 + className="flex-row items-center gap-2" 152 + > 153 + <Repeat size={16} color={reposted ? "#2563eb" : "#1C1C1E"} /> 154 + <Text 155 + style={{ 156 + color: reposted ? "#2563eb" : "#1C1C1E", 157 + }} 158 + > 159 + {(item.post.repostCount ?? 0) + 160 + (reposted && repostUri !== item.post.viewer?.repost ? 1 : 0)} 161 + </Text> 162 + </TouchableOpacity> 163 + <TouchableOpacity 164 + disabled={toggleLike.isLoading} 165 + onPress={() => toggleLike.mutate()} 166 + className="flex-row items-center gap-2" 167 + > 168 + <Heart 169 + size={16} 170 + fill={liked ? "#dc2626" : "transparent"} 171 + color={liked ? "#dc2626" : "#1C1C1E"} 172 + /> 173 + <Text 174 + style={{ 175 + color: liked ? "#dc2626" : "#1C1C1E", 176 + }} 177 + > 178 + {(item.post.likeCount ?? 0) + 179 + (liked && likeUri !== item.post.viewer?.like ? 1 : 0)} 180 + </Text> 181 + </TouchableOpacity> 182 + <View className="w-8" /> 183 + </View> 184 + </View> 185 + </View> 186 + </> 187 + ); 188 + };
+27
apps/expo/src/lib/utils/time.ts
··· 1 + /* eslint-disable @typescript-eslint/restrict-plus-operands */ 2 + export const timeSince = (date: Date) => { 3 + const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000); 4 + 5 + let interval = seconds / 31536000; 6 + 7 + if (interval > 1) { 8 + return Math.floor(interval) + "y"; 9 + } 10 + interval = seconds / 2592000; 11 + if (interval > 1) { 12 + return Math.floor(interval) + "mo"; 13 + } 14 + interval = seconds / 86400; 15 + if (interval > 1) { 16 + return Math.floor(interval) + "d"; 17 + } 18 + interval = seconds / 3600; 19 + if (interval > 1) { 20 + return Math.floor(interval) + "h"; 21 + } 22 + interval = seconds / 60; 23 + if (interval > 1) { 24 + return Math.floor(interval) + "m"; 25 + } 26 + return Math.floor(seconds) + "s"; 27 + };