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 local react ctx for manual stamping flow
Natalie B.
1 year ago
71118aca
af1059e4
+256
-28
5 changed files
expand all
collapse all
unified
split
apps
amethyst
app
(tabs)
(stamp)
_layout.tsx
stamp
_layout.tsx
index.tsx
submit.tsx
success.tsx
+1
-1
apps/amethyst/app/(tabs)/(stamp)/_layout.tsx
···
21
}
22
}, [segment]);
23
24
-
return <Stack>{rootScreen}</Stack>;
25
};
26
27
export default Layout;
···
21
}
22
}, [segment]);
23
24
+
return <Stack screenOptions={{ headerShown: false }}>{rootScreen}</Stack>;
25
};
26
27
export default Layout;
+37
apps/amethyst/app/(tabs)/(stamp)/stamp/_layout.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { MusicBrainzRecording, PlaySubmittedData } from "@/lib/oldStamp";
2
+
import { Slot, Stack } from "expo-router";
3
+
import { createContext, useState } from "react";
4
+
5
+
export enum StampStep {
6
+
IDLE = "IDLE",
7
+
SUBMITTING = "SUBMITTING",
8
+
SUBMITTED = "SUBMITTED",
9
+
}
10
+
11
+
export type StampContextState =
12
+
| { step: StampStep.IDLE; resetSearchState: boolean }
13
+
| { step: StampStep.SUBMITTING; submittingStamp: MusicBrainzRecording }
14
+
| { step: StampStep.SUBMITTED; submittedStamp: PlaySubmittedData };
15
+
16
+
export type StampContextValue = {
17
+
state: StampContextState;
18
+
setState: React.Dispatch<React.SetStateAction<StampContextState>>;
19
+
};
20
+
21
+
export const StampContext = createContext<StampContextValue | null>(null);
22
+
23
+
const Layout = ({ segment }: { segment: string }) => {
24
+
const [state, setState] = useState<StampContextState>({
25
+
step: StampStep.IDLE,
26
+
resetSearchState: false,
27
+
});
28
+
return (
29
+
<StampContext.Provider value={{ state, setState }}>
30
+
<Stack>
31
+
<Slot />
32
+
</Stack>
33
+
</StampContext.Provider>
34
+
);
35
+
};
36
+
37
+
export default Layout;
+25
-8
apps/amethyst/app/(tabs)/(stamp)/stamp/index.tsx
···
3
import { Stack, useRouter } from "expo-router";
4
import { Check, ChevronDown, ChevronRight } from "lucide-react-native";
5
6
-
import React, { useRef, useState } from "react";
7
import {
8
FlatList,
9
Image,
···
22
SearchResultProps,
23
} from "@/lib/oldStamp";
24
import { BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
25
-
import SheetBackdrop from "@/components/ui/sheetBackdrop";
0
26
27
export default function StepOne() {
28
const router = useRouter();
0
0
29
const [selectedTrack, setSelectedTrack] =
30
useState<MusicBrainzRecording | null>(null);
31
···
41
const [releaseSelections, setReleaseSelections] = useState<ReleaseSelections>(
42
{},
43
);
0
0
0
0
0
0
0
0
0
0
44
45
const handleSearch = async (): Promise<void> => {
46
if (!searchFields.track && !searchFields.artist && !searchFields.release) {
···
148
{selectedTrack && (
149
<View className="mt-4 sticky bottom-0">
150
<Button
151
-
onPress={() =>
0
0
0
0
152
router.push({
153
pathname: "/stamp/submit",
154
-
params: { track: JSON.stringify(selectedTrack) },
155
-
})
156
-
}
157
className="w-full flex flex-row align-middle"
158
>
159
<Text>{`Submit "${selectedTrack.title}" as Play`}</Text>
···
258
enableDynamicSizing={true}
259
detached={true}
260
backdropComponent={SheetBackdrop}
0
261
>
262
-
<View className="pb-4 border-b border-gray-200 -mt-2">
263
<Text className="text-lg font-bold text-center">Select Release</Text>
264
<TouchableOpacity
265
className="absolute right-4 top-1.5"
···
268
<Text className="text-primary">Done</Text>
269
</TouchableOpacity>
270
</View>
271
-
<BottomSheetScrollView className="bg-card min-h-64">
272
{result.releases?.map((release) => (
273
<TouchableOpacity
274
key={release.id}
···
3
import { Stack, useRouter } from "expo-router";
4
import { Check, ChevronDown, ChevronRight } from "lucide-react-native";
5
6
+
import React, { useContext, useEffect, useRef, useState } from "react";
7
import {
8
FlatList,
9
Image,
···
22
SearchResultProps,
23
} from "@/lib/oldStamp";
24
import { BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
25
+
import SheetBackdrop, { SheetHandle } from "@/components/ui/sheetBackdrop";
26
+
import { StampContext, StampContextValue, StampStep } from "./_layout";
27
28
export default function StepOne() {
29
const router = useRouter();
30
+
const ctx = useContext(StampContext);
31
+
const { state, setState } = ctx as StampContextValue;
32
const [selectedTrack, setSelectedTrack] =
33
useState<MusicBrainzRecording | null>(null);
34
···
44
const [releaseSelections, setReleaseSelections] = useState<ReleaseSelections>(
45
{},
46
);
47
+
48
+
// reset search state if requested
49
+
useEffect(() => {
50
+
if (state.step === StampStep.IDLE && state.resetSearchState) {
51
+
setSearchFields({ track: "", artist: "", release: "" });
52
+
setSearchResults([]);
53
+
setSelectedTrack(null);
54
+
setReleaseSelections({});
55
+
}
56
+
}, [state]);
57
58
const handleSearch = async (): Promise<void> => {
59
if (!searchFields.track && !searchFields.artist && !searchFields.release) {
···
161
{selectedTrack && (
162
<View className="mt-4 sticky bottom-0">
163
<Button
164
+
onPress={() => {
165
+
setState({
166
+
step: StampStep.SUBMITTING,
167
+
submittingStamp: selectedTrack,
168
+
});
169
router.push({
170
pathname: "/stamp/submit",
171
+
});
172
+
}}
0
173
className="w-full flex flex-row align-middle"
174
>
175
<Text>{`Submit "${selectedTrack.title}" as Play`}</Text>
···
274
enableDynamicSizing={true}
275
detached={true}
276
backdropComponent={SheetBackdrop}
277
+
handleComponent={SheetHandle}
278
>
279
+
<View className="pb-4 border-b -mt-2 bg-background border-x border-neutral-500/30">
280
<Text className="text-lg font-bold text-center">Select Release</Text>
281
<TouchableOpacity
282
className="absolute right-4 top-1.5"
···
285
<Text className="text-primary">Done</Text>
286
</TouchableOpacity>
287
</View>
288
+
<BottomSheetScrollView className="bg-card min-h-64 border-x border-neutral-500/30">
289
{result.releases?.map((release) => (
290
<TouchableOpacity
291
key={release.id}
+159
-12
apps/amethyst/app/(tabs)/(stamp)/stamp/submit.tsx
···
1
import VerticalPlayView from "@/components/play/verticalPlayView";
2
import { Button } from "@/components/ui/button";
3
import { useStore } from "@/stores/mainStore";
4
-
import { ComAtprotoRepoCreateRecord } from "@atproto/api";
5
import {
6
Record as PlayRecord,
7
validateRecord,
8
} from "@teal/lexicons/src/types/fm/teal/alpha/feed/play";
9
-
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
10
-
import { useState } from "react";
11
import { Switch, View } from "react-native";
12
import { MusicBrainzRecording, PlaySubmittedData } from "@/lib/oldStamp";
13
import { Text } from "@/components/ui/text";
14
import { ExternalLink } from "@/components/ExternalLink";
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
15
16
const createPlayRecord = (result: MusicBrainzRecording): PlayRecord => {
17
let artistNames: string[] = [];
···
41
export default function Submit() {
42
const router = useRouter();
43
const agent = useStore((state) => state.pdsAgent);
44
-
// awful awful awful!
45
-
// I don't wanna use global state for something like this though!
46
-
const { track } = useLocalSearchParams();
47
-
48
-
const selectedTrack: MusicBrainzRecording | null = JSON.parse(
49
-
track as string,
50
-
);
51
52
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
53
const [shareWithBluesky, setShareWithBluesky] = useState<boolean>(false);
54
0
0
0
0
0
0
0
0
0
55
if (selectedTrack === null) {
56
return <Text>No track selected</Text>;
57
}
58
0
59
const handleSubmit = async () => {
60
setIsSubmitting(true);
61
try {
···
85
playRecord: record,
86
blueskyPostUrl: null,
87
};
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
88
router.replace({
89
pathname: "/stamp/success",
90
-
params: { submittedData: JSON.stringify(submittedData) },
91
});
92
} catch (error) {
93
console.error("Failed to submit play:", error);
···
111
selectedTrack?.title ||
112
"No track selected! This should never happen!"
113
}
114
-
artistName={selectedTrack?.["artist-credit"]?.[0]?.artist?.name}
0
0
115
releaseTitle={selectedTrack?.selectedRelease?.title}
116
/>
117
<Text className="text-sm text-gray-500 text-center mt-4">
···
1
import VerticalPlayView from "@/components/play/verticalPlayView";
2
import { Button } from "@/components/ui/button";
3
import { useStore } from "@/stores/mainStore";
4
+
import { Agent, ComAtprotoRepoCreateRecord, RichText } from "@atproto/api";
5
import {
6
Record as PlayRecord,
7
validateRecord,
8
} from "@teal/lexicons/src/types/fm/teal/alpha/feed/play";
9
+
import { Stack, useRouter } from "expo-router";
10
+
import { useContext, useState } from "react";
11
import { Switch, View } from "react-native";
12
import { MusicBrainzRecording, PlaySubmittedData } from "@/lib/oldStamp";
13
import { Text } from "@/components/ui/text";
14
import { ExternalLink } from "@/components/ExternalLink";
15
+
import { StampContext, StampContextValue, StampStep } from "./_layout";
16
+
17
+
type CardyBResponse = {
18
+
error: string;
19
+
likely_type: string;
20
+
url: string;
21
+
title: string;
22
+
description: string;
23
+
image: string;
24
+
};
25
+
// call CardyB API to get embed card
26
+
const getUrlMetadata = async (url: string): Promise<CardyBResponse> => {
27
+
const response = await fetch(`https://cardyb.bsky.app/v1/extract?url=${url}`);
28
+
if (response.status === 200) {
29
+
return await response.json();
30
+
} else {
31
+
throw new Error("Failed to fetch metadata from CardyB");
32
+
}
33
+
};
34
+
35
+
const getBlueskyEmbedCard = async (
36
+
url: string | undefined,
37
+
agent: Agent,
38
+
customUrl?: string,
39
+
customTitle?: string,
40
+
customDescription?: string,
41
+
) => {
42
+
if (!url) return;
43
+
44
+
try {
45
+
const metadata = await getUrlMetadata(url);
46
+
const blob = await fetch(metadata.image).then((r) => r.blob());
47
+
const { data } = await agent.uploadBlob(blob, { encoding: "image/jpeg" });
48
+
49
+
return {
50
+
$type: "app.bsky.embed.external",
51
+
external: {
52
+
uri: customUrl || metadata.url,
53
+
title: customTitle || metadata.title,
54
+
description: customDescription || metadata.description,
55
+
thumb: data.blob,
56
+
},
57
+
};
58
+
} catch (error) {
59
+
console.error("Error fetching embed card:", error);
60
+
return;
61
+
}
62
+
};
63
+
interface EmbedInfo {
64
+
urlEmbed: string;
65
+
customUrl: string;
66
+
}
67
+
const getEmbedInfo = async (mbid: string): Promise<EmbedInfo | null> => {
68
+
let appleMusicResponse = await fetch(
69
+
`https://labs.api.listenbrainz.org/apple-music-id-from-mbid/json?recording_mbid=${mbid}`,
70
+
);
71
+
if (appleMusicResponse.status === 200) {
72
+
const appleMusicData = await appleMusicResponse.json();
73
+
console.log("Apple Music data:", appleMusicData);
74
+
if (appleMusicData[0].apple_music_track_ids.length > 0) {
75
+
let trackId = appleMusicData[0].apple_music_track_ids[0];
76
+
return {
77
+
urlEmbed: `https://music.apple.com/us/song/its-not-living-if-its-not-with-you/${trackId}`,
78
+
customUrl: `https://song.link/i/${trackId}`,
79
+
};
80
+
} else {
81
+
let spotifyResponse = await fetch(
82
+
`https://labs.api.listenbrainz.org/spotify-id-from-mbid/json?recording_mbid=${mbid}`,
83
+
);
84
+
if (spotifyResponse.status === 200) {
85
+
const spotifyData = await spotifyResponse.json();
86
+
console.log("Spotify data:", spotifyData);
87
+
if (spotifyData[0].spotify_track_ids.length > 0) {
88
+
let trackId = spotifyData[0].spotify_track_ids[0];
89
+
return {
90
+
urlEmbed: `https://open.spotify.com/track/${trackId}`,
91
+
customUrl: `https://song.link/s/${trackId}`,
92
+
};
93
+
}
94
+
}
95
+
}
96
+
}
97
+
return null;
98
+
};
99
+
100
+
const ms2hms = (ms: number): string => {
101
+
let seconds = Math.floor(ms / 1000);
102
+
let minutes = Math.floor(seconds / 60);
103
+
seconds = seconds % 60;
104
+
minutes = minutes % 60;
105
+
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
106
+
};
107
108
const createPlayRecord = (result: MusicBrainzRecording): PlayRecord => {
109
let artistNames: string[] = [];
···
133
export default function Submit() {
134
const router = useRouter();
135
const agent = useStore((state) => state.pdsAgent);
136
+
const ctx = useContext(StampContext);
137
+
const { state, setState } = ctx as StampContextValue;
0
0
0
0
0
138
139
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
140
const [shareWithBluesky, setShareWithBluesky] = useState<boolean>(false);
141
142
+
if (state.step !== StampStep.SUBMITTING) {
143
+
console.log("Stamp step is not SUBMITTING");
144
+
console.log(state);
145
+
return <Text>No track selected?</Text>;
146
+
//return <Redirect href="/stamp" />;
147
+
}
148
+
149
+
const selectedTrack = state.submittingStamp;
150
+
151
if (selectedTrack === null) {
152
return <Text>No track selected</Text>;
153
}
154
155
+
// TODO: PLEASE refactor me ASAP!!!
156
const handleSubmit = async () => {
157
setIsSubmitting(true);
158
try {
···
182
playRecord: record,
183
blueskyPostUrl: null,
184
};
185
+
if (shareWithBluesky && agent) {
186
+
// lol this type
187
+
const rt = new RichText({
188
+
text: `💮 now playing:
189
+
${record.trackName} by ${record.artistNames.join(", ")}
190
+
191
+
powered by @teal.fm`,
192
+
});
193
+
await rt.detectFacets(agent);
194
+
// get metadata from Apple if available
195
+
// https://labs.api.listenbrainz.org/apple-music-id-from-mbid/json?recording_mbid=81c3eb6e-d8f4-423c-9007-694aefe62754
196
+
// https://music.apple.com/us/album/i-always-wanna-die-sometimes/1435546528?i=1435546783
197
+
let embedInfo = await getEmbedInfo(selectedTrack.id);
198
+
let urlEmbed: string | undefined = embedInfo?.urlEmbed;
199
+
let customUrl: string | undefined = embedInfo?.customUrl;
200
+
201
+
let releaseYear = selectedTrack.selectedRelease?.date?.split("-")[0];
202
+
let title = `${record.trackName} by ${record.artistNames.join(", ")}`;
203
+
let description = `Song${releaseYear && " · "}${releaseYear} · ${
204
+
selectedTrack.length && " · " + ms2hms(selectedTrack.length)
205
+
}`;
206
+
207
+
console.log(urlEmbed, customUrl, title, description);
208
+
209
+
console.log(
210
+
await getBlueskyEmbedCard(
211
+
urlEmbed,
212
+
agent,
213
+
customUrl,
214
+
title,
215
+
description,
216
+
),
217
+
);
218
+
219
+
const post = await agent.post({
220
+
text: rt.text,
221
+
facets: rt.facets,
222
+
embed: urlEmbed
223
+
? await getBlueskyEmbedCard(urlEmbed, agent)
224
+
: undefined,
225
+
});
226
+
submittedData.blueskyPostUrl = post.uri;
227
+
}
228
+
setState({
229
+
step: StampStep.SUBMITTED,
230
+
submittedStamp: submittedData,
231
+
});
232
+
// wait for state updates
233
+
await Promise.resolve();
234
router.replace({
235
pathname: "/stamp/success",
0
236
});
237
} catch (error) {
238
console.error("Failed to submit play:", error);
···
256
selectedTrack?.title ||
257
"No track selected! This should never happen!"
258
}
259
+
artistName={selectedTrack?.["artist-credit"]
260
+
?.map((a) => a.artist?.name)
261
+
.join(", ")}
262
releaseTitle={selectedTrack?.selectedRelease?.title}
263
/>
264
<Text className="text-sm text-gray-500 text-center mt-4">
+34
-7
apps/amethyst/app/(tabs)/(stamp)/stamp/success.tsx
···
1
import { ExternalLink } from "@/components/ExternalLink";
2
-
import { PlaySubmittedData } from "@/lib/oldStamp";
3
-
import { Stack, useLocalSearchParams } from "expo-router";
4
import { Check, ExternalLinkIcon } from "lucide-react-native";
5
import { View } from "react-native";
6
import { Text } from "@/components/ui/text";
0
0
0
7
8
export default function StepThree() {
9
-
const { submittedData } = useLocalSearchParams();
10
-
const responseData: PlaySubmittedData = JSON.parse(submittedData as string);
0
0
0
0
0
0
0
0
0
0
0
0
11
return (
12
<View className="flex-1 p-4 bg-background items-center h-screen-safe">
13
<Stack.Screen
···
22
You can view your play{" "}
23
<ExternalLink
24
className="text-blue-600 dark:text-blue-400"
25
-
href={`https://pdsls.dev/${responseData.playAtUrl}`}
26
>
27
on PDSls
28
</ExternalLink>
29
<ExternalLinkIcon className="inline mb-0.5 ml-0.5" size="1rem" />
30
</Text>
31
-
{responseData.blueskyPostUrl && (
32
<Text>
33
Or you can{" "}
34
<ExternalLink
35
className="text-blue-600 dark:text-blue-400"
36
-
href={`https://pdsls.dev/`}
37
>
38
view your Bluesky post.
39
</ExternalLink>
40
</Text>
41
)}
0
0
0
0
0
0
0
0
0
0
0
0
0
42
</View>
43
</View>
44
);
···
1
import { ExternalLink } from "@/components/ExternalLink";
2
+
import { Stack, useRouter } from "expo-router";
0
3
import { Check, ExternalLinkIcon } from "lucide-react-native";
4
import { View } from "react-native";
5
import { Text } from "@/components/ui/text";
6
+
import { StampContext, StampContextValue, StampStep } from "./_layout";
7
+
import { useContext, useEffect } from "react";
8
+
import { Button } from "@/components/ui/button";
9
10
export default function StepThree() {
11
+
const router = useRouter();
12
+
const ctx = useContext(StampContext);
13
+
const { state, setState } = ctx as StampContextValue;
14
+
// reset on unmount
15
+
useEffect(() => {
16
+
return () => {
17
+
setState({ step: StampStep.IDLE, resetSearchState: true });
18
+
};
19
+
}, [setState]);
20
+
if (state.step !== StampStep.SUBMITTED) {
21
+
console.log("Stamp state is not submitted!");
22
+
console.log(state.step);
23
+
return <Text>No track selected?</Text>;
24
+
}
25
return (
26
<View className="flex-1 p-4 bg-background items-center h-screen-safe">
27
<Stack.Screen
···
36
You can view your play{" "}
37
<ExternalLink
38
className="text-blue-600 dark:text-blue-400"
39
+
href={`https://pdsls.dev/${state.submittedStamp.playAtUrl}`}
40
>
41
on PDSls
42
</ExternalLink>
43
<ExternalLinkIcon className="inline mb-0.5 ml-0.5" size="1rem" />
44
</Text>
45
+
{state.submittedStamp.blueskyPostUrl && (
46
<Text>
47
Or you can{" "}
48
<ExternalLink
49
className="text-blue-600 dark:text-blue-400"
50
+
href={state.submittedStamp.blueskyPostUrl}
51
>
52
view your Bluesky post.
53
</ExternalLink>
54
</Text>
55
)}
56
+
<Button
57
+
className="mt-2"
58
+
onPress={() => {
59
+
setState({ step: StampStep.IDLE, resetSearchState: true });
60
+
router.back();
61
+
// in case above doesn't work
62
+
router.replace({
63
+
pathname: "/stamp",
64
+
});
65
+
}}
66
+
>
67
+
<Text>Submit another</Text>
68
+
</Button>
69
</View>
70
</View>
71
);