diff --git a/apps/amethyst/app/(tabs)/index.tsx b/apps/amethyst/app/(tabs)/index.tsx index f4516bc..3664f3a 100644 --- a/apps/amethyst/app/(tabs)/index.tsx +++ b/apps/amethyst/app/(tabs)/index.tsx @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, + Pressable, View, type NativeScrollEvent, type NativeSyntheticEvent, @@ -14,21 +15,61 @@ import TealShell, { SectionHeading, } from "@/components/teal/TealShell"; import { Text } from "@/components/ui/text"; +import { Icon } from "@/lib/icons/iconWithClassName"; import { getLatestPlays, getSocialFeed, type SocialPostView, } from "@/lib/teal/api"; +import { MessageCircle, Music2 } from "lucide-react-native"; import type { PlayView } from "@teal/lexicons/src/types/fm/teal/alpha/feed/defs"; -type FeedPlayView = PlayView & { - createdAt?: string; -}; +type HomeFeed = "posts" | "listens"; + +function HomeFeedTabButton({ + active, + count, + icon, + label, + onPress, +}: { + active: boolean; + count: number; + icon: typeof Music2; + label: string; + onPress: () => void; +}) { + return ( + + + + {label} + + {count} + + ); +} export default function HomeScreen() { const [plays, setPlays] = useState(null); const [socialPosts, setSocialPosts] = useState([]); + const [activeFeed, setActiveFeed] = useState("posts"); const [playCursor, setPlayCursor] = useState(); const [postCursor, setPostCursor] = useState(); const [error, setError] = useState(null); @@ -56,53 +97,24 @@ export default function HomeScreen() { }; }, []); - const feedItems = useMemo(() => { - const playItems = - plays?.map((play, index) => ({ - type: "play" as const, - key: play.uri || `${play.trackName}-${play.playedTime}-${index}`, - sortTime: feedDate(play.playedTime, (play as FeedPlayView).createdAt), - play, - })) || []; - const postItems = socialPosts.map((post) => ({ - type: "post" as const, - key: post.uri, - sortTime: feedDate(post.createdAt), - post, - })); - - return [...playItems, ...postItems].sort((a, b) => b.sortTime - a.sortTime); - }, [plays, socialPosts]); + const visiblePlays = plays || []; + const activeItemsCount = + activeFeed === "posts" ? socialPosts.length : visiblePlays.length; + const activeCursor = activeFeed === "posts" ? postCursor : playCursor; const loadMore = useCallback(() => { - if ((!playCursor && !postCursor) || loadingMoreRef.current) return; + const cursor = activeFeed === "posts" ? postCursor : playCursor; + if (!cursor || loadingMoreRef.current) return; loadingMoreRef.current = true; setLoadingMore(true); - Promise.all([ - playCursor - ? getLatestPlays(30, playCursor) - : Promise.resolve({ plays: [] as PlayView[], cursor: undefined }), - postCursor - ? getSocialFeed(30, postCursor) - : Promise.resolve({ - items: [] as SocialPostView[], - cursor: undefined, - }), - ]) - .then(([playRes, postRes]) => { - if (playCursor) { - setPlays((current) => { - const knownUris = new Set(current?.map((play) => play.uri) || []); - return [ - ...(current || []), - ...playRes.plays.filter( - (play) => !play.uri || !knownUris.has(play.uri), - ), - ]; - }); - setPlayCursor(playRes.cursor); - } - if (postCursor) { + const request = + activeFeed === "posts" + ? getSocialFeed(30, cursor) + : getLatestPlays(30, cursor); + request + .then((res) => { + if (activeFeed === "posts") { + const postRes = res as Awaited>; setSocialPosts((current) => { const knownUris = new Set(current.map((post) => post.uri)); return [ @@ -111,7 +123,20 @@ export default function HomeScreen() { ]; }); setPostCursor(postRes.cursor); + return; } + + const playRes = res as Awaited>; + setPlays((current) => { + const knownUris = new Set(current?.map((play) => play.uri) || []); + return [ + ...(current || []), + ...playRes.plays.filter( + (play) => !play.uri || !knownUris.has(play.uri), + ), + ]; + }); + setPlayCursor(playRes.cursor); }) .catch((e) => { setError(e instanceof Error ? e.message : String(e)); @@ -120,7 +145,7 @@ export default function HomeScreen() { loadingMoreRef.current = false; setLoadingMore(false); }); - }, [playCursor, postCursor]); + }, [activeFeed, playCursor, postCursor]); const handleScroll = useCallback( (event: NativeSyntheticEvent) => { @@ -138,17 +163,36 @@ export default function HomeScreen() { - setSocialPosts((current) => [post, ...current]) - } + onPublished={(post) => { + setSocialPosts((current) => [post, ...current]); + setActiveFeed("posts"); + }} /> + + + setActiveFeed("posts")} + /> + setActiveFeed("listens")} + /> + + {!plays && ( @@ -161,44 +205,41 @@ export default function HomeScreen() { )} - {plays && feedItems.length === 0 && !error && ( + {plays && activeItemsCount === 0 && !error && ( - No feed activity indexed yet. + {activeFeed === "posts" + ? "No posts indexed yet." + : "No listens indexed yet."} - Cadet will fill this feed as ATProto records arrive. + Cadet will fill this view as ATProto records arrive. )} - {feedItems.map((item) => - item.type === "play" ? ( - - ) : ( - - + {activeFeed === "posts" && + socialPosts.map((post) => ( + + - ), - )} + ))} + {activeFeed === "listens" && + visiblePlays.map((play, index) => ( + + ))} {loadingMore && ( )} - {plays && feedItems.length > 0 && !playCursor && !postCursor && ( + {plays && activeItemsCount > 0 && !activeCursor && ( - You reached the beginning of the indexed feed. + You reached the beginning of this feed. )} ); } - -function feedDate(...dates: Array) { - for (const date of dates) { - if (!date) continue; - const timestamp = new Date(date).getTime(); - if (Number.isFinite(timestamp)) return timestamp; - } - return 0; -} diff --git a/todo.md b/todo.md index 1bda462..388ce3d 100644 --- a/todo.md +++ b/todo.md @@ -10,7 +10,7 @@ This file is the working handoff for the Teal-native Teal clone. Keep it updated - Cadet consumes Teal records from Jetstream, stores a durable cursor in Redis with file fallback, and ingests create, update, and delete events for profiles and plays. - The public Amethyst feed uses only live Aqua XRPC data. There is no seeded, mocked, demo, or backup play feed. - Amethyst Home uses one inline Teal social composer. Signed-in users get their most recent indexed play attached automatically, can click the attached song to change it with recent plays or MusicBrainz search, write rich text, and publish `fm.teal.alpha.feed.social.post` records. -- Amethyst Home renders one mixed global feed with social posts and listens sorted together by play `playedTime`, falling back to record `createdAt` when no play timestamp exists. Aqua's global latest-play endpoint also keyset-paginates by that play timestamp so the client receives a globally ordered listen page before merging posts. +- Amethyst Home has a single inline social composer followed by a Posts/Listens feed toggle. Posts show only Teal social posts, Listens show only indexed plays, and each tab paginates its own cursor. - Amethyst social posts and recent listens merge indexed Teal actor data with Bluesky fallback handle/display-name/avatar data when the appview only has partial profile rows. - Amethyst profile images use the Bluesky CDN avatar/banner transforms for indexed Teal blob CIDs, and signed-in users can edit their Teal display name, bio, avatar, and banner from their own profile page. - Amethyst profile headers show display name, handle, bio, images, and current listening only; protocol internals like DID and onboarding status are hidden from the public profile UI.