From dbf0da171355cfca9e522678a2e3fa591fc64987 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Thu, 27 Mar 2025 22:09:46 -0500 Subject: [PATCH] add viewing of other profiles --- apps/amethyst/app/(tabs)/profile/[handle].tsx | 38 +++++++ apps/amethyst/app/(tabs)/search/index.tsx | 45 ++++++++- apps/amethyst/app/auth/signup.tsx | 34 +++---- apps/amethyst/app/onboarding/index.tsx | 20 ++-- apps/amethyst/components/actor/actorView.tsx | 22 +++-- .../components/play/actorPlaysView.tsx | 60 ++++++------ apps/amethyst/components/ui/ago.tsx | 58 +++++++++++ apps/amethyst/stores/authenticationSlice.tsx | 88 ++++++++++------- apps/aqua/src/xrpc/feed/getActorFeed.ts | 98 ++++++++++--------- 9 files changed, 318 insertions(+), 145 deletions(-) create mode 100644 apps/amethyst/app/(tabs)/profile/[handle].tsx create mode 100644 apps/amethyst/components/ui/ago.tsx diff --git a/apps/amethyst/app/(tabs)/profile/[handle].tsx b/apps/amethyst/app/(tabs)/profile/[handle].tsx new file mode 100644 index 0000000..7030279 --- /dev/null +++ b/apps/amethyst/app/(tabs)/profile/[handle].tsx @@ -0,0 +1,38 @@ +import ActorView from '@/components/actor/actorView'; +import { Text } from '@/components/ui/text'; +import { resolveHandle } from '@/lib/atp/pid'; +import { useStore } from '@/stores/mainStore'; +import { Stack, useLocalSearchParams } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { ActivityIndicator, ScrollView, View } from 'react-native'; + +export default function Handle() { + let { handle } = useLocalSearchParams(); + + let agent = useStore((state) => state.pdsAgent); + + // resolve handle + const [did, setDid] = useState(null); + useEffect(() => { + const fetchAgent = async () => { + const agent = await resolveHandle(handle); + setDid(agent); + }; + fetchAgent(); + }, [handle]); + + if (!did) return ; + + return ( + + + + + ); +} diff --git a/apps/amethyst/app/(tabs)/search/index.tsx b/apps/amethyst/app/(tabs)/search/index.tsx index fd74d2c..1a1b578 100644 --- a/apps/amethyst/app/(tabs)/search/index.tsx +++ b/apps/amethyst/app/(tabs)/search/index.tsx @@ -1,11 +1,14 @@ import React, { useEffect, useState } from 'react'; import { ScrollView, View } from 'react-native'; -import { Stack } from 'expo-router'; +import { Link, Stack } from 'expo-router'; import { Input } from '@/components/ui/input'; +import { Text } from '@/components/ui/text'; import { useStore } from '@/stores/mainStore'; import { OutputSchema as SearchActorsOutputSchema } from '@teal/lexicons/src/types/fm/teal/alpha/actor/searchActors'; import { MiniProfileView } from '@teal/lexicons/src/types/fm/teal/alpha/actor/defs'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import getImageCdnLink from '@/lib/atp/getImageCdnLink'; export default function Search() { const [searchQuery, setSearchQuery] = React.useState(''); @@ -56,13 +59,51 @@ export default function Search() { headerShown: false, }} /> - + + + {searchResults.map((user) => ( + + + + + + {user.displayName?.substring(0, 1) ?? + user.handle?.substring(0, 1) ?? + 'R'} + + + + + {user.displayName} + + {user.handle?.replace('at://', '@')} + + + + ))} + ); } diff --git a/apps/amethyst/app/auth/signup.tsx b/apps/amethyst/app/auth/signup.tsx index 523b370..aac268c 100644 --- a/apps/amethyst/app/auth/signup.tsx +++ b/apps/amethyst/app/auth/signup.tsx @@ -1,31 +1,31 @@ -import React from "react"; -import { Platform, View } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { Text } from "@/components/ui/text"; -import { Button } from "@/components/ui/button"; -import { Icon } from "@/lib/icons/iconWithClassName"; -import { ArrowRight, AtSignIcon } from "lucide-react-native"; +import React from 'react'; +import { Platform, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Icon } from '@/lib/icons/iconWithClassName'; +import { ArrowRight, AtSignIcon } from 'lucide-react-native'; -import { Stack, router } from "expo-router"; +import { Stack, router } from 'expo-router'; const LoginScreen = () => { return ( - Sign up via
the{" "} + Sign up via
the{' '} {" "} + /> Atmosphere
@@ -43,13 +43,13 @@ const LoginScreen = () => { ) : ( - @@ -224,9 +234,9 @@ export default function ActorView({ actorDid, pdsAgent }: ActorViewProps) {
- Your Stamps + Stamps - + {isSelf && ( { - const [play, setPlay] = useState(null); - const agent = useStore((state) => state.pdsAgent); +const ActorPlaysView = ({ repo, pdsAgent }: ActorPlaysViewProps) => { + const [play, setPlay] = useState(null); const isReady = useStore((state) => state.isAgentReady); + const tealDid = useStore((state) => state.tealDid); useEffect(() => { - if (agent) { - agent - .call("com.atproto.repo.listRecords", { - repo, - collection: "fm.teal.alpha.feed.play", - }) - .then((profile) => { - profile.data.records as PlayWrapper[]; - return setPlay(profile.data.records); + if (pdsAgent) { + pdsAgent + .call( + 'fm.teal.alpha.feed.getActorFeed', + { authorDID: repo }, + {}, + { headers: { 'atproto-proxy': tealDid + '#teal_fm_appview' } }, + ) + .then((res) => { + res.data.plays as ActorFeedResponse; + return setPlay(res.data.plays); }) .catch((e) => { console.log(e); }); } else { - console.log("No agent"); + console.log('No agent'); } - }, [isReady, agent, repo]); + }, [isReady, pdsAgent, repo, tealDid]); if (!play) { return Loading...; } @@ -41,11 +41,11 @@ const ActorPlaysView = ({ repo }: ActorPlaysViewProps) => { {play.map((p) => ( ))} diff --git a/apps/amethyst/components/ui/ago.tsx b/apps/amethyst/components/ui/ago.tsx new file mode 100644 index 0000000..24f20c3 --- /dev/null +++ b/apps/amethyst/components/ui/ago.tsx @@ -0,0 +1,58 @@ +import { Text } from './text'; + +const Ago = ({ time }: { time: Date }) => { + return ( + {timeAgoSinceDate(time)} + ); +}; + +/** + * Calculates a human-readable string representing how long ago a date occurred relative to now. + * Mimics the behavior of the provided Dart function. + * + * @param createdDate The date to compare against the current time. + * @param numericDates If true, uses numeric representations like "1 minute ago", otherwise uses text like "A minute ago". Defaults to true. + * @returns A string describing the time elapsed since the createdDate. + */ +function timeAgoSinceDate( + createdDate: Date, + numericDates: boolean = true, +): string { + const now = new Date(); + const differenceInMs = now.getTime() - createdDate.getTime(); + + const seconds = Math.floor(differenceInMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 5) { + return 'Just now'; + } else if (seconds <= 60) { + return `${seconds} seconds ago`; + } else if (minutes <= 1) { + return numericDates ? '1 minute ago' : 'A minute ago'; + } else if (minutes <= 60) { + return `${minutes} minutes ago`; + } else if (hours <= 1) { + return numericDates ? '1 hour ago' : 'An hour ago'; + } else if (hours <= 60) { + return `${hours} hours ago`; + } else if (days <= 1) { + return numericDates ? '1 day ago' : 'Yesterday'; + } else if (days <= 6) { + return `${days} days ago`; + } else if (Math.ceil(days / 7) <= 1) { + return numericDates ? '1 week ago' : 'Last week'; + } else if (Math.ceil(days / 7) <= 4) { + return `${Math.ceil(days / 7)} weeks ago`; + } else if (Math.ceil(days / 30) <= 1) { + return numericDates ? '1 month ago' : 'Last month'; + } else if (Math.ceil(days / 30) <= 30) { + return `${Math.ceil(days / 30)} months ago`; + } else if (Math.ceil(days / 365) <= 1) { + return numericDates ? '1 year ago' : 'Last year'; + } else { + return `${Math.floor(days / 365)} years ago`; + } +} diff --git a/apps/amethyst/stores/authenticationSlice.tsx b/apps/amethyst/stores/authenticationSlice.tsx index 681a7e4..d76eb81 100644 --- a/apps/amethyst/stores/authenticationSlice.tsx +++ b/apps/amethyst/stores/authenticationSlice.tsx @@ -1,19 +1,21 @@ -import { StateCreator } from "./mainStore"; -import createOAuthClient, { AquareumOAuthClient } from "../lib/atp/oauth"; -import { OAuthSession } from "@atproto/oauth-client"; -import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; -import { Agent } from "@atproto/api"; -import * as Lexicons from "@teal/lexicons/src/lexicons"; -import { resolveFromIdentity } from "@/lib/atp/pid"; +import { StateCreator } from './mainStore'; +import createOAuthClient, { AquareumOAuthClient } from '../lib/atp/oauth'; +import { OAuthSession } from '@atproto/oauth-client'; +import { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; +import { OutputSchema as GetProfileOutputSchema } from '@teal/lexicons/src/types/fm/teal/alpha/actor/getProfile'; +import { Agent } from '@atproto/api'; +import * as Lexicons from '@teal/lexicons/src/lexicons'; +import { resolveFromIdentity } from '@/lib/atp/pid'; export interface AllProfileViews { bsky: null | ProfileViewDetailed; + teal: null | GetProfileOutputSchema['actor']; // todo: teal profile view } export interface AuthenticationSlice { auth: AquareumOAuthClient; - status: "start" | "loggedIn" | "loggedOut"; + status: 'start' | 'loggedIn' | 'loggedOut'; oauthState: null | string; oauthSession: null | OAuthSession; pdsAgent: null | Agent; @@ -41,15 +43,15 @@ export const createAuthenticationSlice: StateCreator = ( get, ) => { // check if we have CF_PAGES_URL set. if not, use localhost - const baseUrl = process.env.EXPO_PUBLIC_BASE_URL || "http://localhost:8081"; - console.log("Using base URL:", baseUrl); - const initialAuth = createOAuthClient(baseUrl, "bsky.social"); + const baseUrl = process.env.EXPO_PUBLIC_BASE_URL || 'http://localhost:8081'; + console.log('Using base URL:', baseUrl); + const initialAuth = createOAuthClient(baseUrl, 'bsky.social'); - console.log("Auth client created!"); + console.log('Auth client created!'); return { auth: initialAuth, - status: "start", + status: 'start', oauthState: null, oauthSession: null, pdsAgent: null, @@ -78,40 +80,41 @@ export const createAuthenticationSlice: StateCreator = ( }); return url; } catch (error) { - console.error("Failed to get login URL:", error); + console.error('Failed to get login URL:', error); return null; } }, oauthCallback: async (state: URLSearchParams) => { try { - if (!(state.has("code") && state.has("state") && state.has("iss"))) { - throw new Error("Missing params, got: " + state); + if (!(state.has('code') && state.has('state') && state.has('iss'))) { + throw new Error('Missing params, got: ' + state); } // are we already logged in? - if (get().status === "loggedIn") { + if (get().status === 'loggedIn') { return; } const { session, state: oauthState } = await initialAuth.callback(state); const agent = new Agent(session); set({ - oauthSession: session, + // TODO: fork or update auth lib + oauthSession: session as any, oauthState, - status: "loggedIn", + status: 'loggedIn', pdsAgent: addDocs(agent), isAgentReady: true, }); get().populateLoggedInProfile(); } catch (error: any) { - console.error("OAuth callback failed:", error); + console.error('OAuth callback failed:', error); set({ - status: "loggedOut", + status: 'loggedOut', login: { loading: false, error: (error?.message as string) || - "Unknown error during OAuth callback", + 'Unknown error during OAuth callback', }, }); } @@ -128,7 +131,7 @@ export const createAuthenticationSlice: StateCreator = ( let sess = await initialAuth.restore(did); if (!sess) { - throw new Error("Failed to restore session"); + throw new Error('Failed to restore session'); } const agent = new Agent(sess); @@ -136,22 +139,22 @@ export const createAuthenticationSlice: StateCreator = ( set({ pdsAgent: addDocs(agent), isAgentReady: true, - status: "loggedIn", + status: 'loggedIn', }); get().populateLoggedInProfile(); - console.log("Restored agent"); + console.log('Restored agent'); } catch (error) { - console.error("Failed to restore agent:", error); + console.error('Failed to restore agent:', error); get().logOut(); } }, logOut: () => { - console.log("Logging out"); + console.log('Logging out'); let profiles = { ...get().profiles }; // TODO: something better than 'delete' - delete profiles[get().pdsAgent?.did ?? ""]; + delete profiles[get().pdsAgent?.did ?? '']; set({ - status: "loggedOut", + status: 'loggedOut', oauthSession: null, oauthState: null, profiles, @@ -161,13 +164,13 @@ export const createAuthenticationSlice: StateCreator = ( }); }, populateLoggedInProfile: async () => { - console.log("Populating logged in profile"); + console.log('Populating logged in profile'); const agent = get().pdsAgent; if (!agent) { - throw new Error("No agent"); + throw new Error('No agent'); } if (!agent.did) { - throw new Error("No agent did! This is bad!"); + throw new Error('No agent did! This is bad!'); } try { let bskyProfile = await agent @@ -176,14 +179,27 @@ export const createAuthenticationSlice: StateCreator = ( console.log(profile); return profile.data || null; }); + // get teal did + let tealDid = get().tealDid; + let tealProfile = await agent + .call( + 'fm.teal.alpha.actor.getProfile', + { actor: agent?.did }, + {}, + { headers: { 'atproto-proxy': tealDid + '#teal_fm_appview' } }, + ) + .then((profile) => { + console.log(profile); + return profile.data.agent || null; + }); set({ profiles: { - [agent.did]: { bsky: bskyProfile }, + [agent.did]: { bsky: bskyProfile, teal: tealProfile }, }, }); } catch (error) { - console.error("Failed to get profile:", error); + console.error('Failed to get profile:', error); } }, }; @@ -191,12 +207,12 @@ export const createAuthenticationSlice: StateCreator = ( function addDocs(agent: Agent) { Lexicons.schemas - .filter((schema) => !schema.id.startsWith("app.bsky.")) + .filter((schema) => !schema.id.startsWith('app.bsky.')) .map((schema) => { try { agent.lex.add(schema); } catch (e) { - console.error("Failed to add schema:", e); + console.error('Failed to add schema:', e); } }); return agent; diff --git a/apps/aqua/src/xrpc/feed/getActorFeed.ts b/apps/aqua/src/xrpc/feed/getActorFeed.ts index 37c4862..106c120 100644 --- a/apps/aqua/src/xrpc/feed/getActorFeed.ts +++ b/apps/aqua/src/xrpc/feed/getActorFeed.ts @@ -1,23 +1,23 @@ -import { TealContext } from "@/ctx"; -import { artists, db, plays, playToArtists } from "@teal/db"; -import { eq, and, lt, desc, sql } from "drizzle-orm"; -import { OutputSchema } from "@teal/lexicons/src/types/fm/teal/alpha/feed/getActorFeed"; +import { TealContext } from '@/ctx'; +import { artists, db, plays, playToArtists } from '@teal/db'; +import { eq, and, lt, desc, sql } from 'drizzle-orm'; +import { OutputSchema } from '@teal/lexicons/src/types/fm/teal/alpha/feed/getActorFeed'; export default async function getActorFeed(c: TealContext) { const params = c.req.query(); - if (!params.authorDid) { - throw new Error("authorDid is required"); + if (!params.authorDID) { + throw new Error('authorDID is required'); } let limit = 20; if (params.limit) { limit = Number(params.limit); - if (limit > 50) throw new Error("Limit is over max allowed."); + if (limit > 50) throw new Error('Limit is over max allowed.'); } // 'and' is here for typing reasons - let whereClause = and(eq(plays.did, params.authorDid)); + let whereClause = and(eq(plays.did, params.authorDID)); // Add cursor pagination if provided if (params.cursor) { @@ -30,7 +30,7 @@ export default async function getActorFeed(c: TealContext) { const cursorPlay = cursorResult[0]?.playedTime; if (!cursorPlay) { - throw new Error("Cursor not found"); + throw new Error('Cursor not found'); } whereClause = and(whereClause, lt(plays.playedTime, cursorPlay as any)); @@ -53,18 +53,16 @@ export default async function getActorFeed(c: TealContext) { submissionClientAgent: plays.submissionClientAgent, musicServiceBaseDomain: plays.musicServiceBaseDomain, artists: sql>` -COALESCE -array_agg( -CASE WHEN ${playToArtists.artistMbid} IS NOT NULL THEN - jsonb_build_object( - 'mbid', ${playToArtists.artistMbid}, - 'name', ${playToArtists.artistName} - ) -END -) FILTER (WHERE ${playToArtists.artistName} IS NOT NULL), -ARRAY[]::jsonb[] -) -`.as("artists"), + COALESCE( + ( + SELECT jsonb_agg(jsonb_build_object('mbid', pa.artist_mbid, 'name', pa.artist_name)) + FROM ${playToArtists} pa + WHERE pa.play_uri = ${plays.uri} + AND pa.artist_mbid IS NOT NULL + AND pa.artist_name IS NOT NULL -- Ensure both are non-null + ), + '[]'::jsonb -- Correct empty JSONB array literal + )`.as('artists'), }) .from(plays) .leftJoin(playToArtists, sql`${plays.uri} = ${playToArtists.playUri}`) @@ -88,23 +86,19 @@ ARRAY[]::jsonb[] ) .orderBy(desc(plays.playedTime)) .limit(limit); - - if (playRes.length === 0) { - throw new Error("Play not found"); - } + const cursor = + playRes.length === limit ? playRes[playRes.length - 1]?.uri : undefined; return { + cursor: cursor ?? undefined, // Ensure cursor itself can be undefined plays: playRes.map( ({ - uri, - did: authorDid, - processedTime: createdAt, - processedTime: indexedAt, + // Destructure fields from the DB result trackName, - cid: trackMbId, + cid: trackMbId, // Note the alias was used here in the DB query select recordingMbid, duration, - artists, + artists, // This is guaranteed to be an array '[]' if no artists, due to COALESCE releaseName, releaseMbid, isrc, @@ -112,25 +106,33 @@ ARRAY[]::jsonb[] musicServiceBaseDomain, submissionClientAgent, playedTime, + // Other destructured fields like uri, did, etc. are not directly used here by name }) => ({ - uri, - authorDid, - createdAt: createdAt?.toISOString(), - indexedAt: indexedAt?.toISOString(), - trackName, - trackMbId, - recordingMbId: recordingMbid, - duration, - artistNames: artists.map((artist) => artist.name), - artistMbIds: artists.map((artist) => artist.mbid), - releaseName, - releaseMbId: releaseMbid, - isrc, - originUrl, - musicServiceBaseDomain, - submissionClientAgent, - playedTime: playedTime?.toISOString(), + // Apply '?? undefined' to each potentially nullable/undefined scalar field + trackName: trackName ?? undefined, + trackMbId: trackMbId ?? undefined, + recordingMbId: recordingMbid ?? undefined, + duration: duration ?? undefined, + + // For arrays derived from a guaranteed array, map is safe. + // The SQL query ensures `artists` is '[]'::jsonb if empty. + // The SQL query also ensures artist.name/mbid are NOT NULL within the jsonb_agg + artistNames: artists.map((artist) => artist.name), // Will be [] if artists is [] + artistMbIds: artists.map((artist) => artist.mbid), // Will be [] if artists is [] + + releaseName: releaseName ?? undefined, + releaseMbId: releaseMbid ?? undefined, + isrc: isrc ?? undefined, + originUrl: originUrl ?? undefined, + musicServiceBaseDomain: musicServiceBaseDomain ?? undefined, + submissionClientAgent: submissionClientAgent ?? undefined, + + // playedTime specific handling: convert to ISO string if exists, else undefined + playedTime: playedTime ? playedTime.toISOString() : undefined, + // Alternative using optional chaining (effectively the same) + // playedTime: playedTime?.toISOString(), }), ), + // Explicitly cast to OutputSchema. Make sure OutputSchema allows undefined for these fields. } as OutputSchema; } -- 2.51.2