diff --git a/apps/amethyst/app/(tabs)/_layout.tsx b/apps/amethyst/app/(tabs)/_layout.tsx index 7ccad8e..b6b1f00 100644 --- a/apps/amethyst/app/(tabs)/_layout.tsx +++ b/apps/amethyst/app/(tabs)/_layout.tsx @@ -118,6 +118,13 @@ export default function TabLayout() { href: null, }} /> + ); } diff --git a/apps/amethyst/app/(tabs)/index.tsx b/apps/amethyst/app/(tabs)/index.tsx index 5dffc51..ce20582 100644 --- a/apps/amethyst/app/(tabs)/index.tsx +++ b/apps/amethyst/app/(tabs)/index.tsx @@ -21,6 +21,7 @@ import { getSocialFeed, type SocialPostView, } from "@/lib/teal/api"; +import { useStore } from "@/stores/mainStore"; import { MessageCircle, Music2 } from "lucide-react-native"; import type { PlayView } from "@teal/lexicons/src/types/fm/teal/alpha/feed/defs"; @@ -72,10 +73,11 @@ export default function HomeScreen() { const [error, setError] = useState(null); const [loadingMore, setLoadingMore] = useState(false); const loadingMoreRef = useRef(false); + const viewerDid = useStore((state) => state.pdsAgent?.did); useEffect(() => { let mounted = true; - Promise.all([getLatestPlays(30), getSocialFeed(30)]) + Promise.all([getLatestPlays(30), getSocialFeed(30, undefined, viewerDid)]) .then(([playRes, postRes]) => { if (!mounted) return; setPlays(playRes.plays); @@ -92,7 +94,7 @@ export default function HomeScreen() { return () => { mounted = false; }; - }, []); + }, [viewerDid]); const visiblePlays = plays || []; const activeItemsCount = @@ -106,7 +108,7 @@ export default function HomeScreen() { setLoadingMore(true); const request = activeFeed === "posts" - ? getSocialFeed(30, cursor) + ? getSocialFeed(30, cursor, viewerDid) : getLatestPlays(30, cursor); request .then((res) => { @@ -142,7 +144,7 @@ export default function HomeScreen() { loadingMoreRef.current = false; setLoadingMore(false); }); - }, [activeFeed, playCursor, postCursor]); + }, [activeFeed, playCursor, postCursor, viewerDid]); const handleScroll = useCallback( (event: NativeSyntheticEvent) => { diff --git a/apps/amethyst/app/(tabs)/post/[did]/[rkey].tsx b/apps/amethyst/app/(tabs)/post/[did]/[rkey].tsx new file mode 100644 index 0000000..73fc5cd --- /dev/null +++ b/apps/amethyst/app/(tabs)/post/[did]/[rkey].tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from "react"; +import { ActivityIndicator, View } from "react-native"; +import { Stack, useLocalSearchParams } from "expo-router"; +import RightRail from "@/components/teal/RightRail"; +import SocialPostCard from "@/components/teal/SocialPostCard"; +import TealShell, { SectionHeading } from "@/components/teal/TealShell"; +import { Text } from "@/components/ui/text"; +import { + getSocialPost, + getSocialPostReplies, + type SocialPostView, +} from "@/lib/teal/api"; +import { useStore } from "@/stores/mainStore"; + +function postUri(did?: string, rkey?: string) { + if (!did || !rkey) return undefined; + return `at://${did}/fm.teal.alpha.feed.social.post/${rkey}`; +} + +export default function PostDetail() { + const params = useLocalSearchParams(); + const did = Array.isArray(params.did) ? params.did[0] : params.did; + const rkey = Array.isArray(params.rkey) ? params.rkey[0] : params.rkey; + const viewerDid = useStore((state) => state.pdsAgent?.did); + const [post, setPost] = useState(null); + const [replies, setReplies] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + const uri = postUri(did, rkey); + async function load() { + if (!uri) return; + try { + setError(null); + const [postRes, repliesRes] = await Promise.all([ + getSocialPost(uri, viewerDid), + getSocialPostReplies(uri, 30, undefined, viewerDid), + ]); + if (!mounted) return; + setPost(postRes.post); + setReplies(repliesRes.items); + } catch (e) { + if (mounted) { + setError(e instanceof Error ? e.message : String(e)); + setPost(null); + setReplies([]); + } + } + } + load(); + return () => { + mounted = false; + }; + }, [did, rkey, viewerDid]); + + return ( + }> + + {!post && !error && ( + + + + )} + {error && ( + + + Could not load post: {error} + + + )} + {post && ( + <> + + + + + + {replies.length === 0 ? ( + + + No indexed replies yet. + + + ) : ( + replies.map((reply) => ( + + + + )) + )} + + )} + + ); +} diff --git a/apps/amethyst/components/teal/SocialPostCard.tsx b/apps/amethyst/components/teal/SocialPostCard.tsx index 0567911..311fafe 100644 --- a/apps/amethyst/components/teal/SocialPostCard.tsx +++ b/apps/amethyst/components/teal/SocialPostCard.tsx @@ -4,7 +4,12 @@ import { Link } from "expo-router"; import { Button } from "@/components/ui/button"; import { Text } from "@/components/ui/text"; import { Icon } from "@/lib/icons/iconWithClassName"; -import { displayArtists, type SocialPostView } from "@/lib/teal/api"; +import { + coverArtUrl, + displayArtists, + getRecordingCoverArtUrl, + type SocialPostView, +} from "@/lib/teal/api"; import { actorAvatarUrl, actorProfileHref, @@ -16,10 +21,11 @@ import { import { musicHref } from "@/components/teal/PlayFeedCard"; import RichText from "@/components/teal/RichText"; import SocialComposer from "@/components/teal/SocialComposer"; +import { postHrefFromUri, rkeyFromAtUri } from "@/lib/teal/routes"; import { trackViewToPlayView } from "@/lib/teal/social"; import { timeAgo } from "@/lib/utils"; import { useStore } from "@/stores/mainStore"; -import { Heart, MessageCircle, Music2, Repeat2 } from "lucide-react-native"; +import { Disc3, Heart, MessageCircle, Repeat2 } from "lucide-react-native"; type ActionState = { uri?: string; @@ -28,8 +34,13 @@ type ActionState = { busy: boolean; }; -function rkeyFromUri(uri: string) { - return uri.split("/").pop(); +function actionStateFromUri(uri?: string): ActionState { + return { + active: Boolean(uri), + busy: false, + rkey: rkeyFromAtUri(uri), + uri, + }; } export default function SocialPostCard({ post }: { post: SocialPostView }) { @@ -41,11 +52,14 @@ export default function SocialPostCard({ post }: { post: SocialPostView }) { const [replyCount, setReplyCount] = useState(post.replyCount); const [replyOpen, setReplyOpen] = useState(false); const [blueskyAuthor, setBlueskyAuthor] = useState(); - const [like, setLike] = useState({ active: false, busy: false }); - const [repost, setRepost] = useState({ - active: false, - busy: false, - }); + const [like, setLike] = useState(() => + actionStateFromUri(post.viewerLike), + ); + const [repost, setRepost] = useState(() => + actionStateFromUri(post.viewerRepost), + ); + const [releaseArtFailed, setReleaseArtFailed] = useState(false); + const [recordingArt, setRecordingArt] = useState(); const indexedAuthor = post.author as DisplayActor | undefined; const authorProfile = indexedAuthor ? { @@ -61,6 +75,24 @@ export default function SocialPostCard({ post }: { post: SocialPostView }) { const authorLabel = displayActorName(authorProfile, authorDid); const authorHref = actorProfileHref(authorProfile, authorDid); const authorAvatar = actorAvatarUrl(authorProfile, authorDid); + const releaseArt = coverArtUrl(play.releaseMbId); + const art = !releaseArtFailed && releaseArt ? releaseArt : recordingArt; + const postHref = postHrefFromUri(post.uri); + + useEffect(() => { + setLikeCount(post.likeCount); + setRepostCount(post.repostCount); + setReplyCount(post.replyCount); + setLike(actionStateFromUri(post.viewerLike)); + setRepost(actionStateFromUri(post.viewerRepost)); + }, [ + post.likeCount, + post.replyCount, + post.repostCount, + post.uri, + post.viewerLike, + post.viewerRepost, + ]); useEffect(() => { let mounted = true; @@ -78,6 +110,27 @@ export default function SocialPostCard({ post }: { post: SocialPostView }) { }; }, [authorDid, indexedAuthor]); + useEffect(() => { + let mounted = true; + setRecordingArt(undefined); + if (releaseArt && !releaseArtFailed) { + return; + } + if (!play.recordingMbId) { + return; + } + getRecordingCoverArtUrl(play.recordingMbId).then((url) => { + if (mounted) setRecordingArt(url); + }); + return () => { + mounted = false; + }; + }, [play.recordingMbId, releaseArt, releaseArtFailed]); + + useEffect(() => { + setReleaseArtFailed(false); + }, [releaseArt]); + async function toggleAction(kind: "like" | "repost") { if (!pdsAgent?.did || status !== "loggedIn") return; const current = kind === "like" ? like : repost; @@ -87,14 +140,14 @@ export default function SocialPostCard({ post }: { post: SocialPostView }) { setCurrent({ ...current, busy: true }); try { - if (current.active && current.rkey) { + if (current.active && (current.rkey || current.uri)) { await pdsAgent.call( "com.atproto.repo.deleteRecord", {}, { repo: pdsAgent.did, collection: `fm.teal.alpha.feed.social.${kind}`, - rkey: current.rkey, + rkey: current.rkey || rkeyFromAtUri(current.uri)!, }, ); setCount((count) => Math.max(0, count - 1)); @@ -121,7 +174,7 @@ export default function SocialPostCard({ post }: { post: SocialPostView }) { active: true, busy: false, uri, - rkey: uri ? rkeyFromUri(uri) : undefined, + rkey: rkeyFromAtUri(uri), }); } catch (error) { console.error(`Failed to ${kind} social post`, error); @@ -163,9 +216,19 @@ export default function SocialPostCard({ post }: { post: SocialPostView }) { @{authorHandle} )} - - posted {timeAgo(new Date(post.createdAt))} - + {postHref ? ( + + + + posted {timeAgo(new Date(post.createdAt))} + + + + ) : ( + + posted {timeAgo(new Date(post.createdAt))} + + )} @@ -181,8 +244,22 @@ export default function SocialPostCard({ post }: { post: SocialPostView }) { - - + + {art ? ( + { + if (art === releaseArt) { + setReleaseArtFailed(true); + } else { + setRecordingArt(undefined); + } + }} + /> + ) : ( + + )} diff --git a/apps/amethyst/components/teal/TealShell.tsx b/apps/amethyst/components/teal/TealShell.tsx index 9cb4e2f..dec8eb8 100644 --- a/apps/amethyst/components/teal/TealShell.tsx +++ b/apps/amethyst/components/teal/TealShell.tsx @@ -98,12 +98,15 @@ function LeftRail() { const agent = useStore((state) => state.pdsAgent); const profiles = useStore((state) => state.profiles); const profile = agent?.did ? profiles[agent.did] : undefined; + const tealHandle = normalizeHandle(profile?.teal?.handle as string | undefined); + const bskyHandle = normalizeHandle(profile?.bsky?.handle); const displayName = profile?.teal?.displayName || profile?.bsky?.displayName || - normalizeHandle(profile?.bsky?.handle) || + tealHandle || + bskyHandle || "Your profile"; - const handle = normalizeHandle(profile?.bsky?.handle); + const handle = tealHandle || bskyHandle; const avatar = agent?.did ? getProfileImageUrl( agent.did, diff --git a/apps/amethyst/lib/teal/api.ts b/apps/amethyst/lib/teal/api.ts index 762dd28..235838f 100644 --- a/apps/amethyst/lib/teal/api.ts +++ b/apps/amethyst/lib/teal/api.ts @@ -64,6 +64,8 @@ export type SocialPostView = { likeCount: number; repostCount: number; replyCount: number; + viewerLike?: string; + viewerRepost?: string; }; export type SocialBadgeView = { @@ -230,10 +232,29 @@ export function getSearchResults(q: string, limit = 8) { }); } -export function getSocialFeed(limit = 30, cursor?: string) { +export function getSocialFeed(limit = 30, cursor?: string, viewer?: string) { return getXrpc<{ items: SocialPostView[]; cursor?: string }>( "fm.teal.alpha.feed.social.getFeed", - { limit, cursor }, + { limit, cursor, viewer }, + ); +} + +export function getSocialPost(uri: string, viewer?: string) { + return getXrpc<{ post: SocialPostView }>("fm.teal.alpha.feed.social.getPost", { + uri, + viewer, + }); +} + +export function getSocialPostReplies( + uri: string, + limit = 30, + cursor?: string, + viewer?: string, +) { + return getXrpc<{ items: SocialPostView[]; cursor?: string }>( + "fm.teal.alpha.feed.social.getReplies", + { uri, limit, cursor, viewer }, ); } diff --git a/apps/amethyst/lib/teal/routes.ts b/apps/amethyst/lib/teal/routes.ts index 4d799e0..1c47e22 100644 --- a/apps/amethyst/lib/teal/routes.ts +++ b/apps/amethyst/lib/teal/routes.ts @@ -36,6 +36,23 @@ export function listenHref(authorDid?: string, rkey?: string) { return `/listen/${encodeURIComponent(authorDid)}/${encodeURIComponent(rkey)}`; } +export function postHref(authorDid?: string, rkey?: string) { + if (!authorDid || !rkey) return undefined; + return `/post/${encodeURIComponent(authorDid)}/${encodeURIComponent(rkey)}`; +} + +export function rkeyFromAtUri(uri?: string) { + return uri?.split("/").pop(); +} + +export function postHrefFromUri(uri?: string) { + if (!uri?.startsWith("at://")) return undefined; + const [, rest] = uri.split("at://"); + const [did, collection, rkey] = rest.split("/"); + if (collection !== "fm.teal.alpha.feed.social.post") return undefined; + return postHref(did, rkey); +} + export function playlistHref(name: string, uri: string) { return `/playlist/${routePart(name)}?uri=${encodeURIComponent(uri)}`; } diff --git a/apps/amethyst/stores/authenticationSlice.tsx b/apps/amethyst/stores/authenticationSlice.tsx index e962f7c..587798d 100644 --- a/apps/amethyst/stores/authenticationSlice.tsx +++ b/apps/amethyst/stores/authenticationSlice.tsx @@ -3,15 +3,14 @@ import { Agent, type AppBskyActorDefs } from "@atproto/api"; import { OAuthSession } from "@atproto/oauth-client"; import * as Lexicons from "@teal/lexicons/src/lexicons"; -import { OutputSchema as GetProfileOutputSchema } from "@teal/lexicons/src/types/fm/teal/alpha/actor/getProfile"; +import type { ProfileView } from "@teal/lexicons/src/types/fm/teal/alpha/actor/defs"; import createOAuthClient, { AquareumOAuthClient } from "../lib/atp/oauth"; import { StateCreator } from "./mainStore"; export interface AllProfileViews { bsky: null | AppBskyActorDefs.ProfileViewDetailed; - teal: null | GetProfileOutputSchema["actor"]; - // todo: teal profile view + teal: null | ProfileView; } export interface AuthenticationSlice { @@ -182,8 +181,8 @@ export const createAuthenticationSlice: StateCreator = ( }); // get teal did try { - let tealDid = get().tealDid; - let tealProfile = await agent + const tealDid = get().tealDid; + const tealProfile = await agent .call( "fm.teal.alpha.actor.getProfile", { actor: agent?.did }, @@ -192,7 +191,11 @@ export const createAuthenticationSlice: StateCreator = ( ) .then((profile) => { console.log(profile); - return profile.data.agent || null; + const data = profile.data as { + actor?: ProfileView; + profile?: ProfileView; + }; + return data.profile || data.actor || null; }); set({ diff --git a/apps/aqua/src/repos/social.rs b/apps/aqua/src/repos/social.rs index 9ac8ddc..8925419 100644 --- a/apps/aqua/src/repos/social.rs +++ b/apps/aqua/src/repos/social.rs @@ -25,6 +25,8 @@ pub struct SocialPostView { pub like_count: i32, pub repost_count: i32, pub reply_count: i32, + pub viewer_like: Option, + pub viewer_repost: Option, } #[derive(Serialize)] @@ -128,6 +130,8 @@ struct PostRow { like_count: i32, repost_count: i32, reply_count: i32, + viewer_like: Option, + viewer_repost: Option, } fn normalize_limit(limit: Option) -> i64 { @@ -160,13 +164,19 @@ pub trait SocialRepo { &self, limit: Option, cursor: Option<&str>, + viewer: Option<&str>, ) -> anyhow::Result>; - async fn get_post(&self, uri: &str) -> anyhow::Result>; + async fn get_post( + &self, + uri: &str, + viewer: Option<&str>, + ) -> anyhow::Result>; async fn get_post_replies( &self, uri: &str, limit: Option, cursor: Option<&str>, + viewer: Option<&str>, ) -> anyhow::Result>; async fn get_post_likes( &self, @@ -217,6 +227,7 @@ impl SocialRepo for PgDataSource { &self, limit: Option, cursor: Option<&str>, + viewer: Option<&str>, ) -> anyhow::Result> { let limit = normalize_limit(limit); let offset = decode_offset(cursor)?; @@ -226,6 +237,7 @@ impl SocialRepo for PgDataSource { limit, offset, None, + viewer, ) .await?; let cursor = page_cursor(&rows, limit, offset)?; @@ -235,8 +247,12 @@ impl SocialRepo for PgDataSource { }) } - async fn get_post(&self, uri: &str) -> anyhow::Result> { - let rows = fetch_posts(self, "WHERE p.uri = $3 LIMIT 1", 1, 0, Some(uri)).await?; + async fn get_post( + &self, + uri: &str, + viewer: Option<&str>, + ) -> anyhow::Result> { + let rows = fetch_posts(self, "WHERE p.uri = $3 LIMIT 1", 1, 0, Some(uri), viewer).await?; Ok(rows.into_iter().next()) } @@ -245,6 +261,7 @@ impl SocialRepo for PgDataSource { uri: &str, limit: Option, cursor: Option<&str>, + viewer: Option<&str>, ) -> anyhow::Result> { let limit = normalize_limit(limit); let offset = decode_offset(cursor)?; @@ -254,6 +271,7 @@ impl SocialRepo for PgDataSource { limit, offset, Some(uri), + viewer, ) .await?; let cursor = page_cursor(&rows, limit, offset)?; @@ -519,6 +537,7 @@ async fn fetch_posts( limit: i64, offset: i64, uri: Option<&str>, + viewer: Option<&str>, ) -> anyhow::Result> { let sql = format!( r#" @@ -531,7 +550,19 @@ async fn fetch_posts( profile.avatar AS profile_avatar, COALESCE(c.like_count, 0) AS like_count, COALESCE(c.repost_count, 0) AS repost_count, - COALESCE(c.reply_count, 0) AS reply_count + COALESCE(c.reply_count, 0) AS reply_count, + ( + SELECT l.uri + FROM social_likes l + WHERE l.subject_uri = p.uri AND l.did = $4 + LIMIT 1 + ) AS viewer_like, + ( + SELECT r.uri + FROM social_reposts r + WHERE r.subject_uri = p.uri AND r.did = $4 + LIMIT 1 + ) AS viewer_repost FROM social_posts p LEFT JOIN profiles profile ON p.did = profile.did LEFT JOIN social_subject_counts c ON p.uri = c.subject_uri @@ -539,11 +570,12 @@ async fn fetch_posts( "# ); let rows = sqlx::query_as::<_, PostRow>(&sql) - .bind(limit + 1) - .bind(offset) - .bind(uri) - .fetch_all(&repo.db) - .await?; + .bind(limit + 1) + .bind(offset) + .bind(uri) + .bind(viewer) + .fetch_all(&repo.db) + .await?; Ok(rows .into_iter() @@ -570,6 +602,8 @@ async fn fetch_posts( like_count: row.like_count, repost_count: row.repost_count, reply_count: row.reply_count, + viewer_like: row.viewer_like, + viewer_repost: row.viewer_repost, }) .collect()) } diff --git a/apps/aqua/src/xrpc/social.rs b/apps/aqua/src/xrpc/social.rs index f6dd95a..693faf0 100644 --- a/apps/aqua/src/xrpc/social.rs +++ b/apps/aqua/src/xrpc/social.rs @@ -13,10 +13,7 @@ pub fn social_routes() -> axum::Router { "/fm.teal.alpha.feed.social.getActorPlaylists", get(get_actor_playlists), ) - .route( - "/fm.teal.alpha.feed.social.getPlaylist", - get(get_playlist), - ) + .route("/fm.teal.alpha.feed.social.getPlaylist", get(get_playlist)) .route( "/fm.teal.alpha.feed.social.getBadgeCatalog", get(get_badge_catalog), @@ -35,6 +32,7 @@ pub fn social_routes() -> axum::Router { pub struct PageQuery { pub limit: Option, pub cursor: Option, + pub viewer: Option, } #[derive(Deserialize)] @@ -42,6 +40,7 @@ pub struct UriPageQuery { pub uri: String, pub limit: Option, pub cursor: Option, + pub viewer: Option, } #[derive(Deserialize)] @@ -83,7 +82,11 @@ pub async fn get_feed( axum::extract::Query(query): axum::extract::Query, ) -> Result { ctx.db - .get_social_feed(query.limit, query.cursor.as_deref()) + .get_social_feed( + query.limit, + query.cursor.as_deref(), + query.viewer.as_deref(), + ) .await .map(PageResponse::from) .map(axum::Json) @@ -94,7 +97,7 @@ pub async fn get_post( Extension(ctx): Extension, axum::extract::Query(query): axum::extract::Query, ) -> Result { - match ctx.db.get_post(&query.uri).await { + match ctx.db.get_post(&query.uri, query.viewer.as_deref()).await { Ok(Some(post)) => Ok(axum::Json(PostResponse { post })), Ok(None) => Err((StatusCode::NOT_FOUND, "post not found".to_string())), Err(e) => Err(internal_error(e)), @@ -106,7 +109,12 @@ pub async fn get_replies( axum::extract::Query(query): axum::extract::Query, ) -> Result { ctx.db - .get_post_replies(&query.uri, query.limit, query.cursor.as_deref()) + .get_post_replies( + &query.uri, + query.limit, + query.cursor.as_deref(), + query.viewer.as_deref(), + ) .await .map(PageResponse::from) .map(axum::Json) diff --git a/todo.md b/todo.md index a437961..d293532 100644 --- a/todo.md +++ b/todo.md @@ -43,10 +43,11 @@ This file is the working handoff for the Teal-native Teal clone. Keep it updated ## Next: Current User-Reported Fixes -- [ ] Use the signed-in viewer's Teal profile display name and images in the bottom-left desktop account control instead of the viewer's Bluesky profile data. -- [ ] Add standalone permalink pages for Teal social posts so posts can be linked directly. -- [ ] Fix persisted post liked-state hydration so posts already liked by the signed-in viewer still render as liked after refresh. -- [ ] Fix cover art rendering on social posts, including posts whose attached tracks only have MusicBrainz IDs or legacy track metadata. +- [x] Use the signed-in viewer's Teal profile display name and images in the bottom-left desktop account control instead of the viewer's Bluesky profile data. +- [x] Add standalone permalink pages for Teal social posts so posts can be linked directly. +- [x] Fix persisted post liked-state hydration so posts already liked by the signed-in viewer still render as liked after refresh. +- [x] Fix cover art rendering on social posts, including posts whose attached tracks only have MusicBrainz IDs or legacy track metadata. +- [x] Prune Docker builder cache and redeploy the stable preview with rebuilt Aqua and Amethyst containers. ## Next: New Lexicon Implementation