tangled
alpha
login
or
join now
teal.fm
/
teal
110
fork
atom
Your music, beautifully tracked. All yours. (coming soon)
teal.fm
teal-fm
atproto
110
fork
atom
overview
issues
pulls
pipelines
add viewing of other profiles
Natalie B.
11 months ago
dbf0da17
5e690bf1
+318
-145
9 changed files
expand all
collapse all
unified
split
apps
amethyst
app
(tabs)
profile
[handle].tsx
search
index.tsx
auth
signup.tsx
onboarding
index.tsx
components
actor
actorView.tsx
play
actorPlaysView.tsx
ui
ago.tsx
stores
authenticationSlice.tsx
aqua
src
xrpc
feed
getActorFeed.ts
+38
apps/amethyst/app/(tabs)/profile/[handle].tsx
···
1
1
+
import ActorView from '@/components/actor/actorView';
2
2
+
import { Text } from '@/components/ui/text';
3
3
+
import { resolveHandle } from '@/lib/atp/pid';
4
4
+
import { useStore } from '@/stores/mainStore';
5
5
+
import { Stack, useLocalSearchParams } from 'expo-router';
6
6
+
import { useEffect, useState } from 'react';
7
7
+
import { ActivityIndicator, ScrollView, View } from 'react-native';
8
8
+
9
9
+
export default function Handle() {
10
10
+
let { handle } = useLocalSearchParams();
11
11
+
12
12
+
let agent = useStore((state) => state.pdsAgent);
13
13
+
14
14
+
// resolve handle
15
15
+
const [did, setDid] = useState<string | null>(null);
16
16
+
useEffect(() => {
17
17
+
const fetchAgent = async () => {
18
18
+
const agent = await resolveHandle(handle);
19
19
+
setDid(agent);
20
20
+
};
21
21
+
fetchAgent();
22
22
+
}, [handle]);
23
23
+
24
24
+
if (!did) return <ActivityIndicator size="large" color="#0000ff" />;
25
25
+
26
26
+
return (
27
27
+
<ScrollView className="flex-1 justify-start items-center gap-5 bg-background w-full">
28
28
+
<Stack.Screen
29
29
+
options={{
30
30
+
title: 'Home',
31
31
+
headerBackButtonDisplayMode: 'minimal',
32
32
+
headerShown: false,
33
33
+
}}
34
34
+
/>
35
35
+
<ActorView actorDid={did} pdsAgent={agent} />
36
36
+
</ScrollView>
37
37
+
);
38
38
+
}
+43
-2
apps/amethyst/app/(tabs)/search/index.tsx
···
1
1
import React, { useEffect, useState } from 'react';
2
2
import { ScrollView, View } from 'react-native';
3
3
-
import { Stack } from 'expo-router';
3
3
+
import { Link, Stack } from 'expo-router';
4
4
import { Input } from '@/components/ui/input';
5
5
+
import { Text } from '@/components/ui/text';
5
6
import { useStore } from '@/stores/mainStore';
6
7
7
8
import { OutputSchema as SearchActorsOutputSchema } from '@teal/lexicons/src/types/fm/teal/alpha/actor/searchActors';
8
9
import { MiniProfileView } from '@teal/lexicons/src/types/fm/teal/alpha/actor/defs';
10
10
+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
11
11
+
import getImageCdnLink from '@/lib/atp/getImageCdnLink';
9
12
10
13
export default function Search() {
11
14
const [searchQuery, setSearchQuery] = React.useState('');
···
56
59
headerShown: false,
57
60
}}
58
61
/>
59
59
-
<View className="max-w-2xl flex-1 w-screen flex flex-col p-4 divide-y divide-muted-foreground/50 gap-4 rounded-xl my-2 mx-5">
62
62
+
<View className="max-w-2xl flex-1 w-screen flex flex-col p-4 divide-y divide-muted-foreground/50 gap-4 rounded-xl my-2 mt-5">
60
63
<Input
61
64
placeholder="Search for users..."
62
65
value={searchQuery}
63
66
onChangeText={setSearchQuery}
64
67
/>
68
68
+
</View>
69
69
+
<View className="my-2 mx-5">
70
70
+
{searchResults.map((user) => (
71
71
+
<Link
72
72
+
href={`/profile/${user.handle?.replace('at://', '')}`}
73
73
+
key={user.did}
74
74
+
className="flex flex-row items-center gap-4 hover:bg-muted-foreground/20 p-2 rounded-xl"
75
75
+
>
76
76
+
<Avatar
77
77
+
alt={`${user.displayName}'s profile`}
78
78
+
className="w-14 h-14 border border-border"
79
79
+
>
80
80
+
<AvatarImage
81
81
+
source={{
82
82
+
uri:
83
83
+
user.avatar &&
84
84
+
getImageCdnLink({
85
85
+
did: user.did!,
86
86
+
hash: user.avatar,
87
87
+
}),
88
88
+
}}
89
89
+
/>
90
90
+
<AvatarFallback>
91
91
+
<Text>
92
92
+
{user.displayName?.substring(0, 1) ??
93
93
+
user.handle?.substring(0, 1) ??
94
94
+
'R'}
95
95
+
</Text>
96
96
+
</AvatarFallback>
97
97
+
</Avatar>
98
98
+
<View className="flex flex-col">
99
99
+
<Text className="font-semibold">{user.displayName}</Text>
100
100
+
<Text className="text-muted-foreground">
101
101
+
{user.handle?.replace('at://', '@')}
102
102
+
</Text>
103
103
+
</View>
104
104
+
</Link>
105
105
+
))}
65
106
</View>
66
107
</ScrollView>
67
108
);
+17
-17
apps/amethyst/app/auth/signup.tsx
···
1
1
-
import React from "react";
2
2
-
import { Platform, View } from "react-native";
3
3
-
import { SafeAreaView } from "react-native-safe-area-context";
4
4
-
import { Text } from "@/components/ui/text";
5
5
-
import { Button } from "@/components/ui/button";
6
6
-
import { Icon } from "@/lib/icons/iconWithClassName";
7
7
-
import { ArrowRight, AtSignIcon } from "lucide-react-native";
1
1
+
import React from 'react';
2
2
+
import { Platform, View } from 'react-native';
3
3
+
import { SafeAreaView } from 'react-native-safe-area-context';
4
4
+
import { Text } from '@/components/ui/text';
5
5
+
import { Button } from '@/components/ui/button';
6
6
+
import { Icon } from '@/lib/icons/iconWithClassName';
7
7
+
import { ArrowRight, AtSignIcon } from 'lucide-react-native';
8
8
9
9
-
import { Stack, router } from "expo-router";
9
9
+
import { Stack, router } from 'expo-router';
10
10
11
11
const LoginScreen = () => {
12
12
return (
13
13
<SafeAreaView className="flex-1 flex justify-center items-center">
14
14
<Stack.Screen
15
15
options={{
16
16
-
title: "Sign in",
17
17
-
headerBackButtonDisplayMode: "minimal",
16
16
+
title: 'Sign in',
17
17
+
headerBackButtonDisplayMode: 'minimal',
18
18
headerShown: false,
19
19
}}
20
20
/>
21
21
<View className="flex-1 justify-center p-8 gap-4 pb-32 w-screen max-w-md">
22
22
<Text className="text-3xl text-center text-foreground -mb-2">
23
23
-
Sign up via <br /> the{" "}
23
23
+
Sign up via <br /> the{' '}
24
24
<Icon
25
25
icon={AtSignIcon}
26
26
-
className="color-bsky inline mb-2"
26
26
+
className="color-bsky inline mb-2 mr-1.5"
27
27
size={32}
28
28
-
/>{" "}
28
28
+
/>
29
29
Atmosphere
30
30
</Text>
31
31
<Text className="text-foreground text-xl text-center">
···
43
43
<Button
44
44
onPress={() => {
45
45
// on web, open new tab
46
46
-
if (Platform.OS === "web") {
47
47
-
window.open("https://bsky.app/signup", "_blank");
46
46
+
if (Platform.OS === 'web') {
47
47
+
window.open('https://bsky.app/signup', '_blank');
48
48
} else {
49
49
-
router.navigate("https://bsky.app");
49
49
+
router.navigate('https://bsky.app');
50
50
}
51
51
setTimeout(() => {
52
52
-
router.replace("/auth/login");
52
52
+
router.replace('/auth/login');
53
53
}, 1000);
54
54
}}
55
55
className="flex flex-row justify-center items-center gap-2 bg-bsky"
+14
-6
apps/amethyst/app/onboarding/index.tsx
···
1
1
import React, { useState } from 'react';
2
2
-
import { ActivityIndicator, ScrollView, View } from 'react-native';
2
2
+
import { ActivityIndicator, View } from 'react-native';
3
3
import { Text } from '@/components/ui/text'; // Your UI components
4
4
-
import { Button } from '@/components/ui/button';
5
4
import ImageSelectionPage from './imageSelectionPage'; // Separate page components
6
5
import DisplayNamePage from './displayNamePage';
7
6
import DescriptionPage from './descriptionPage';
···
10
9
11
10
import { Record as ProfileRecord } from '@teal/lexicons/src/types/fm/teal/alpha/actor/profile';
12
11
import { useStore } from '@/stores/mainStore';
13
13
-
import { Loader } from 'lucide-react-native';
14
14
-
import { navigate } from 'expo-router/build/global-state/routing';
15
12
import { useRouter } from 'expo-router';
16
13
17
14
const OnboardingSubmissionSteps: string[] = [
···
31
28
const [bannerUri, setBannerUri] = useState('');
32
29
33
30
const [submissionStep, setSubmissionStep] = useState(1);
34
34
-
const [submissionError, setSubmissionError] = useState(0);
31
31
+
const [submissionError, setSubmissionError] = useState('');
35
32
36
33
const router = useRouter();
37
34
38
35
const agent = useStore((store) => store.pdsAgent);
36
36
+
const profile = useStore((store) => store.profiles);
39
37
40
38
const handleImageSelectionComplete = (avatar: string, banner: string) => {
41
39
setAvatarUri(avatar);
···
127
125
return <div>Loading...</div>;
128
126
}
129
127
128
128
+
// if we already have stuff then go back
129
129
+
//
130
130
+
if (profile[agent?.did!].teal) {
131
131
+
return (
132
132
+
<Text>
133
133
+
Profile already exists: {JSON.stringify(profile[agent?.did!].teal)}
134
134
+
</Text>
135
135
+
);
136
136
+
}
137
137
+
130
138
if (submissionStep) {
131
139
return (
132
140
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
133
141
<ActivityIndicator size="large" color="#0000ff" />
134
134
-
<Text>Profile updated successfully!</Text>
142
142
+
<Text>{OnboardingSubmissionSteps[submissionStep]}</Text>
135
143
</View>
136
144
);
137
145
}
+16
-6
apps/amethyst/components/actor/actorView.tsx
···
1
1
-
import { ScrollView, View, Image } from 'react-native';
1
1
+
import { View, Image } from 'react-native';
2
2
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
3
3
import { CardTitle } from '../../components/ui/card';
4
4
import { Text } from '@/components/ui/text';
···
21
21
22
22
export interface ActorViewProps {
23
23
actorDid: string;
24
24
-
pdsAgent: Agent;
24
24
+
pdsAgent: Agent | null;
25
25
}
26
26
27
27
export default function ActorView({ actorDid, pdsAgent }: ActorViewProps) {
···
36
36
let isMounted = true;
37
37
38
38
const fetchProfile = async () => {
39
39
+
if (!pdsAgent) {
40
40
+
return;
41
41
+
}
39
42
try {
40
43
let res = await pdsAgent.call(
41
44
'fm.teal.alpha.actor.getProfile',
···
58
61
};
59
62
}, [pdsAgent, actorDid, tealDid]);
60
63
61
61
-
const isSelf = actorDid === pdsAgent.did;
64
64
+
const isSelf = actorDid === (pdsAgent?.did || "");
62
65
63
66
const handleSave = async (
64
67
updatedProfile: { displayName: any; description: any },
65
68
newAvatarUri: string,
66
69
newBannerUri: string,
67
70
) => {
71
71
+
if (!pdsAgent) {
72
72
+
return;
73
73
+
}
68
74
// Implement your save logic here (e.g., update your database or state)
69
75
console.log('Saving profile:', updatedProfile, newAvatarUri, newBannerUri);
70
76
···
195
201
<Text>Edit</Text>
196
202
</Button>
197
203
) : (
198
198
-
<Button variant="outline" size="sm" className="">
204
204
+
<Button
205
205
+
variant="outline"
206
206
+
size="sm"
207
207
+
className="rounded-xl flex-row gap-2 justify-center items-center"
208
208
+
>
199
209
<Icon icon={Plus} size={18} />
200
210
<Text>Follow</Text>
201
211
</Button>
···
224
234
</View>
225
235
<View className="max-w-2xl w-full gap-4 py-4 pl-8">
226
236
<Text className="text-left text-2xl border-b border-b-muted-foreground/30 -ml-2 pl-2 mr-6">
227
227
-
Your Stamps
237
237
+
Stamps
228
238
</Text>
229
229
-
<ActorPlaysView repo={actorDid} />
239
239
+
<ActorPlaysView repo={actorDid} pdsAgent={pdsAgent} />
230
240
</View>
231
241
{isSelf && (
232
242
<EditProfileModal
+30
-30
apps/amethyst/components/play/actorPlaysView.tsx
···
1
1
-
import { useStore } from "@/stores/mainStore";
2
2
-
import { Record as Play } from "@teal/lexicons/src/types/fm/teal/alpha/feed/play";
3
3
-
import { useEffect, useState } from "react";
4
4
-
import { ScrollView } from "react-native";
5
5
-
import { Text } from "@/components/ui/text";
6
6
-
import PlayView from "./playView";
1
1
+
import { useStore } from '@/stores/mainStore';
2
2
+
import { OutputSchema as ActorFeedResponse } from '@teal/lexicons/src/types/fm/teal/alpha/feed/getActorFeed';
3
3
+
import { useEffect, useState } from 'react';
4
4
+
import { ScrollView } from 'react-native';
5
5
+
import { Text } from '@/components/ui/text';
6
6
+
import PlayView from './playView';
7
7
+
import { Agent } from '@atproto/api';
8
8
+
7
9
interface ActorPlaysViewProps {
8
10
repo: string | undefined;
9
9
-
}
10
10
-
interface PlayWrapper {
11
11
-
cid: string;
12
12
-
uri: string;
13
13
-
value: Play;
11
11
+
pdsAgent: Agent | null;
14
12
}
15
15
-
const ActorPlaysView = ({ repo }: ActorPlaysViewProps) => {
16
16
-
const [play, setPlay] = useState<PlayWrapper[] | null>(null);
17
17
-
const agent = useStore((state) => state.pdsAgent);
13
13
+
const ActorPlaysView = ({ repo, pdsAgent }: ActorPlaysViewProps) => {
14
14
+
const [play, setPlay] = useState<ActorFeedResponse['plays'] | null>(null);
18
15
const isReady = useStore((state) => state.isAgentReady);
16
16
+
const tealDid = useStore((state) => state.tealDid);
19
17
useEffect(() => {
20
20
-
if (agent) {
21
21
-
agent
22
22
-
.call("com.atproto.repo.listRecords", {
23
23
-
repo,
24
24
-
collection: "fm.teal.alpha.feed.play",
25
25
-
})
26
26
-
.then((profile) => {
27
27
-
profile.data.records as PlayWrapper[];
28
28
-
return setPlay(profile.data.records);
18
18
+
if (pdsAgent) {
19
19
+
pdsAgent
20
20
+
.call(
21
21
+
'fm.teal.alpha.feed.getActorFeed',
22
22
+
{ authorDID: repo },
23
23
+
{},
24
24
+
{ headers: { 'atproto-proxy': tealDid + '#teal_fm_appview' } },
25
25
+
)
26
26
+
.then((res) => {
27
27
+
res.data.plays as ActorFeedResponse;
28
28
+
return setPlay(res.data.plays);
29
29
})
30
30
.catch((e) => {
31
31
console.log(e);
32
32
});
33
33
} else {
34
34
-
console.log("No agent");
34
34
+
console.log('No agent');
35
35
}
36
36
-
}, [isReady, agent, repo]);
36
36
+
}, [isReady, pdsAgent, repo, tealDid]);
37
37
if (!play) {
38
38
return <Text>Loading...</Text>;
39
39
}
···
41
41
<ScrollView className="w-full *:gap-4">
42
42
{play.map((p) => (
43
43
<PlayView
44
44
-
key={p.uri}
45
45
-
releaseTitle={p.value.releaseName}
46
46
-
trackTitle={p.value.trackName}
47
47
-
artistName={p.value.artistNames.join(", ")}
48
48
-
releaseMbid={p.value.releaseMbId}
44
44
+
key={p.playedTime + p.trackName}
45
45
+
releaseTitle={p.releaseName}
46
46
+
trackTitle={p.trackName}
47
47
+
artistName={p.artistNames.join(', ')}
48
48
+
releaseMbid={p.releaseMbId}
49
49
/>
50
50
))}
51
51
</ScrollView>
+58
apps/amethyst/components/ui/ago.tsx
···
1
1
+
import { Text } from './text';
2
2
+
3
3
+
const Ago = ({ time }: { time: Date }) => {
4
4
+
return (
5
5
+
<Text className="text-gray-500 text-sm">{timeAgoSinceDate(time)}</Text>
6
6
+
);
7
7
+
};
8
8
+
9
9
+
/**
10
10
+
* Calculates a human-readable string representing how long ago a date occurred relative to now.
11
11
+
* Mimics the behavior of the provided Dart function.
12
12
+
*
13
13
+
* @param createdDate The date to compare against the current time.
14
14
+
* @param numericDates If true, uses numeric representations like "1 minute ago", otherwise uses text like "A minute ago". Defaults to true.
15
15
+
* @returns A string describing the time elapsed since the createdDate.
16
16
+
*/
17
17
+
function timeAgoSinceDate(
18
18
+
createdDate: Date,
19
19
+
numericDates: boolean = true,
20
20
+
): string {
21
21
+
const now = new Date();
22
22
+
const differenceInMs = now.getTime() - createdDate.getTime();
23
23
+
24
24
+
const seconds = Math.floor(differenceInMs / 1000);
25
25
+
const minutes = Math.floor(seconds / 60);
26
26
+
const hours = Math.floor(minutes / 60);
27
27
+
const days = Math.floor(hours / 24);
28
28
+
29
29
+
if (seconds < 5) {
30
30
+
return 'Just now';
31
31
+
} else if (seconds <= 60) {
32
32
+
return `${seconds} seconds ago`;
33
33
+
} else if (minutes <= 1) {
34
34
+
return numericDates ? '1 minute ago' : 'A minute ago';
35
35
+
} else if (minutes <= 60) {
36
36
+
return `${minutes} minutes ago`;
37
37
+
} else if (hours <= 1) {
38
38
+
return numericDates ? '1 hour ago' : 'An hour ago';
39
39
+
} else if (hours <= 60) {
40
40
+
return `${hours} hours ago`;
41
41
+
} else if (days <= 1) {
42
42
+
return numericDates ? '1 day ago' : 'Yesterday';
43
43
+
} else if (days <= 6) {
44
44
+
return `${days} days ago`;
45
45
+
} else if (Math.ceil(days / 7) <= 1) {
46
46
+
return numericDates ? '1 week ago' : 'Last week';
47
47
+
} else if (Math.ceil(days / 7) <= 4) {
48
48
+
return `${Math.ceil(days / 7)} weeks ago`;
49
49
+
} else if (Math.ceil(days / 30) <= 1) {
50
50
+
return numericDates ? '1 month ago' : 'Last month';
51
51
+
} else if (Math.ceil(days / 30) <= 30) {
52
52
+
return `${Math.ceil(days / 30)} months ago`;
53
53
+
} else if (Math.ceil(days / 365) <= 1) {
54
54
+
return numericDates ? '1 year ago' : 'Last year';
55
55
+
} else {
56
56
+
return `${Math.floor(days / 365)} years ago`;
57
57
+
}
58
58
+
}
+52
-36
apps/amethyst/stores/authenticationSlice.tsx
···
1
1
-
import { StateCreator } from "./mainStore";
2
2
-
import createOAuthClient, { AquareumOAuthClient } from "../lib/atp/oauth";
3
3
-
import { OAuthSession } from "@atproto/oauth-client";
4
4
-
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
5
5
-
import { Agent } from "@atproto/api";
6
6
-
import * as Lexicons from "@teal/lexicons/src/lexicons";
7
7
-
import { resolveFromIdentity } from "@/lib/atp/pid";
1
1
+
import { StateCreator } from './mainStore';
2
2
+
import createOAuthClient, { AquareumOAuthClient } from '../lib/atp/oauth';
3
3
+
import { OAuthSession } from '@atproto/oauth-client';
4
4
+
import { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs';
5
5
+
import { OutputSchema as GetProfileOutputSchema } from '@teal/lexicons/src/types/fm/teal/alpha/actor/getProfile';
6
6
+
import { Agent } from '@atproto/api';
7
7
+
import * as Lexicons from '@teal/lexicons/src/lexicons';
8
8
+
import { resolveFromIdentity } from '@/lib/atp/pid';
8
9
9
10
export interface AllProfileViews {
10
11
bsky: null | ProfileViewDetailed;
12
12
+
teal: null | GetProfileOutputSchema['actor'];
11
13
// todo: teal profile view
12
14
}
13
15
14
16
export interface AuthenticationSlice {
15
17
auth: AquareumOAuthClient;
16
16
-
status: "start" | "loggedIn" | "loggedOut";
18
18
+
status: 'start' | 'loggedIn' | 'loggedOut';
17
19
oauthState: null | string;
18
20
oauthSession: null | OAuthSession;
19
21
pdsAgent: null | Agent;
···
41
43
get,
42
44
) => {
43
45
// check if we have CF_PAGES_URL set. if not, use localhost
44
44
-
const baseUrl = process.env.EXPO_PUBLIC_BASE_URL || "http://localhost:8081";
45
45
-
console.log("Using base URL:", baseUrl);
46
46
-
const initialAuth = createOAuthClient(baseUrl, "bsky.social");
46
46
+
const baseUrl = process.env.EXPO_PUBLIC_BASE_URL || 'http://localhost:8081';
47
47
+
console.log('Using base URL:', baseUrl);
48
48
+
const initialAuth = createOAuthClient(baseUrl, 'bsky.social');
47
49
48
48
-
console.log("Auth client created!");
50
50
+
console.log('Auth client created!');
49
51
50
52
return {
51
53
auth: initialAuth,
52
52
-
status: "start",
54
54
+
status: 'start',
53
55
oauthState: null,
54
56
oauthSession: null,
55
57
pdsAgent: null,
···
78
80
});
79
81
return url;
80
82
} catch (error) {
81
81
-
console.error("Failed to get login URL:", error);
83
83
+
console.error('Failed to get login URL:', error);
82
84
return null;
83
85
}
84
86
},
85
87
86
88
oauthCallback: async (state: URLSearchParams) => {
87
89
try {
88
88
-
if (!(state.has("code") && state.has("state") && state.has("iss"))) {
89
89
-
throw new Error("Missing params, got: " + state);
90
90
+
if (!(state.has('code') && state.has('state') && state.has('iss'))) {
91
91
+
throw new Error('Missing params, got: ' + state);
90
92
}
91
93
// are we already logged in?
92
92
-
if (get().status === "loggedIn") {
94
94
+
if (get().status === 'loggedIn') {
93
95
return;
94
96
}
95
97
const { session, state: oauthState } =
96
98
await initialAuth.callback(state);
97
99
const agent = new Agent(session);
98
100
set({
99
99
-
oauthSession: session,
101
101
+
// TODO: fork or update auth lib
102
102
+
oauthSession: session as any,
100
103
oauthState,
101
101
-
status: "loggedIn",
104
104
+
status: 'loggedIn',
102
105
pdsAgent: addDocs(agent),
103
106
isAgentReady: true,
104
107
});
105
108
get().populateLoggedInProfile();
106
109
} catch (error: any) {
107
107
-
console.error("OAuth callback failed:", error);
110
110
+
console.error('OAuth callback failed:', error);
108
111
set({
109
109
-
status: "loggedOut",
112
112
+
status: 'loggedOut',
110
113
login: {
111
114
loading: false,
112
115
error:
113
116
(error?.message as string) ||
114
114
-
"Unknown error during OAuth callback",
117
117
+
'Unknown error during OAuth callback',
115
118
},
116
119
});
117
120
}
···
128
131
let sess = await initialAuth.restore(did);
129
132
130
133
if (!sess) {
131
131
-
throw new Error("Failed to restore session");
134
134
+
throw new Error('Failed to restore session');
132
135
}
133
136
134
137
const agent = new Agent(sess);
···
136
139
set({
137
140
pdsAgent: addDocs(agent),
138
141
isAgentReady: true,
139
139
-
status: "loggedIn",
142
142
+
status: 'loggedIn',
140
143
});
141
144
get().populateLoggedInProfile();
142
142
-
console.log("Restored agent");
145
145
+
console.log('Restored agent');
143
146
} catch (error) {
144
144
-
console.error("Failed to restore agent:", error);
147
147
+
console.error('Failed to restore agent:', error);
145
148
get().logOut();
146
149
}
147
150
},
148
151
logOut: () => {
149
149
-
console.log("Logging out");
152
152
+
console.log('Logging out');
150
153
let profiles = { ...get().profiles };
151
154
// TODO: something better than 'delete'
152
152
-
delete profiles[get().pdsAgent?.did ?? ""];
155
155
+
delete profiles[get().pdsAgent?.did ?? ''];
153
156
set({
154
154
-
status: "loggedOut",
157
157
+
status: 'loggedOut',
155
158
oauthSession: null,
156
159
oauthState: null,
157
160
profiles,
···
161
164
});
162
165
},
163
166
populateLoggedInProfile: async () => {
164
164
-
console.log("Populating logged in profile");
167
167
+
console.log('Populating logged in profile');
165
168
const agent = get().pdsAgent;
166
169
if (!agent) {
167
167
-
throw new Error("No agent");
170
170
+
throw new Error('No agent');
168
171
}
169
172
if (!agent.did) {
170
170
-
throw new Error("No agent did! This is bad!");
173
173
+
throw new Error('No agent did! This is bad!');
171
174
}
172
175
try {
173
176
let bskyProfile = await agent
···
176
179
console.log(profile);
177
180
return profile.data || null;
178
181
});
182
182
+
// get teal did
183
183
+
let tealDid = get().tealDid;
184
184
+
let tealProfile = await agent
185
185
+
.call(
186
186
+
'fm.teal.alpha.actor.getProfile',
187
187
+
{ actor: agent?.did },
188
188
+
{},
189
189
+
{ headers: { 'atproto-proxy': tealDid + '#teal_fm_appview' } },
190
190
+
)
191
191
+
.then((profile) => {
192
192
+
console.log(profile);
193
193
+
return profile.data.agent || null;
194
194
+
});
179
195
180
196
set({
181
197
profiles: {
182
182
-
[agent.did]: { bsky: bskyProfile },
198
198
+
[agent.did]: { bsky: bskyProfile, teal: tealProfile },
183
199
},
184
200
});
185
201
} catch (error) {
186
186
-
console.error("Failed to get profile:", error);
202
202
+
console.error('Failed to get profile:', error);
187
203
}
188
204
},
189
205
};
···
191
207
192
208
function addDocs(agent: Agent) {
193
209
Lexicons.schemas
194
194
-
.filter((schema) => !schema.id.startsWith("app.bsky."))
210
210
+
.filter((schema) => !schema.id.startsWith('app.bsky.'))
195
211
.map((schema) => {
196
212
try {
197
213
agent.lex.add(schema);
198
214
} catch (e) {
199
199
-
console.error("Failed to add schema:", e);
215
215
+
console.error('Failed to add schema:', e);
200
216
}
201
217
});
202
218
return agent;
+50
-48
apps/aqua/src/xrpc/feed/getActorFeed.ts
···
1
1
-
import { TealContext } from "@/ctx";
2
2
-
import { artists, db, plays, playToArtists } from "@teal/db";
3
3
-
import { eq, and, lt, desc, sql } from "drizzle-orm";
4
4
-
import { OutputSchema } from "@teal/lexicons/src/types/fm/teal/alpha/feed/getActorFeed";
1
1
+
import { TealContext } from '@/ctx';
2
2
+
import { artists, db, plays, playToArtists } from '@teal/db';
3
3
+
import { eq, and, lt, desc, sql } from 'drizzle-orm';
4
4
+
import { OutputSchema } from '@teal/lexicons/src/types/fm/teal/alpha/feed/getActorFeed';
5
5
6
6
export default async function getActorFeed(c: TealContext) {
7
7
const params = c.req.query();
8
8
-
if (!params.authorDid) {
9
9
-
throw new Error("authorDid is required");
8
8
+
if (!params.authorDID) {
9
9
+
throw new Error('authorDID is required');
10
10
}
11
11
12
12
let limit = 20;
13
13
14
14
if (params.limit) {
15
15
limit = Number(params.limit);
16
16
-
if (limit > 50) throw new Error("Limit is over max allowed.");
16
16
+
if (limit > 50) throw new Error('Limit is over max allowed.');
17
17
}
18
18
19
19
// 'and' is here for typing reasons
20
20
-
let whereClause = and(eq(plays.did, params.authorDid));
20
20
+
let whereClause = and(eq(plays.did, params.authorDID));
21
21
22
22
// Add cursor pagination if provided
23
23
if (params.cursor) {
···
30
30
const cursorPlay = cursorResult[0]?.playedTime;
31
31
32
32
if (!cursorPlay) {
33
33
-
throw new Error("Cursor not found");
33
33
+
throw new Error('Cursor not found');
34
34
}
35
35
36
36
whereClause = and(whereClause, lt(plays.playedTime, cursorPlay as any));
···
53
53
submissionClientAgent: plays.submissionClientAgent,
54
54
musicServiceBaseDomain: plays.musicServiceBaseDomain,
55
55
artists: sql<Array<{ mbid: string; name: string }>>`
56
56
-
COALESCE
57
57
-
array_agg(
58
58
-
CASE WHEN ${playToArtists.artistMbid} IS NOT NULL THEN
59
59
-
jsonb_build_object(
60
60
-
'mbid', ${playToArtists.artistMbid},
61
61
-
'name', ${playToArtists.artistName}
62
62
-
)
63
63
-
END
64
64
-
) FILTER (WHERE ${playToArtists.artistName} IS NOT NULL),
65
65
-
ARRAY[]::jsonb[]
66
66
-
)
67
67
-
`.as("artists"),
56
56
+
COALESCE(
57
57
+
(
58
58
+
SELECT jsonb_agg(jsonb_build_object('mbid', pa.artist_mbid, 'name', pa.artist_name))
59
59
+
FROM ${playToArtists} pa
60
60
+
WHERE pa.play_uri = ${plays.uri}
61
61
+
AND pa.artist_mbid IS NOT NULL
62
62
+
AND pa.artist_name IS NOT NULL -- Ensure both are non-null
63
63
+
),
64
64
+
'[]'::jsonb -- Correct empty JSONB array literal
65
65
+
)`.as('artists'),
68
66
})
69
67
.from(plays)
70
68
.leftJoin(playToArtists, sql`${plays.uri} = ${playToArtists.playUri}`)
···
88
86
)
89
87
.orderBy(desc(plays.playedTime))
90
88
.limit(limit);
91
91
-
92
92
-
if (playRes.length === 0) {
93
93
-
throw new Error("Play not found");
94
94
-
}
89
89
+
const cursor =
90
90
+
playRes.length === limit ? playRes[playRes.length - 1]?.uri : undefined;
95
91
96
92
return {
93
93
+
cursor: cursor ?? undefined, // Ensure cursor itself can be undefined
97
94
plays: playRes.map(
98
95
({
99
99
-
uri,
100
100
-
did: authorDid,
101
101
-
processedTime: createdAt,
102
102
-
processedTime: indexedAt,
96
96
+
// Destructure fields from the DB result
103
97
trackName,
104
104
-
cid: trackMbId,
98
98
+
cid: trackMbId, // Note the alias was used here in the DB query select
105
99
recordingMbid,
106
100
duration,
107
107
-
artists,
101
101
+
artists, // This is guaranteed to be an array '[]' if no artists, due to COALESCE
108
102
releaseName,
109
103
releaseMbid,
110
104
isrc,
···
112
106
musicServiceBaseDomain,
113
107
submissionClientAgent,
114
108
playedTime,
109
109
+
// Other destructured fields like uri, did, etc. are not directly used here by name
115
110
}) => ({
116
116
-
uri,
117
117
-
authorDid,
118
118
-
createdAt: createdAt?.toISOString(),
119
119
-
indexedAt: indexedAt?.toISOString(),
120
120
-
trackName,
121
121
-
trackMbId,
122
122
-
recordingMbId: recordingMbid,
123
123
-
duration,
124
124
-
artistNames: artists.map((artist) => artist.name),
125
125
-
artistMbIds: artists.map((artist) => artist.mbid),
126
126
-
releaseName,
127
127
-
releaseMbId: releaseMbid,
128
128
-
isrc,
129
129
-
originUrl,
130
130
-
musicServiceBaseDomain,
131
131
-
submissionClientAgent,
132
132
-
playedTime: playedTime?.toISOString(),
111
111
+
// Apply '?? undefined' to each potentially nullable/undefined scalar field
112
112
+
trackName: trackName ?? undefined,
113
113
+
trackMbId: trackMbId ?? undefined,
114
114
+
recordingMbId: recordingMbid ?? undefined,
115
115
+
duration: duration ?? undefined,
116
116
+
117
117
+
// For arrays derived from a guaranteed array, map is safe.
118
118
+
// The SQL query ensures `artists` is '[]'::jsonb if empty.
119
119
+
// The SQL query also ensures artist.name/mbid are NOT NULL within the jsonb_agg
120
120
+
artistNames: artists.map((artist) => artist.name), // Will be [] if artists is []
121
121
+
artistMbIds: artists.map((artist) => artist.mbid), // Will be [] if artists is []
122
122
+
123
123
+
releaseName: releaseName ?? undefined,
124
124
+
releaseMbId: releaseMbid ?? undefined,
125
125
+
isrc: isrc ?? undefined,
126
126
+
originUrl: originUrl ?? undefined,
127
127
+
musicServiceBaseDomain: musicServiceBaseDomain ?? undefined,
128
128
+
submissionClientAgent: submissionClientAgent ?? undefined,
129
129
+
130
130
+
// playedTime specific handling: convert to ISO string if exists, else undefined
131
131
+
playedTime: playedTime ? playedTime.toISOString() : undefined,
132
132
+
// Alternative using optional chaining (effectively the same)
133
133
+
// playedTime: playedTime?.toISOString(),
133
134
}),
134
135
),
136
136
+
// Explicitly cast to OutputSchema. Make sure OutputSchema allows undefined for these fields.
135
137
} as OutputSchema;
136
138
}