diff --git a/packages/atproto-browser/app/actions.ts b/packages/atproto-browser/app/actions.ts
index c9447000..aa2efe62 100644
--- a/packages/atproto-browser/app/actions.ts
+++ b/packages/atproto-browser/app/actions.ts
@@ -1,8 +1,10 @@
"use server";
+import { getAtUriPath } from "@/lib/util";
+import { AtUri } from "@atproto/syntax";
import { redirect } from "next/navigation";
export async function navigateUri(_state: unknown, formData: FormData) {
- const uri = formData.get("uri") as string;
- redirect(`/at?u=${uri}`);
+ const uri = new AtUri(formData.get("uri") as string);
+ redirect(getAtUriPath(uri));
}
diff --git a/packages/atproto-browser/app/at/[identifier]/[collection]/(rkey)/[rkey]/page.tsx b/packages/atproto-browser/app/at/[identifier]/[collection]/(rkey)/[rkey]/page.tsx
new file mode 100644
index 00000000..54f87f6e
--- /dev/null
+++ b/packages/atproto-browser/app/at/[identifier]/[collection]/(rkey)/[rkey]/page.tsx
@@ -0,0 +1,129 @@
+import { JSONType, JSONValue } from "@/app/at/_lib/atproto-json";
+import { resolveIdentity } from "@/lib/atproto-server";
+import { getHandle, getKey, getPds } from "@atproto/identity";
+import { verifyRecords } from "@atproto/repo";
+import { Suspense } from "react";
+
+export default async function RkeyPage({
+ params,
+}: {
+ params: {
+ identifier: string;
+ collection: string;
+ rkey: string;
+ };
+}) {
+ const identityResult = await resolveIdentity(params.identifier);
+ if (!identityResult.success) {
+ return
{identityResult.error}
;
+ }
+ const didDocument = identityResult.identity;
+ const handle = getHandle(didDocument);
+ if (!handle) {
+ return No handle found for DID: {didDocument.id}
;
+ }
+ const pds = getPds(didDocument);
+ if (!pds) {
+ return No PDS found for DID: {didDocument.id}
;
+ }
+
+ const getRecordUrl = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`);
+ getRecordUrl.searchParams.set("repo", didDocument.id);
+ getRecordUrl.searchParams.set("collection", params.collection);
+ getRecordUrl.searchParams.set("rkey", params.rkey);
+
+ const response = await fetch(getRecordUrl, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+
+ if (!response.ok) {
+ return (
+
+ Failed to fetch record: {response.statusText}. URL:{" "}
+ {getRecordUrl.toString()}
+
+ );
+ }
+
+ const record = (await response.json()) as JSONType;
+
+ return (
+ <>
+
+ Record
+
+ 🤔
+
+ }
+ >
+
+
+
+
+ >
+ );
+}
+
+async function RecordVerificationBadge({
+ did,
+ collection,
+ rkey,
+}: {
+ did: string;
+ collection: string;
+ rkey: string;
+}) {
+ const identityResult = await resolveIdentity(did);
+ if (!identityResult.success) {
+ throw new Error(identityResult.error);
+ }
+ const didDoc = identityResult.identity;
+ const pds = getPds(didDoc);
+ if (!pds) {
+ return ❌;
+ }
+
+ const verifyRecordsUrl = new URL(`${pds}/xrpc/com.atproto.sync.getRecord`);
+ verifyRecordsUrl.searchParams.set("did", did);
+ verifyRecordsUrl.searchParams.set("collection", collection);
+ verifyRecordsUrl.searchParams.set("rkey", rkey);
+
+ const response = await fetch(verifyRecordsUrl, {
+ headers: {
+ accept: "application/vnd.ipld.car",
+ },
+ });
+
+ if (!response.ok) {
+ return (
+
+ ❌
+
+ );
+ }
+ const car = new Uint8Array(await response.arrayBuffer());
+ const key = getKey(didDoc);
+ if (!key) {
+ return ❌;
+ }
+
+ try {
+ await verifyRecords(car, did, key);
+ return 🔒;
+ } catch (e) {
+ if (e instanceof Error) {
+ return ❌;
+ } else {
+ return ❌;
+ }
+ }
+}
diff --git a/packages/atproto-browser/app/at/[identifier]/[collection]/(rkey)/layout.tsx b/packages/atproto-browser/app/at/[identifier]/[collection]/(rkey)/layout.tsx
new file mode 100644
index 00000000..a08e75c7
--- /dev/null
+++ b/packages/atproto-browser/app/at/[identifier]/[collection]/(rkey)/layout.tsx
@@ -0,0 +1,43 @@
+import { resolveIdentity } from "@/lib/atproto-server";
+import { getHandle, getPds } from "@atproto/identity";
+import Link from "next/link";
+import { Suspense } from "react";
+import { DidSummary } from "@/app/at/_lib/did-components";
+
+export default async function Layout({
+ children,
+ params,
+}: {
+ children: React.ReactNode;
+ params: { identifier: string };
+}) {
+ const identityResult = await resolveIdentity(params.identifier);
+ if (!identityResult.success) {
+ return {identityResult.error}
;
+ }
+ const didDocument = identityResult.identity;
+ const handle = getHandle(didDocument);
+ if (!handle) {
+ return No handle found for DID: {didDocument.id}
;
+ }
+ const pds = getPds(didDocument);
+ if (!pds) {
+ return No PDS found for DID: {didDocument.id}
;
+ }
+
+ return (
+
+
+
+ Author: {handle} (
+ {didDocument.id})
+
+
+
+
+
+
+ {children}
+
+ );
+}
diff --git a/packages/atproto-browser/app/at/[identifier]/[collection]/page.tsx b/packages/atproto-browser/app/at/[identifier]/[collection]/page.tsx
new file mode 100644
index 00000000..7781db8a
--- /dev/null
+++ b/packages/atproto-browser/app/at/[identifier]/[collection]/page.tsx
@@ -0,0 +1,59 @@
+import { listRecords } from "@/lib/atproto";
+import { resolveIdentity } from "@/lib/atproto-server";
+import { getHandle, getPds } from "@atproto/identity";
+import Link from "next/link";
+import { SWRConfig } from "swr";
+import { CollectionItems } from "../../_lib/collection";
+
+export default async function CollectionPage({
+ params,
+}: {
+ params: { identifier: string; collection: string };
+}) {
+ const identityResult = await resolveIdentity(params.identifier);
+ if (!identityResult.success) {
+ return {identityResult.error}
;
+ }
+ const didDocument = identityResult.identity;
+ const handle = getHandle(didDocument);
+ if (!handle) {
+ return No handle found for DID: {didDocument.id}
;
+ }
+ const pds = getPds(didDocument);
+ if (!pds) {
+ return No PDS found for DID: {didDocument.id}
;
+ }
+
+ const fetchKey =
+ `listCollections/collection:${params.collection}/cursor:none` as const;
+
+ return (
+
+
+ {handle}'s {params.collection} records{" "}
+ }`}
+ title="RSS feed"
+ >
+ 🛜
+
+
+
+
+ );
+}
diff --git a/packages/atproto-browser/app/at/[identifier]/page.tsx b/packages/atproto-browser/app/at/[identifier]/page.tsx
new file mode 100644
index 00000000..7e92748a
--- /dev/null
+++ b/packages/atproto-browser/app/at/[identifier]/page.tsx
@@ -0,0 +1,317 @@
+import { getAtUriPath, isNotNull, utcDateFormatter } from "@/lib/util";
+import Link from "next/link";
+import { Fragment, Suspense } from "react";
+import { ErrorBoundary } from "react-error-boundary";
+import { z } from "zod";
+import { DidSummary } from "../_lib/did-components";
+import { resolveIdentity } from "@/lib/atproto-server";
+import { getHandle } from "@atproto/identity";
+import { AtUri } from "@atproto/syntax";
+
+export default async function IdentifierPage({
+ params,
+}: {
+ params: { identifier: string };
+}) {
+ const identityResult = await resolveIdentity(params.identifier);
+ if (!identityResult.success) {
+ return {identityResult.error}
;
+ }
+ const didDocument = identityResult.identity;
+ const handle = getHandle(didDocument);
+ if (!handle) {
+ return No handle found for DID: {didDocument.id}
;
+ }
+
+ return (
+ <>
+
+ {handle} ({didDocument.id})
+
+
+
+ Loading history...}>
+ Failed to fetch history.}>
+ History
+
+
+
+ >
+ );
+}
+
+async function DidHistory({ did }: { did: string }) {
+ const response = await fetch(`https://plc.directory/${did}/log/audit`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch history: ${response.statusText}`);
+ }
+
+ const auditLog = PlcLogAuditResponse.parse(await response.json());
+
+ return (
+
+ {auditLog.map((previous, index) => {
+ const previousOperation = previous.operation;
+ if (previousOperation.type !== "plc_operation") {
+ return (
+ // eslint-disable-next-line react/no-array-index-key
+ -
+ Change created at {utcDateFormatter.format(previous.createdAt)}{" "}
+ (UTC) of type "{previousOperation.type}".
+
+ );
+ }
+ const entry = auditLog[index + 1];
+ if (!entry) {
+ return null;
+ }
+ const entryOperation = entry.operation;
+ if (entryOperation.type !== "plc_operation") {
+ return null;
+ }
+
+ const alsoKnownAsAdded = entryOperation.alsoKnownAs.filter(
+ (x) => !previousOperation.alsoKnownAs.includes(x),
+ );
+ const alsoKnownAsRemoved = previousOperation.alsoKnownAs.filter(
+ (x) => !entryOperation.alsoKnownAs.includes(x),
+ );
+
+ const servicesChanged = Object.entries(entryOperation.services)
+ .map(([id, service]) => {
+ const previousService = previousOperation.services[id];
+ if (!previousService) return null;
+ return {
+ id,
+ type:
+ service.type !== previousService.type
+ ? {
+ from: previousService.type,
+ to: service.type,
+ }
+ : null,
+ endpoint:
+ service.endpoint !== previousService.endpoint
+ ? {
+ from: previousService.endpoint,
+ to: service.endpoint,
+ }
+ : null,
+ };
+ })
+ .filter(isNotNull);
+
+ const servicesAdded = Object.entries(entryOperation.services).filter(
+ ([id]) => !previousOperation.services[id],
+ );
+ const servicesRemoved = Object.entries(
+ previousOperation.services,
+ ).filter(([id]) => !entryOperation.services[id]);
+
+ const rotationKeysAdded = entryOperation.rotationKeys.filter(
+ (x) => !previousOperation.rotationKeys.includes(x),
+ );
+ const rotationKeysRemoved = previousOperation.rotationKeys.filter(
+ (x) => !entryOperation.rotationKeys.includes(x),
+ );
+
+ const verificationMethodsChanged = Object.entries(
+ entryOperation.verificationMethods,
+ )
+ .map(([id, key]) => {
+ const previousKey = previousOperation.verificationMethods[id];
+ if (!previousKey) return null;
+ if (key === previousKey) return null;
+ return {
+ id,
+ from: previousKey,
+ to: key,
+ };
+ })
+ .filter(isNotNull);
+ const verificationMethodsAdded = Object.entries(
+ entryOperation.verificationMethods,
+ ).filter(([id]) => !previousOperation.verificationMethods[id]);
+ const verificationMethodsRemoved = Object.entries(
+ previousOperation.verificationMethods,
+ ).filter(([id]) => !entryOperation.verificationMethods[id]);
+
+ return (
+ // eslint-disable-next-line react/no-array-index-key
+ -
+
+ Change created at {utcDateFormatter.format(entry.createdAt)} (UTC)
+
+
+ {alsoKnownAsAdded.length === 1 &&
+ alsoKnownAsRemoved.length === 1 ? (
+ -
+ Alias changed from{" "}
+
+ {alsoKnownAsRemoved[0]}
+ {" "}
+ to{" "}
+
+ {alsoKnownAsAdded[0]}
+
+
+ ) : (
+ <>
+ {alsoKnownAsAdded.length > 0 && (
+ -
+ Alias added:{" "}
+ {alsoKnownAsAdded.flatMap((aka) => [
+
+ {aka}
+ ,
+ ", ",
+ ])}
+
+ )}
+ {alsoKnownAsRemoved.length > 0 && (
+ -
+ Alias removed:{" "}
+ {alsoKnownAsRemoved.flatMap((aka) => [
+
+ {aka}
+ ,
+ ", ",
+ ])}
+
+ )}
+ >
+ )}
+ {servicesChanged.length > 0 &&
+ servicesChanged.map((service) => (
+
+ {!!service.type && (
+ -
+ Service "{service.id}" changed type from
+ "
+ {service.type.from}" to "{service.type.to}
+ "
+
+ )}
+ {!!service.endpoint && (
+ -
+ Service "{service.id}" changed endpoint from{" "}
+
+ {service.endpoint.from}
+ {" "}
+ to{" "}
+ {service.endpoint.to}
+
+ )}
+
+ ))}
+ {servicesAdded.length > 0 && (
+ -
+ Services added:{" "}
+ {servicesAdded.flatMap(([id, service]) => [
+
+ {id} ({service.type})
+ ,
+ ", ",
+ ])}
+
+ )}
+ {servicesRemoved.length > 0 && (
+ -
+ Services removed:{" "}
+ {servicesRemoved.flatMap(([id, service]) => [
+
+ {id} ({service.type})
+ ,
+ ", ",
+ ])}
+
+ )}
+ {rotationKeysAdded.length > 0 && (
+ -
+ Rotation keys added:{" "}
+ {rotationKeysAdded.flatMap((key) => [
+
{key}
,
+ ", ",
+ ])}
+
+ )}
+ {rotationKeysRemoved.length > 0 && (
+ -
+ Rotation keys removed:{" "}
+ {rotationKeysRemoved.flatMap((key) => [
+
{key}
,
+ ", ",
+ ])}
+
+ )}
+ {verificationMethodsChanged.length > 0 &&
+ verificationMethodsChanged.map((method) => (
+ -
+ Verification method "{method.id}" changed from{" "}
+
{method.from}
to {method.to}
+
+ ))}
+ {verificationMethodsAdded.length > 0 && (
+ -
+ Verification methods added:{" "}
+ {verificationMethodsAdded.flatMap(([id, key]) => [
+
+
{key}
("{id}")
+ ,
+ ", ",
+ ])}
+
+ )}
+ {verificationMethodsRemoved.length > 0 && (
+ -
+ Verification methods removed:{" "}
+ {verificationMethodsRemoved.flatMap(([id, key]) => [
+
+
{key}
("{id}")
+ ,
+ ", ",
+ ])}
+
+ )}
+
+
+ );
+ })}
+
+ );
+}
+
+const PlcLogAuditResponse = z.array(
+ z.object({
+ createdAt: z
+ .string()
+ .datetime()
+ .transform((x) => new Date(x)),
+ operation: z.union([
+ z.object({
+ type: z.literal("plc_operation"),
+ sig: z.string(),
+ prev: z.string().nullable(),
+ services: z.record(
+ z.object({
+ type: z.string(),
+ endpoint: z.string(),
+ }),
+ ),
+ alsoKnownAs: z.array(z.string()),
+ rotationKeys: z.array(z.string()),
+ verificationMethods: z.record(z.string()),
+ }),
+ z.object({
+ type: z.literal("create"),
+ signingKey: z.string(),
+ recoveryKey: z.string(),
+ handle: z.string(),
+ service: z.string(),
+ }),
+ z.object({
+ type: z.literal("plc_tombstone"),
+ }),
+ ]),
+ }),
+);
diff --git a/packages/atproto-browser/app/at/_lib/atproto-json.tsx b/packages/atproto-browser/app/at/_lib/atproto-json.tsx
new file mode 100644
index 00000000..18a2e4cd
--- /dev/null
+++ b/packages/atproto-browser/app/at/_lib/atproto-json.tsx
@@ -0,0 +1,170 @@
+import { isDid } from "@atproto/did";
+import Link from "next/link";
+import { AtBlob } from "../../../lib/at-blob";
+import { getAtUriPath } from "@/lib/util";
+import { AtUri } from "@atproto/syntax";
+
+function naiveAtUriCheck(atUri: string) {
+ if (!atUri.startsWith("at://")) {
+ return false;
+ }
+
+ // Check there is no whitespace in the URI
+ return atUri.split(" ").length === 1;
+}
+
+function JSONString({ data }: { data: string }) {
+ return (
+
+ {naiveAtUriCheck(data) ? (
+ <>
+ "{data}
+ "
+ >
+ ) : isDid(data) ? (
+ <>
+ "{data}
+ "
+ >
+ ) : URL.canParse(data) ? (
+ <>
+ "
+
+ {data}
+
+ "
+ >
+ ) : (
+ `"${data}"`
+ )}
+
+ );
+}
+
+function JSONNumber({ data }: { data: number }) {
+ return (
+
+ {data}
+
+ );
+}
+
+function JSONBoolean({ data }: { data: boolean }) {
+ return (
+
+ {data ? "true" : "false"}
+
+ );
+}
+
+function JSONNull() {
+ return (
+
+ null
+
+ );
+}
+
+function JSONObject({
+ data,
+ repo,
+}: {
+ data: { [x: string]: JSONType };
+ repo: string;
+}) {
+ const rawObj = (
+
+ {Object.entries(data).map(([key, value]) => (
+
+ ))}
+
+ );
+
+ const parseBlobResult = AtBlob.safeParse(data);
+ if (
+ parseBlobResult.success &&
+ parseBlobResult.data.mimeType.startsWith("image/")
+ ) {
+ return (
+ <>
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ View blob content
+ {rawObj}
+
+ >
+ );
+ }
+
+ return rawObj;
+}
+
+function JSONArray({ data, repo }: { data: JSONType[]; repo: string }) {
+ return (
+
+ {data.map((value, index) => (
+ // eslint-disable-next-line react/no-array-index-key
+ -
+
+
+ ))}
+
+ );
+}
+
+export function JSONValue({ data, repo }: { data: JSONType; repo: string }) {
+ if (typeof data === "string") {
+ return ;
+ }
+ if (typeof data === "number") {
+ return ;
+ }
+ if (typeof data === "boolean") {
+ return ;
+ }
+ if (data === null) {
+ return ;
+ }
+ if (Array.isArray(data)) {
+ return ;
+ }
+ return ;
+}
+
+export type JSONType =
+ | string
+ | number
+ | boolean
+ | null
+ | {
+ [x: string]: JSONType;
+ }
+ | JSONType[];
diff --git a/packages/atproto-browser/app/at/_lib/collection-server.tsx b/packages/atproto-browser/app/at/_lib/collection-server.tsx
new file mode 100644
index 00000000..8d5632c4
--- /dev/null
+++ b/packages/atproto-browser/app/at/_lib/collection-server.tsx
@@ -0,0 +1,50 @@
+import { resolveIdentity } from "@/lib/atproto-server";
+import { getPds } from "@atproto/identity";
+import Link from "next/link";
+
+export async function DidCollections({ did }: { did: string }) {
+ const identityResult = await resolveIdentity(did);
+ if (!identityResult.success) {
+ throw new Error(`Could not resolve DID: ${did}`);
+ }
+ const didDocument = identityResult.identity;
+ const pds = getPds(didDocument);
+ if (!pds) {
+ throw new Error(`No PDS found for DID: ${didDocument.id}`);
+ }
+
+ const describeRepoUrl = new URL(`${pds}/xrpc/com.atproto.repo.describeRepo`);
+ describeRepoUrl.searchParams.set("repo", didDocument.id);
+ const response = await fetch(describeRepoUrl, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch collections: ${response.statusText}. URL: ${describeRepoUrl.toString()}`,
+ );
+ }
+
+ const { collections } = (await response.json()) as {
+ collections: string[];
+ };
+
+ return (
+
+ {collections.length === 0 ? (
+ No collections.
+ ) : (
+ collections.map((nsid) => {
+ return (
+ -
+ {nsid}
+
+ );
+ })
+ )}
+
+ );
+}
diff --git a/packages/atproto-browser/app/at/_lib/collection.tsx b/packages/atproto-browser/app/at/_lib/collection.tsx
index 5b86edc1..7d209020 100644
--- a/packages/atproto-browser/app/at/_lib/collection.tsx
+++ b/packages/atproto-browser/app/at/_lib/collection.tsx
@@ -1,6 +1,7 @@
"use client";
import { listRecords } from "@/lib/atproto";
+import { getAtUriPath } from "@/lib/util";
import { AtUri } from "@atproto/syntax";
import Link from "next/link";
import { Suspense, useState } from "react";
@@ -34,7 +35,7 @@ export function CollectionItems({
const uri = new AtUri(record.uri);
return (
- {uri.rkey}
+ {uri.rkey}
);
})}
diff --git a/packages/atproto-browser/app/at/_lib/did-components.tsx b/packages/atproto-browser/app/at/_lib/did-components.tsx
new file mode 100644
index 00000000..18286412
--- /dev/null
+++ b/packages/atproto-browser/app/at/_lib/did-components.tsx
@@ -0,0 +1,23 @@
+import { ErrorBoundary } from "react-error-boundary";
+import { JSONType, JSONValue } from "./atproto-json";
+import { resolveIdentity } from "@/lib/atproto-server";
+import { DidCollections } from "./collection-server";
+
+export async function DidSummary({ did }: { did: string }) {
+ return (
+ <>
+ Failed to fetch collections for {did}.}
+ >
+ Collections
+
+
+ DID Doc
+
+ >
+ );
+}
+async function DidDoc({ did }: { did: string }) {
+ const identityResult = await resolveIdentity(did);
+ return ;
+}
diff --git a/packages/atproto-browser/app/at/_lib/uri-bar.tsx b/packages/atproto-browser/app/at/_lib/uri-bar.tsx
index 14fdf3c2..40a03f42 100644
--- a/packages/atproto-browser/app/at/_lib/uri-bar.tsx
+++ b/packages/atproto-browser/app/at/_lib/uri-bar.tsx
@@ -1,19 +1,30 @@
"use client";
import Link from "next/link";
-import { useSearchParams } from "next/navigation";
+import { useParams, usePathname, useSearchParams } from "next/navigation";
import { AtUriForm } from "../../aturi-form";
export function UriBar() {
const searchParams = useSearchParams();
+ const pathname = usePathname();
+ const params = useParams() as {
+ identifier?: string;
+ collection?: string;
+ rkey?: string;
+ };
+
+ const uri =
+ pathname === "/at"
+ ? searchParams.get("u") ?? undefined
+ : `at://${[params.identifier, params.collection, params.rkey]
+ .map((c) => c && decodeURIComponent(c))
+ .filter(Boolean)
+ .join("/")}`;
return (
);
}
diff --git a/packages/atproto-browser/app/at/page.tsx b/packages/atproto-browser/app/at/page.tsx
index d61f5569..fe84b14a 100644
--- a/packages/atproto-browser/app/at/page.tsx
+++ b/packages/atproto-browser/app/at/page.tsx
@@ -1,713 +1,12 @@
-import { getHandle, getKey, getPds } from "@atproto/identity";
+import { getAtUriPath } from "@/lib/util";
import { AtUri } from "@atproto/syntax";
-import { isDid } from "@atproto/did";
-import { Fragment, Suspense } from "react";
-import Link from "next/link";
-import { AtBlob } from "./_lib/at-blob";
-import { CollectionItems } from "./_lib/collection";
-import { SWRConfig } from "swr";
-import { listRecords } from "@/lib/atproto";
-import { verifyRecords } from "@atproto/repo";
-import { ErrorBoundary } from "react-error-boundary";
-import { z } from "zod";
-import { resolveIdentity } from "@/lib/atproto-server";
+import { redirect } from "next/navigation";
-export default async function AtPage({
+export default function AtPage({
searchParams,
}: {
searchParams: Record;
}) {
const uri = new AtUri(searchParams.u!);
-
- const identityResult = await resolveIdentity(uri.hostname);
- if (!identityResult.success) {
- return {identityResult.error}
;
- }
-
- const didDocument = identityResult.identity;
-
- const pds = getPds(didDocument);
- if (!pds) {
- return No PDS found for DID: {didDocument.id}
;
- }
-
- const handle = getHandle(didDocument) ?? ``;
-
- if (uri.pathname === "/" || uri.pathname === "") {
- return (
- <>
-
- {handle} ({didDocument.id})
-
-
-
- Loading history...}>
- Failed to fetch history.}>
- History
-
-
-
- >
- );
- }
-
- if (!uri.rkey) {
- const fetchKey =
- `listCollections/collection:${uri.collection}/cursor:none` as const;
- return (
-
-
- {handle}'s {uri.collection} records{" "}
-
- 🛜
-
-
-
-
- );
- }
-
- const getRecordUrl = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`);
- getRecordUrl.searchParams.set("repo", didDocument.id);
- getRecordUrl.searchParams.set("collection", uri.collection);
- getRecordUrl.searchParams.set("rkey", uri.rkey);
-
- const response = await fetch(getRecordUrl, {
- method: "GET",
- headers: {
- "Content-Type": "application/json",
- },
- });
-
- if (!response.ok) {
- return (
-
- Failed to fetch record: {response.statusText}. URL:{" "}
- {getRecordUrl.toString()}
-
- );
- }
-
- const record = (await response.json()) as JSONType;
-
- return (
-
-
-
- Author: {handle} (
- {didDocument.id})
-
-
-
-
-
-
- Record
-
- 🤔
-
- }
- >
-
-
-
-
-
- );
-}
-
-async function RecordVerificationBadge({
- did,
- collection,
- rkey,
-}: {
- did: string;
- collection: string;
- rkey: string;
-}) {
- const identityResult = await resolveIdentity(did);
- if (!identityResult.success) {
- throw new Error(identityResult.error);
- }
- const didDoc = identityResult.identity;
- const pds = getPds(didDoc);
- if (!pds) {
- return ❌;
- }
-
- const verifyRecordsUrl = new URL(`${pds}/xrpc/com.atproto.sync.getRecord`);
- verifyRecordsUrl.searchParams.set("did", did);
- verifyRecordsUrl.searchParams.set("collection", collection);
- verifyRecordsUrl.searchParams.set("rkey", rkey);
-
- const response = await fetch(verifyRecordsUrl, {
- headers: {
- accept: "application/vnd.ipld.car",
- },
- });
-
- if (!response.ok) {
- return (
-
- ❌
-
- );
- }
- const car = new Uint8Array(await response.arrayBuffer());
- const key = getKey(didDoc);
- if (!key) {
- return ❌;
- }
-
- try {
- await verifyRecords(car, did, key);
- return 🔒;
- } catch (e) {
- if (e instanceof Error) {
- return ❌;
- } else {
- return ❌;
- }
- }
-}
-
-async function DidSummary({ did }: { did: string }) {
- return (
- <>
- Failed to fetch collections for {did}.}
- >
- Collections
-
-
- DID Doc
-
- >
- );
+ redirect(getAtUriPath(uri));
}
-
-async function DidDoc({ did }: { did: string }) {
- const identityResult = await resolveIdentity(did);
- return ;
-}
-
-async function DidCollections({ did }: { did: string }) {
- const identityResult = await resolveIdentity(did);
- if (!identityResult.success) {
- throw new Error(`Could not resolve DID: ${did}`);
- }
- const didDocument = identityResult.identity;
- const pds = getPds(didDocument);
- if (!pds) {
- throw new Error(`No PDS found for DID: ${didDocument.id}`);
- }
-
- const describeRepoUrl = new URL(`${pds}/xrpc/com.atproto.repo.describeRepo`);
- describeRepoUrl.searchParams.set("repo", didDocument.id);
- const response = await fetch(describeRepoUrl, {
- method: "GET",
- headers: {
- "Content-Type": "application/json",
- },
- });
-
- if (!response.ok) {
- throw new Error(
- `Failed to fetch collections: ${response.statusText}. URL: ${describeRepoUrl.toString()}`,
- );
- }
-
- const { collections } = (await response.json()) as {
- collections: string[];
- };
-
- return (
-
- {collections.length === 0 ? (
- No collections.
- ) : (
- collections.map((nsid) => {
- const collectionUri = `at://${[did, nsid].join("/")}`;
-
- return (
- -
- {nsid}
-
- );
- })
- )}
-
- );
-}
-
-const PlcLogAuditResponse = z.array(
- z.object({
- createdAt: z
- .string()
- .datetime()
- .transform((x) => new Date(x)),
- operation: z.union([
- z.object({
- type: z.literal("plc_operation"),
- sig: z.string(),
- prev: z.string().nullable(),
- services: z.record(
- z.object({
- type: z.string(),
- endpoint: z.string(),
- }),
- ),
- alsoKnownAs: z.array(z.string()),
- rotationKeys: z.array(z.string()),
- verificationMethods: z.record(z.string()),
- }),
- z.object({
- type: z.literal("create"),
- signingKey: z.string(),
- recoveryKey: z.string(),
- handle: z.string(),
- service: z.string(),
- }),
- z.object({
- type: z.literal("plc_tombstone"),
- }),
- ]),
- }),
-);
-
-const utcDateFormatter = new Intl.DateTimeFormat("en-US", {
- dateStyle: "medium",
- timeStyle: "short",
- timeZone: "UTC",
-});
-
-function isNotNull(x: T | null): x is T {
- return x !== null;
-}
-
-async function DidHistory({ did }: { did: string }) {
- const response = await fetch(`https://plc.directory/${did}/log/audit`);
- if (!response.ok) {
- throw new Error(`Failed to fetch history: ${response.statusText}`);
- }
-
- const auditLog = PlcLogAuditResponse.parse(await response.json());
-
- return (
-
- {auditLog.map((previous, index) => {
- const previousOperation = previous.operation;
- if (previousOperation.type !== "plc_operation") {
- return (
- // eslint-disable-next-line react/no-array-index-key
- -
- Change created at {utcDateFormatter.format(previous.createdAt)}{" "}
- (UTC) of type "{previousOperation.type}".
-
- );
- }
- const entry = auditLog[index + 1];
- if (!entry) {
- return null;
- }
- const entryOperation = entry.operation;
- if (entryOperation.type !== "plc_operation") {
- return null;
- }
-
- const alsoKnownAsAdded = entryOperation.alsoKnownAs.filter(
- (x) => !previousOperation.alsoKnownAs.includes(x),
- );
- const alsoKnownAsRemoved = previousOperation.alsoKnownAs.filter(
- (x) => !entryOperation.alsoKnownAs.includes(x),
- );
-
- const servicesChanged = Object.entries(entryOperation.services)
- .map(([id, service]) => {
- const previousService = previousOperation.services[id];
- if (!previousService) return null;
- return {
- id,
- type:
- service.type !== previousService.type
- ? {
- from: previousService.type,
- to: service.type,
- }
- : null,
- endpoint:
- service.endpoint !== previousService.endpoint
- ? {
- from: previousService.endpoint,
- to: service.endpoint,
- }
- : null,
- };
- })
- .filter(isNotNull);
-
- const servicesAdded = Object.entries(entryOperation.services).filter(
- ([id]) => !previousOperation.services[id],
- );
- const servicesRemoved = Object.entries(
- previousOperation.services,
- ).filter(([id]) => !entryOperation.services[id]);
-
- const rotationKeysAdded = entryOperation.rotationKeys.filter(
- (x) => !previousOperation.rotationKeys.includes(x),
- );
- const rotationKeysRemoved = previousOperation.rotationKeys.filter(
- (x) => !entryOperation.rotationKeys.includes(x),
- );
-
- const verificationMethodsChanged = Object.entries(
- entryOperation.verificationMethods,
- )
- .map(([id, key]) => {
- const previousKey = previousOperation.verificationMethods[id];
- if (!previousKey) return null;
- if (key === previousKey) return null;
- return {
- id,
- from: previousKey,
- to: key,
- };
- })
- .filter(isNotNull);
- const verificationMethodsAdded = Object.entries(
- entryOperation.verificationMethods,
- ).filter(([id]) => !previousOperation.verificationMethods[id]);
- const verificationMethodsRemoved = Object.entries(
- previousOperation.verificationMethods,
- ).filter(([id]) => !entryOperation.verificationMethods[id]);
-
- return (
- // eslint-disable-next-line react/no-array-index-key
- -
-
- Change created at {utcDateFormatter.format(entry.createdAt)} (UTC)
-
-
- {alsoKnownAsAdded.length === 1 &&
- alsoKnownAsRemoved.length === 1 ? (
- -
- Alias changed from{" "}
-
- {alsoKnownAsRemoved[0]}
- {" "}
- to{" "}
-
- {alsoKnownAsAdded[0]}
-
-
- ) : (
- <>
- {alsoKnownAsAdded.length > 0 && (
- -
- Alias added:{" "}
- {alsoKnownAsAdded.flatMap((aka) => [
-
- {aka}
- ,
- ", ",
- ])}
-
- )}
- {alsoKnownAsRemoved.length > 0 && (
- -
- Alias removed:{" "}
- {alsoKnownAsRemoved.flatMap((aka) => [
-
- {aka}
- ,
- ", ",
- ])}
-
- )}
- >
- )}
- {servicesChanged.length > 0 &&
- servicesChanged.map((service) => (
-
- {!!service.type && (
- -
- Service "{service.id}" changed type from
- "
- {service.type.from}" to "{service.type.to}
- "
-
- )}
- {!!service.endpoint && (
- -
- Service "{service.id}" changed endpoint from{" "}
-
- {service.endpoint.from}
- {" "}
- to{" "}
- {service.endpoint.to}
-
- )}
-
- ))}
- {servicesAdded.length > 0 && (
- -
- Services added:{" "}
- {servicesAdded.flatMap(([id, service]) => [
-
- {id} ({service.type})
- ,
- ", ",
- ])}
-
- )}
- {servicesRemoved.length > 0 && (
- -
- Services removed:{" "}
- {servicesRemoved.flatMap(([id, service]) => [
-
- {id} ({service.type})
- ,
- ", ",
- ])}
-
- )}
- {rotationKeysAdded.length > 0 && (
- -
- Rotation keys added:{" "}
- {rotationKeysAdded.flatMap((key) => [
-
{key}
,
- ", ",
- ])}
-
- )}
- {rotationKeysRemoved.length > 0 && (
- -
- Rotation keys removed:{" "}
- {rotationKeysRemoved.flatMap((key) => [
-
{key}
,
- ", ",
- ])}
-
- )}
- {verificationMethodsChanged.length > 0 &&
- verificationMethodsChanged.map((method) => (
- -
- Verification method "{method.id}" changed from{" "}
-
{method.from}
to {method.to}
-
- ))}
- {verificationMethodsAdded.length > 0 && (
- -
- Verification methods added:{" "}
- {verificationMethodsAdded.flatMap(([id, key]) => [
-
-
{key}
("{id}")
- ,
- ", ",
- ])}
-
- )}
- {verificationMethodsRemoved.length > 0 && (
- -
- Verification methods removed:{" "}
- {verificationMethodsRemoved.flatMap(([id, key]) => [
-
-
{key}
("{id}")
- ,
- ", ",
- ])}
-
- )}
-
-
- );
- })}
-
- );
-}
-
-function naiveAtUriCheck(atUri: string) {
- if (!atUri.startsWith("at://")) {
- return false;
- }
-
- // Check there is no whitespace in the URI
- return atUri.split(" ").length === 1;
-}
-
-function JSONString({ data }: { data: string }) {
- return (
-
- {naiveAtUriCheck(data) ? (
- <>
- "{data}
- "
- >
- ) : isDid(data) ? (
- <>
- "{data}
- "
- >
- ) : URL.canParse(data) ? (
- <>
- "
-
- {data}
-
- "
- >
- ) : (
- `"${data}"`
- )}
-
- );
-}
-
-function JSONNumber({ data }: { data: number }) {
- return (
-
- {data}
-
- );
-}
-
-function JSONBoolean({ data }: { data: boolean }) {
- return (
-
- {data ? "true" : "false"}
-
- );
-}
-
-function JSONNull() {
- return (
-
- null
-
- );
-}
-
-function JSONObject({
- data,
- repo,
-}: {
- data: { [x: string]: JSONType };
- repo: string;
-}) {
- const rawObj = (
-
- {Object.entries(data).map(([key, value]) => (
-
- ))}
-
- );
-
- const parseBlobResult = AtBlob.safeParse(data);
- if (
- parseBlobResult.success &&
- parseBlobResult.data.mimeType.startsWith("image/")
- ) {
- return (
- <>
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
-
- View blob content
- {rawObj}
-
- >
- );
- }
-
- return rawObj;
-}
-
-function JSONArray({ data, repo }: { data: JSONType[]; repo: string }) {
- return (
-
- {data.map((value, index) => (
- // eslint-disable-next-line react/no-array-index-key
- -
-
-
- ))}
-
- );
-}
-
-function JSONValue({ data, repo }: { data: JSONType; repo: string }) {
- if (typeof data === "string") {
- return ;
- }
- if (typeof data === "number") {
- return ;
- }
- if (typeof data === "boolean") {
- return ;
- }
- if (data === null) {
- return ;
- }
- if (Array.isArray(data)) {
- return ;
- }
- return ;
-}
-
-type JSONType =
- | string
- | number
- | boolean
- | null
- | {
- [x: string]: JSONType;
- }
- | JSONType[];
diff --git a/packages/atproto-browser/app/collection-rss/route.tsx b/packages/atproto-browser/app/collection-rss/route.tsx
index 917a90a6..5e72b546 100644
--- a/packages/atproto-browser/app/collection-rss/route.tsx
+++ b/packages/atproto-browser/app/collection-rss/route.tsx
@@ -1,5 +1,6 @@
import { listRecords } from "@/lib/atproto";
import { resolveIdentity } from "@/lib/atproto-server";
+import { getAtUriPath } from "@/lib/util";
import { getHandle, getPds } from "@atproto/identity";
import { AtUri } from "@atproto/syntax";
@@ -47,7 +48,7 @@ export async function GET(request: Request) {
month: "2-digit",
day: "2-digit",
}).format(new Date())}
- ${ORIGIN}/at?u=${uri.toString()}
+ ${ORIGIN}${getAtUriPath(uri)}
${record.cid}
`.trim(),
diff --git a/packages/atproto-browser/app/page.tsx b/packages/atproto-browser/app/page.tsx
index c7ca4651..21d235b4 100644
--- a/packages/atproto-browser/app/page.tsx
+++ b/packages/atproto-browser/app/page.tsx
@@ -7,8 +7,7 @@ export const metadata: Metadata = {
description: "Browse the atmosphere.",
};
-const EXAMPLE_URI =
- "at://did:plc:2xau7wbgdq4phuou2ypwuen7/app.bsky.feed.like/3kyutnrmg3s2r";
+const EXAMPLE_PATH = "tom-sherman.com/app.bsky.feed.like/3kyutnrmg3s2r";
export default function Home() {
return (
@@ -18,7 +17,7 @@ export default function Home() {
- eg. {EXAMPLE_URI}
+ eg. at://{EXAMPLE_PATH}
);
diff --git a/packages/atproto-browser/app/at/_lib/at-blob.ts b/packages/atproto-browser/lib/at-blob.ts
similarity index 100%
rename from packages/atproto-browser/app/at/_lib/at-blob.ts
rename to packages/atproto-browser/lib/at-blob.ts
diff --git a/packages/atproto-browser/lib/atproto-server.ts b/packages/atproto-browser/lib/atproto-server.ts
index 4a1b3ab2..80c39cb0 100644
--- a/packages/atproto-browser/lib/atproto-server.ts
+++ b/packages/atproto-browser/lib/atproto-server.ts
@@ -48,20 +48,21 @@ export async function resolveIdentity(
): Promise<
{ success: true; identity: DidDocument } | { success: false; error: string }
> {
+ const decoded = decodeURIComponent(didOrHandle);
let didStr;
- if (isValidHandle(didOrHandle)) {
- didStr = await resolveHandle(didOrHandle).catch(() => undefined);
+ if (isValidHandle(decoded)) {
+ didStr = await resolveHandle(decoded).catch(() => undefined);
if (!didStr) {
return {
success: false,
- error: `Could not resolve did from handle: ${didOrHandle}`,
+ error: `Could not resolve did from handle: ${decoded}`,
};
}
} else {
- if (!isDid(didOrHandle)) {
- return { success: false, error: `Invalid DID: ${didOrHandle}` };
+ if (!isDid(decoded)) {
+ return { success: false, error: `Invalid DID: ${decoded}` };
}
- didStr = didOrHandle;
+ didStr = decoded;
}
const didDocument = await resolveDid(didStr);
diff --git a/packages/atproto-browser/lib/util.ts b/packages/atproto-browser/lib/util.ts
new file mode 100644
index 00000000..3a92be65
--- /dev/null
+++ b/packages/atproto-browser/lib/util.ts
@@ -0,0 +1,18 @@
+import { AtUri } from "@atproto/syntax";
+
+export const utcDateFormatter = new Intl.DateTimeFormat("en-US", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ timeZone: "UTC",
+});
+
+export function isNotNull(x: T | null): x is T {
+ return x !== null;
+}
+
+export function getAtUriPath(uri: AtUri): string {
+ return `/at/${[uri.host, uri.collection, uri.rkey]
+ .filter(Boolean)
+ .map((c) => c && decodeURIComponent(c))
+ .join("/")}`;
+}