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.