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