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.