diff --git a/apps/amethyst/app/(tabs)/_layout.tsx b/apps/amethyst/app/(tabs)/_layout.tsx index b6b1f00..6e85c03 100644 --- a/apps/amethyst/app/(tabs)/_layout.tsx +++ b/apps/amethyst/app/(tabs)/_layout.tsx @@ -125,6 +125,13 @@ export default function TabLayout() { href: null, }} /> + ); } diff --git a/apps/amethyst/app/(tabs)/at:/[...uri].tsx b/apps/amethyst/app/(tabs)/at:/[...uri].tsx new file mode 100644 index 0000000..62a98be --- /dev/null +++ b/apps/amethyst/app/(tabs)/at:/[...uri].tsx @@ -0,0 +1,55 @@ +import { useEffect, useMemo } from "react"; +import { View } from "react-native"; +import { Redirect, Stack, useLocalSearchParams } from "expo-router"; +import RightRail from "@/components/teal/RightRail"; +import TealShell from "@/components/teal/TealShell"; +import { Text } from "@/components/ui/text"; +import { hrefFromAtUri } from "@/lib/teal/routes"; + +function atUriFromParams(param: string | string[] | undefined) { + if (typeof window !== "undefined") { + const path = window.location.pathname; + if (path.startsWith("/at://")) { + return `at://${decodeURIComponent(path.slice("/at://".length))}`; + } + if (path.startsWith("/at:/")) { + return `at://${decodeURIComponent(path.slice("/at:/".length))}`; + } + } + + const parts = Array.isArray(param) ? param : param ? [param] : []; + return parts.length > 0 ? `at://${parts.join("/")}` : undefined; +} + +export default function AtUriResolver() { + const params = useLocalSearchParams(); + const atUri = useMemo(() => atUriFromParams(params.uri), [params.uri]); + const href = hrefFromAtUri(atUri); + + useEffect(() => { + if (!href && atUri) { + console.warn("Unsupported Teal AT-URI route", atUri); + } + }, [atUri, href]); + + if (href) { + return ; + } + + return ( + }> + + + + AT URI + + + Unsupported Teal link + + + Teal can open profile, listen, and post AT-URIs. + + + + ); +} diff --git a/apps/amethyst/lib/teal/routes.ts b/apps/amethyst/lib/teal/routes.ts index 1c47e22..21cf088 100644 --- a/apps/amethyst/lib/teal/routes.ts +++ b/apps/amethyst/lib/teal/routes.ts @@ -36,6 +36,14 @@ export function listenHref(authorDid?: string, rkey?: string) { return `/listen/${encodeURIComponent(authorDid)}/${encodeURIComponent(rkey)}`; } +export function listenHrefFromUri(uri?: string) { + const parsed = parseAtUri(uri); + if (!parsed || parsed.collection !== "fm.teal.alpha.feed.play") { + return undefined; + } + return listenHref(parsed.did, parsed.rkey); +} + export function postHref(authorDid?: string, rkey?: string) { if (!authorDid || !rkey) return undefined; return `/post/${encodeURIComponent(authorDid)}/${encodeURIComponent(rkey)}`; @@ -46,11 +54,46 @@ export function rkeyFromAtUri(uri?: string) { } export function postHrefFromUri(uri?: string) { + const parsed = parseAtUri(uri); + if (!parsed || parsed.collection !== "fm.teal.alpha.feed.social.post") { + return undefined; + } + return postHref(parsed.did, parsed.rkey); +} + +type ParsedAtUri = { + did: string; + collection?: string; + rkey?: string; +}; + +export function parseAtUri(uri?: string): ParsedAtUri | undefined { if (!uri?.startsWith("at://")) return undefined; - const [, rest] = uri.split("at://"); + const rest = uri.slice("at://".length); const [did, collection, rkey] = rest.split("/"); - if (collection !== "fm.teal.alpha.feed.social.post") return undefined; - return postHref(did, rkey); + if (!did) return undefined; + return { did, collection, rkey }; +} + +export function profileHrefFromAtUri(uri?: string) { + const parsed = parseAtUri(uri); + if (!parsed) return undefined; + if (!parsed.collection) return `/profile/${encodeURIComponent(parsed.did)}`; + if ( + parsed.collection === "fm.teal.alpha.actor.profile" || + parsed.collection === "app.bsky.actor.profile" + ) { + return `/profile/${encodeURIComponent(parsed.did)}`; + } + return undefined; +} + +export function hrefFromAtUri(uri?: string) { + return ( + listenHrefFromUri(uri) || + postHrefFromUri(uri) || + profileHrefFromAtUri(uri) + ); } export function playlistHref(name: string, uri: string) { diff --git a/todo.md b/todo.md index d293532..0bd4dd9 100644 --- a/todo.md +++ b/todo.md @@ -18,6 +18,7 @@ This file is the working handoff for the Teal-native Teal clone. Keep it updated - Amethyst profile pages show Teal social graph counts, first-page followers/following lists, and signed-in follow/unfollow controls backed by `fm.teal.alpha.graph.follow` records. - Amethyst profiles show a minimal current-listening row only when an active status exists, including status-backed profiles that have no Teal profile record and render Bluesky identity as a fallback, with album art resolved from release MBID and recording fallback. - Amethyst listens have stable permalink pages at `/listen/:did/:rkey`; feed cards link their listen timestamps to the activity page while song titles still link to the music page. +- Amethyst resolves Teal AT-URI deep links from `/at://...` to canonical profile, listen, and post pages. - Amethyst music track pretty URLs resolve from artist/release/track slugs when no play URI query string is present, instead of falling back to the latest global play. - Cadet has a TAP backfill consumer and `pnpm backfill` command. The command discovers Teal repos with `TAP_SIGNAL_COLLECTION=fm.teal.alpha.feed.play`, filters delivered records with `TAP_COLLECTION_FILTERS=fm.teal.*`, and consumes TAP record events through the existing Teal ingestors. Full-network TAP backfill remains an explicit env override. - Cadet normalizes historical play MBID fields during ingestion: empty optional MBIDs are treated as missing, and bare MusicBrainz UUIDs are canonicalized to `mbid:` before storage.