From 36a8ebb48021cb46bee3b3e4ba9d08debffeb26a Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Mon, 9 Jun 2025 09:44:36 -0500 Subject: [PATCH 001/171] start post lexicon --- .../real/fm/teal/alpha/feed/social/post.json | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 packages/lexicons/real/fm/teal/alpha/feed/social/post.json diff --git a/packages/lexicons/real/fm/teal/alpha/feed/social/post.json b/packages/lexicons/real/fm/teal/alpha/feed/social/post.json new file mode 100644 index 00000000..3aae4de3 --- /dev/null +++ b/packages/lexicons/real/fm/teal/alpha/feed/social/post.json @@ -0,0 +1,102 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.feed.social.post", + "description": "This lexicon is in a not officially released state. It is subject to change. | Record containing a teal.fm post. Teal.fm posts include a track that is connected to the post, and could have some text. Replies, by default, have the same track as the parent post.", + "defs": { + "main": { + "type": "record", + "description": "Record containing a teal.fm post.", + "key": "tid", + "record": { + "type": "object", + "required": ["text", "createdAt"], + "properties": { + "text": { + "type": "string", + "maxLength": 3000, + "maxGraphemes": 300, + "description": "The primary post content. May be an empty string, if there are embeds." + }, + "trackName": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "maxGraphemes": 2560, + "description": "The name of the track" + }, + "trackMbId": { + "type": "string", + "description": "The Musicbrainz ID of the track" + }, + "recordingMbId": { + "type": "string", + "description": "The Musicbrainz recording ID of the track" + }, + "duration": { + "type": "integer", + "description": "The duration of the track in seconds" + }, + "artistNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "maxGraphemes": 2560 + }, + "description": "The names of the artists" + }, + "artistMbIds": { + "type": "array", + "items": { "type": "string" }, + "description": "The Musicbrainz IDs of the artists" + }, + "releaseName": { + "type": "string", + "maxLength": 256, + "maxGraphemes": 2560, + "description": "The name of the release/album" + }, + "releaseMbId": { + "type": "string", + "description": "The Musicbrainz ID of the release/album" + }, + "isrc": { + "type": "string", + "description": "The ISRC code associated with the recording" + }, + "facets": { + "type": "array", + "items": { "type": "ref", "ref": "#facetRef" } + }, + "reply": { "type": "ref", "ref": "#replyRef" }, + "langs": { + "type": "array", + "description": "Indicates human language of post primary text content.", + "maxLength": 3, + "items": { "type": "string", "format": "language" } + }, + "tags": { + "type": "array", + "description": "Additional hashtags, in addition to any included in post text and facets.", + "maxLength": 8, + "items": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this post was originally created." + } + } + } + }, + "replyRef": { + "type": "object", + "required": ["root", "parent"], + "properties": { + "root": { "type": "ref", "ref": "com.atproto.repo.strongRef" }, + "parent": { "type": "ref", "ref": "com.atproto.repo.strongRef" } + } + } + } +} From dcfff8a651057ced9473800638f839c53d7ed325 Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Mon, 9 Jun 2025 09:59:01 -0500 Subject: [PATCH 002/171] like, playlist+item, and repost lexicons --- .../real/fm/teal/alpha/feed/play.json | 1 - .../real/fm/teal/alpha/feed/social/like.json | 24 +++++++ .../fm/teal/alpha/feed/social/playlist.json | 30 ++++++++ .../teal/alpha/feed/social/playlistItem.json | 69 +++++++++++++++++++ .../fm/teal/alpha/feed/social/repost.json | 24 +++++++ 5 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 packages/lexicons/real/fm/teal/alpha/feed/social/like.json create mode 100644 packages/lexicons/real/fm/teal/alpha/feed/social/playlist.json create mode 100644 packages/lexicons/real/fm/teal/alpha/feed/social/playlistItem.json create mode 100644 packages/lexicons/real/fm/teal/alpha/feed/social/repost.json diff --git a/packages/lexicons/real/fm/teal/alpha/feed/play.json b/packages/lexicons/real/fm/teal/alpha/feed/play.json index a9216b4e..6b9f697e 100644 --- a/packages/lexicons/real/fm/teal/alpha/feed/play.json +++ b/packages/lexicons/real/fm/teal/alpha/feed/play.json @@ -19,7 +19,6 @@ }, "trackMbId": { "type": "string", - "description": "The Musicbrainz ID of the track" }, "recordingMbId": { diff --git a/packages/lexicons/real/fm/teal/alpha/feed/social/like.json b/packages/lexicons/real/fm/teal/alpha/feed/social/like.json new file mode 100644 index 00000000..09489c80 --- /dev/null +++ b/packages/lexicons/real/fm/teal/alpha/feed/social/like.json @@ -0,0 +1,24 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.feed.social.like", + "description": "This lexicon is in a not officially released state. It is subject to change. | The action of 'Liking' a Teal.fm post.", + "defs": { + "main": { + "type": "record", + "description": "Record containing a like for a teal.fm post.", + "key": "tid", + "record": { + "type": "object", + "required": ["subject", "createdAt"], + "properties": { + "subject": { "type": "ref", "ref": "com.atproto.repo.strongRef" }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this post was originally created." + } + } + } + } + } +} diff --git a/packages/lexicons/real/fm/teal/alpha/feed/social/playlist.json b/packages/lexicons/real/fm/teal/alpha/feed/social/playlist.json new file mode 100644 index 00000000..06e978f8 --- /dev/null +++ b/packages/lexicons/real/fm/teal/alpha/feed/social/playlist.json @@ -0,0 +1,30 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.feed.social.playlist", + "description": "This lexicon is in a not officially released state. It is subject to change. | A teal.fm playlist, representing a list of tracks.", + "defs": { + "main": { + "type": "record", + "description": "Record containing a repost for a teal.fm post.", + "key": "tid", + "record": { + "type": "object", + "required": ["name", "createdAt"], + "properties": { + "name": { + "type": "string", + "description": "Display name for the playlist, required.", + "minLength": 1, + "maxLength": 50 + }, + "description": { "type": "string", "maxLength": 5000 }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this post was originally created." + } + } + } + } + } +} diff --git a/packages/lexicons/real/fm/teal/alpha/feed/social/playlistItem.json b/packages/lexicons/real/fm/teal/alpha/feed/social/playlistItem.json new file mode 100644 index 00000000..0893cd6d --- /dev/null +++ b/packages/lexicons/real/fm/teal/alpha/feed/social/playlistItem.json @@ -0,0 +1,69 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.feed.social.playlistItem", + "description": "This lexicon is in a not officially released state. It is subject to change. | A teal.fm playlist item.", + "defs": { + "main": { + "type": "record", + "description": "Record containing a repost for a teal.fm post.", + "key": "tid", + "record": { + "type": "object", + "required": ["subject", "createdAt", "trackName"], + "properties": { + "subject": { "type": "record", "ref": "com.atproto.repo.strongRef" }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this post was originally created." + }, + "trackName": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "maxGraphemes": 2560, + "description": "The name of the track" + }, + "trackMbId": { + "type": "string", + "description": "The Musicbrainz ID of the track" + }, + "recordingMbId": { + "type": "string", + "description": "The Musicbrainz recording ID of the track" + }, + "artistNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "maxGraphemes": 2560 + }, + "description": "Array of artist names in order of original appearance. Prefer using 'artists'." + }, + "artistMbIds": { + "type": "array", + "items": { "type": "string" }, + "description": "Array of Musicbrainz artist IDs. Prefer using 'artists'." + }, + "artists": { + "type": "array", + "items": { "type": "ref", "ref": "fm.teal.alpha.feed.defs#artist" }, + "description": "Array of artists in order of original appearance." + }, + "releaseName": { + "type": "string", + "maxLength": 256, + "maxGraphemes": 2560, + "description": "The name of the release/album" + }, + "releaseMbId": { + "type": "string", + "description": "The Musicbrainz release ID" + } + } + } + } + } +} diff --git a/packages/lexicons/real/fm/teal/alpha/feed/social/repost.json b/packages/lexicons/real/fm/teal/alpha/feed/social/repost.json new file mode 100644 index 00000000..683b7918 --- /dev/null +++ b/packages/lexicons/real/fm/teal/alpha/feed/social/repost.json @@ -0,0 +1,24 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.feed.social.repost", + "description": "This lexicon is in a not officially released state. It is subject to change. | The action of 'Reposting' a Teal.fm post.", + "defs": { + "main": { + "type": "record", + "description": "Record containing a repost for a teal.fm post.", + "key": "tid", + "record": { + "type": "object", + "required": ["subject", "createdAt"], + "properties": { + "subject": { "type": "ref", "ref": "com.atproto.repo.strongRef" }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this post was originally created." + } + } + } + } + } +} From f8200896350807654e0fca4452a67fa835b28ce5 Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Wed, 9 Jul 2025 22:05:16 -0500 Subject: [PATCH 003/171] fix playlistItem desc. --- .../lexicons/real/fm/teal/alpha/feed/social/playlistItem.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexicons/real/fm/teal/alpha/feed/social/playlistItem.json b/packages/lexicons/real/fm/teal/alpha/feed/social/playlistItem.json index 0893cd6d..a227d317 100644 --- a/packages/lexicons/real/fm/teal/alpha/feed/social/playlistItem.json +++ b/packages/lexicons/real/fm/teal/alpha/feed/social/playlistItem.json @@ -5,7 +5,7 @@ "defs": { "main": { "type": "record", - "description": "Record containing a repost for a teal.fm post.", + "description": "Record containing a playlist item for a teal.fm playlist.", "key": "tid", "record": { "type": "object", From 0537cd77c37931b85dc33dcf95f038217c77cee1 Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Wed, 9 Jul 2025 22:07:29 -0500 Subject: [PATCH 004/171] add richtext facet - lexicon stolen from place.stream.richtext.facet lol --- .../real/fm/teal/alpha/feed/social/post.json | 6 +++-- .../real/fm/teal/alpha/richtext/facet.json | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 packages/lexicons/real/fm/teal/alpha/richtext/facet.json diff --git a/packages/lexicons/real/fm/teal/alpha/feed/social/post.json b/packages/lexicons/real/fm/teal/alpha/feed/social/post.json index 3aae4de3..d178159a 100644 --- a/packages/lexicons/real/fm/teal/alpha/feed/social/post.json +++ b/packages/lexicons/real/fm/teal/alpha/feed/social/post.json @@ -17,6 +17,7 @@ "maxGraphemes": 300, "description": "The primary post content. May be an empty string, if there are embeds." }, + "trackName": { "type": "string", "minLength": 1, @@ -65,11 +66,12 @@ "type": "string", "description": "The ISRC code associated with the recording" }, + "reply": { "type": "ref", "ref": "#replyRef" }, "facets": { "type": "array", - "items": { "type": "ref", "ref": "#facetRef" } + "description": "Rich text facets, which may include mentions, links, and other features.", + "items": { "type": "ref", "ref": "fm.teal.alpha.richtext.facet" } }, - "reply": { "type": "ref", "ref": "#replyRef" }, "langs": { "type": "array", "description": "Indicates human language of post primary text content.", diff --git a/packages/lexicons/real/fm/teal/alpha/richtext/facet.json b/packages/lexicons/real/fm/teal/alpha/richtext/facet.json new file mode 100644 index 00000000..11440bea --- /dev/null +++ b/packages/lexicons/real/fm/teal/alpha/richtext/facet.json @@ -0,0 +1,24 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.richtext.facet", + "defs": { + "main": { + "type": "object", + "description": "Annotation of a sub-string within rich text.", + "required": ["index", "features"], + "properties": { + "index": { "type": "ref", "ref": "app.bsky.richtext.facet#byteSlice" }, + "features": { + "type": "array", + "items": { + "type": "union", + "refs": [ + "app.bsky.richtext.facet#mention", + "app.bsky.richtext.facet#link" + ] + } + } + } + } + } +} From bd4858d8e2ade7802fa12316250af4c8e1f4d682 Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Tue, 16 Sep 2025 13:16:19 -0500 Subject: [PATCH 005/171] move social lexicons to new folder --- .../teal/alpha => lexicons/fm.teal.alpha}/feed/social/like.json | 0 .../alpha => lexicons/fm.teal.alpha}/feed/social/playlist.json | 0 .../fm.teal.alpha}/feed/social/playlistItem.json | 0 .../teal/alpha => lexicons/fm.teal.alpha}/feed/social/post.json | 0 .../teal/alpha => lexicons/fm.teal.alpha}/feed/social/repost.json | 0 .../fm/teal/alpha => lexicons/fm.teal.alpha}/richtext/facet.json | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename {packages/lexicons/real/fm/teal/alpha => lexicons/fm.teal.alpha}/feed/social/like.json (100%) rename {packages/lexicons/real/fm/teal/alpha => lexicons/fm.teal.alpha}/feed/social/playlist.json (100%) rename {packages/lexicons/real/fm/teal/alpha => lexicons/fm.teal.alpha}/feed/social/playlistItem.json (100%) rename {packages/lexicons/real/fm/teal/alpha => lexicons/fm.teal.alpha}/feed/social/post.json (100%) rename {packages/lexicons/real/fm/teal/alpha => lexicons/fm.teal.alpha}/feed/social/repost.json (100%) rename {packages/lexicons/real/fm/teal/alpha => lexicons/fm.teal.alpha}/richtext/facet.json (100%) diff --git a/packages/lexicons/real/fm/teal/alpha/feed/social/like.json b/lexicons/fm.teal.alpha/feed/social/like.json similarity index 100% rename from packages/lexicons/real/fm/teal/alpha/feed/social/like.json rename to lexicons/fm.teal.alpha/feed/social/like.json diff --git a/packages/lexicons/real/fm/teal/alpha/feed/social/playlist.json b/lexicons/fm.teal.alpha/feed/social/playlist.json similarity index 100% rename from packages/lexicons/real/fm/teal/alpha/feed/social/playlist.json rename to lexicons/fm.teal.alpha/feed/social/playlist.json diff --git a/packages/lexicons/real/fm/teal/alpha/feed/social/playlistItem.json b/lexicons/fm.teal.alpha/feed/social/playlistItem.json similarity index 100% rename from packages/lexicons/real/fm/teal/alpha/feed/social/playlistItem.json rename to lexicons/fm.teal.alpha/feed/social/playlistItem.json diff --git a/packages/lexicons/real/fm/teal/alpha/feed/social/post.json b/lexicons/fm.teal.alpha/feed/social/post.json similarity index 100% rename from packages/lexicons/real/fm/teal/alpha/feed/social/post.json rename to lexicons/fm.teal.alpha/feed/social/post.json diff --git a/packages/lexicons/real/fm/teal/alpha/feed/social/repost.json b/lexicons/fm.teal.alpha/feed/social/repost.json similarity index 100% rename from packages/lexicons/real/fm/teal/alpha/feed/social/repost.json rename to lexicons/fm.teal.alpha/feed/social/repost.json diff --git a/packages/lexicons/real/fm/teal/alpha/richtext/facet.json b/lexicons/fm.teal.alpha/richtext/facet.json similarity index 100% rename from packages/lexicons/real/fm/teal/alpha/richtext/facet.json rename to lexicons/fm.teal.alpha/richtext/facet.json From 040926adaeb91e5f9189f02253703f4a853ca659 Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Mon, 22 Sep 2025 10:46:38 -0500 Subject: [PATCH 006/171] #trackView def in feed.social --- lexicons/fm.teal.alpha/feed/social/defs.json | 54 +++++++++++++++++++ .../feed/social/playlistItem.json | 47 ++-------------- 2 files changed, 57 insertions(+), 44 deletions(-) create mode 100644 lexicons/fm.teal.alpha/feed/social/defs.json diff --git a/lexicons/fm.teal.alpha/feed/social/defs.json b/lexicons/fm.teal.alpha/feed/social/defs.json new file mode 100644 index 00000000..734a1435 --- /dev/null +++ b/lexicons/fm.teal.alpha/feed/social/defs.json @@ -0,0 +1,54 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.feed.social.defs", + "description": "This lexicon is in a not officially released state. It is subject to change. | Misc. items related to the social feed..", + "defs": { + "trackView": { + "trackName": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "maxGraphemes": 2560, + "description": "The name of the track" + }, + "trackMbId": { + "type": "string", + "description": "The Musicbrainz ID of the track" + }, + "recordingMbId": { + "type": "string", + "description": "The Musicbrainz recording ID of the track" + }, + "artistNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "maxGraphemes": 2560 + }, + "description": "Array of artist names in order of original appearance. Prefer using 'artists'." + }, + "artistMbIds": { + "type": "array", + "items": { "type": "string" }, + "description": "Array of Musicbrainz artist IDs. Prefer using 'artists'." + }, + "artists": { + "type": "array", + "items": { "type": "ref", "ref": "fm.teal.alpha.feed.defs#artist" }, + "description": "Array of artists in order of original appearance." + }, + "releaseName": { + "type": "string", + "maxLength": 256, + "maxGraphemes": 2560, + "description": "The name of the release/album" + }, + "releaseMbId": { + "type": "string", + "description": "The Musicbrainz release ID" + } + } + } +} diff --git a/lexicons/fm.teal.alpha/feed/social/playlistItem.json b/lexicons/fm.teal.alpha/feed/social/playlistItem.json index a227d317..220b3c73 100644 --- a/lexicons/fm.teal.alpha/feed/social/playlistItem.json +++ b/lexicons/fm.teal.alpha/feed/social/playlistItem.json @@ -17,50 +17,9 @@ "format": "datetime", "description": "Client-declared timestamp when this post was originally created." }, - "trackName": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "maxGraphemes": 2560, - "description": "The name of the track" - }, - "trackMbId": { - "type": "string", - "description": "The Musicbrainz ID of the track" - }, - "recordingMbId": { - "type": "string", - "description": "The Musicbrainz recording ID of the track" - }, - "artistNames": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "maxGraphemes": 2560 - }, - "description": "Array of artist names in order of original appearance. Prefer using 'artists'." - }, - "artistMbIds": { - "type": "array", - "items": { "type": "string" }, - "description": "Array of Musicbrainz artist IDs. Prefer using 'artists'." - }, - "artists": { - "type": "array", - "items": { "type": "ref", "ref": "fm.teal.alpha.feed.defs#artist" }, - "description": "Array of artists in order of original appearance." - }, - "releaseName": { - "type": "string", - "maxLength": 256, - "maxGraphemes": 2560, - "description": "The name of the release/album" - }, - "releaseMbId": { - "type": "string", - "description": "The Musicbrainz release ID" + "track": { + "type": "ref", + "ref": "fm.teal.alpha.feed.social.defs#trackView" } } } From 8e27c31ea9d644c28459fe2f803804c22da7dd71 Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Mon, 22 Sep 2025 10:47:22 -0500 Subject: [PATCH 007/171] add ordering to playlistItem lexicon --- lexicons/fm.teal.alpha/feed/social/playlistItem.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lexicons/fm.teal.alpha/feed/social/playlistItem.json b/lexicons/fm.teal.alpha/feed/social/playlistItem.json index 220b3c73..e67ca590 100644 --- a/lexicons/fm.teal.alpha/feed/social/playlistItem.json +++ b/lexicons/fm.teal.alpha/feed/social/playlistItem.json @@ -20,6 +20,10 @@ "track": { "type": "ref", "ref": "fm.teal.alpha.feed.social.defs#trackView" + }, + "order": { + "type": "integer", + "description": "The order of the track in the playlist" } } } From e14ab8253751372df473afed973141bdee7b2f5c Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Mon, 22 Sep 2025 10:48:31 -0500 Subject: [PATCH 008/171] fix description for playlist lexicon --- lexicons/fm.teal.alpha/feed/social/playlist.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lexicons/fm.teal.alpha/feed/social/playlist.json b/lexicons/fm.teal.alpha/feed/social/playlist.json index 06e978f8..e5d815f9 100644 --- a/lexicons/fm.teal.alpha/feed/social/playlist.json +++ b/lexicons/fm.teal.alpha/feed/social/playlist.json @@ -5,7 +5,7 @@ "defs": { "main": { "type": "record", - "description": "Record containing a repost for a teal.fm post.", + "description": "Record containing the playlist metadata.", "key": "tid", "record": { "type": "object", @@ -21,7 +21,7 @@ "createdAt": { "type": "string", "format": "datetime", - "description": "Client-declared timestamp when this post was originally created." + "description": "Client-declared timestamp when this playlist was originally created." } } } From cb419bc25147ee1c25cf01edafd65484bf9750e7 Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Sat, 30 May 2026 22:11:17 -0500 Subject: [PATCH 009/171] feat(teal): add songish-style appview and jetstream ingestion Build the Teal-branded Amethyst feed UI, expose matching Aqua XRPC fields, and make Cadet the durable Jetstream ingestion path. Add Compose services, same-origin XRPC proxying, OAuth tunnel notes, and a working project handoff checklist. --- .env.template | 13 +- apps/amethyst/Caddyfile | 5 +- apps/amethyst/Dockerfile | 71 +++---- .../:o/music/[artist]/[release]/[track].tsx | 100 +++++++++ apps/amethyst/app/(tabs)/_layout.tsx | 26 +-- apps/amethyst/app/(tabs)/index.tsx | 140 +++++-------- apps/amethyst/app/(tabs)/notifications.tsx | 31 +++ apps/amethyst/app/(tabs)/profile/[handle].tsx | 117 ++++++++--- apps/amethyst/app/(tabs)/search/index.tsx | 115 +--------- apps/amethyst/app/_layout.tsx | 6 +- .../components/songish/PlayFeedCard.tsx | 99 +++++++++ .../amethyst/components/songish/RightRail.tsx | 67 ++++++ .../components/songish/SongishShell.tsx | 197 ++++++++++++++++++ apps/amethyst/lib/teal/api.ts | 155 ++++++++++++++ apps/aqua/src/repos/feed_play.rs | 9 + apps/aqua/src/repos/stats.rs | 5 + apps/aqua/src/xrpc/feed.rs | 62 +++++- compose.dev.yml | 114 +++++----- compose.yaml | 92 ++++++++ lexicons/fm.teal.alpha/feed/defs.json | 18 ++ packages/lexicons/lex-gen.sh | 7 +- services/cadet/src/cursor.rs | 83 +++++++- .../cadet/src/ingestors/teal/feed_play.rs | 9 +- services/cadet/src/main.rs | 22 +- todo.md | 65 ++++++ 25 files changed, 1253 insertions(+), 375 deletions(-) create mode 100644 apps/amethyst/app/(tabs)/:o/music/[artist]/[release]/[track].tsx create mode 100644 apps/amethyst/app/(tabs)/notifications.tsx create mode 100644 apps/amethyst/components/songish/PlayFeedCard.tsx create mode 100644 apps/amethyst/components/songish/RightRail.tsx create mode 100644 apps/amethyst/components/songish/SongishShell.tsx create mode 100644 apps/amethyst/lib/teal/api.ts create mode 100644 todo.md diff --git a/.env.template b/.env.template index 0d54d1c0..168d6211 100644 --- a/.env.template +++ b/.env.template @@ -7,7 +7,8 @@ DB_USER=postgres DB_PASSWORD=supersecurepassword123987 DB_NAME=teal DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@localhost:5432/${DB_NAME}" -DOCKER_DB_URL="postgresql://${DB_USER}:${DB_PASSWORD}@host.docker.internal:5432/${DB_NAME}" +DOCKER_DB_URL="postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}" +REDIS_URL="redis://garnet:6379" # `cargo run --bin teal gen-key` to generate a new pubkey DID_WEB_PUBKEY=zQ3sheEnMKhEK87PSu4P2mjAevViqHcjKmgxBWsDQPjLRM9wP CLIENT_ADDRESS= # A publicly accessible host for amethyst like amethyst.teal.fm @@ -16,10 +17,20 @@ PUBLIC_DID_WEB= # did:web:{aqua's PUBLIC_URL goes here after did:web:} # amethyst EXPO_PUBLIC_DID_WEB= # same as PUBLIC_DID_WEB EXPO_PUBLIC_BASE_URL= # same as CLIENT_ADDRESS but with http scheme like https://amethyst.teal.fm +EXPO_PUBLIC_AQUA_URL= # public Aqua URL, e.g. https://aqua.teal.fm SQLX_OFFLINE=true SQLX_OFFLINE_DIR="./.sqlx" +# Cadet ATProto stream ingestion +CADET_STREAM_MODE=jetstream +JETSTREAM_URL=wss://jetstream1.us-east.bsky.network/subscribe +CADET_CURSOR_REDIS_KEY=cadet:jetstream:cursor + +# Optional dev tunnel: +# docker compose -f compose.dev.yml --profile tunnel up +# Set EXPO_PUBLIC_BASE_URL to the Cloudflare URL for ATProto OAuth callback testing. + # Last.fm eval (scripts/eval/evaluate.ts) # Get your API key at https://www.last.fm/api/account/create LASTFM_API_KEY= diff --git a/apps/amethyst/Caddyfile b/apps/amethyst/Caddyfile index ff458bde..58c13730 100644 --- a/apps/amethyst/Caddyfile +++ b/apps/amethyst/Caddyfile @@ -1,5 +1,8 @@ {env.CLIENT_ADDRESS} { + handle /xrpc/* { + reverse_proxy aqua-api:3000 + } + try_files {path} /index.html file_server } - diff --git a/apps/amethyst/Dockerfile b/apps/amethyst/Dockerfile index ce270af4..94a15aa2 100644 --- a/apps/amethyst/Dockerfile +++ b/apps/amethyst/Dockerfile @@ -1,63 +1,44 @@ FROM node:22-slim AS builder -ARG CLIENT_ADDRESS + +ARG CLIENT_ADDRESS=localhost +ARG EXPO_PUBLIC_BASE_URL +ARG EXPO_PUBLIC_AQUA_URL=http://localhost:3000 +ARG EXPO_PUBLIC_DID_WEB ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable -# Set working directory -WORKDIR /app - -# Copy root workspace files -COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ - -# Copy turbo.json -COPY turbo.json ./ - -# Copy workspace packages -COPY packages/db/ ./packages/db/ -COPY packages/lexicons/ ./packages/lexicons/ -COPY packages/tsconfig/ ./packages/tsconfig/ - -# Copy lexicons source data -COPY lexicons/ ./lexicons/ +ENV EXPO_PUBLIC_BASE_URL=${EXPO_PUBLIC_BASE_URL} +ENV EXPO_PUBLIC_AQUA_URL=${EXPO_PUBLIC_AQUA_URL} +ENV EXPO_PUBLIC_DID_WEB=${EXPO_PUBLIC_DID_WEB} -# Copy the aqua app -COPY apps/amethyst/ ./apps/amethyst/ +RUN corepack enable -# Copy .env -COPY ../../.env ./apps/amethyst/.env +WORKDIR /app -# Install dependencies and generate lexicons -RUN cd tools/lexicon-cli && pnpm build +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json tsconfig.json ./ +COPY packages ./packages +COPY tools ./tools +COPY lexicons ./lexicons +COPY apps/amethyst ./apps/amethyst -# Generate lexicons before building amethyst +RUN pnpm install --frozen-lockfile --ignore-scripts +RUN pnpm rebuild esbuild unrs-resolver RUN pnpm lex:gen-server -RUN pnpm install - -# Build the amethyst app WORKDIR /app/apps/amethyst RUN pnpm run build:web -#create the client-json -RUN echo '{ \ - "redirect_uris": ["https://'"${CLIENT_ADDRESS}"'/auth/callback"], \ - "response_types": ["code"], \ - "grant_types": ["authorization_code", "refresh_token"], \ - "scope": "atproto transition:generic", \ - "token_endpoint_auth_method": "none", \ - "application_type": "web", \ - "client_id": "https://'"${CLIENT_ADDRESS}"'/client-metadata.json", \ - "client_name": "teal", \ - "client_uri": "https://'"${CLIENT_ADDRESS}"'", \ - "dpop_bound_access_tokens": true \ - }' > /app/client-metadata.json - - -FROM caddy:2.1.0-alpine AS caddy +RUN node -e 'const fs=require("fs"); const host=process.env.CLIENT_ADDRESS || "'"${CLIENT_ADDRESS}"'"; const base=host.startsWith("http") ? host : `https://${host}`; const metadata={redirect_uris:[`${base}/auth/callback`],response_types:["code"],grant_types:["authorization_code","refresh_token"],scope:"atproto transition:generic",token_endpoint_auth_method:"none",application_type:"web",client_id:`${base}/client-metadata.json`,client_name:"teal",client_uri:base,dpop_bound_access_tokens:true}; fs.writeFileSync("/app/client-metadata.json", JSON.stringify(metadata, null, 2));' + +FROM caddy:2.8-alpine + +ARG CLIENT_ADDRESS=:80 +ENV CLIENT_ADDRESS=${CLIENT_ADDRESS} + EXPOSE 80 EXPOSE 443 EXPOSE 443/udp -COPY /apps/amethyst/Caddyfile /etc/caddy/Caddyfile + +COPY apps/amethyst/Caddyfile /etc/caddy/Caddyfile COPY --from=builder /app/apps/amethyst/build /srv COPY --from=builder /app/client-metadata.json /srv/client-metadata.json diff --git a/apps/amethyst/app/(tabs)/:o/music/[artist]/[release]/[track].tsx b/apps/amethyst/app/(tabs)/:o/music/[artist]/[release]/[track].tsx new file mode 100644 index 00000000..4aed314d --- /dev/null +++ b/apps/amethyst/app/(tabs)/:o/music/[artist]/[release]/[track].tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from "react"; +import { ActivityIndicator, Image, View } from "react-native"; +import { Stack, useLocalSearchParams } from "expo-router"; +import PlayFeedCard from "@/components/songish/PlayFeedCard"; +import RightRail from "@/components/songish/RightRail"; +import SongishShell from "@/components/songish/SongishShell"; +import { Text } from "@/components/ui/text"; +import { coverArtUrl, displayArtists, getLatestPlays, getPlayByUri } from "@/lib/teal/api"; +import type { PlayView } from "@teal/lexicons/src/types/fm/teal/alpha/feed/defs"; + +export default function MusicDetail() { + const params = useLocalSearchParams(); + const uri = Array.isArray(params.uri) ? params.uri[0] : params.uri; + const [play, setPlay] = useState(null); + const [related, setRelated] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + async function load() { + try { + const selected = uri + ? (await getPlayByUri(uri)).play + : (await getLatestPlays(1)).plays[0]; + const latest = await getLatestPlays(20); + if (!mounted) return; + setPlay(selected); + setRelated( + latest.plays.filter((candidate) => + selected?.trackName + ? candidate.trackName === selected.trackName || + candidate.releaseMbId === selected.releaseMbId + : false, + ), + ); + } catch (e) { + if (mounted) setError(e instanceof Error ? e.message : String(e)); + } + } + load(); + return () => { + mounted = false; + }; + }, [uri]); + + return ( + }> + + {!play && !error && ( + + + + )} + {error && ( + + Could not load music detail: {error} + + )} + {play && ( + <> + + + {coverArtUrl(play.releaseMbId, 500) && ( + + )} + + + {coverArtUrl(play.releaseMbId) ? ( + + ) : ( + + )} + + + {play.trackName} + + + {displayArtists(play) || "Unknown artist"} + + {play.releaseName && ( + {play.releaseName} + )} + + + + Plays + {(related.length ? related : [play]).map((item, index) => ( + + ))} + + )} + + ); +} diff --git a/apps/amethyst/app/(tabs)/_layout.tsx b/apps/amethyst/app/(tabs)/_layout.tsx index 6703ad3a..08b05f0a 100644 --- a/apps/amethyst/app/(tabs)/_layout.tsx +++ b/apps/amethyst/app/(tabs)/_layout.tsx @@ -2,7 +2,6 @@ import React from "react"; import { Pressable } from "react-native"; import { Link, Tabs } from "expo-router"; import useIsMobile from "@/hooks/useIsMobile"; -//import useIsMobile from "@/hooks/useIsMobile"; import { useStore } from "@/stores/mainStore"; import { FilePen, @@ -16,7 +15,6 @@ import { useColorScheme } from "nativewind"; import Colors from "../../constants/Colors"; import { Icon, iconWithClassName } from "../../lib/icons/iconWithClassName"; -import AuthOptions from "../auth/options"; function TabBarIcon(props: { name: LucideIcon; color: string }) { const Name = props.name; @@ -28,17 +26,7 @@ export default function TabLayout() { const { colorScheme } = useColorScheme(); const authStatus = useStore((state) => state.status); const isMobile = useIsMobile(); - // if we are on web but not native and web width is greater than 1024px - const hideTabBar = authStatus !== "loggedIn"; // || useIsMobile() - - const j = useStore((state) => state.status); - // @me - const agent = useStore((state) => state.pdsAgent); - const profile = useStore((state) => state.profiles[agent?.did ?? ""]); - - if (j !== "loggedIn") { - return ; - } + const hideTabBar = !isMobile; return ( , }} /> + ( ), @@ -100,7 +96,7 @@ export default function TabLayout() { name="settings/index" options={{ title: "Settings", - + href: authStatus === "loggedIn" ? undefined : null, tabBarIcon: ({ color }) => ( ), diff --git a/apps/amethyst/app/(tabs)/index.tsx b/apps/amethyst/app/(tabs)/index.tsx index 3b029d84..5625be2c 100644 --- a/apps/amethyst/app/(tabs)/index.tsx +++ b/apps/amethyst/app/(tabs)/index.tsx @@ -1,97 +1,63 @@ -import * as React from "react"; import { useEffect, useState } from "react"; -import { ActivityIndicator, ScrollView, View } from "react-native"; -import { Redirect, Stack, useRouter } from "expo-router"; -import ActorView from "@/components/actor/actorView"; -import { useStore } from "@/stores/mainStore"; - -import { Record as ProfileStatusRecord } from "@teal/lexicons/src/types/fm/teal/alpha/actor/profileStatus"; - -import AuthOptions from "../auth/options"; - -export default function Screen() { - const router = useRouter(); - const j = useStore((state) => state.status); - // @me - const agent = useStore((state) => state.pdsAgent); - const profile = useStore((state) => state.profiles[agent?.did ?? ""]); - const tealDid = useStore((state) => state.tealDid); - const [profileStatus, setProfileStatus] = useState(null); - const [statusLoading, setStatusLoading] = useState(true); +import { ActivityIndicator, View } from "react-native"; +import { Stack } from "expo-router"; +import PlayFeedCard from "@/components/songish/PlayFeedCard"; +import RightRail from "@/components/songish/RightRail"; +import SongishShell from "@/components/songish/SongishShell"; +import { Text } from "@/components/ui/text"; +import { getLatestPlays } from "@/lib/teal/api"; +import type { PlayView } from "@teal/lexicons/src/types/fm/teal/alpha/feed/defs"; + +export default function HomeScreen() { + const [plays, setPlays] = useState(null); + const [error, setError] = useState(null); useEffect(() => { - let isMounted = true; - - const fetchProfileStatus = async () => { - try { - if (!agent) return; - - const res = await agent.call("com.atproto.repo.getRecord", { - repo: agent.did, - collection: "fm.teal.alpha.actor.profileStatus", - rkey: "self", - }); - - if (isMounted) { - setProfileStatus(res.data.value as ProfileStatusRecord); - } - } catch (error) { - if (isMounted) { - // If no record exists, user hasn't completed onboarding - setProfileStatus(null); - } - console.error("Error fetching profile status:", error); - if ( - error instanceof Error && - error.message.includes("could not resolve proxy did") - ) { - router.replace("/offline"); + let mounted = true; + getLatestPlays(50) + .then((res) => { + if (mounted) setPlays(res.plays); + }) + .catch((e) => { + if (mounted) { + setError(e instanceof Error ? e.message : String(e)); + setPlays([]); } - } finally { - if (isMounted) { - setStatusLoading(false); - } - } - }; - - fetchProfileStatus(); - + }); return () => { - isMounted = false; + mounted = false; }; - }, [agent, router]); - - if (j !== "loggedIn") { - return ; - } - - if (!statusLoading && (!profileStatus || profileStatus.completedOnboarding === "none")) { - return ( - - - - ); - } - - // TODO: replace with skeleton - if (!profile || !agent || statusLoading) { - return ( - - - - ); - } + }, []); return ( - - - - + }> + + {!plays && ( + + + + )} + {error && ( + + + Could not load the Teal play feed: {error} + + + )} + {plays?.length === 0 && !error && ( + + No plays indexed yet. + + Cadet will fill this feed as ATProto firehose records arrive. + + + )} + {plays?.map((play, index) => ( + + ))} + ); } diff --git a/apps/amethyst/app/(tabs)/notifications.tsx b/apps/amethyst/app/(tabs)/notifications.tsx new file mode 100644 index 00000000..3a6424cf --- /dev/null +++ b/apps/amethyst/app/(tabs)/notifications.tsx @@ -0,0 +1,31 @@ +import { View } from "react-native"; +import { Link, Stack } from "expo-router"; +import RightRail from "@/components/songish/RightRail"; +import SongishShell from "@/components/songish/SongishShell"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { useStore } from "@/stores/mainStore"; + +export default function Notifications() { + const status = useStore((state) => state.status); + + return ( + }> + + + + {status === "loggedIn" + ? "Notifications are coming later." + : "You must be signed in to view your notifications."} + + {status !== "loggedIn" && ( + + + + )} + + + ); +} diff --git a/apps/amethyst/app/(tabs)/profile/[handle].tsx b/apps/amethyst/app/(tabs)/profile/[handle].tsx index 801f0bce..33cd201a 100644 --- a/apps/amethyst/app/(tabs)/profile/[handle].tsx +++ b/apps/amethyst/app/(tabs)/profile/[handle].tsx @@ -1,44 +1,97 @@ import { useEffect, useState } from "react"; -import { ActivityIndicator, ScrollView, View } from "react-native"; +import { ActivityIndicator, Image, View } from "react-native"; import { Stack, useLocalSearchParams } from "expo-router"; -import ActorView from "@/components/actor/actorView"; +import PlayFeedCard from "@/components/songish/PlayFeedCard"; +import RightRail from "@/components/songish/RightRail"; +import SongishShell from "@/components/songish/SongishShell"; import { Text } from "@/components/ui/text"; import { resolveHandle } from "@/lib/atp/pid"; -import { useStore } from "@/stores/mainStore"; +import { getActorFeed, getProfile } from "@/lib/teal/api"; +import type { ProfileView } from "@teal/lexicons/src/types/fm/teal/alpha/actor/defs"; +import type { PlayView } from "@teal/lexicons/src/types/fm/teal/alpha/feed/defs"; -export default function Handle() { - let { handle } = useLocalSearchParams(); - - let agent = useStore((state) => state.pdsAgent); - - // resolve handle +export default function ProfileScreen() { + const { handle } = useLocalSearchParams(); + const actor = Array.isArray(handle) ? handle[0] : handle; const [did, setDid] = useState(null); + const [profile, setProfile] = useState(null); + const [plays, setPlays] = useState([]); + const [error, setError] = useState(null); + useEffect(() => { - const fetchAgent = async () => { - const agent = await resolveHandle( - typeof handle === "string" ? handle : handle[0] && handle[0], - ); - setDid(agent); + let mounted = true; + async function load() { + if (!actor) return; + try { + const resolved = actor.startsWith("did:") ? actor : await resolveHandle(actor); + if (!mounted) return; + setDid(resolved); + const [profileRes, feedRes] = await Promise.all([ + getProfile(resolved), + getActorFeed(resolved, 50), + ]); + if (!mounted) return; + setProfile(profileRes.profile); + setPlays(feedRes.plays); + } catch (e) { + if (mounted) setError(e instanceof Error ? e.message : String(e)); + } + } + load(); + return () => { + mounted = false; }; - if (handle !== "undefined") fetchAgent(); - }, [handle]); - - if (handle === "undefined") { - return Handle is undefined; - } - - if (!did) return ; + }, [actor]); return ( - - - - + }> + + {!did && !error && ( + + + + )} + {error && ( + + Could not load profile: {error} + + )} + {did && ( + <> + + + {profile?.banner && ( + + )} + + + + + {(profile?.displayName || actor || "T").slice(0, 1)} + + + + {profile?.displayName || actor} + + {did} + {profile?.description && ( + {profile.description} + )} + + + Plays + {plays.length === 0 ? ( + No indexed plays yet. + ) : ( + plays.map((play, index) => ( + + )) + )} + + )} + ); } diff --git a/apps/amethyst/app/(tabs)/search/index.tsx b/apps/amethyst/app/(tabs)/search/index.tsx index 3388a163..6550eca8 100644 --- a/apps/amethyst/app/(tabs)/search/index.tsx +++ b/apps/amethyst/app/(tabs)/search/index.tsx @@ -1,109 +1,16 @@ -import React, { useEffect, useState } from "react"; -import { ScrollView, View } from "react-native"; -import { Link, Stack } from "expo-router"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Input } from "@/components/ui/input"; +import { Stack } from "expo-router"; +import RightRail from "@/components/songish/RightRail"; +import SongishShell from "@/components/songish/SongishShell"; import { Text } from "@/components/ui/text"; -import getImageCdnLink from "@/lib/atp/getImageCdnLink"; -import { useStore } from "@/stores/mainStore"; - -import { MiniProfileView } from "@teal/lexicons/src/types/fm/teal/alpha/actor/defs"; -import { OutputSchema as SearchActorsOutputSchema } from "@teal/lexicons/src/types/fm/teal/alpha/actor/searchActors"; - -export default function Search() { - const [searchQuery, setSearchQuery] = React.useState(""); - const [searchResults, setSearchResults] = useState([]); - - const tealDid = useStore((state) => state.tealDid); - const agent = useStore((state) => state.pdsAgent); - - useEffect(() => { - let isMounted = true; - - const fetchResults = async () => { - if (!agent || !searchQuery) { - // Don't fetch if searchQuery is empty - setSearchResults([]); // Clear results when searchQuery is empty - return; - } - try { - let res = await agent.call( - "fm.teal.alpha.actor.searchActors", - { q: searchQuery }, - {}, - { headers: { "atproto-proxy": tealDid + "#teal_fm_appview" } }, - ); - if (isMounted) { - setSearchResults( - res.data["actors"] as SearchActorsOutputSchema["actors"], - ); - } - } catch (error) { - console.error("Error fetching profile:", error); - } - }; - - fetchResults(); - - return () => { - isMounted = false; - }; - }, [agent, tealDid, searchQuery]); +export default function Explore() { return ( - - - - - - - {searchResults.map((user) => ( - - - - - - {user.displayName?.substring(0, 1) ?? - user.handle?.substring(0, 1) ?? - "R"} - - - - - {user.displayName} - - {user.handle?.replace("at://", "@")} - - - - ))} - - + }> + + Events + + No events at the moment. + + ); } diff --git a/apps/amethyst/app/_layout.tsx b/apps/amethyst/app/_layout.tsx index 38004b55..1981abd0 100644 --- a/apps/amethyst/app/_layout.tsx +++ b/apps/amethyst/app/_layout.tsx @@ -86,10 +86,8 @@ export default function RootLayout() { } return ( - - - - + + ); } diff --git a/apps/amethyst/components/songish/PlayFeedCard.tsx b/apps/amethyst/components/songish/PlayFeedCard.tsx new file mode 100644 index 00000000..bc7d1360 --- /dev/null +++ b/apps/amethyst/components/songish/PlayFeedCard.tsx @@ -0,0 +1,99 @@ +import { Image, Pressable, View } from "react-native"; +import { Link } from "expo-router"; +import type { PlayView } from "@teal/lexicons/src/types/fm/teal/alpha/feed/defs"; +import { Icon } from "@/lib/icons/iconWithClassName"; +import { cn, timeAgo } from "@/lib/utils"; +import { coverArtUrl, displayArtists } from "@/lib/teal/api"; +import { Disc3, Headphones, MoreVertical, Play } from "lucide-react-native"; + +import { Text } from "../ui/text"; + +type PlayFeedCardProps = { + play: PlayView; + compact?: boolean; +}; + +function routePart(value?: string) { + return encodeURIComponent( + (value || "unknown") + .toLowerCase() + .replace(/^mbid:/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") || "unknown", + ); +} + +export function musicHref(play: PlayView) { + return `/:o/music/${routePart(displayArtists(play))}/${routePart(play.releaseName)}/${routePart(play.trackName)}?uri=${encodeURIComponent(play.uri || "")}`; +} + +export default function PlayFeedCard({ play, compact }: PlayFeedCardProps) { + const art = coverArtUrl(play.releaseMbId); + const author = play.authorDid || "unknown listener"; + const when = play.playedTime ? timeAgo(new Date(play.playedTime)) : "recently"; + + return ( + + + + + + + + + + + {author} + + listened {when} + + + + + + + + {play.trackName} + + + {displayArtists(play) || "Unknown artist"} + + + + {art ? ( + + ) : ( + + + + )} + + + + + + + + + + {play.releaseName ? `from ${play.releaseName}` : "a fresh Teal play"} + + + + + ♡ 0 + ◼ 0 + + + + + ); +} diff --git a/apps/amethyst/components/songish/RightRail.tsx b/apps/amethyst/components/songish/RightRail.tsx new file mode 100644 index 00000000..01670dfd --- /dev/null +++ b/apps/amethyst/components/songish/RightRail.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import type { ArtistView, ReleaseView } from "@teal/lexicons/src/types/fm/teal/alpha/stats/defs"; +import { getTopArtists, getTopReleases } from "@/lib/teal/api"; + +import { Text } from "../ui/text"; + +export default function RightRail() { + const [artists, setArtists] = useState([]); + const [releases, setReleases] = useState([]); + + useEffect(() => { + let mounted = true; + Promise.all([getTopArtists(3), getTopReleases(5)]) + .then(([artistRes, releaseRes]) => { + if (!mounted) return; + setArtists(artistRes.artists); + setReleases(releaseRes.releases); + }) + .catch(() => { + if (!mounted) return; + setArtists([]); + setReleases([]); + }); + return () => { + mounted = false; + }; + }, []); + + return ( + + + Featured Listeners + + + + {artists[0]?.name || "Teal listeners"} + + + {artists[0]?.playCount ? `${artists[0].playCount} plays indexed` : "live from ATProto"} + + + + + Trending Releases + + {releases.length === 0 ? ( + Waiting for plays. + ) : ( + releases.map((release) => ( + + + {release.name} + + + {release.playCount} + + + )) + )} + + + ); +} diff --git a/apps/amethyst/components/songish/SongishShell.tsx b/apps/amethyst/components/songish/SongishShell.tsx new file mode 100644 index 00000000..f2339b28 --- /dev/null +++ b/apps/amethyst/components/songish/SongishShell.tsx @@ -0,0 +1,197 @@ +import { ReactNode } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { Link, usePathname } from "expo-router"; +import useIsMobile from "@/hooks/useIsMobile"; +import { useStore } from "@/stores/mainStore"; +import { Icon } from "@/lib/icons/iconWithClassName"; +import { Bell, Home, LogIn, Search, UserCircle } from "lucide-react-native"; + +import { Text } from "../ui/text"; + +type SongishShellProps = { + children: ReactNode; + rightRail?: ReactNode; + title?: string; +}; + +function RecordLogo() { + return ( + + + + + + + + Teal + + + ); +} + +function NavItem({ + href, + icon, + label, + active, +}: { + href: string; + icon: any; + label: string; + active: boolean; +}) { + return ( + + + + + {label} + + + + ); +} + +function LeftRail() { + const pathname = usePathname(); + const status = useStore((state) => state.status); + const agent = useStore((state) => state.pdsAgent); + + return ( + + + + + + + + + + + + + + + {status === "loggedIn" ? "Profile" : "Login"} + + + + + + + ); +} + +function MobileNav() { + const pathname = usePathname(); + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default function SongishShell({ + children, + rightRail, + title, +}: SongishShellProps) { + const isMobile = useIsMobile(); + + return ( + + + + teal is in active development: expect bugs, missing features, and regular index rebuilds + + + + + + {title && ( + + {title} + + )} + {children} + + + {!isMobile && ( + + {rightRail} + + )} + + + + ); +} diff --git a/apps/amethyst/lib/teal/api.ts b/apps/amethyst/lib/teal/api.ts new file mode 100644 index 00000000..7246ea1c --- /dev/null +++ b/apps/amethyst/lib/teal/api.ts @@ -0,0 +1,155 @@ +import type { PlayView } from "@teal/lexicons/src/types/fm/teal/alpha/feed/defs"; +import type { ProfileView } from "@teal/lexicons/src/types/fm/teal/alpha/actor/defs"; +import type { ArtistView, ReleaseView } from "@teal/lexicons/src/types/fm/teal/alpha/stats/defs"; + +const rawBase = + process.env.EXPO_PUBLIC_AQUA_URL || + process.env.EXPO_PUBLIC_APPVIEW_URL || + ""; +const demoFallbackEnabled = process.env.EXPO_PUBLIC_ENABLE_DEMO_FALLBACK === "true"; + +const requestBase = + rawBase || (typeof window === "undefined" ? "http://localhost:3000" : window.location.origin); +const xrpcBase = requestBase.endsWith("/xrpc") + ? requestBase + : `${requestBase.replace(/\/$/, "")}/xrpc`; + +const demoPlays: PlayView[] = [ + { + uri: "at://did:plc:tealpreview/fm.teal.alpha.feed.play/3demo001", + cid: "bafyreitealpreview001", + authorDid: "did:plc:tealpreview", + rkey: "3demo001", + trackName: "Everything In Its Right Place", + artists: [{ artistName: "Radiohead" }], + releaseName: "Kid A", + musicServiceBaseDomain: "music.apple.com", + submissionClientAgent: "teal-preview/0.1", + playedTime: new Date(Date.now() - 7 * 60 * 1000).toISOString(), + }, + { + uri: "at://did:plc:amethystpreview/fm.teal.alpha.feed.play/3demo002", + cid: "bafyreitealpreview002", + authorDid: "did:plc:amethystpreview", + rkey: "3demo002", + trackName: "Archie, Marry Me", + artists: [{ artistName: "Alvvays" }], + releaseName: "Alvvays", + musicServiceBaseDomain: "spotify.com", + submissionClientAgent: "teal-preview/0.1", + playedTime: new Date(Date.now() - 22 * 60 * 1000).toISOString(), + }, + { + uri: "at://did:plc:cadetpreview/fm.teal.alpha.feed.play/3demo003", + cid: "bafyreitealpreview003", + authorDid: "did:plc:cadetpreview", + rkey: "3demo003", + trackName: "A Walk", + artists: [{ artistName: "Tycho" }], + releaseName: "Dive", + musicServiceBaseDomain: "tidal.com", + submissionClientAgent: "teal-preview/0.1", + playedTime: new Date(Date.now() - 48 * 60 * 1000).toISOString(), + }, +]; + +const demoArtists: ArtistView[] = [ + { name: "Radiohead", playCount: 128 }, + { name: "Alvvays", playCount: 96 }, + { name: "Tycho", playCount: 74 }, +]; + +const demoReleases: ReleaseView[] = [ + { name: "Kid A", playCount: 42 }, + { name: "Alvvays", playCount: 31 }, + { name: "Dive", playCount: 26 }, +]; + +function demoResponse(method: string): T | undefined { + if (!demoFallbackEnabled) return undefined; + + if (method === "fm.teal.alpha.stats.getLatest") return { plays: demoPlays } as T; + if (method === "fm.teal.alpha.stats.getTopArtists") return { artists: demoArtists } as T; + if (method === "fm.teal.alpha.stats.getTopReleases") return { releases: demoReleases } as T; + + return undefined; +} + +async function getXrpc( + method: string, + params: Record = {}, +): Promise { + const url = new URL(`${xrpcBase}/${method}`); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + }); + + try { + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`${method} failed with ${response.status}`); + } + const result = (await response.json()) as T; + const fallback = demoResponse(method); + if ( + fallback && + typeof result === "object" && + result !== null && + Object.values(result).some((value) => Array.isArray(value) && value.length === 0) + ) { + return fallback; + } + return result; + } catch (error) { + const fallback = demoResponse(method); + if (fallback) return fallback; + throw error; + } +} + +export function getLatestPlays(limit = 50) { + return getXrpc<{ plays: PlayView[] }>("fm.teal.alpha.stats.getLatest", { + limit, + }); +} + +export function getActorFeed(authorDID: string, limit = 50) { + return getXrpc<{ plays: PlayView[] }>("fm.teal.alpha.feed.getActorFeed", { + authorDID, + limit, + }); +} + +export function getPlayByUri(uri: string) { + return getXrpc<{ play: PlayView }>("fm.teal.alpha.feed.getPlay", { uri }); +} + +export function getProfile(actor: string) { + return getXrpc<{ profile: ProfileView }>("fm.teal.alpha.actor.getProfile", { + actor, + }); +} + +export function getTopArtists(limit = 5) { + return getXrpc<{ artists: ArtistView[] }>("fm.teal.alpha.stats.getTopArtists", { + limit, + }); +} + +export function getTopReleases(limit = 5) { + return getXrpc<{ releases: ReleaseView[] }>( + "fm.teal.alpha.stats.getTopReleases", + { limit }, + ); +} + +export function coverArtUrl(releaseMbId?: string, size = 250) { + const mbid = releaseMbId?.replace(/^mbid:/, ""); + return mbid ? `https://coverartarchive.org/release/${mbid}/front-${size}` : undefined; +} + +export function displayArtists(play: PlayView) { + return play.artists.map((artist) => artist.artistName).join(", "); +} diff --git a/apps/aqua/src/repos/feed_play.rs b/apps/aqua/src/repos/feed_play.rs index 2f95a573..6b28af6c 100644 --- a/apps/aqua/src/repos/feed_play.rs +++ b/apps/aqua/src/repos/feed_play.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; use jacquard_common::from_json_value; +use jacquard_common::types::string::{AtUri, Did}; use types::fm_teal::alpha::feed::{Artist, PlayView}; use super::{mbid_uri, pg::PgDataSource, utc_to_atrium_datetime}; @@ -51,6 +52,10 @@ impl FeedPlayRepo for PgDataSource { Ok(Some(PlayView { track_name: row.track_name.clone().into(), + uri: AtUri::try_from(row.uri.clone()).ok(), + cid: Some(row.cid.clone().into()), + author_did: Did::new_owned(&row.did).ok(), + rkey: Some(row.rkey.clone().into()), track_mb_id: row.recording_mbid.map(mbid_uri), recording_mb_id: row.recording_mbid.map(mbid_uri), duration: row.duration.map(|d| d as i64), @@ -110,6 +115,10 @@ impl FeedPlayRepo for PgDataSource { result.push(PlayView { track_name: row.track_name.clone().into(), + uri: AtUri::try_from(row.uri.clone()).ok(), + cid: Some(row.cid.clone().into()), + author_did: Did::new_owned(&row.did).ok(), + rkey: Some(row.rkey.clone().into()), track_mb_id: row.recording_mbid.map(mbid_uri), recording_mb_id: row.recording_mbid.map(mbid_uri), duration: row.duration.map(|d| d as i64), diff --git a/apps/aqua/src/repos/stats.rs b/apps/aqua/src/repos/stats.rs index 00496719..7e4634ec 100644 --- a/apps/aqua/src/repos/stats.rs +++ b/apps/aqua/src/repos/stats.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; use jacquard_common::from_json_value; +use jacquard_common::types::string::{AtUri, Did}; use types::fm_teal::alpha::feed::PlayView; use types::fm_teal::alpha::stats::{ArtistView, ReleaseView}; @@ -223,6 +224,10 @@ impl StatsRepo for PgDataSource { result.push(PlayView { track_name: row.track_name.into(), + uri: AtUri::try_from(row.uri.clone()).ok(), + cid: Some(row.cid.clone().into()), + author_did: Did::new_owned(&row.did).ok(), + rkey: Some(row.rkey.clone().into()), track_mb_id: row.recording_mbid.map(mbid_uri), recording_mb_id: row.recording_mbid.map(mbid_uri), duration: row.duration.map(|d| d as i64), diff --git a/apps/aqua/src/xrpc/feed.rs b/apps/aqua/src/xrpc/feed.rs index b9fa97f0..aaaf759c 100644 --- a/apps/aqua/src/xrpc/feed.rs +++ b/apps/aqua/src/xrpc/feed.rs @@ -8,12 +8,16 @@ use types::fm_teal::alpha::feed::PlayView; pub fn feed_routes() -> axum::Router { axum::Router::new() .route("/fm.teal.alpha.feed.getPlay", get(get_feed_play)) + .route("/fm.teal.alpha.feed.getActorFeed", get(get_actor_feed)) .route("/fm.teal.alpha.feed.getPlays", get(get_feed_plays)) } #[derive(Deserialize)] pub struct GetFeedPlayQuery { - pub identity: Option, + #[serde(rename = "authorDID")] + pub author_did: Option, + pub rkey: Option, + pub uri: Option, } #[derive(Serialize)] @@ -26,16 +30,20 @@ pub async fn get_feed_play( axum::extract::Query(query): axum::extract::Query, ) -> Result { let repo = &ctx.db; - let identity = &query.identity; + let uri = match (query.uri, query.author_did, query.rkey) { + (Some(uri), _, _) => uri, + (None, Some(author_did), Some(rkey)) => { + format!("at://{author_did}/fm.teal.alpha.feed.play/{rkey}") + } + _ => { + return Err(( + StatusCode::BAD_REQUEST, + "uri or authorDID and rkey are required".to_string(), + )); + } + }; - if identity.is_none() { - return Err((StatusCode::BAD_REQUEST, "identity is required".to_string())); - } - - match repo - .get_feed_play(identity.as_ref().expect("identity is not none").as_str()) - .await - { + match repo.get_feed_play(&uri).await { Ok(Some(play)) => Ok(axum::Json(GetFeedPlayResponse { play: play.into_static(), })), @@ -44,6 +52,40 @@ pub async fn get_feed_play( } } +#[derive(Deserialize)] +pub struct GetActorFeedQuery { + #[serde(rename = "authorDID")] + pub author_did: String, + pub limit: Option, + pub cursor: Option, +} + +#[derive(Serialize)] +pub struct GetActorFeedResponse { + plays: Vec, +} + +pub async fn get_actor_feed( + Extension(ctx): Extension, + axum::extract::Query(query): axum::extract::Query, +) -> Result { + let repo = &ctx.db; + + if query.author_did.is_empty() { + return Err((StatusCode::BAD_REQUEST, "authorDID is required".to_string())); + } + + // Cursor and limit are accepted for lexicon compatibility; repository pagination is deferred. + let _ = (query.limit, query.cursor); + + match repo.get_feed_plays_for_profile(&[query.author_did]).await { + Ok(plays) => Ok(axum::Json(GetActorFeedResponse { + plays: plays.into_static(), + })), + Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), + } +} + #[derive(Deserialize)] pub struct GetFeedPlaysQuery { pub identities: Vec, diff --git a/compose.dev.yml b/compose.dev.yml index dc68dbca..5fc89155 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -1,4 +1,22 @@ services: + amethyst: + build: + context: . + dockerfile: apps/amethyst/Dockerfile + args: + CLIENT_ADDRESS: ${CLIENT_ADDRESS:-localhost} + EXPO_PUBLIC_BASE_URL: ${EXPO_PUBLIC_BASE_URL:-http://localhost:8081} + EXPO_PUBLIC_AQUA_URL: ${EXPO_PUBLIC_AQUA_URL:-http://localhost:3000} + EXPO_PUBLIC_DID_WEB: ${EXPO_PUBLIC_DID_WEB:-did:web:localhost} + ports: + - "8081:80" + environment: + CLIENT_ADDRESS: ${CLIENT_ADDRESS:-:80} + networks: + - app_network + depends_on: + - aqua-api + aqua-api: build: context: . @@ -6,45 +24,35 @@ services: container_name: aqua-app ports: - "3000:3000" - extra_hosts: - - "host.docker.internal:host-gateway" - networks: - - app_network - depends_on: - - postgres env_file: - .env environment: - DATABASE_URL: ${DOCKER_DB_URL} - amethyst: - build: - context: . - dockerfile: apps/amethyst/Dockerfile - args: - - CLIENT_ADDRESS=${CLIENT_ADDRESS} - ports: - - "80:80" - - "443:443" - - "443:443/udp" - volumes: - - caddy_data:/data - - caddy_config:/config + DATABASE_URL: ${DOCKER_DB_URL:-postgres://teal:teal@postgres:5432/teal} + REDIS_URL: ${REDIS_URL:-redis://garnet:6379} networks: - app_network - environment: - CLIENT_ADDRESS: ${CLIENT_ADDRESS} + depends_on: + - postgres + - garnet cadet: build: context: . dockerfile: services/cadet/Dockerfile container_name: cadet-app - ports: - - "3001:3000" + env_file: + - .env + environment: + DATABASE_URL: ${DOCKER_DB_URL:-postgres://teal:teal@postgres:5432/teal} + REDIS_URL: ${REDIS_URL:-redis://garnet:6379} + CADET_STREAM_MODE: ${CADET_STREAM_MODE:-jetstream} + JETSTREAM_URL: ${JETSTREAM_URL:-wss://jetstream1.us-east.bsky.network/subscribe} + CADET_CURSOR_REDIS_KEY: ${CADET_CURSOR_REDIS_KEY:-cadet:jetstream:cursor} networks: - app_network depends_on: - postgres + - garnet satellite: image: ghcr.io/espeon/satellite @@ -53,24 +61,21 @@ services: env_file: - .env environment: - DATABASE_URL: ${DOCKER_DB_URL} - extra_hosts: - - "host.docker.internal:host-gateway" + DATABASE_URL: ${DOCKER_DB_URL:-postgres://teal:teal@postgres:5432/teal} networks: - app_network depends_on: - postgres - piper: - image: ghcr.io/teal-fm/piper:main - # Depends on your .env.air - ports: - - "8080:8080" - env_file: - - .env.air - volumes: - - piper_data:/db - - garnet + cloudflared: + image: cloudflare/cloudflared:latest + profiles: + - tunnel + command: tunnel --no-autoupdate --url http://amethyst:80 + networks: + - app_network + depends_on: + - amethyst garnet: image: ghcr.io/microsoft/garnet:latest @@ -79,48 +84,27 @@ services: - "6379:6379" volumes: - garnet_data:/data - command: --storage-tier Storage --index-size 1g networks: - app_network postgres: - image: postgres:latest + image: postgres:16 container_name: postgres_db environment: - POSTGRES_USER: ${DB_USER} - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER:-teal} + POSTGRES_PASSWORD: ${DB_PASSWORD:-teal} + POSTGRES_DB: ${DB_NAME:-teal} ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - extra_hosts: - - "host.docker.internal:host-gateway" networks: - app_network - # traefik: - # image: traefik:v2.10 - # container_name: traefik - # command: - # - "--api.insecure=true" - # - "--providers.file.directory=/etc/traefik/dynamic" - # - "--providers.file.watch=true" - # - "--entrypoints.web.address=:80" - # ports: - # - "80:80" # HTTP - # - "8080:8080" # Dashboard - # volumes: - # - ./traefik/dynamic:/etc/traefik/dynamic:ro - # networks: - # - app_network - # extra_hosts: - # - "host.docker.internal:host-gateway" # This allows reaching host machine + networks: app_network: driver: bridge + volumes: - postgres_data: - caddy_data: - caddy_config: - piper_data: garnet_data: + postgres_data: diff --git a/compose.yaml b/compose.yaml index 48329757..9fb15fd6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,4 +1,27 @@ services: + amethyst: + build: + context: . + dockerfile: apps/amethyst/Dockerfile + args: + CLIENT_ADDRESS: ${CLIENT_ADDRESS:-localhost} + EXPO_PUBLIC_BASE_URL: ${EXPO_PUBLIC_BASE_URL:-http://localhost} + EXPO_PUBLIC_AQUA_URL: ${EXPO_PUBLIC_AQUA_URL:-http://localhost:3000} + EXPO_PUBLIC_DID_WEB: ${EXPO_PUBLIC_DID_WEB:-did:web:localhost} + ports: + - "80:80" + - "443:443" + - "443:443/udp" + environment: + CLIENT_ADDRESS: ${CLIENT_ADDRESS:-:80} + volumes: + - caddy_data:/data + - caddy_config:/config + networks: + - app_network + depends_on: + - aqua-api + aqua-api: build: context: . @@ -6,10 +29,79 @@ services: container_name: aqua-app ports: - "3000:3000" + env_file: + - .env + environment: + DATABASE_URL: ${DOCKER_DB_URL:-postgres://teal:teal@postgres:5432/teal} + REDIS_URL: ${REDIS_URL:-redis://garnet:6379} + networks: + - app_network + depends_on: + - postgres + - garnet + + cadet: + build: + context: . + dockerfile: services/cadet/Dockerfile + container_name: cadet-app + env_file: + - .env + environment: + DATABASE_URL: ${DOCKER_DB_URL:-postgres://teal:teal@postgres:5432/teal} + REDIS_URL: ${REDIS_URL:-redis://garnet:6379} + CADET_STREAM_MODE: ${CADET_STREAM_MODE:-jetstream} + JETSTREAM_URL: ${JETSTREAM_URL:-wss://jetstream1.us-east.bsky.network/subscribe} + CADET_CURSOR_REDIS_KEY: ${CADET_CURSOR_REDIS_KEY:-cadet:jetstream:cursor} networks: - app_network depends_on: - postgres - garnet + + satellite: + image: ghcr.io/espeon/satellite env_file: - .env + environment: + DATABASE_URL: ${DOCKER_DB_URL:-postgres://teal:teal@postgres:5432/teal} + ports: + - "3132:3000" + networks: + - app_network + depends_on: + - postgres + + garnet: + image: ghcr.io/microsoft/garnet:latest + container_name: garnet + ports: + - "6379:6379" + volumes: + - garnet_data:/data + networks: + - app_network + + postgres: + image: postgres:16 + container_name: postgres_db + environment: + POSTGRES_USER: ${DB_USER:-teal} + POSTGRES_PASSWORD: ${DB_PASSWORD:-teal} + POSTGRES_DB: ${DB_NAME:-teal} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - app_network + +networks: + app_network: + driver: bridge + +volumes: + caddy_data: + caddy_config: + garnet_data: + postgres_data: diff --git a/lexicons/fm.teal.alpha/feed/defs.json b/lexicons/fm.teal.alpha/feed/defs.json index ee26698e..265f0f7e 100644 --- a/lexicons/fm.teal.alpha/feed/defs.json +++ b/lexicons/fm.teal.alpha/feed/defs.json @@ -7,6 +7,24 @@ "type": "object", "required": ["trackName", "artists"], "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "description": "The AT URI for this play record" + }, + "cid": { + "type": "string", + "description": "The CID for this play record" + }, + "authorDid": { + "type": "string", + "format": "did", + "description": "The DID of the account that authored this play" + }, + "rkey": { + "type": "string", + "description": "The record key for this play" + }, "trackName": { "type": "string", "minLength": 1, diff --git a/packages/lexicons/lex-gen.sh b/packages/lexicons/lex-gen.sh index bd5e6b06..f4b43efe 100755 --- a/packages/lexicons/lex-gen.sh +++ b/packages/lexicons/lex-gen.sh @@ -1,9 +1,12 @@ #!/bin/bash set -e -# Navigate to the lexicons directory and find all .json files +# Navigate to the lexicons directory and find Teal schemas plus the upstream +# schemas referenced by Teal records. Avoid generating the full ATProto tree: +# newer upstream lexicons may use syntax unsupported by this repo's lex-cli. cd ../../lexicons -json_files=$(find . -name "*.json" -type f) +json_files=$(find ./fm.teal.alpha -name "*.json" -type f) +json_files="$json_files ./app/bsky/richtext/facet.json" # Go back to the lexicons package directory cd ../packages/lexicons diff --git a/services/cadet/src/cursor.rs b/services/cadet/src/cursor.rs index d2a35a28..186236cb 100644 --- a/services/cadet/src/cursor.rs +++ b/services/cadet/src/cursor.rs @@ -1,14 +1,85 @@ +use redis::AsyncCommands; +use tracing::warn; + +fn cursor_file() -> String { + std::env::var("CURSOR_FILE").unwrap_or_else(|_| "./cursor.txt".to_string()) +} + +fn cursor_key() -> String { + std::env::var("CADET_CURSOR_REDIS_KEY").unwrap_or_else(|_| "cadet:jetstream:cursor".to_string()) +} + +async fn redis_connection() -> anyhow::Result { + let redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + let client = redis::Client::open(redis_url)?; + Ok(client.get_multiplexed_async_connection().await?) +} + pub async fn store_cursor(cursor: u64) -> anyhow::Result<()> { - // get cursor location from env CURSOR_FILE - let cursor_file = std::env::var("CURSOR_FILE").unwrap_or_else(|_| "./cursor.txt".to_string()); - tokio::fs::write(cursor_file, cursor.to_string()).await?; - Ok(()) + match redis_connection().await { + Ok(mut conn) => { + let _: () = conn.set(cursor_key(), cursor).await?; + Ok(()) + } + Err(e) => { + warn!( + "Redis cursor store unavailable, falling back to file: {}", + e + ); + tokio::fs::write(cursor_file(), cursor.to_string()).await?; + Ok(()) + } + } } pub async fn load_cursor() -> Option { - let cursor_file = std::env::var("CURSOR_FILE").unwrap_or_else(|_| "./cursor.txt".to_string()); - tokio::fs::read_to_string(cursor_file) + if let Ok(mut conn) = redis_connection().await { + match conn.get::<_, Option>(cursor_key()).await { + Ok(Some(cursor)) => return Some(cursor), + Ok(None) => {} + Err(e) => warn!("Redis cursor load failed, falling back to file: {}", e), + } + } + + tokio::fs::read_to_string(cursor_file()) .await .ok() .and_then(|s| s.parse().ok()) } + +#[cfg(test)] +mod tests { + use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + + fn unique_cursor_file() -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + std::env::temp_dir().join(format!("cadet-cursor-{suffix}.txt")) + } + + #[tokio::test] + async fn stores_and_loads_cursor_from_file_when_redis_is_unavailable() { + let cursor_file = unique_cursor_file(); + std::env::set_var("REDIS_URL", "redis://127.0.0.1:0"); + std::env::set_var("CURSOR_FILE", &cursor_file); + std::env::set_var("CADET_CURSOR_REDIS_KEY", "cadet:test:cursor"); + + store_cursor(42).await.expect("cursor should store"); + + let loaded = load_cursor().await; + assert_eq!(loaded, Some(42)); + + let _ = tokio::fs::remove_file(cursor_file).await; + std::env::remove_var("REDIS_URL"); + std::env::remove_var("CURSOR_FILE"); + std::env::remove_var("CADET_CURSOR_REDIS_KEY"); + } +} diff --git a/services/cadet/src/ingestors/teal/feed_play.rs b/services/cadet/src/ingestors/teal/feed_play.rs index 745b5992..32e84e7a 100644 --- a/services/cadet/src/ingestors/teal/feed_play.rs +++ b/services/cadet/src/ingestors/teal/feed_play.rs @@ -1576,6 +1576,10 @@ impl PlayIngestor { } async fn remove_play(&self, uri: &str) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM play_to_artists_extended WHERE play_uri = $1") + .bind(uri) + .execute(&self.sql) + .await?; sqlx::query!("DELETE FROM play_to_artists WHERE play_uri = $1", uri) .execute(&self.sql) .await?; @@ -1609,8 +1613,9 @@ impl LexiconIngestor for PlayIngestor { } } } else { - println!("{}: Message {} deleted", message.did, commit.rkey); - self.remove_play(&message.did).await?; + let uri = assemble_at_uri(&message.did, &commit.collection, &commit.rkey); + tracing::info!("{}: Play {} deleted", message.did, uri); + self.remove_play(&uri).await?; } } else { return Err(anyhow!("Message has no commit")); diff --git a/services/cadet/src/main.rs b/services/cadet/src/main.rs index 7b7fe494..3d6be94f 100644 --- a/services/cadet/src/main.rs +++ b/services/cadet/src/main.rs @@ -5,10 +5,11 @@ use std::{ use cursor::load_cursor; use metrics_exporter_prometheus::PrometheusBuilder; -use tracing::error; +use tracing::{error, info}; use rocketman::{ connection::JetstreamConnection, + endpoints::JetstreamEndpoints, handler, ingestion::{DefaultLexiconIngestor, LexiconIngestor}, options::JetstreamOptions, @@ -43,11 +44,30 @@ async fn main() { setup_tracing(); setup_metrics(); + let stream_mode = + std::env::var("CADET_STREAM_MODE").unwrap_or_else(|_| "jetstream".to_string()); + let jetstream_url = std::env::var("JETSTREAM_URL") + .unwrap_or_else(|_| "wss://jetstream1.us-east.bsky.network/subscribe".to_string()); + + if stream_mode != "jetstream" { + error!( + "Unsupported CADET_STREAM_MODE={}. subscribeRepos is reserved for a later CBOR firehose adapter; use jetstream for now.", + stream_mode + ); + std::process::exit(1); + } + + info!( + "Starting Cadet in {} mode with Jetstream endpoint {}", + stream_mode, jetstream_url + ); + let pool = db::init_pool() .await .expect("Could not get PostgreSQL pool"); let opts = JetstreamOptions::builder() + .ws_url(JetstreamEndpoints::Custom(jetstream_url.clone())) .wanted_collections( [ "fm.teal.alpha.feed.play", diff --git a/todo.md b/todo.md new file mode 100644 index 00000000..dfaf14a2 --- /dev/null +++ b/todo.md @@ -0,0 +1,65 @@ +# Teal Songish Clone TODO + +This file is the working handoff for the Songish-style Teal clone. Keep it updated as implementation and QA move forward. + +## Current State + +- Amethyst has a Teal-branded Songish-style shell with desktop navigation, mobile navigation, Home, Explore, Notifications, Profile, and music detail views. +- Aqua exposes Teal XRPC routes for latest plays, individual plays, actor feeds, profiles, and stats. +- Cadet consumes Teal records from Jetstream, stores a durable cursor in Redis with file fallback, ingests profiles and plays, and deletes plays by AT URI. +- Development and production Compose files include Amethyst, Aqua, Cadet, Satellite, Postgres, and Garnet. +- Development Compose includes an optional Cloudflare Tunnel profile. +- Current temporary UI preview: `https://directory-extensive-viewer-agreement.trycloudflare.com` + - This is an account-less Cloudflare quick tunnel. It remains available while the local tunnel process is running and its hostname will change after restart. + - The preview serves the current Amethyst export and proxies `/xrpc/*` to the locally running Aqua API through the same public hostname. + - The current preview build enables a demo fallback when Aqua returns an empty feed, so the UI remains inspectable while Cadet fills the local index from Jetstream. + - The current preview build embeds `EXPO_PUBLIC_BASE_URL=https://directory-extensive-viewer-agreement.trycloudflare.com` and serves a matching `/client-metadata.json` OAuth redirect. + - OAuth callback testing still requires the stable-host work below. + +## Next: Public Demo And OAuth + +- [ ] Reserve a stable Cloudflare Tunnel hostname for development OAuth testing. Quick tunnels are useful for UI previews but their random hostnames change after restart. +- [ ] Route the stable public hostname to Amethyst and expose Aqua through a public HTTPS origin or a same-origin reverse proxy. +- [ ] Build Amethyst with `EXPO_PUBLIC_BASE_URL=https://` and `EXPO_PUBLIC_AQUA_URL=https://`. +- [ ] Serve `/client-metadata.json` with `redirect_uris=["https:///auth/callback"]`. +- [ ] Complete ATProto OAuth sign-in and callback QA through the stable public hostname. +- [ ] Document the stable tunnel token or named-tunnel setup without committing secrets. + +## Next: Firehose Ingestion + +- [ ] Add Cadet create, update, and delete integration tests for `fm.teal.alpha.feed.play`. +- [ ] Add profile ingestion integration tests for `fm.teal.alpha.actor.profile`. +- [ ] Verify Jetstream filtering against `wantedCollections=fm.teal.alpha.feed.play` in a live environment. +- [ ] Verify Cadet cursor recovery after restart with Garnet enabled. +- [ ] Verify delete handling removes the play URI from `plays`, `play_to_artists`, and `play_to_artists_extended`. +- [ ] Add a `subscribeRepos` CBOR adapter only if relay-level firehose sync becomes necessary. +- [ ] Keep CAR import as a backfill path and add regression tests for it. + +## Next: Aqua And Lexicons + +- [ ] Add pagination support for `fm.teal.alpha.feed.getActorFeed` cursor and limit parameters. +- [ ] Run SQLx prepare against the development Postgres instance and commit refreshed query cache data. +- [ ] Resolve the existing Satellite SQLx offline-cache gap so `pnpm turbo run test:rust` passes without a live Docker hostname. +- [ ] Decide whether the legacy `play_to_artists` join table can be removed after Aqua reads move fully to `play_to_artists_extended`. +- [ ] Validate the Teal lexicons and regenerate Rust and TypeScript bindings before each PR. + +## Next: Amethyst UI + +- [ ] Finish profile avatar and banner blob URL rendering. +- [ ] Add artist and release detail routes in addition to track detail. +- [ ] Render real Cover Art Archive images for recordings with MusicBrainz IDs and polished fallbacks for missing art. +- [ ] Exercise empty, loading, error, signed-out, and populated feed states at desktop and mobile widths. +- [ ] Verify SPA fallback routing in the production Caddy image for Home, Explore, Notifications, Profile, music detail, and OAuth callback routes. +- [ ] Capture final Chrome screenshots after Aqua and Cadet are running with live ingested data. + +## Verification Commands + +```bash +pnpm lex:gen-server +SQLX_OFFLINE=true cargo check -p aqua -p cadet +SQLX_OFFLINE=true cargo test -p cadet stores_and_loads_cursor_from_file_when_redis_is_unavailable +pnpm --filter=@teal/amethyst build:web +docker compose -f compose.dev.yml config +docker compose -f compose.yaml config +docker compose -f compose.dev.yml --profile tunnel up +``` From a9127fdba760d3e04eccfbeadbc79604960dd926 Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Sat, 30 May 2026 22:49:16 -0500 Subject: [PATCH 010/171] docs(agent): add teal clone working workflow Capture checkpoint commit discipline, todo handoff updates, OrbStack preview startup, same-origin XRPC proxying, and Cloudflare OAuth callback verification for future sessions. --- AGENT.md | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 AGENT.md diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 00000000..4c363c91 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,99 @@ +# Teal Clone Agent Workflow + +This file is the operational addendum for work on the Songish-style Teal clone. Read `AGENTS.md` first for the full repository development guidelines. + +## Keep The Handoff Current + +- Read `todo.md` before starting work. +- Update `todo.md` whenever implementation state, blockers, public preview URLs, or next steps change. +- Keep future work concrete: name the service, route, test, or deployment action that remains. +- Do not commit secrets, tunnel tokens, OAuth codes, or private credentials. + +## Commit As You Go + +- Work on a feature branch. Do not push directly to `main`. +- Create focused checkpoint commits after each meaningful, verified unit of work. +- Use the repository commit format: `type(scope): description`. +- Before committing, inspect `git status --short` and leave unrelated user files unstaged. +- Do not wait until the end of a long task to make the first commit. +- Report the commit hash after each checkpoint. + +## Public Preview Workflow + +- Use OrbStack for local Docker services when available. +- Start Postgres and Garnet before Aqua and Cadet: + +```bash +open -a OrbStack +docker compose -f compose.dev.yml up -d postgres garnet +DATABASE_URL=postgres://teal:teal@127.0.0.1:5432/teal pnpm db:migrate +``` + +- Aqua should run against local Postgres and Garnet. +- Cadet should run continuously with Jetstream ingestion enabled: + +```bash +DATABASE_URL=postgres://teal:teal@127.0.0.1:5432/teal \ +REDIS_URL=redis://127.0.0.1:6379 \ +CADET_STREAM_MODE=jetstream \ +JETSTREAM_URL=wss://jetstream1.us-east.bsky.network/subscribe \ +SQLX_OFFLINE=true cargo run -p cadet +``` + +- Serve Amethyst and proxy `/xrpc/*` to Aqua through the same public hostname. +- For temporary demos, a Cloudflare quick tunnel is acceptable. Record the active URL in `todo.md`. +- Treat quick-tunnel URLs as ephemeral. A tunnel restart changes the hostname. + +## OAuth Tunnel Rule + +- Before testing ATProto OAuth, rebuild Amethyst with the active public origin: + +```bash +EXPO_PUBLIC_BASE_URL=https:// \ +pnpm --filter=@teal/amethyst build:web +``` + +- Serve `/client-metadata.json` from the same public hostname. +- Ensure the metadata uses: + +```text +client_id=https:///client-metadata.json +redirect_uris=["https:///auth/callback"] +``` + +- Verify the deployed web bundle contains the active tunnel hostname. +- Verify `/client-metadata.json` publicly before initiating login. +- Never test public OAuth with a bundle that falls back to `localhost` or `127.0.0.1`. + +## Preview Feed Rule + +- The production data path is Cadet Jetstream ingestion into Postgres, surfaced by Aqua XRPC. +- Keep `/xrpc/*` same-origin through the Amethyst reverse proxy. +- Preview-only fallback data may be enabled with: + +```bash +EXPO_PUBLIC_ENABLE_DEMO_FALLBACK=true +``` + +- Demo fallback must remain opt-in and must not replace live ingestion in production. + +## Minimum Verification + +Run the relevant subset before checkpoint commits: + +```bash +pnpm lex:gen-server +SQLX_OFFLINE=true cargo check -p aqua -p cadet +SQLX_OFFLINE=true cargo test -p cadet stores_and_loads_cursor_from_file_when_redis_is_unavailable +pnpm --filter=@teal/amethyst build:web +docker compose -f compose.dev.yml config +docker compose -f compose.yaml config +``` + +For public-preview changes, also verify: + +```bash +curl --fail https:///client-metadata.json +curl --fail "https:///xrpc/fm.teal.alpha.stats.getLatest?limit=5" +``` + From 975cc51e12d286b22442d98c02bbb72a9422c8f0 Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Sat, 30 May 2026 22:58:40 -0500 Subject: [PATCH 011/171] fix(cadet): unblock live jetstream play ingestion Remove Amethyst demo fallback data, replace blocking discriminant lookups with normal async awaits, and read canonical extended artist joins from Aqua so live Jetstream records reach the public feed with artist metadata. --- ...675eceb89f1d14e52174bda67dc65cc68d273.json | 35 -------- ...87193483ca5ba76659baf8b18b35cab952e0d.json | 34 ++++++++ ...8bdbb519f5cb2f21131635aa68ca523611a3d.json | 35 ++++++++ ...47338dbc276a4cbf8e116e7dd11bc3c7949e.json} | 4 +- ...7b3f6aa61951da0c7618f5c06324f25b0afd.json} | 4 +- ...3643e497db9a1f01d4d51b99dfdbddd2d4c0e.json | 34 -------- ...fee801076979110792acbdb5dc17e87d9a37.json} | 4 +- AGENT.md | 13 +-- apps/amethyst/lib/teal/api.ts | 86 +------------------ apps/aqua/src/repos/feed_play.rs | 18 ++-- apps/aqua/src/repos/stats.rs | 61 +++++++------ .../cadet/src/ingestors/teal/feed_play.rs | 63 ++++++-------- todo.md | 6 +- 13 files changed, 151 insertions(+), 246 deletions(-) delete mode 100644 .sqlx/query-0e053ba402c8b769b697f60d189675eceb89f1d14e52174bda67dc65cc68d273.json create mode 100644 .sqlx/query-348a15835fbf1e1f62a09e5f94287193483ca5ba76659baf8b18b35cab952e0d.json create mode 100644 .sqlx/query-3c1690ead831005f4469309ec6b8bdbb519f5cb2f21131635aa68ca523611a3d.json rename .sqlx/{query-f224b252a34a67a71266caca5affc5022e74dc42496aef9e61cec0e86d80f9d0.json => query-5edc2de23cd7ca5b0c3c16e4eb1947338dbc276a4cbf8e116e7dd11bc3c7949e.json} (74%) rename .sqlx/{query-0ff59e15ce4faa50bb4b9996ae7877681060ed462a7905012f8097c9545f60b1.json => query-7fa22b474b224ecad073e47528ca7b3f6aa61951da0c7618f5c06324f25b0afd.json} (73%) delete mode 100644 .sqlx/query-b8bf07c21c04acf3b4d908b2db93643e497db9a1f01d4d51b99dfdbddd2d4c0e.json rename .sqlx/{query-651c94b4edd5afa55c3679a5f8c1ef1cbe53f7dac01b050ec7ad9100950527c0.json => query-f90899d33bea3bb7fef53c14fd2dfee801076979110792acbdb5dc17e87d9a37.json} (74%) diff --git a/.sqlx/query-0e053ba402c8b769b697f60d189675eceb89f1d14e52174bda67dc65cc68d273.json b/.sqlx/query-0e053ba402c8b769b697f60d189675eceb89f1d14e52174bda67dc65cc68d273.json deleted file mode 100644 index 8bda3ba8..00000000 --- a/.sqlx/query-0e053ba402c8b769b697f60d189675eceb89f1d14e52174bda67dc65cc68d273.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n pta.artist_mbid as mbid,\n pta.artist_name as name,\n COUNT(*) as play_count\n FROM plays p\n INNER JOIN play_to_artists pta ON p.uri = pta.play_uri\n WHERE p.did = $1\n AND pta.artist_mbid IS NOT NULL\n AND pta.artist_name IS NOT NULL\n GROUP BY pta.artist_mbid, pta.artist_name\n ORDER BY play_count DESC\n LIMIT $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "mbid", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "play_count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Int8" - ] - }, - "nullable": [ - false, - true, - null - ] - }, - "hash": "0e053ba402c8b769b697f60d189675eceb89f1d14e52174bda67dc65cc68d273" -} diff --git a/.sqlx/query-348a15835fbf1e1f62a09e5f94287193483ca5ba76659baf8b18b35cab952e0d.json b/.sqlx/query-348a15835fbf1e1f62a09e5f94287193483ca5ba76659baf8b18b35cab952e0d.json new file mode 100644 index 00000000..0730cafd --- /dev/null +++ b/.sqlx/query-348a15835fbf1e1f62a09e5f94287193483ca5ba76659baf8b18b35cab952e0d.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n ae.mbid,\n ptae.artist_name as name,\n COUNT(*) as play_count\n FROM plays p\n INNER JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri\n INNER JOIN artists_extended ae ON ptae.artist_id = ae.id\n WHERE ptae.artist_name IS NOT NULL\n GROUP BY ae.mbid, ptae.artist_name\n ORDER BY play_count DESC\n LIMIT $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mbid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "play_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + true, + false, + null + ] + }, + "hash": "348a15835fbf1e1f62a09e5f94287193483ca5ba76659baf8b18b35cab952e0d" +} diff --git a/.sqlx/query-3c1690ead831005f4469309ec6b8bdbb519f5cb2f21131635aa68ca523611a3d.json b/.sqlx/query-3c1690ead831005f4469309ec6b8bdbb519f5cb2f21131635aa68ca523611a3d.json new file mode 100644 index 00000000..80d78b17 --- /dev/null +++ b/.sqlx/query-3c1690ead831005f4469309ec6b8bdbb519f5cb2f21131635aa68ca523611a3d.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n ae.mbid,\n ptae.artist_name as name,\n COUNT(*) as play_count\n FROM plays p\n INNER JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri\n INNER JOIN artists_extended ae ON ptae.artist_id = ae.id\n WHERE p.did = $1\n AND ptae.artist_name IS NOT NULL\n GROUP BY ae.mbid, ptae.artist_name\n ORDER BY play_count DESC\n LIMIT $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mbid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "play_count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + true, + false, + null + ] + }, + "hash": "3c1690ead831005f4469309ec6b8bdbb519f5cb2f21131635aa68ca523611a3d" +} diff --git a/.sqlx/query-f224b252a34a67a71266caca5affc5022e74dc42496aef9e61cec0e86d80f9d0.json b/.sqlx/query-5edc2de23cd7ca5b0c3c16e4eb1947338dbc276a4cbf8e116e7dd11bc3c7949e.json similarity index 74% rename from .sqlx/query-f224b252a34a67a71266caca5affc5022e74dc42496aef9e61cec0e86d80f9d0.json rename to .sqlx/query-5edc2de23cd7ca5b0c3c16e4eb1947338dbc276a4cbf8e116e7dd11bc3c7949e.json index 0dfa066c..62c174c5 100644 --- a/.sqlx/query-f224b252a34a67a71266caca5affc5022e74dc42496aef9e61cec0e86d80f9d0.json +++ b/.sqlx/query-5edc2de23cd7ca5b0c3c16e4eb1947338dbc276a4cbf8e116e7dd11bc3c7949e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url,\n COALESCE(\n json_agg(\n json_build_object(\n 'artist_mbid', pta.artist_mbid,\n 'artist_name', pta.artist_name\n )\n ) FILTER (WHERE pta.artist_name IS NOT NULL),\n '[]'\n ) AS artists\n FROM plays p\n LEFT JOIN play_to_artists as pta ON p.uri = pta.play_uri\n GROUP BY uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url\n ORDER BY processed_time DESC\n LIMIT $1\n ", + "query": "\n SELECT\n uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url,\n COALESCE(\n json_agg(\n json_build_object(\n 'artistMbId', ae.mbid,\n 'artistName', ptae.artist_name\n )\n ) FILTER (WHERE ptae.artist_name IS NOT NULL),\n '[]'\n ) AS artists\n FROM plays p\n LEFT JOIN play_to_artists_extended as ptae ON p.uri = ptae.play_uri\n LEFT JOIN artists_extended as ae ON ptae.artist_id = ae.id\n GROUP BY uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url\n ORDER BY processed_time DESC\n LIMIT $1\n ", "describe": { "columns": [ { @@ -108,5 +108,5 @@ null ] }, - "hash": "f224b252a34a67a71266caca5affc5022e74dc42496aef9e61cec0e86d80f9d0" + "hash": "5edc2de23cd7ca5b0c3c16e4eb1947338dbc276a4cbf8e116e7dd11bc3c7949e" } diff --git a/.sqlx/query-0ff59e15ce4faa50bb4b9996ae7877681060ed462a7905012f8097c9545f60b1.json b/.sqlx/query-7fa22b474b224ecad073e47528ca7b3f6aa61951da0c7618f5c06324f25b0afd.json similarity index 73% rename from .sqlx/query-0ff59e15ce4faa50bb4b9996ae7877681060ed462a7905012f8097c9545f60b1.json rename to .sqlx/query-7fa22b474b224ecad073e47528ca7b3f6aa61951da0c7618f5c06324f25b0afd.json index de2e1a25..94f2bcd0 100644 --- a/.sqlx/query-0ff59e15ce4faa50bb4b9996ae7877681060ed462a7905012f8097c9545f60b1.json +++ b/.sqlx/query-7fa22b474b224ecad073e47528ca7b3f6aa61951da0c7618f5c06324f25b0afd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url,\n COALESCE(\n json_agg(\n json_build_object(\n 'artist_mbid', pta.artist_mbid,\n 'artist_name', pta.artist_name\n )\n ) FILTER (WHERE pta.artist_name IS NOT NULL),\n '[]'\n ) AS artists\n FROM plays\n LEFT JOIN play_to_artists as pta ON uri = pta.play_uri\n WHERE did = ANY($1)\n GROUP BY uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url\n ORDER BY processed_time desc\n ", + "query": "\n SELECT\n uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url,\n COALESCE(\n json_agg(\n json_build_object(\n 'artistMbId', ae.mbid,\n 'artistName', ptae.artist_name\n )\n ) FILTER (WHERE ptae.artist_name IS NOT NULL),\n '[]'\n ) AS artists\n FROM plays\n LEFT JOIN play_to_artists_extended as ptae ON uri = ptae.play_uri\n LEFT JOIN artists_extended as ae ON ptae.artist_id = ae.id\n WHERE did = ANY($1)\n GROUP BY uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url\n ORDER BY processed_time desc\n ", "describe": { "columns": [ { @@ -108,5 +108,5 @@ null ] }, - "hash": "0ff59e15ce4faa50bb4b9996ae7877681060ed462a7905012f8097c9545f60b1" + "hash": "7fa22b474b224ecad073e47528ca7b3f6aa61951da0c7618f5c06324f25b0afd" } diff --git a/.sqlx/query-b8bf07c21c04acf3b4d908b2db93643e497db9a1f01d4d51b99dfdbddd2d4c0e.json b/.sqlx/query-b8bf07c21c04acf3b4d908b2db93643e497db9a1f01d4d51b99dfdbddd2d4c0e.json deleted file mode 100644 index 7d29a7f8..00000000 --- a/.sqlx/query-b8bf07c21c04acf3b4d908b2db93643e497db9a1f01d4d51b99dfdbddd2d4c0e.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n pta.artist_mbid as mbid,\n pta.artist_name as name,\n COUNT(*) as play_count\n FROM plays p\n INNER JOIN play_to_artists pta ON p.uri = pta.play_uri\n WHERE pta.artist_mbid IS NOT NULL\n AND pta.artist_name IS NOT NULL\n GROUP BY pta.artist_mbid, pta.artist_name\n ORDER BY play_count DESC\n LIMIT $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "mbid", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "play_count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - true, - null - ] - }, - "hash": "b8bf07c21c04acf3b4d908b2db93643e497db9a1f01d4d51b99dfdbddd2d4c0e" -} diff --git a/.sqlx/query-651c94b4edd5afa55c3679a5f8c1ef1cbe53f7dac01b050ec7ad9100950527c0.json b/.sqlx/query-f90899d33bea3bb7fef53c14fd2dfee801076979110792acbdb5dc17e87d9a37.json similarity index 74% rename from .sqlx/query-651c94b4edd5afa55c3679a5f8c1ef1cbe53f7dac01b050ec7ad9100950527c0.json rename to .sqlx/query-f90899d33bea3bb7fef53c14fd2dfee801076979110792acbdb5dc17e87d9a37.json index f0b773f5..2e6ead27 100644 --- a/.sqlx/query-651c94b4edd5afa55c3679a5f8c1ef1cbe53f7dac01b050ec7ad9100950527c0.json +++ b/.sqlx/query-f90899d33bea3bb7fef53c14fd2dfee801076979110792acbdb5dc17e87d9a37.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url,\n COALESCE(\n json_agg(\n json_build_object(\n 'artist_mbid', pta.artist_mbid,\n 'artist_name', pta.artist_name\n )\n ) FILTER (WHERE pta.artist_name IS NOT NULL),\n '[]'\n ) AS artists\n FROM plays\n LEFT JOIN play_to_artists as pta ON uri = pta.play_uri\n WHERE uri = $1\n GROUP BY uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url\n ORDER BY processed_time desc\n ", + "query": "\n SELECT\n uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url,\n COALESCE(\n json_agg(\n json_build_object(\n 'artistMbId', ae.mbid,\n 'artistName', ptae.artist_name\n )\n ) FILTER (WHERE ptae.artist_name IS NOT NULL),\n '[]'\n ) AS artists\n FROM plays\n LEFT JOIN play_to_artists_extended as ptae ON uri = ptae.play_uri\n LEFT JOIN artists_extended as ae ON ptae.artist_id = ae.id\n WHERE uri = $1\n GROUP BY uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time,\n release_mbid, release_name, recording_mbid, submission_client_agent,\n music_service_base_domain, origin_url\n ORDER BY processed_time desc\n ", "describe": { "columns": [ { @@ -108,5 +108,5 @@ null ] }, - "hash": "651c94b4edd5afa55c3679a5f8c1ef1cbe53f7dac01b050ec7ad9100950527c0" + "hash": "f90899d33bea3bb7fef53c14fd2dfee801076979110792acbdb5dc17e87d9a37" } diff --git a/AGENT.md b/AGENT.md index 4c363c91..25097919 100644 --- a/AGENT.md +++ b/AGENT.md @@ -65,17 +65,13 @@ redirect_uris=["https:///auth/callback"] - Verify `/client-metadata.json` publicly before initiating login. - Never test public OAuth with a bundle that falls back to `localhost` or `127.0.0.1`. -## Preview Feed Rule +## Live Feed Rule - The production data path is Cadet Jetstream ingestion into Postgres, surfaced by Aqua XRPC. - Keep `/xrpc/*` same-origin through the Amethyst reverse proxy. -- Preview-only fallback data may be enabled with: - -```bash -EXPO_PUBLIC_ENABLE_DEMO_FALLBACK=true -``` - -- Demo fallback must remain opt-in and must not replace live ingestion in production. +- Never add backup, seeded, mocked, or demo play data to Amethyst. +- If Aqua is unavailable, show an error state. +- If Aqua has not indexed plays yet, show an empty state. ## Minimum Verification @@ -96,4 +92,3 @@ For public-preview changes, also verify: curl --fail https:///client-metadata.json curl --fail "https:///xrpc/fm.teal.alpha.stats.getLatest?limit=5" ``` - diff --git a/apps/amethyst/lib/teal/api.ts b/apps/amethyst/lib/teal/api.ts index 7246ea1c..3ed3a284 100644 --- a/apps/amethyst/lib/teal/api.ts +++ b/apps/amethyst/lib/teal/api.ts @@ -6,7 +6,6 @@ const rawBase = process.env.EXPO_PUBLIC_AQUA_URL || process.env.EXPO_PUBLIC_APPVIEW_URL || ""; -const demoFallbackEnabled = process.env.EXPO_PUBLIC_ENABLE_DEMO_FALLBACK === "true"; const requestBase = rawBase || (typeof window === "undefined" ? "http://localhost:3000" : window.location.origin); @@ -14,67 +13,6 @@ const xrpcBase = requestBase.endsWith("/xrpc") ? requestBase : `${requestBase.replace(/\/$/, "")}/xrpc`; -const demoPlays: PlayView[] = [ - { - uri: "at://did:plc:tealpreview/fm.teal.alpha.feed.play/3demo001", - cid: "bafyreitealpreview001", - authorDid: "did:plc:tealpreview", - rkey: "3demo001", - trackName: "Everything In Its Right Place", - artists: [{ artistName: "Radiohead" }], - releaseName: "Kid A", - musicServiceBaseDomain: "music.apple.com", - submissionClientAgent: "teal-preview/0.1", - playedTime: new Date(Date.now() - 7 * 60 * 1000).toISOString(), - }, - { - uri: "at://did:plc:amethystpreview/fm.teal.alpha.feed.play/3demo002", - cid: "bafyreitealpreview002", - authorDid: "did:plc:amethystpreview", - rkey: "3demo002", - trackName: "Archie, Marry Me", - artists: [{ artistName: "Alvvays" }], - releaseName: "Alvvays", - musicServiceBaseDomain: "spotify.com", - submissionClientAgent: "teal-preview/0.1", - playedTime: new Date(Date.now() - 22 * 60 * 1000).toISOString(), - }, - { - uri: "at://did:plc:cadetpreview/fm.teal.alpha.feed.play/3demo003", - cid: "bafyreitealpreview003", - authorDid: "did:plc:cadetpreview", - rkey: "3demo003", - trackName: "A Walk", - artists: [{ artistName: "Tycho" }], - releaseName: "Dive", - musicServiceBaseDomain: "tidal.com", - submissionClientAgent: "teal-preview/0.1", - playedTime: new Date(Date.now() - 48 * 60 * 1000).toISOString(), - }, -]; - -const demoArtists: ArtistView[] = [ - { name: "Radiohead", playCount: 128 }, - { name: "Alvvays", playCount: 96 }, - { name: "Tycho", playCount: 74 }, -]; - -const demoReleases: ReleaseView[] = [ - { name: "Kid A", playCount: 42 }, - { name: "Alvvays", playCount: 31 }, - { name: "Dive", playCount: 26 }, -]; - -function demoResponse(method: string): T | undefined { - if (!demoFallbackEnabled) return undefined; - - if (method === "fm.teal.alpha.stats.getLatest") return { plays: demoPlays } as T; - if (method === "fm.teal.alpha.stats.getTopArtists") return { artists: demoArtists } as T; - if (method === "fm.teal.alpha.stats.getTopReleases") return { releases: demoReleases } as T; - - return undefined; -} - async function getXrpc( method: string, params: Record = {}, @@ -86,27 +24,11 @@ async function getXrpc( } }); - try { - const response = await fetch(url.toString()); - if (!response.ok) { - throw new Error(`${method} failed with ${response.status}`); - } - const result = (await response.json()) as T; - const fallback = demoResponse(method); - if ( - fallback && - typeof result === "object" && - result !== null && - Object.values(result).some((value) => Array.isArray(value) && value.length === 0) - ) { - return fallback; - } - return result; - } catch (error) { - const fallback = demoResponse(method); - if (fallback) return fallback; - throw error; + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`${method} failed with ${response.status}`); } + return response.json() as Promise; } export function getLatestPlays(limit = 50) { diff --git a/apps/aqua/src/repos/feed_play.rs b/apps/aqua/src/repos/feed_play.rs index 6b28af6c..1ac42664 100644 --- a/apps/aqua/src/repos/feed_play.rs +++ b/apps/aqua/src/repos/feed_play.rs @@ -26,14 +26,15 @@ impl FeedPlayRepo for PgDataSource { COALESCE( json_agg( json_build_object( - 'artist_mbid', pta.artist_mbid, - 'artist_name', pta.artist_name + 'artistMbId', ae.mbid, + 'artistName', ptae.artist_name ) - ) FILTER (WHERE pta.artist_name IS NOT NULL), + ) FILTER (WHERE ptae.artist_name IS NOT NULL), '[]' ) AS artists FROM plays - LEFT JOIN play_to_artists as pta ON uri = pta.play_uri + LEFT JOIN play_to_artists_extended as ptae ON uri = ptae.play_uri + LEFT JOIN artists_extended as ae ON ptae.artist_id = ae.id WHERE uri = $1 GROUP BY uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time, release_mbid, release_name, recording_mbid, submission_client_agent, @@ -86,14 +87,15 @@ impl FeedPlayRepo for PgDataSource { COALESCE( json_agg( json_build_object( - 'artist_mbid', pta.artist_mbid, - 'artist_name', pta.artist_name + 'artistMbId', ae.mbid, + 'artistName', ptae.artist_name ) - ) FILTER (WHERE pta.artist_name IS NOT NULL), + ) FILTER (WHERE ptae.artist_name IS NOT NULL), '[]' ) AS artists FROM plays - LEFT JOIN play_to_artists as pta ON uri = pta.play_uri + LEFT JOIN play_to_artists_extended as ptae ON uri = ptae.play_uri + LEFT JOIN artists_extended as ae ON ptae.artist_id = ae.id WHERE did = ANY($1) GROUP BY uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time, release_mbid, release_name, recording_mbid, submission_client_agent, diff --git a/apps/aqua/src/repos/stats.rs b/apps/aqua/src/repos/stats.rs index 7e4634ec..cdec8c1a 100644 --- a/apps/aqua/src/repos/stats.rs +++ b/apps/aqua/src/repos/stats.rs @@ -31,14 +31,14 @@ impl StatsRepo for PgDataSource { let rows = sqlx::query!( r#" SELECT - pta.artist_mbid as mbid, - pta.artist_name as name, + ae.mbid, + ptae.artist_name as name, COUNT(*) as play_count FROM plays p - INNER JOIN play_to_artists pta ON p.uri = pta.play_uri - WHERE pta.artist_mbid IS NOT NULL - AND pta.artist_name IS NOT NULL - GROUP BY pta.artist_mbid, pta.artist_name + INNER JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri + INNER JOIN artists_extended ae ON ptae.artist_id = ae.id + WHERE ptae.artist_name IS NOT NULL + GROUP BY ae.mbid, ptae.artist_name ORDER BY play_count DESC LIMIT $1 "#, @@ -49,14 +49,12 @@ impl StatsRepo for PgDataSource { let mut result = Vec::with_capacity(rows.len()); for row in rows { - if let Some(name) = row.name { - result.push(ArtistView { - mbid: Some(mbid_uri(row.mbid)), - name: Some(name.into()), - play_count: Some(row.play_count.unwrap_or(0)), - extra_data: Default::default(), - }); - } + result.push(ArtistView { + mbid: row.mbid.map(mbid_uri), + name: Some(row.name.into()), + play_count: Some(row.play_count.unwrap_or(0)), + extra_data: Default::default(), + }); } Ok(result) @@ -108,15 +106,15 @@ impl StatsRepo for PgDataSource { let rows = sqlx::query!( r#" SELECT - pta.artist_mbid as mbid, - pta.artist_name as name, + ae.mbid, + ptae.artist_name as name, COUNT(*) as play_count FROM plays p - INNER JOIN play_to_artists pta ON p.uri = pta.play_uri + INNER JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri + INNER JOIN artists_extended ae ON ptae.artist_id = ae.id WHERE p.did = $1 - AND pta.artist_mbid IS NOT NULL - AND pta.artist_name IS NOT NULL - GROUP BY pta.artist_mbid, pta.artist_name + AND ptae.artist_name IS NOT NULL + GROUP BY ae.mbid, ptae.artist_name ORDER BY play_count DESC LIMIT $2 "#, @@ -128,14 +126,12 @@ impl StatsRepo for PgDataSource { let mut result = Vec::with_capacity(rows.len()); for row in rows { - if let Some(name) = row.name { - result.push(ArtistView { - mbid: Some(mbid_uri(row.mbid)), - name: Some(name.into()), - play_count: Some(row.play_count.unwrap_or(0)), - extra_data: Default::default(), - }); - } + result.push(ArtistView { + mbid: row.mbid.map(mbid_uri), + name: Some(row.name.into()), + play_count: Some(row.play_count.unwrap_or(0)), + extra_data: Default::default(), + }); } Ok(result) @@ -195,14 +191,15 @@ impl StatsRepo for PgDataSource { COALESCE( json_agg( json_build_object( - 'artist_mbid', pta.artist_mbid, - 'artist_name', pta.artist_name + 'artistMbId', ae.mbid, + 'artistName', ptae.artist_name ) - ) FILTER (WHERE pta.artist_name IS NOT NULL), + ) FILTER (WHERE ptae.artist_name IS NOT NULL), '[]' ) AS artists FROM plays p - LEFT JOIN play_to_artists as pta ON p.uri = pta.play_uri + LEFT JOIN play_to_artists_extended as ptae ON p.uri = ptae.play_uri + LEFT JOIN artists_extended as ae ON ptae.artist_id = ae.id GROUP BY uri, did, rkey, cid, isrc, duration, track_name, played_time, processed_time, release_mbid, release_name, recording_mbid, submission_client_agent, music_service_base_domain, origin_url diff --git a/services/cadet/src/ingestors/teal/feed_play.rs b/services/cadet/src/ingestors/teal/feed_play.rs index 32e84e7a..bdb829ba 100644 --- a/services/cadet/src/ingestors/teal/feed_play.rs +++ b/services/cadet/src/ingestors/teal/feed_play.rs @@ -1230,12 +1230,10 @@ impl PlayIngestor { // Extract discriminant from release name for new releases // Prioritize edition-specific patterns for better quality - let discriminant = self - .extract_edition_discriminant_from_db(name) - .await - .or_else(|| { - futures::executor::block_on(async { self.extract_discriminant_from_db(name).await }) - }); + let discriminant = match self.extract_edition_discriminant_from_db(name).await { + Some(discriminant) => Some(discriminant), + None => self.extract_discriminant_from_db(name).await, + }; let res = sqlx::query!( r#" @@ -1266,12 +1264,10 @@ impl PlayIngestor { // Extract discriminant from recording name for new recordings // Prioritize edition-specific patterns for better quality - let discriminant = self - .extract_edition_discriminant_from_db(name) - .await - .or_else(|| { - futures::executor::block_on(async { self.extract_discriminant_from_db(name).await }) - }); + let discriminant = match self.extract_edition_discriminant_from_db(name).await { + Some(discriminant) => Some(discriminant), + None => self.extract_discriminant_from_db(name).await, + }; let res = sqlx::query!( r#" @@ -1331,7 +1327,6 @@ impl PlayIngestor { did: &str, rkey: &str, ) -> anyhow::Result<()> { - dbg!("ingesting", play_record); let play_record = clean(play_record); let mut parsed_artists: Vec<(i32, String)> = vec![]; let mut artist_names_raw: Vec = vec![]; @@ -1422,36 +1417,28 @@ impl PlayIngestor { // First try lexicon fields, then extract from names with preference for edition-specific patterns // TODO: Enable when types are updated with discriminant fields // let track_discriminant = play_record.track_discriminant.clone().or_else(|| { - let track_discriminant = { - // Try edition-specific patterns first, then general patterns - futures::executor::block_on(async { - self.extract_edition_discriminant_from_db(&play_record.track_name) + let track_discriminant = match self + .extract_edition_discriminant_from_db(&play_record.track_name) + .await + { + Some(discriminant) => Some(discriminant), + None => { + self.extract_discriminant_from_db(&play_record.track_name) .await - .or_else(|| { - futures::executor::block_on(async { - self.extract_discriminant_from_db(&play_record.track_name) - .await - }) - }) - }) + } }; // let release_discriminant = play_record.release_discriminant.clone().or_else(|| { - let release_discriminant = { - if let Some(release_name) = &play_record.release_name { - futures::executor::block_on(async { - // Try edition-specific patterns first, then general patterns - self.extract_edition_discriminant_from_db(release_name) - .await - .or_else(|| { - futures::executor::block_on(async { - self.extract_discriminant_from_db(release_name).await - }) - }) - }) - } else { - None + let release_discriminant = if let Some(release_name) = &play_record.release_name { + match self + .extract_edition_discriminant_from_db(release_name) + .await + { + Some(discriminant) => Some(discriminant), + None => self.extract_discriminant_from_db(release_name).await, } + } else { + None }; // Our main insert into plays with raw artist names and discriminants diff --git a/todo.md b/todo.md index dfaf14a2..f6cbe346 100644 --- a/todo.md +++ b/todo.md @@ -7,12 +7,13 @@ This file is the working handoff for the Songish-style Teal clone. Keep it updat - Amethyst has a Teal-branded Songish-style shell with desktop navigation, mobile navigation, Home, Explore, Notifications, Profile, and music detail views. - Aqua exposes Teal XRPC routes for latest plays, individual plays, actor feeds, profiles, and stats. - Cadet consumes Teal records from Jetstream, stores a durable cursor in Redis with file fallback, ingests profiles and plays, and deletes plays by AT URI. +- The public Amethyst feed uses only live Aqua XRPC data. There is no seeded, mocked, demo, or backup play feed. +- Live Jetstream ingestion has been verified end-to-end through Cadet, Postgres, Aqua, and the public preview URL. - Development and production Compose files include Amethyst, Aqua, Cadet, Satellite, Postgres, and Garnet. - Development Compose includes an optional Cloudflare Tunnel profile. - Current temporary UI preview: `https://directory-extensive-viewer-agreement.trycloudflare.com` - This is an account-less Cloudflare quick tunnel. It remains available while the local tunnel process is running and its hostname will change after restart. - The preview serves the current Amethyst export and proxies `/xrpc/*` to the locally running Aqua API through the same public hostname. - - The current preview build enables a demo fallback when Aqua returns an empty feed, so the UI remains inspectable while Cadet fills the local index from Jetstream. - The current preview build embeds `EXPO_PUBLIC_BASE_URL=https://directory-extensive-viewer-agreement.trycloudflare.com` and serves a matching `/client-metadata.json` OAuth redirect. - OAuth callback testing still requires the stable-host work below. @@ -38,7 +39,7 @@ This file is the working handoff for the Songish-style Teal clone. Keep it updat ## Next: Aqua And Lexicons - [ ] Add pagination support for `fm.teal.alpha.feed.getActorFeed` cursor and limit parameters. -- [ ] Run SQLx prepare against the development Postgres instance and commit refreshed query cache data. +- [x] Run SQLx prepare against the development Postgres instance and commit refreshed query cache data. - [ ] Resolve the existing Satellite SQLx offline-cache gap so `pnpm turbo run test:rust` passes without a live Docker hostname. - [ ] Decide whether the legacy `play_to_artists` join table can be removed after Aqua reads move fully to `play_to_artists_extended`. - [ ] Validate the Teal lexicons and regenerate Rust and TypeScript bindings before each PR. @@ -49,6 +50,7 @@ This file is the working handoff for the Songish-style Teal clone. Keep it updat - [ ] Add artist and release detail routes in addition to track detail. - [ ] Render real Cover Art Archive images for recordings with MusicBrainz IDs and polished fallbacks for missing art. - [ ] Exercise empty, loading, error, signed-out, and populated feed states at desktop and mobile widths. +- [ ] Fix populated desktop feed-card text collisions for long DIDs, track titles, and artist names. - [ ] Verify SPA fallback routing in the production Caddy image for Home, Explore, Notifications, Profile, music detail, and OAuth callback routes. - [ ] Capture final Chrome screenshots after Aqua and Cadet are running with live ingested data. From 23adda8b7c0d592834415b2211351a5c8061022d Mon Sep 17 00:00:00 2001 From: mmattbtw Date: Sat, 30 May 2026 23:08:56 -0500 Subject: [PATCH 012/171] feat(amethyst): add teal profile onboarding fallback Publish Teal profile records through a polished onboarding wizard, prefill setup from the signed-in Bluesky identity, and render public Bluesky profile data with a clear disclaimer when a listener has not created a Teal profile yet. --- apps/amethyst/app/(tabs)/profile/[handle].tsx | 104 ++++++++++++-- apps/amethyst/app/_layout.tsx | 1 + apps/amethyst/app/onboarding/_layout.tsx | 5 + .../app/onboarding/descriptionPage.tsx | 37 +++-- .../app/onboarding/displayNamePage.tsx | 34 ++--- .../app/onboarding/imageSelectionPage.tsx | 43 ++++-- apps/amethyst/app/onboarding/index.tsx | 135 +++++++++++++++--- .../components/onboarding/progressDots.tsx | 2 +- apps/amethyst/lib/teal/api.ts | 26 +++- todo.md | 1 + 10 files changed, 304 insertions(+), 84 deletions(-) create mode 100644 apps/amethyst/app/onboarding/_layout.tsx diff --git a/apps/amethyst/app/(tabs)/profile/[handle].tsx b/apps/amethyst/app/(tabs)/profile/[handle].tsx index 33cd201a..1983b353 100644 --- a/apps/amethyst/app/(tabs)/profile/[handle].tsx +++ b/apps/amethyst/app/(tabs)/profile/[handle].tsx @@ -1,22 +1,45 @@ import { useEffect, useState } from "react"; import { ActivityIndicator, Image, View } from "react-native"; -import { Stack, useLocalSearchParams } from "expo-router"; +import { Link, Stack, useLocalSearchParams } from "expo-router"; import PlayFeedCard from "@/components/songish/PlayFeedCard"; import RightRail from "@/components/songish/RightRail"; import SongishShell from "@/components/songish/SongishShell"; +import { Button } from "@/components/ui/button"; import { Text } from "@/components/ui/text"; import { resolveHandle } from "@/lib/atp/pid"; -import { getActorFeed, getProfile } from "@/lib/teal/api"; +import { + getActorFeed, + getBlueskyProfile, + getProfile, + XrpcError, +} from "@/lib/teal/api"; +import { Icon } from "@/lib/icons/iconWithClassName"; +import { useStore } from "@/stores/mainStore"; +import type { AppBskyActorDefs } from "@atproto/api"; import type { ProfileView } from "@teal/lexicons/src/types/fm/teal/alpha/actor/defs"; import type { PlayView } from "@teal/lexicons/src/types/fm/teal/alpha/feed/defs"; +import { Info, Music2, UserRoundPlus } from "lucide-react-native"; + +type DisplayProfile = Pick< + ProfileView, + "displayName" | "description" | "avatar" | "banner" +> & { + handle?: string; +}; + +function isHttpUrl(value?: string) { + return value?.startsWith("http://") || value?.startsWith("https://"); +} export default function ProfileScreen() { const { handle } = useLocalSearchParams(); const actor = Array.isArray(handle) ? handle[0] : handle; const [did, setDid] = useState(null); - const [profile, setProfile] = useState(null); + const [profile, setProfile] = useState(null); const [plays, setPlays] = useState([]); + const [isBlueskyFallback, setIsBlueskyFallback] = useState(false); const [error, setError] = useState(null); + const pdsAgent = useStore((state) => state.pdsAgent); useEffect(() => { let mounted = true; @@ -26,12 +49,26 @@ export default function ProfileScreen() { const resolved = actor.startsWith("did:") ? actor : await resolveHandle(actor); if (!mounted) return; setDid(resolved); - const [profileRes, feedRes] = await Promise.all([ - getProfile(resolved), - getActorFeed(resolved, 50), - ]); + const feedRes = await getActorFeed(resolved, 50); + let nextProfile: DisplayProfile | null = null; + let nextIsBlueskyFallback = false; + + try { + nextProfile = (await getProfile(resolved)).profile; + } catch (profileError) { + if (!(profileError instanceof XrpcError) || profileError.status !== 404) { + throw profileError; + } + + const bskyProfile: AppBskyActorDefs.ProfileViewDetailed = + await getBlueskyProfile(resolved); + nextProfile = bskyProfile; + nextIsBlueskyFallback = true; + } + if (!mounted) return; - setProfile(profileRes.profile); + setProfile(nextProfile); + setIsBlueskyFallback(nextIsBlueskyFallback); setPlays(feedRes.plays); } catch (e) { if (mounted) setError(e instanceof Error ? e.message : String(e)); @@ -43,6 +80,8 @@ export default function ProfileScreen() { }; }, [actor]); + const isSelf = did === pdsAgent?.did; + return ( }> @@ -60,26 +99,61 @@ export default function ProfileScreen() { <> - {profile?.banner && ( + {profile?.banner && isHttpUrl(profile.banner) && ( )} - - - {(profile?.displayName || actor || "T").slice(0, 1)} - - + {profile?.avatar && isHttpUrl(profile.avatar) ? ( + + ) : ( + + + {(profile?.displayName || actor || "T").slice(0, 1)} + + + )} {profile?.displayName || actor} + {profile?.handle && ( + + @{profile.handle} + + )} {did} + {isBlueskyFallback && ( + + + + Showing Bluesky profile + + This listener has not created a Teal profile yet. Their + Bluesky profile is shown as a fallback. + + + + )} {profile?.description && ( {profile.description} )} + {isSelf && isBlueskyFallback && ( + + + + )} - Plays + + + Plays + {plays.length === 0 ? ( No indexed plays yet. ) : ( diff --git a/apps/amethyst/app/_layout.tsx b/apps/amethyst/app/_layout.tsx index 1981abd0..230a2b54 100644 --- a/apps/amethyst/app/_layout.tsx +++ b/apps/amethyst/app/_layout.tsx @@ -113,6 +113,7 @@ function RootLayoutNav() { + ; +} diff --git a/apps/amethyst/app/onboarding/descriptionPage.tsx b/apps/amethyst/app/onboarding/descriptionPage.tsx index 1b5649b9..da20783c 100644 --- a/apps/amethyst/app/onboarding/descriptionPage.tsx +++ b/apps/amethyst/app/onboarding/descriptionPage.tsx @@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"; import { Text } from "@/components/ui/text"; import { Textarea } from "@/components/ui/textarea"; import { Icon } from "@/lib/icons/iconWithClassName"; -import { CheckCircle } from "lucide-react-native"; +import { ArrowLeft, ArrowRight, MessageSquareText } from "lucide-react-native"; interface DescriptionPageProps { onComplete: (description: string) => void; @@ -20,25 +20,24 @@ const DescriptionPage: React.FC = ({ const [description, setDescription] = useState(initialDescription || ""); const handleComplete = () => { - if (description) { - onComplete(description); - } + onComplete(description); }; return ( - - - - - Tell us about yourself! + + + + + + + Add a liner note. - - Your bio is your chance to shine. Let your creativity flow and tell - the world who you are. You can always edit it later. + + Say a little about your listening life, or leave this blank for now.