this repo has no description
0
fork

Configure Feed

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

notifications page

+382 -83
+19
apps/expo/src/app/(tabs)/_layout.tsx
··· 1 1 import { Stack, Tabs } from "expo-router"; 2 + import { useQuery } from "@tanstack/react-query"; 2 3 import { Bell, Cloudy, Search, User } from "lucide-react-native"; 3 4 5 + import { useAuthedAgent } from "../../lib/agent"; 6 + 4 7 export default function AppLayout() { 8 + const agent = useAuthedAgent(); 9 + 10 + const notifications = useQuery({ 11 + queryKey: ["notifications", "unread"], 12 + queryFn: async () => { 13 + return await agent.countUnreadNotifications(); 14 + }, 15 + // refetch every 30 seconds 16 + refetchInterval: 1000 * 30, 17 + }); 18 + 5 19 return ( 6 20 <> 7 21 <Stack.Screen ··· 36 50 options={{ 37 51 title: "Notifications", 38 52 tabBarShowLabel: false, 53 + tabBarBadge: notifications.data?.data?.count || undefined, 54 + tabBarBadgeStyle: { 55 + backgroundColor: "#505050", 56 + fontSize: 12, 57 + }, 39 58 tabBarIcon({ focused }) { 40 59 return <Bell color={focused ? "#505050" : "#9b9b9b"} />; 41 60 },
+330 -77
apps/expo/src/app/(tabs)/notifications.tsx
··· 1 - // import { ActivityIndicator, Text, View } from "react-native"; 2 - // import { Stack } from "expo-router"; 3 - // import { FlashList } from "@shopify/flash-list"; 4 - // import { useInfiniteQuery } from "@tanstack/react-query"; 1 + import { useMemo } from "react"; 2 + import { 3 + ActivityIndicator, 4 + Image, 5 + Text, 6 + TouchableOpacity, 7 + View, 8 + } from "react-native"; 9 + import { Link, Stack } from "expo-router"; 10 + import { 11 + AppBskyFeedDefs, 12 + AppBskyFeedPost, 13 + AppBskyNotificationListNotifications, 14 + } from "@atproto/api"; 15 + import { FlashList } from "@shopify/flash-list"; 16 + import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 17 + import { Heart, Repeat, UserPlus } from "lucide-react-native"; 5 18 6 - // import { Button } from "../../components/button"; 7 - // import { useAuthedAgent } from "../../lib/agent"; 19 + import { Button } from "../../components/button"; 20 + import { Embed } from "../../components/embed"; 21 + import { FeedPost } from "../../components/feed-post"; 22 + import { RichText } from "../../components/rich-text"; 23 + import { useAuthedAgent } from "../../lib/agent"; 24 + import { queryClient } from "../../lib/query-client"; 25 + import { assert } from "../../lib/utils/assert"; 26 + import { cx } from "../../lib/utils/cx"; 27 + import { timeSince } from "../../lib/utils/time"; 8 28 9 - // export default function NotificationsPage() { 10 - // const agent = useAuthedAgent(); 11 - // const notifications = useInfiniteQuery({ 12 - // queryKey: ["notifications"], 13 - // queryFn: async ({ pageParam }) => { 14 - // const notifs = await agent.listNotifications({ 15 - // cursor: pageParam as string | undefined, 16 - // }); 17 - // return notifs.data; 18 - // }, 19 - // getNextPageParam: (lastPage) => lastPage.cursor, 20 - // }); 29 + type NotificationGroup = { 30 + reason: AppBskyNotificationListNotifications.Notification["reason"]; 31 + subject: AppBskyNotificationListNotifications.Notification["reasonSubject"]; 32 + actors: AppBskyNotificationListNotifications.Notification["author"][]; 33 + isRead: boolean; 34 + indexedAt: string; 35 + }; 21 36 22 - // switch (notifications.status) { 23 - // case "loading": 24 - // return ( 25 - // <View className="flex-1 items-center justify-center"> 26 - // <Stack.Screen options={{ headerShown: true }} /> 27 - // <ActivityIndicator /> 28 - // </View> 29 - // ); 37 + export default function NotificationsPage() { 38 + const agent = useAuthedAgent(); 39 + const notifications = useInfiniteQuery({ 40 + queryKey: ["notifications", "list"], 41 + queryFn: async ({ pageParam }) => { 42 + const notifs = await agent.listNotifications({ 43 + cursor: pageParam as string | undefined, 44 + }); 45 + // mark as read 46 + if (pageParam === undefined) { 47 + agent.updateSeenNotifications().then(() => { 48 + queryClient.invalidateQueries({ 49 + queryKey: ["notifications", "unread"], 50 + }); 51 + }); 52 + } 53 + // refetch the count and post queries so they update 54 + // TODO: this doesn't seem to work! 55 + queryClient.invalidateQueries({ 56 + queryKey: ["notifications", "post"], 57 + exact: false, 58 + }); 59 + return notifs.data; 60 + }, 61 + getNextPageParam: (lastPage) => lastPage.cursor, 62 + }); 30 63 31 - // case "error": 32 - // return ( 33 - // <View className="flex-1 items-center justify-center p-4"> 34 - // <Stack.Screen options={{ headerShown: true }} /> 35 - // <Text className="text-center text-xl"> 36 - // {(notifications.error as Error).message || "An error occurred"} 37 - // </Text> 38 - // <Button 39 - // variant="outline" 40 - // onPress={() => void notifications.refetch()} 41 - // > 42 - // Retry 43 - // </Button> 44 - // </View> 45 - // ); 64 + const data = useMemo(() => { 65 + if (!notifications.data) return []; 66 + const notifs = notifications.data.pages.flatMap( 67 + (page) => page.notifications, 68 + ); 46 69 47 - // case "success": 48 - // return ( 49 - // <> 50 - // <Stack.Screen options={{ headerShown: true }} /> 51 - // <FlashList 52 - // data={notifications.data.pages.flatMap( 53 - // (page) => page.notifications, 54 - // )} 55 - // renderItem={({ item }) => ( 56 - // <View className="w-full border-b p-4"> 57 - // <Text>{JSON.stringify(item, null, 2)}</Text> 58 - // </View> 59 - // )} 60 - // onEndReachedThreshold={0.5} 61 - // onEndReached={() => void notifications.fetchNextPage()} 62 - // onRefresh={() => { 63 - // if (!notifications.isRefetching) void notifications.refetch(); 64 - // }} 65 - // refreshing={notifications.isRefetching} 66 - // ListFooterComponent={ 67 - // notifications.isFetching ? ( 68 - // <View className="w-full items-center py-4"> 69 - // <ActivityIndicator /> 70 - // </View> 71 - // ) : null 72 - // } 73 - // /> 74 - // </> 75 - // ); 76 - // } 77 - // } 70 + const grouped: NotificationGroup[] = []; 71 + for (const notif of notifs) { 72 + const previous = grouped[grouped.length - 1]; 73 + if ( 74 + previous && 75 + previous.reason === notif.reason && 76 + previous.subject === notif.reasonSubject 77 + ) { 78 + previous.actors.push(notif.author); 79 + } else { 80 + let subject = notif.reasonSubject; 81 + if (["reply", "quote", "mention"].includes(notif.reason)) { 82 + subject = notif.uri; 83 + } 84 + grouped.push({ 85 + reason: notif.reason, 86 + subject, 87 + actors: [notif.author], 88 + isRead: notif.isRead, 89 + indexedAt: notif.indexedAt, 90 + }); 91 + } 92 + } 93 + return grouped; 94 + }, [notifications.data]); 95 + 96 + switch (notifications.status) { 97 + case "loading": 98 + return ( 99 + <View className="flex-1 items-center justify-center"> 100 + <Stack.Screen options={{ headerShown: true }} /> 101 + <ActivityIndicator /> 102 + </View> 103 + ); 104 + case "error": 105 + return ( 106 + <View className="flex-1 items-center justify-center p-4"> 107 + <Stack.Screen options={{ headerShown: true }} /> 108 + <Text className="text-center text-xl"> 109 + {(notifications.error as Error).message || "An error occurred"} 110 + </Text> 111 + <Button 112 + variant="outline" 113 + onPress={() => void notifications.refetch()} 114 + > 115 + Retry 116 + </Button> 117 + </View> 118 + ); 119 + case "success": 120 + return ( 121 + <> 122 + <Stack.Screen options={{ headerShown: true }} /> 123 + <FlashList 124 + data={data} 125 + renderItem={({ item }) => <Notification {...item} />} 126 + estimatedItemSize={105} 127 + onEndReachedThreshold={0.5} 128 + onEndReached={() => void notifications.fetchNextPage()} 129 + onRefresh={() => { 130 + if (!notifications.isRefetching) void notifications.refetch(); 131 + }} 132 + refreshing={notifications.isRefetching} 133 + ListFooterComponent={ 134 + notifications.isFetching ? ( 135 + <View className="w-full items-center py-4"> 136 + <ActivityIndicator /> 137 + </View> 138 + ) : null 139 + } 140 + /> 141 + </> 142 + ); 143 + } 144 + } 145 + 146 + const Notification = ({ 147 + reason, 148 + subject, 149 + actors, 150 + isRead, 151 + indexedAt, 152 + }: NotificationGroup) => { 153 + let href: string | undefined; 154 + if (subject && subject.startsWith("at://")) { 155 + const [did, _, id] = subject.slice("at://".length).split("/"); 156 + href = `/profile/${did}/post/${id}`; 157 + } 158 + 159 + switch (reason) { 160 + case "like": 161 + return ( 162 + <NotificationItem 163 + href={href} 164 + unread={!isRead} 165 + left={<Heart size={24} fill="#dc2626" color="#dc2626" />} 166 + > 167 + <ProfileList 168 + actors={actors} 169 + action="liked your post" 170 + indexedAt={indexedAt} 171 + /> 172 + {subject && ( 173 + <PostNotification uri={subject} unread={!isRead} inline /> 174 + )} 175 + </NotificationItem> 176 + ); 177 + case "repost": 178 + return ( 179 + <NotificationItem 180 + href={href} 181 + unread={!isRead} 182 + left={<Repeat size={24} color="#2563eb" />} 183 + > 184 + <ProfileList 185 + actors={actors} 186 + action="reposted your post" 187 + indexedAt={indexedAt} 188 + /> 189 + {subject && ( 190 + <PostNotification uri={subject} unread={!isRead} inline /> 191 + )} 192 + </NotificationItem> 193 + ); 194 + case "follow": 195 + return ( 196 + <NotificationItem 197 + href={ 198 + actors.length === 1 ? `/profile/${actors[0]!.handle}` : undefined 199 + } 200 + unread={!isRead} 201 + left={<UserPlus size={24} color="#2563eb" />} 202 + > 203 + <ProfileList 204 + actors={actors} 205 + action="started following you" 206 + indexedAt={indexedAt} 207 + /> 208 + </NotificationItem> 209 + ); 210 + case "reply": 211 + case "quote": 212 + case "mention": 213 + if (!subject) return null; 214 + return <PostNotification uri={subject} unread={!isRead} />; 215 + default: 216 + console.warn("Unknown notification reason", reason); 217 + return null; 218 + } 219 + }; 78 220 79 - import { Text, View } from "react-native"; 221 + const NotificationItem = ({ 222 + left = null, 223 + children, 224 + unread, 225 + href, 226 + }: { 227 + left?: React.ReactNode; 228 + children: React.ReactNode; 229 + unread: boolean; 230 + href?: string; 231 + }) => { 232 + const className = cx( 233 + "flex-row border-b p-2", 234 + unread ? "border-blue-200 bg-blue-50" : "border-neutral-200 bg-white", 235 + ); 236 + const wrapper = (children: React.ReactNode) => 237 + href ? ( 238 + <Link href={href} asChild> 239 + <TouchableOpacity className={className}>{children}</TouchableOpacity> 240 + </Link> 241 + ) : ( 242 + <View className={className}>{children}</View> 243 + ); 244 + return wrapper( 245 + <> 246 + <View className="w-16 shrink-0 items-end px-2">{left}</View> 247 + <View className="flex-1 px-2">{children}</View> 248 + </>, 249 + ); 250 + }; 80 251 81 - export default function NotificationsPage() { 252 + const ProfileList = ({ 253 + actors, 254 + action, 255 + indexedAt, 256 + }: Pick<NotificationGroup, "actors" | "indexedAt"> & { action: string }) => { 257 + if (!actors[0]) return null; 82 258 return ( 83 - <View className="flex-1 justify-center"> 84 - <Text className="text-center text-xl">Coming soon</Text> 259 + <View> 260 + <View className="flex-row"> 261 + {actors.map((actor) => ( 262 + <Link href={`/profile/${actor.handle}`} asChild key={actor.did}> 263 + <TouchableOpacity className="mr-2 rounded-full"> 264 + <Image 265 + className="h-8 w-8 rounded-full bg-neutral-200" 266 + source={{ uri: actor.avatar }} 267 + alt={actor.displayName} 268 + /> 269 + </TouchableOpacity> 270 + </Link> 271 + ))} 272 + </View> 273 + <Text className="mt-2 text-base"> 274 + <Text className="font-medium"> 275 + {actors[0].displayName?.trim() ?? actors[0].handle} 276 + {actors.length > 1 && ` and ${actors.length - 1} others`} 277 + </Text> 278 + {" " + action + " · " + timeSince(new Date(indexedAt))} 279 + </Text> 85 280 </View> 86 281 ); 87 - } 282 + }; 283 + 284 + const PostNotification = ({ 285 + uri, 286 + unread, 287 + inline, 288 + }: { 289 + uri: string; 290 + unread: boolean; 291 + inline?: boolean; 292 + }) => { 293 + const agent = useAuthedAgent(); 294 + 295 + const post = useQuery({ 296 + queryKey: ["notifications", "post", uri], 297 + queryFn: async () => { 298 + const { data } = await agent.getPostThread({ 299 + uri: uri, 300 + depth: 0, 301 + }); 302 + 303 + if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) 304 + throw Error("Post not found"); 305 + assert(AppBskyFeedDefs.validateThreadViewPost(data.thread)); 306 + 307 + return data.thread; 308 + }, 309 + }); 310 + 311 + switch (post.status) { 312 + case "loading": 313 + if (inline) return <View className="h-10" />; 314 + return ( 315 + <NotificationItem unread={unread}> 316 + <View className="h-20" /> 317 + </NotificationItem> 318 + ); 319 + case "error": 320 + console.warn(post.error); 321 + return null; 322 + case "success": 323 + if (inline) { 324 + if (!AppBskyFeedPost.isRecord(post.data.post.record)) return null; 325 + assert(AppBskyFeedPost.validateRecord(post.data.post.record)); 326 + 327 + return ( 328 + <View className="mt-0.5"> 329 + <Text className="text-neutral-500"> 330 + <RichText value={post.data.post.record.text} size="sm" /> 331 + </Text> 332 + {post.data.post.embed && ( 333 + <Embed content={post.data.post.embed} truncate depth={1} /> 334 + )} 335 + </View> 336 + ); 337 + } 338 + return <FeedPost item={post.data} unread={unread} />; 339 + } 340 + };
+5 -2
apps/expo/src/app/(tabs)/timeline.tsx
··· 71 71 if (timeline.status !== "success") return []; 72 72 const flattened = timeline.data.pages.flatMap((page) => page.feed); 73 73 return flattened 74 - .map((item) => 75 - item.reply 74 + .map((item, i, arr) => 75 + // if the preview item is replying to this one, skip 76 + // arr[i - 1]?.reply?.parent?.cid === item.cid 77 + // ? [] : 78 + item.reply && !item.reason 76 79 ? [ 77 80 { item: { post: item.reply.parent }, hasReply: true }, 78 81 { item, hasReply: false },
+18 -2
apps/expo/src/app/profile/[handle]/post/[id].tsx
··· 1 + import { useEffect, useRef } from "react"; 1 2 import { ActivityIndicator, Text, View } from "react-native"; 2 3 import { Stack, useLocalSearchParams } from "expo-router"; 3 4 import { AppBskyFeedDefs } from "@atproto/api"; ··· 22 23 handle: string; 23 24 }; 24 25 const agent = useAuthedAgent(); 26 + const ref = useRef<FlashList<any>>(null); 27 + const hasScrolled = useRef(false); 25 28 26 29 const thread = useQuery(["profile", handle, "post", id], async () => { 27 30 let did = handle; ··· 104 107 return { posts, index }; 105 108 }); 106 109 110 + // hacky but needed until https://github.com/Shopify/flash-list/issues/671 is fixed 111 + useEffect(() => { 112 + if (thread.data) { 113 + const index = thread.data.index; 114 + hasScrolled.current = true; 115 + setTimeout(() => { 116 + ref.current?.scrollToIndex({ 117 + animated: true, 118 + index, 119 + }); 120 + }, 50); 121 + } 122 + }, [thread.data]); 123 + 107 124 switch (thread.status) { 108 125 case "loading": 109 126 return ( ··· 126 143 <> 127 144 <Stack.Screen options={{ headerTitle: "Post" }} /> 128 145 <FlashList 146 + ref={ref} 129 147 data={thread.data.posts} 130 - initialScrollIndex={thread.data.index} 131 - // estimatedFirstItemOffset={thread.data.index * 91} 132 148 estimatedItemSize={91} 133 149 getItemType={(item) => (item.primary ? "big" : "small")} 134 150 renderItem={({ item }) =>
+10 -2
apps/expo/src/components/feed-post.tsx
··· 13 13 interface Props { 14 14 item: AppBskyFeedDefs.FeedViewPost; 15 15 hasReply?: boolean; 16 + unread?: boolean; 17 + inlineReason?: React.ReactNode; 16 18 } 17 19 18 - export const FeedPost = ({ item, hasReply = false }: Props) => { 20 + export const FeedPost = ({ 21 + item, 22 + hasReply = false, 23 + unread, 24 + inlineReason, 25 + }: Props) => { 19 26 const { liked, likeCount, toggleLike } = useLike(item.post); 20 27 const { reposted, repostCount, toggleRepost } = useRepost(item.post); 21 28 ··· 36 43 className={cx( 37 44 "bg-white px-2 pt-2", 38 45 item.reply?.parent && "pt-0", 39 - !hasReply && "border-b border-b-neutral-200", 46 + !hasReply && "border-b border-neutral-200", 47 + unread && "border-blue-200 bg-blue-50", 40 48 )} 41 49 > 42 50 <Reason item={item} />