feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+7
View File
@@ -0,0 +1,7 @@
import { RouterProvider } from "@tanstack/react-router";
import { router } from "@/app/router";
export function App() {
return <RouterProvider router={router} />;
}
+144
View File
@@ -0,0 +1,144 @@
// biome-ignore-all lint: generated by TanStack Router
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/root";
import { Route as reposRouteImport } from "./routes/repos";
import { Route as indexRouteImport } from "./routes/index";
import { Route as reposDotrepoIdRouteImport } from "./routes/repos.$repoId";
import { Route as inviteDotcodeRouteImport } from "./routes/invite.$code";
import { Route as reposDotrepoIdDotblobDotsplatRouteImport } from "./routes/repos.$repoId.blob.$";
const reposRoute = reposRouteImport.update({
id: "/repos",
path: "/repos",
getParentRoute: () => rootRouteImport,
} as any);
const indexRoute = indexRouteImport.update({
id: "/",
path: "/",
getParentRoute: () => rootRouteImport,
} as any);
const reposDotrepoIdRoute = reposDotrepoIdRouteImport.update({
id: "/repos/$repoId",
path: "/repos/$repoId",
getParentRoute: () => rootRouteImport,
} as any);
const inviteDotcodeRoute = inviteDotcodeRouteImport.update({
id: "/invite/$code",
path: "/invite/$code",
getParentRoute: () => rootRouteImport,
} as any);
const reposDotrepoIdDotblobDotsplatRoute =
reposDotrepoIdDotblobDotsplatRouteImport.update({
id: "/repos/$repoId/blob/$",
path: "/repos/$repoId/blob/$",
getParentRoute: () => rootRouteImport,
} as any);
export interface FileRoutesByFullPath {
"/": typeof indexRoute;
"/repos": typeof reposRoute;
"/invite/$code": typeof inviteDotcodeRoute;
"/repos/$repoId": typeof reposDotrepoIdRoute;
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
}
export interface FileRoutesByTo {
"/": typeof indexRoute;
"/repos": typeof reposRoute;
"/invite/$code": typeof inviteDotcodeRoute;
"/repos/$repoId": typeof reposDotrepoIdRoute;
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof indexRoute;
"/repos": typeof reposRoute;
"/invite/$code": typeof inviteDotcodeRoute;
"/repos/$repoId": typeof reposDotrepoIdRoute;
"/repos/$repoId/blob/$": typeof reposDotrepoIdDotblobDotsplatRoute;
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths:
| "/"
| "/repos"
| "/invite/$code"
| "/repos/$repoId"
| "/repos/$repoId/blob/$";
fileRoutesByTo: FileRoutesByTo;
to:
| "/"
| "/repos"
| "/invite/$code"
| "/repos/$repoId"
| "/repos/$repoId/blob/$";
id:
| "__root__"
| "/"
| "/repos"
| "/invite/$code"
| "/repos/$repoId"
| "/repos/$repoId/blob/$";
fileRoutesById: FileRoutesById;
}
export interface RootRouteChildren {
indexRoute: typeof indexRoute;
reposRoute: typeof reposRoute;
inviteDotcodeRoute: typeof inviteDotcodeRoute;
reposDotrepoIdRoute: typeof reposDotrepoIdRoute;
reposDotrepoIdDotblobDotsplatRoute: typeof reposDotrepoIdDotblobDotsplatRoute;
}
declare module "@tanstack/react-router" {
interface FileRoutesByPath {
"/repos": {
id: "/repos";
path: "/repos";
fullPath: "/repos";
preLoaderRoute: typeof reposRouteImport;
parentRoute: typeof rootRouteImport;
};
"/": {
id: "/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof indexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/repos/$repoId": {
id: "/repos/$repoId";
path: "/repos/$repoId";
fullPath: "/repos/$repoId";
preLoaderRoute: typeof reposDotrepoIdRouteImport;
parentRoute: typeof rootRouteImport;
};
"/invite/$code": {
id: "/invite/$code";
path: "/invite/$code";
fullPath: "/invite/$code";
preLoaderRoute: typeof inviteDotcodeRouteImport;
parentRoute: typeof rootRouteImport;
};
"/repos/$repoId/blob/$": {
id: "/repos/$repoId/blob/$";
path: "/repos/$repoId/blob/$";
fullPath: "/repos/$repoId/blob/$";
preLoaderRoute: typeof reposDotrepoIdDotblobDotsplatRouteImport;
parentRoute: typeof rootRouteImport;
};
}
}
const rootRouteChildren: RootRouteChildren = {
indexRoute: indexRoute,
reposRoute: reposRoute,
inviteDotcodeRoute: inviteDotcodeRoute,
reposDotrepoIdRoute: reposDotrepoIdRoute,
reposDotrepoIdDotblobDotsplatRoute: reposDotrepoIdDotblobDotsplatRoute,
};
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>();
+19
View File
@@ -0,0 +1,19 @@
import { createBrowserHistory, createRouter } from "@tanstack/react-router";
import { routeTree } from "@/app/routeTree.gen";
import { appBasePath } from "@/shared/lib/app-base";
export const router = createRouter({
routeTree,
basepath: appBasePath(),
history: createBrowserHistory(),
scrollRestoration: true,
getScrollRestorationKey: (location: { pathname: string }) =>
location.pathname,
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { index, route, rootRoute } from "@tanstack/virtual-file-routes";
export const routes = rootRoute("root.tsx", [
index("index.tsx"),
route("/invite/$code", "invite.$code.tsx"),
route("/repos", "repos.tsx"),
route("/repos/$repoId", "repos.$repoId.tsx"),
route("/repos/$repoId/blob/$", "repos.$repoId.blob.$.tsx"),
]);
+6
View File
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { ReposPage } from "@/features/repos/ui/ReposPage";
export const Route = createFileRoute("/")({
component: ReposPage,
});
+11
View File
@@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { InvitePage } from "@/features/invite/ui/InvitePage";
export const Route = createFileRoute("/invite/$code")({
component: InvitePageRoute,
});
function InvitePageRoute() {
const { code } = Route.useParams();
return <InvitePage code={code} />;
}
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { RepoBlobPage } from "@/features/repos/ui/RepoBlobViewer";
export const Route = createFileRoute("/repos/$repoId/blob/$")({
component: RepoBlobPage,
});
+6
View File
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { RepoDetailPage } from "@/features/repos/ui/RepoDetailPage";
export const Route = createFileRoute("/repos/$repoId")({
component: RepoDetailPage,
});
+5
View File
@@ -0,0 +1,5 @@
import { Navigate, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/repos")({
component: () => <Navigate to="/" />,
});
+19
View File
@@ -0,0 +1,19 @@
import { Outlet, createRootRoute } from "@tanstack/react-router";
import { LocaleToggle } from "@/shared/i18n/LocaleToggle";
export const Route = createRootRoute({
component: RootLayout,
});
function RootLayout() {
return (
<div className="relative flex min-h-dvh flex-col">
<div className="fixed top-4 right-4 z-40">
<LocaleToggle />
</div>
<main className="flex flex-1 flex-col">
<Outlet />
</main>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File
+51
View File
@@ -0,0 +1,51 @@
import { makeNip98AuthHeader } from "@/shared/lib/nip98";
import { relayHttpBaseUrl } from "@/shared/lib/relay-url";
const INVITE_REQUEST_TIMEOUT_MS = 15_000;
export type BrowserInviteClaim = {
status: "joined" | "already_member";
communityId: string;
host: string;
role: string;
};
export async function claimInviteInBrowser(
code: string,
policyReceipt?: string,
): Promise<BrowserInviteClaim> {
const url = `${relayHttpBaseUrl().replace(/\/+$/, "")}/api/invites/claim`;
const body = JSON.stringify({
code,
policy_receipt: policyReceipt,
});
const authorization = await makeNip98AuthHeader(url, "POST", {
body,
requireNip07: true,
});
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: authorization,
"Content-Type": "application/json",
},
body,
signal: AbortSignal.timeout(INVITE_REQUEST_TIMEOUT_MS),
});
const json = (await response.json().catch(() => ({}))) as Record<
string,
unknown
>;
if (!response.ok) {
const message =
typeof json.error === "string" ? json.error : `HTTP ${response.status}`;
throw new Error(message);
}
return {
status: json.status as BrowserInviteClaim["status"],
communityId: String(json.community_id),
host: String(json.host),
role: String(json.role),
};
}
@@ -0,0 +1,163 @@
import * as React from "react";
import { useI18n } from "@/shared/i18n/I18nProvider";
type InviteJoinPolicy = {
terms_markdown?: string;
privacy_markdown?: string;
age_attestation_required: boolean;
};
type PolicyCheckboxProps = {
accessibleLabel: string;
checked: boolean;
children: React.ReactNode;
onCheckedChange: (checked: boolean) => void;
};
function PolicyCheckbox({
accessibleLabel,
checked,
children,
onCheckedChange,
}: PolicyCheckboxProps) {
const id = React.useId();
return (
<div className="relative">
<input
aria-label={accessibleLabel}
checked={checked}
className="peer sr-only"
id={id}
onChange={(event) => onCheckedChange(event.target.checked)}
type="checkbox"
/>
<label
className="flex cursor-pointer items-start gap-3 rounded-sm text-left text-xs leading-5 text-black/60 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-black"
htmlFor={id}
>
<span
aria-hidden="true"
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-[background-color,border-color] duration-150 motion-reduce:transition-none ${
checked ? "border-black bg-black" : "border-black/40 bg-white"
}`}
>
<svg
aria-hidden="true"
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 16 16"
>
<path
className={
checked
? "transition-[stroke-dashoffset] duration-[180ms] [transition-timing-function:cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"
: "transition-none"
}
d="M3 8.25 6.25 11.5 13 4.75"
pathLength="16"
stroke="currentColor"
strokeDasharray="16"
strokeDashoffset={checked ? 0 : 16}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
</span>
<span>{children}</span>
</label>
</div>
);
}
/** The invite page's age and legal confirmations. */
export function InviteJoinPolicyNotice({
ageConfirmed,
agreementConfirmed,
onAgeConfirmedChange,
onAgreementConfirmedChange,
onShowDocument,
policy,
}: {
ageConfirmed: boolean;
agreementConfirmed: boolean;
onAgeConfirmedChange: (checked: boolean) => void;
onAgreementConfirmedChange: (checked: boolean) => void;
onShowDocument: (title: string, markdown: string) => void;
policy: InviteJoinPolicy;
}) {
const { t } = useI18n();
const stopLabelActivation = (
event: React.MouseEvent<HTMLButtonElement>,
title: string,
markdown: string,
) => {
event.preventDefault();
event.stopPropagation();
onShowDocument(title, markdown);
};
return (
<div
className="w-full space-y-3 rounded-xl border border-black/10 bg-black/[0.03] p-4 text-left"
data-testid="invite-join-policy-notice"
>
{policy.age_attestation_required ? (
<PolicyCheckbox
accessibleLabel={t("invite.ageLabel")}
checked={ageConfirmed}
onCheckedChange={onAgeConfirmedChange}
>
{t("invite.ageLabel")}
</PolicyCheckbox>
) : null}
{policy.terms_markdown || policy.privacy_markdown ? (
<PolicyCheckbox
accessibleLabel={t("invite.agreementLabel")}
checked={agreementConfirmed}
onCheckedChange={onAgreementConfirmedChange}
>
{t("invite.agreementPrefix")}
{policy.terms_markdown ? (
<button
className="text-black no-underline underline-offset-4 hover:text-black/70 hover:underline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-black"
type="button"
onClick={(event) =>
stopLabelActivation(
event,
t("invite.terms"),
policy.terms_markdown ?? "",
)
}
>
{t("invite.terms")}
</button>
) : (
t("invite.terms")
)}
{t("invite.agreementJoin")}
{policy.privacy_markdown ? (
<button
className="text-black no-underline underline-offset-4 hover:text-black/70 hover:underline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-black"
type="button"
onClick={(event) =>
stopLabelActivation(
event,
t("invite.privacy"),
policy.privacy_markdown ?? "",
)
}
>
{t("invite.privacy")}
</button>
) : (
t("invite.privacy")
)}
{t("invite.agreementSuffix")}
</PolicyCheckbox>
) : null}
</div>
);
}
+411
View File
@@ -0,0 +1,411 @@
import buzzAppIcon from "@/assets/app-icon@3x.png";
import { claimInviteInBrowser } from "@/features/invite/invite-api";
import {
BUZZ_RELEASES_URL,
type BuzzDownloadPlatform,
detectBuzzDownloadPlatform,
resolveBuzzDownloadUrlForPlatform,
} from "@/shared/lib/buzz-download";
import { hasNip07Provider } from "@/shared/lib/nostr-signer";
import { relayHttpBaseUrl, relayWsUrl } from "@/shared/lib/relay-url";
import { appRootHref } from "@/shared/lib/app-base";
import { useI18n } from "@/shared/i18n/I18nProvider";
import type { TranslationKey } from "@/shared/i18n/catalogs";
import { Button } from "@/shared/ui/button";
import * as React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { InviteJoinPolicyNotice } from "./InviteJoinPolicyNotice";
type JoinPolicy = {
terms_markdown?: string;
privacy_markdown?: string;
age_attestation_required: boolean;
version: string;
};
type PolicyDocument = { title: string; markdown: string };
/** Convert relay invite sentinels into user-facing recovery guidance. */
function inviteClaimErrorMessage(
message: string,
t: (key: TranslationKey) => string,
): string {
if (message.includes("invite_exhausted")) {
return t("invite.error.exhausted");
}
if (message.includes("invite_expired")) {
return t("invite.error.expired");
}
if (message.includes("invite_invalid")) {
return t("invite.error.invalid");
}
console.error("[invite-claim]", message);
return t("invite.error.claimFailed");
}
/** Landing page for a community invite link (`/invite/<code>`). */
export function InvitePage({ code }: { code: string }) {
const { t } = useI18n();
const relay = relayWsUrl();
const relayHttp = relayHttpBaseUrl();
const host = relay.replace(/^wss?:\/\//, "");
const [policy, setPolicy] = React.useState<JoinPolicy | null | undefined>(
undefined,
);
const [document, setDocument] = React.useState<PolicyDocument | null>(null);
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
const [opening, setOpening] = React.useState(false);
const [joiningBrowser, setJoiningBrowser] = React.useState(false);
const [browserJoinError, setBrowserJoinError] = React.useState<string | null>(
null,
);
const [downloadUrl, setDownloadUrl] = React.useState(BUZZ_RELEASES_URL);
const [needsMacChoice, setNeedsMacChoice] = React.useState(false);
const [showMacChoice, setShowMacChoice] = React.useState(false);
const [choosingMacDownload, setChoosingMacDownload] = React.useState(false);
const choosingMacDownloadRef = React.useRef(false);
const downloadTriggerRef = React.useRef<HTMLAnchorElement>(null);
React.useEffect(() => {
let active = true;
detectBuzzDownloadPlatform(navigator).then(async (platform) => {
if (!active) return;
if (
platform.operatingSystem === "macos" &&
platform.architecture === "unknown"
) {
setNeedsMacChoice(true);
return;
}
const url = await resolveBuzzDownloadUrlForPlatform(platform);
if (active) setDownloadUrl(url);
});
return () => {
active = false;
};
}, []);
React.useEffect(() => {
fetch(`${relayHttp}/api/join-policy`)
.then(async (response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const config = (await response.json()) as { policy?: JoinPolicy };
setPolicy(config.policy ?? null);
})
.catch(() => setPolicy(undefined));
}, [relayHttp]);
const acceptPolicy = async (): Promise<string | undefined> => {
if (!policy) return undefined;
const response = await fetch(`${relayHttp}/api/invites/accept-policy`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
policy_version: policy.version,
age_confirmed: ageConfirmed,
}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return ((await response.json()) as { receipt: string }).receipt;
};
const openInvite = async () => {
setOpening(true);
try {
const receipt = await acceptPolicy();
const query = new URLSearchParams({ relay, code });
if (receipt) query.set("policy_receipt", receipt);
window.location.href = `buzz://join?${query.toString()}`;
} finally {
setOpening(false);
}
};
const joinInBrowser = async () => {
setBrowserJoinError(null);
setJoiningBrowser(true);
try {
const receipt = await acceptPolicy();
await claimInviteInBrowser(code, receipt);
window.location.assign(appRootHref());
} catch (error) {
const message =
error instanceof Error ? error.message : t("invite.error.claimFailed");
setBrowserJoinError(inviteClaimErrorMessage(message, t));
} finally {
setJoiningBrowser(false);
}
};
const browserSigningAvailable = hasNip07Provider();
const disabled =
policy === undefined ||
opening ||
joiningBrowser ||
Boolean(policy?.age_attestation_required && !ageConfirmed) ||
Boolean(
policy &&
(policy.terms_markdown || policy.privacy_markdown) &&
!agreementConfirmed,
);
const hasPolicyRequirements = Boolean(
policy &&
(policy.age_attestation_required ||
policy.terms_markdown ||
policy.privacy_markdown),
);
const showDocument = (title: string, markdown: string) =>
setDocument({ title, markdown });
const closeMacChoice = React.useCallback(() => {
setShowMacChoice(false);
window.setTimeout(() => downloadTriggerRef.current?.focus());
}, []);
const chooseMacDownload = async (
event: React.MouseEvent<HTMLAnchorElement>,
platform: BuzzDownloadPlatform,
) => {
event.preventDefault();
if (choosingMacDownloadRef.current) return;
choosingMacDownloadRef.current = true;
setChoosingMacDownload(true);
const downloadWindow = window.open("about:blank", "_blank");
if (downloadWindow) downloadWindow.opener = null;
setShowMacChoice(false);
try {
const url = await resolveBuzzDownloadUrlForPlatform(platform);
downloadWindow?.location.replace(url);
} finally {
choosingMacDownloadRef.current = false;
setChoosingMacDownload(false);
}
};
React.useEffect(() => {
if (!showMacChoice) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") closeMacChoice();
};
window.document.addEventListener("keydown", closeOnEscape);
return () => window.document.removeEventListener("keydown", closeOnEscape);
}, [closeMacChoice, showMacChoice]);
return (
<div
className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center"
style={{
backgroundImage: "linear-gradient(180deg, #D7D72E 0%, #D7E7F6 100%)",
}}
>
<div className="w-full max-w-xl space-y-4">
<div className="flex w-full flex-col items-center rounded-3xl bg-white px-6 py-10 sm:px-12 sm:py-12">
<div
className="h-12 w-12 overflow-hidden bg-black"
style={{ borderRadius: "22.37%" }}
>
<img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
</div>
<h1 className="mt-4 text-2xl font-semibold tracking-tight text-black">
{t("invite.title")}
</h1>
<p className="mt-9 font-mono text-lg text-black/70">{host}</p>
<div
className={`grid w-full max-w-md overflow-hidden transition-[grid-template-rows,margin,opacity,transform] duration-[220ms] [transition-timing-function:cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none ${
hasPolicyRequirements
? "mt-9 -mb-4 grid-rows-[1fr] opacity-100 translate-y-0"
: "m-0 grid-rows-[0fr] opacity-0 -translate-y-1"
}`}
>
<div className="min-h-0 overflow-hidden">
{policy && hasPolicyRequirements ? (
<InviteJoinPolicyNotice
ageConfirmed={ageConfirmed}
agreementConfirmed={agreementConfirmed}
onAgeConfirmedChange={setAgeConfirmed}
onAgreementConfirmedChange={setAgreementConfirmed}
onShowDocument={showDocument}
policy={policy}
/>
) : null}
</div>
</div>
<div className="mt-9 w-full max-w-md space-y-2">
{browserSigningAvailable ? (
<Button
className="h-10 w-full bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
disabled={disabled}
onClick={joinInBrowser}
>
{joiningBrowser
? t("invite.joining")
: t("invite.joinInBrowser")}
</Button>
) : null}
{policy === null ? (
<Button
asChild
className={`h-10 w-full ${
browserSigningAvailable
? "border border-black bg-white text-black hover:bg-black/5"
: "bg-black text-white hover:bg-black/90 focus-visible:ring-black"
}`}
>
<a
href={`buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`}
>
{t("invite.accept")}
</a>
</Button>
) : (
<Button
className={`h-10 w-full disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70 ${
browserSigningAvailable
? "border border-black bg-white text-black hover:bg-black/5"
: "bg-black text-white hover:bg-black/90 focus-visible:ring-black"
}`}
disabled={disabled}
onClick={openInvite}
>
{t("invite.accept")}
</Button>
)}
{browserJoinError ? (
<p className="text-sm text-red-700" role="alert">
{browserJoinError}
</p>
) : null}
</div>
</div>
<p className="flex h-[3.125rem] items-center justify-center rounded-2xl bg-white text-sm text-black/60">
{t("invite.noApp")}{" "}
<a
aria-expanded={needsMacChoice ? showMacChoice : undefined}
aria-haspopup={needsMacChoice ? "dialog" : undefined}
className="ml-1 font-medium text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
href={downloadUrl}
ref={downloadTriggerRef}
rel="noreferrer"
target="_blank"
onClick={(event) => {
if (!needsMacChoice) return;
event.preventDefault();
setShowMacChoice(true);
}}
>
{t("invite.downloadNow")}
</a>
</p>
</div>
{showMacChoice && (
<div
aria-label={t("invite.mac.title")}
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 text-left"
role="dialog"
onMouseDown={(event) => {
if (event.currentTarget === event.target) closeMacChoice();
}}
>
<div className="w-full max-w-lg rounded-3xl bg-white p-7 text-black shadow-xl sm:p-9">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold tracking-tight">
{t("invite.mac.title")}
</h2>
<p className="mt-2 text-sm text-black/60">
{t("invite.mac.description")}
</p>
</div>
<button
aria-label={t("common.close")}
className="text-2xl leading-none text-black/60 hover:text-black"
type="button"
onClick={closeMacChoice}
>
×
</button>
</div>
<div className="mt-6 grid gap-3">
<a
aria-disabled={choosingMacDownload}
className="rounded-2xl border border-black p-5 text-black no-underline hover:bg-black hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black aria-disabled:pointer-events-none aria-disabled:opacity-50"
href={BUZZ_RELEASES_URL}
onClick={(event) =>
void chooseMacDownload(event, {
operatingSystem: "macos",
architecture: "arm64",
})
}
>
<strong className="block text-lg">
{t("invite.mac.newer")}
</strong>
<span className="mt-1 block text-sm">
{t("invite.mac.newerDescription")}
</span>
</a>
<a
aria-disabled={choosingMacDownload}
className="rounded-2xl border border-black p-5 text-black no-underline hover:bg-black hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black aria-disabled:pointer-events-none aria-disabled:opacity-50"
href={BUZZ_RELEASES_URL}
onClick={(event) =>
void chooseMacDownload(event, {
operatingSystem: "macos",
architecture: "x64",
})
}
>
<strong className="block text-lg">
{t("invite.mac.older")}
</strong>
<span className="mt-1 block text-sm">
{t("invite.mac.olderDescription")}
</span>
</a>
</div>
<p className="mt-5 text-sm leading-5">
<strong>{t("invite.mac.notSure")}</strong> {t("invite.mac.help")}
</p>
</div>
</div>
)}
{document && (
<div
aria-label={document.title}
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 text-left"
role="dialog"
onMouseDown={(event) => {
if (event.currentTarget === event.target) setDocument(null);
}}
>
<div className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-2xl bg-white p-6 text-black shadow-xl sm:p-8">
<div className="mb-6 flex items-start justify-between gap-4">
<h2 className="text-xl font-semibold">{document.title}</h2>
<button
aria-label={t("common.close")}
className="text-2xl leading-none text-black/60 hover:text-black"
type="button"
onClick={() => setDocument(null)}
>
×
</button>
</div>
<div className="prose prose-sm max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>
{document.markdown}
</Markdown>
</div>
</div>
</div>
)}
</div>
);
}
+456
View File
@@ -0,0 +1,456 @@
/**
* isomorphic-git wrapper for in-browser repo browsing.
*
* Uses LightningFS (IndexedDB-backed) for persistence and NIP-98 auth
* for the relay's smart HTTP git transport.
*/
// isomorphic-git expects a global Buffer (Node API) for pack-file parsing,
// tree serialization, etc. The `buffer` package (feross/buffer) is the
// standard browser polyfill — we install it before any git imports run.
import { Buffer } from "buffer";
if (typeof (globalThis as Record<string, unknown>).Buffer === "undefined") {
(globalThis as Record<string, unknown>).Buffer = Buffer;
}
import LightningFS from "@isomorphic-git/lightning-fs";
import {
clone,
fetch,
log,
readBlob,
readTree,
resolveRef,
} from "isomorphic-git";
import http from "isomorphic-git/http/web";
import { makeNip98AuthHeader } from "@/shared/lib/nip98";
import { relayHttpBaseUrl } from "@/shared/lib/relay-url";
/** Get a repo-specific LightningFS instance backed by IndexedDB. */
export function getFs(owner: string, repoName: string): LightningFS {
return new LightningFS(`buzz-git-${owner}-${repoName}`);
}
/** Working directory inside the virtual FS. */
export function getDir(owner: string, repoName: string): string {
return `/${owner}/${repoName}`;
}
function repoGitUrl(owner: string, repoName: string): string {
return `${relayHttpBaseUrl()}/git/${owner}/${repoName}.git`;
}
/**
* The NIP-98 `u` tag URL — must match what transport.rs expects after
* stripping `/info/refs`, `/git-upload-pack`, `/git-receive-pack`.
* That means the full path including `.git`.
*/
function repoAuthUrl(owner: string, repoName: string): string {
return `${relayHttpBaseUrl()}/git/${owner}/${repoName}.git`;
}
async function authHeaders(
owner: string,
repoName: string,
): Promise<Record<string, string>> {
return {
Authorization: await makeNip98AuthHeader(
repoAuthUrl(owner, repoName),
"GET",
),
};
}
/**
* Ensure a shallow clone exists in IndexedDB. If it already exists, fetch
* the latest for the given ref.
*/
export async function ensureClone(
owner: string,
repoName: string,
ref: string,
): Promise<{ fs: LightningFS; dir: string }> {
const fs = getFs(owner, repoName);
const dir = getDir(owner, repoName);
const url = repoGitUrl(owner, repoName);
const headers = await authHeaders(owner, repoName);
let exists = false;
try {
await fs.promises.stat(`${dir}/.git`);
exists = true;
} catch {
// repo not cloned yet
}
if (exists) {
try {
await fetch({
fs,
http,
dir,
url,
ref,
depth: 1,
singleBranch: true,
headers,
});
} catch {
// fetch may fail if ref hasn't changed — that's fine
}
} else {
await clone({
fs,
http,
dir,
url,
ref,
depth: 1,
singleBranch: true,
noTags: true,
headers,
});
}
return { fs, dir };
}
export interface TreeEntry {
name: string;
type: "blob" | "tree";
mode: string;
oid: string;
}
/** Read tree entries at a given path (or root if no filepath). */
export async function readTreeEntries(
fs: LightningFS,
dir: string,
oid: string,
filepath?: string,
): Promise<TreeEntry[]> {
const result = await readTree({ fs, dir, oid, filepath });
return result.tree.map((entry) => ({
name: entry.path,
type: entry.type as "blob" | "tree",
mode: entry.mode,
oid: entry.oid,
}));
}
export interface FileContent {
content: string;
isBinary: boolean;
}
/** Read a blob and decode as text. Detects binary by checking for NUL bytes. */
export async function readFileContent(
fs: LightningFS,
dir: string,
oid: string,
filepath: string,
): Promise<FileContent> {
const { blob } = await readBlob({ fs, dir, oid, filepath });
const checkLength = Math.min(blob.length, 512);
for (let i = 0; i < checkLength; i++) {
if (blob[i] === 0) {
return { content: "", isBinary: true };
}
}
const content = new TextDecoder().decode(blob);
return { content, isBinary: false };
}
/**
* Inline-preview caps. Different ceilings per kind:
* - Text: a 1 MiB string is already big to render in the DOM. Over → download.
* - Image: raster decoders handle this cheaply; cap is a sanity ceiling, not
* a perf brake. Normal screenshots (≤ ~few MB) preview just fine.
* - Binary: no preview cap — we always offer download, regardless of size.
*
* The clone in IndexedDB always holds full bytes; these are display caps only.
*/
export const TEXT_PREVIEW_LIMIT_BYTES = 1 * 1024 * 1024;
export const IMAGE_PREVIEW_LIMIT_BYTES = 10 * 1024 * 1024;
/**
* Discriminated view of a blob, suitable for rendering. The viewer component
* is responsible for `URL.createObjectURL` / `revokeObjectURL` over `bytes` —
* we deliberately do NOT create object URLs inside the React Query cache.
*
* Image-by-extension is restricted to raster formats. SVG is intentionally
* absent: it can carry active content; we render SVG via the text path
* (where applicable) instead.
*
* `too-large` carries the cap that was hit, so the viewer can explain which
* limit applied without re-computing it.
*/
export type BlobView =
| { kind: "text"; content: string; sizeBytes: number }
| { kind: "markdown"; content: string; sizeBytes: number }
| { kind: "html"; content: string; sizeBytes: number }
| { kind: "image"; bytes: Uint8Array; contentType: string; sizeBytes: number }
| { kind: "binary"; bytes: Uint8Array; sizeBytes: number }
| {
kind: "too-large";
bytes: Uint8Array;
sizeBytes: number;
limitBytes: number;
};
const RASTER_IMAGE_MIME: Readonly<Record<string, string>> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
};
const MARKDOWN_EXTS = new Set(["md", "markdown"]);
const HTML_EXTS = new Set(["html", "htm"]);
function extOf(filepath: string): string {
const base = filepath.split("/").pop() ?? "";
const dot = base.lastIndexOf(".");
if (dot <= 0) return "";
return base.slice(dot + 1).toLowerCase();
}
function hasNulByte(bytes: Uint8Array): boolean {
const n = Math.min(bytes.length, 512);
for (let i = 0; i < n; i++) {
if (bytes[i] === 0) return true;
}
return false;
}
/**
* Classify a blob into a `BlobView`. Applies per-kind preview caps.
*
* Order: image-by-extension first (so a 2 MiB PNG isn't rejected as oversized
* text), then binary detection, then text/markdown decode.
*/
export async function readBlobView(
fs: LightningFS,
dir: string,
oid: string,
filepath: string,
): Promise<BlobView> {
const { blob } = await readBlob({ fs, dir, oid, filepath });
const bytes = blob as Uint8Array;
const sizeBytes = bytes.length;
const ext = extOf(filepath);
const mime = RASTER_IMAGE_MIME[ext];
if (mime) {
if (sizeBytes > IMAGE_PREVIEW_LIMIT_BYTES) {
return {
kind: "too-large",
bytes,
sizeBytes,
limitBytes: IMAGE_PREVIEW_LIMIT_BYTES,
};
}
return { kind: "image", bytes, contentType: mime, sizeBytes };
}
if (hasNulByte(bytes)) {
// Binary: no preview cap. Always download.
return { kind: "binary", bytes, sizeBytes };
}
if (sizeBytes > TEXT_PREVIEW_LIMIT_BYTES) {
return {
kind: "too-large",
bytes,
sizeBytes,
limitBytes: TEXT_PREVIEW_LIMIT_BYTES,
};
}
// Fatal decode: anything that *looks* binary-ish but slipped past the NUL
// sniff (rare-but-real for non-UTF formats without an early 0x00) falls
// through to the binary path instead of being rendered as mojibake.
let content: string;
try {
content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return { kind: "binary", bytes, sizeBytes };
}
if (MARKDOWN_EXTS.has(ext)) {
return { kind: "markdown", content, sizeBytes };
}
if (HTML_EXTS.has(ext)) {
return { kind: "html", content, sizeBytes };
}
return { kind: "text", content, sizeBytes };
}
/**
* Inline a repo's same-repo relative assets into one self-contained HTML
* string, suitable for rendering inside a sandboxed iframe (which has no
* notion of the repo's directory tree and cannot fetch siblings).
*
* Only *relative* `<script src>`, `<link href>`, and `<img src>` are
* resolved — against the HTML file's own directory, scoped to paths that
* exist in the clone. Absolute paths (`/x`) and external URLs
* (`http(s):`, `data:`, `//host`, `#frag`) are left untouched: we never
* reach outside the repo or rewrite something the author meant for the
* network.
*
* Assets are inlined as `data:` URLs so the result is fully detached — it
* carries no live `blob:` handles that would need revoking. This is a
* display transform on a copy; the clone in IndexedDB is unchanged.
*/
const ASSET_MIME: Readonly<Record<string, string>> = {
...RASTER_IMAGE_MIME,
js: "text/javascript",
mjs: "text/javascript",
css: "text/css",
json: "application/json",
svg: "image/svg+xml",
woff: "font/woff",
woff2: "font/woff2",
};
function isExternalRef(ref: string): boolean {
// Absolute path, protocol URL, protocol-relative, fragment, or empty.
return (
ref === "" ||
ref.startsWith("/") ||
ref.startsWith("#") ||
ref.startsWith("data:") ||
/^[a-z][a-z0-9+.-]*:/i.test(ref) ||
ref.startsWith("//")
);
}
/** Resolve `dir`-relative `ref` (e.g. `../js/app.js`) to a clone path. */
function resolveRelative(baseDir: string, ref: string): string | null {
const clean = ref.split(/[?#]/)[0];
const parts = baseDir ? baseDir.split("/") : [];
for (const seg of clean.split("/")) {
if (seg === "" || seg === ".") continue;
if (seg === "..") {
if (parts.length === 0) return null; // escapes repo root
parts.pop();
} else {
parts.push(seg);
}
}
return parts.join("/");
}
function bytesToDataUrl(bytes: Uint8Array, mime: string): string {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return `data:${mime};base64,${btoa(binary)}`;
}
export async function resolveHtmlAssets(
fs: LightningFS,
dir: string,
oid: string,
htmlPath: string,
html: string,
): Promise<string> {
const slash = htmlPath.lastIndexOf("/");
const baseDir = slash >= 0 ? htmlPath.slice(0, slash) : "";
const doc = new DOMParser().parseFromString(html, "text/html");
const targets: Array<{ el: Element; attr: string }> = [
...[...doc.querySelectorAll("script[src]")].map((el) => ({
el,
attr: "src",
})),
...[...doc.querySelectorAll("link[href]")].map((el) => ({
el,
attr: "href",
})),
...[...doc.querySelectorAll("img[src]")].map((el) => ({ el, attr: "src" })),
];
await Promise.all(
targets.map(async ({ el, attr }) => {
const ref = el.getAttribute(attr);
if (!ref || isExternalRef(ref)) return;
const path = resolveRelative(baseDir, ref);
if (!path) return;
const mime = ASSET_MIME[extOf(path)] ?? "application/octet-stream";
try {
const { blob } = await readBlob({ fs, dir, oid, filepath: path });
el.setAttribute(attr, bytesToDataUrl(blob as Uint8Array, mime));
} catch {
// Sibling not in the clone (e.g. a deferred subtree): leave the
// reference as-is. It will simply fail to load in the sandbox.
}
}),
);
return `<!doctype html>\n${doc.documentElement.outerHTML}`;
}
export interface CommitInfo {
oid: string;
message: string;
author: {
name: string;
email: string;
timestamp: number;
};
}
/** Get recent commits for a ref. */
export async function getCommitLog(
fs: LightningFS,
dir: string,
ref: string,
depth = 20,
): Promise<CommitInfo[]> {
const commits = await log({ fs, dir, ref, depth });
return commits.map((c) => ({
oid: c.oid,
message: c.commit.message,
author: {
name: c.commit.author.name,
email: c.commit.author.email,
timestamp: c.commit.author.timestamp,
},
}));
}
export interface ReadmeResult {
filename: string;
content: string;
}
const README_PATTERNS = ["readme.md", "readme", "readme.rst", "readme.txt"];
/** Find and read a README file from the root tree. */
export async function findReadme(
fs: LightningFS,
dir: string,
ref: string,
): Promise<ReadmeResult | null> {
const oid = await resolveRef({ fs, dir, ref });
const entries = await readTreeEntries(fs, dir, oid);
for (const pattern of README_PATTERNS) {
const entry = entries.find(
(e) => e.type === "blob" && e.name.toLowerCase() === pattern,
);
if (entry) {
const file = await readFileContent(fs, dir, oid, entry.name);
if (!file.isBinary) {
return { filename: entry.name, content: file.content };
}
}
}
return null;
}
+149
View File
@@ -0,0 +1,149 @@
import type { Repo } from "./use-repos";
import type {
BlobView,
CommitInfo,
ReadmeResult,
TreeEntry,
} from "./git-client";
const now = Math.floor(Date.now() / 1000);
const people = {
ada: "1".repeat(64),
grace: "2".repeat(64),
linus: "3".repeat(64),
margaret: "4".repeat(64),
};
/** Local-only data for previewing the populated repositories state. */
export const mockRepos: Repo[] = [
{
id: "buzz-desktop",
name: "buzz-desktop",
description:
"The desktop client for collaborating with people and agents across Buzz communities.",
cloneUrls: ["https://example.com/buzz-desktop.git"],
webUrl: null,
channelId: null,
owner: people.ada,
contributors: [people.grace, people.linus],
createdAt: now - 60 * 24,
},
{
id: "agent-harness",
name: "agent-harness",
description:
"Tools and shared workflows for launching and coordinating coding agents.",
cloneUrls: ["https://example.com/agent-harness.git"],
webUrl: null,
channelId: null,
owner: people.grace,
contributors: [people.ada, people.margaret],
createdAt: now - 60 * 60 * 3,
},
{
id: "relay-infrastructure",
name: "relay-infrastructure",
description:
"Infrastructure and deployment configuration for community relays.",
cloneUrls: ["https://example.com/relay-infrastructure.git"],
webUrl: null,
channelId: null,
owner: people.linus,
contributors: [people.margaret],
createdAt: now - 60 * 60 * 24 * 2,
},
{
id: "design-system",
name: "design-system",
description: "Shared foundations, components, and interaction patterns.",
cloneUrls: ["https://example.com/design-system.git"],
webUrl: null,
channelId: null,
owner: people.margaret,
contributors: [people.ada, people.grace, people.linus],
createdAt: now - 60 * 60 * 24 * 8,
},
];
export function getMockRepo(repoId: string): Repo | undefined {
if (!import.meta.env.DEV) return undefined;
return mockRepos.find((repo) => repo.id === repoId);
}
export const mockRepoTree: TreeEntry[] = [
{
name: "src",
type: "tree",
mode: "040000",
oid: "1".repeat(40),
},
{
name: "README.md",
type: "blob",
mode: "100644",
oid: "2".repeat(40),
},
{
name: "package.json",
type: "blob",
mode: "100644",
oid: "3".repeat(40),
},
];
export const mockRepoCommits: CommitInfo[] = [
{
oid: "a".repeat(40),
message: "Polish repository browsing styles",
author: {
name: "Ada",
email: "ada@example.com",
timestamp: now - 60 * 24,
},
},
{
oid: "b".repeat(40),
message: "Add repository empty states",
author: {
name: "Grace",
email: "grace@example.com",
timestamp: now - 60 * 60 * 5,
},
},
];
export const mockRepoReadme: ReadmeResult = {
filename: "README.md",
content:
"# Buzz Desktop\n\nA focused community for people and agents to collaborate.\n\n## Getting started\n\nInstall dependencies, then start the development app.",
};
export function getMockBlob(
repoId: string,
filepath: string,
): BlobView | undefined {
if (!getMockRepo(repoId)) return undefined;
if (filepath === "README.md") {
return {
kind: "markdown",
content: mockRepoReadme.content,
sizeBytes: mockRepoReadme.content.length,
};
}
if (filepath === "package.json") {
const content = `${JSON.stringify(
{
name: repoId,
private: true,
scripts: { dev: "vite", build: "tsc && vite build" },
},
null,
2,
)}\n`;
return { kind: "text", content, sizeBytes: content.length };
}
return undefined;
}
@@ -0,0 +1,22 @@
import { ExternalLink } from "lucide-react";
import { relayWsUrl } from "@/shared/lib/relay-url";
import { Button } from "@/shared/ui/button";
import { useI18n } from "@/shared/i18n/I18nProvider";
export function ConnectButton({ className }: { className?: string }) {
const { t } = useI18n();
const deepLink = `buzz://connect?relay=${encodeURIComponent(relayWsUrl())}`;
return (
<Button
asChild
className={`bg-black text-white hover:bg-black/90 focus-visible:ring-black dark:bg-white dark:text-black dark:hover:bg-white/90 dark:focus-visible:ring-white ${className ?? ""}`}
>
<a href={deepLink}>
<ExternalLink className="h-4 w-4" />
{t("common.openInBuzz")}
</a>
</Button>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Users } from "lucide-react";
import { useMemo } from "react";
import { useI18n } from "@/shared/i18n/I18nProvider";
import type { Repo } from "../use-repos";
import { ConnectButton } from "./ConnectButton";
import { PubkeyAvatar } from "./PubkeyAvatar";
const MAX_AVATARS = 20;
export function OrgSidebar({ repos }: { repos: Repo[] }) {
const { t } = useI18n();
const uniquePubkeys = useMemo(() => {
const set = new Set<string>();
for (const repo of repos) {
set.add(repo.owner);
for (const c of repo.contributors) {
set.add(c);
}
}
return [...set];
}, [repos]);
const visiblePubkeys = uniquePubkeys.slice(0, MAX_AVATARS);
const overflowCount = uniquePubkeys.length - MAX_AVATARS;
return (
<div className="space-y-6">
{/* Open in Buzz */}
<ConnectButton className="w-full" />
{/* People section */}
{uniquePubkeys.length > 0 && (
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
<Users className="h-4 w-4" />
{t("common.people")}
</h3>
<div className="flex flex-wrap gap-2">
{visiblePubkeys.map((pk) => (
<PubkeyAvatar key={pk} pubkey={pk} />
))}
</div>
{overflowCount > 0 && (
<span className="mt-2 block text-xs text-black/50 dark:text-white/50">
{t("repos.peopleCount", { count: uniquePubkeys.length })}
</span>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,37 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
/** Simple hash of a hex pubkey to a hue value (0-360). */
function pubkeyToHue(hex: string): number {
let hash = 0;
for (let i = 0; i < hex.length; i++) {
hash = (hash * 31 + hex.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 360;
}
export function PubkeyAvatar({
pubkey,
size = "md",
}: {
pubkey: string;
size?: "sm" | "md";
}) {
const hue = pubkeyToHue(pubkey);
const sizeClasses = size === "sm" ? "h-6 w-6 text-[10px]" : "h-8 w-8 text-xs";
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={`flex items-center justify-center rounded-lg font-medium text-white ${sizeClasses}`}
style={{ backgroundColor: `hsl(${hue}, 55%, 45%)` }}
>
{pubkey.slice(0, 2)}
</div>
</TooltipTrigger>
<TooltipContent>
<span className="font-mono text-xs">{pubkey}</span>
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,353 @@
/**
* Renders a single repo blob fetched via `useGitBlob`. Designed to be safe by
* construction: no JS/HTML execution path, no SVG-as-image (SVG can carry
* active content; we render it as text instead), and a hard preview-size cap
* with a download fallback for anything over the limit.
*
* Object URLs for image/binary are created in a local effect and revoked on
* unmount or input change — they are never cached inside React Query results.
*/
import { ArrowLeft, Check, Copy, Download, FileText, Play } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useParams } from "@tanstack/react-router";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { toast } from "sonner";
import { Button } from "@/shared/ui/button";
import { useI18n } from "@/shared/i18n/I18nProvider";
import type { BlobView } from "../git-client";
import { useGitBlob, useGitHtmlDoc } from "../use-git-browse";
import { useRepoContext } from "../use-repo-context";
import { getMockBlob } from "../mock-repos";
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
return `${(n / (1024 * 1024)).toFixed(2)} MiB`;
}
function basename(path: string): string {
return path.split("/").pop() ?? path;
}
/**
* Stable object-URL for a byte buffer. Revokes on dependency change / unmount.
* The viewer creates one per render-lifetime — the cache layer only stores bytes.
*/
function useObjectUrl(
bytes: Uint8Array | null,
contentType: string,
): string | null {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!bytes) {
setUrl(null);
return;
}
// The cast normalises `Uint8Array<ArrayBufferLike>` (isomorphic-git's
// return shape) to `Uint8Array<ArrayBuffer>` so it's accepted as a `BlobPart`
// under strict TS lib types.
const blob = new Blob([bytes as Uint8Array<ArrayBuffer>], {
type: contentType,
});
const next = URL.createObjectURL(blob);
setUrl(next);
return () => {
URL.revokeObjectURL(next);
};
}, [bytes, contentType]);
return url;
}
function CopyTextButton({ content }: { content: string }) {
const [copied, setCopied] = useState(false);
const { t } = useI18n();
return (
<Button
variant="outline"
size="sm"
className="border-black/10 bg-white text-black hover:bg-black/5 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
onClick={async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
toast.success(t("common.copied"));
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error(t("common.copyFailed"));
}
}}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
<span className="ml-2">{t("common.copy")}</span>
</Button>
);
}
function DownloadButton({
bytes,
contentType,
filename,
}: {
bytes: Uint8Array;
contentType: string;
filename: string;
}) {
const { t } = useI18n();
const url = useObjectUrl(bytes, contentType);
if (!url) return null;
return (
<Button
asChild
variant="outline"
size="sm"
className="border-black/10 bg-white text-black hover:bg-black/5 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
>
<a href={url} download={filename}>
<Download className="h-4 w-4" />
<span className="ml-2">{t("common.download")}</span>
</a>
</Button>
);
}
function TextView({ content }: { content: string }) {
// Plain monospace render. Line numbers would be nice but require a list of
// keyed children for an immutable text dump; not worth the linter dance for
// v1. The browser handles wrapping/scrolling via `<pre>`.
return (
<pre className="overflow-auto whitespace-pre rounded-lg border border-black/10 bg-white/50 p-4 font-mono text-sm leading-6 text-black dark:border-white/10 dark:bg-white/5 dark:text-white">
{content}
</pre>
);
}
function ImageView({
bytes,
contentType,
filename,
}: {
bytes: Uint8Array;
contentType: string;
filename: string;
}) {
const url = useObjectUrl(bytes, contentType);
if (!url) return null;
return (
<div className="flex justify-center rounded-lg border border-black/10 bg-white/50 p-4 dark:border-white/10 dark:bg-white/5">
<img
src={url}
alt={filename}
className="max-h-[80vh] max-w-full object-contain"
/>
</div>
);
}
/**
* Runs a repo's HTML in a sandboxed iframe.
*
* SECURITY — the entire trust boundary is the `sandbox` attribute below.
* `allow-scripts` lets the page's JS run; the deliberate ABSENCE of
* `allow-same-origin` forces the frame to an opaque (`null`) origin, so its
* scripts CANNOT read the parent's cookies, IndexedDB, localStorage, relay
* session, or NIP-98 auth — even though we render on the same document origin.
* Do not add `allow-same-origin`: that would hand pushed code the user's
* session. `srcDoc` carries the asset-inlined doc; nothing reaches the network
* for same-repo content.
*/
const RUN_SANDBOX = "allow-scripts";
function HtmlRunView({ doc }: { doc: string }) {
const { t } = useI18n();
return (
<iframe
title={t("blob.sandboxTitle")}
srcDoc={doc}
sandbox={RUN_SANDBOX}
className="h-[80vh] w-full rounded-lg border border-black/10 bg-white dark:border-white/10"
/>
);
}
function ViewerBody({
view,
filename,
htmlDoc,
}: {
view: BlobView;
filename: string;
htmlDoc: string | null;
}) {
const { t } = useI18n();
switch (view.kind) {
case "text":
return <TextView content={view.content} />;
case "markdown":
return (
<div className="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-black/10 bg-white/50 p-4 dark:border-white/10 dark:bg-white/5">
<Markdown remarkPlugins={[remarkGfm]}>{view.content}</Markdown>
</div>
);
case "html":
// `htmlDoc` is the asset-inlined doc, present only once the user opts in
// via "Run"; until then (and while it resolves) we show the source.
return htmlDoc !== null ? (
<HtmlRunView doc={htmlDoc} />
) : (
<TextView content={view.content} />
);
case "image":
return (
<ImageView
bytes={view.bytes}
contentType={view.contentType}
filename={filename}
/>
);
case "binary":
return (
<div className="rounded-lg border border-black/10 bg-white/50 p-6 text-sm text-black/60 dark:border-white/10 dark:bg-white/5 dark:text-white/60">
{t("blob.binary", { size: formatBytes(view.sizeBytes) })}
</div>
);
case "too-large":
return (
<div className="rounded-lg border border-black/10 bg-white/50 p-6 text-sm text-black/60 dark:border-white/10 dark:bg-white/5 dark:text-white/60">
{t("blob.tooLarge", {
size: formatBytes(view.sizeBytes),
limit: formatBytes(view.limitBytes),
})}
</div>
);
}
}
export function RepoBlobPage() {
const { t } = useI18n();
const { repoId, _splat } = useParams({ from: "/repos/$repoId/blob/$" });
const filepath = _splat ?? "";
const preview =
import.meta.env.DEV &&
new URLSearchParams(window.location.search).get("preview") ===
"repositories";
const mockView = preview ? getMockBlob(repoId, filepath) : undefined;
const showMockBlob = Boolean(mockView);
const {
owner,
repoName,
defaultRef,
isLoading: ctxLoading,
error: ctxError,
} = useRepoContext(repoId, { preview: showMockBlob });
const browseOwner = showMockBlob ? "" : owner;
const {
data: fetchedView,
isLoading: isViewLoading,
error,
} = useGitBlob(browseOwner, repoName, defaultRef, filepath);
const view = mockView ?? fetchedView;
const isLoading = showMockBlob ? false : isViewLoading;
const [running, setRunning] = useState(false);
const isHtml = view?.kind === "html";
const { data: htmlDoc, isFetching: htmlFetching } = useGitHtmlDoc(
owner,
repoName,
defaultRef,
filepath,
isHtml ? view.content : "",
running && isHtml,
);
const filename = basename(filepath);
if (ctxError) {
return (
<div className="flex-1 bg-[#F3F3F3] px-4 py-8 text-black dark:bg-[#171717] dark:text-white">
<BackLink repoId={repoId} preview={showMockBlob} />
<p className="mt-4 text-sm text-destructive">
{t("blob.loadRepositoryFailed")}
</p>
</div>
);
}
return (
<div className="flex-1 bg-[#F3F3F3] px-4 py-8 text-black dark:bg-[#171717] dark:text-white">
<BackLink repoId={repoId} preview={showMockBlob} />
<div className="mt-4 flex flex-wrap items-center gap-3">
<FileText className="h-4 w-4 text-black/50 dark:text-white/50" />
<h1 className="min-w-0 truncate font-mono text-sm">{filepath}</h1>
<div className="ml-auto flex items-center gap-2">
{view &&
(view.kind === "text" ||
view.kind === "markdown" ||
view.kind === "html") && (
<CopyTextButton content={view.content} />
)}
{isHtml && (
<Button
variant={running ? "secondary" : "default"}
size="sm"
className="bg-black text-white hover:bg-black/90 dark:bg-white dark:text-black dark:hover:bg-white/90"
onClick={() => setRunning((r) => !r)}
>
<Play className="h-4 w-4" />
<span className="ml-2">
{running ? t("blob.showSource") : t("blob.run")}
</span>
</Button>
)}
{view &&
view.kind !== "text" &&
view.kind !== "markdown" &&
view.kind !== "html" && (
<DownloadButton
bytes={view.bytes}
contentType={
view.kind === "image"
? view.contentType
: "application/octet-stream"
}
filename={filename}
/>
)}
</div>
</div>
<div className="mt-6">
{ctxLoading || isLoading ? (
<div className="h-32 animate-pulse rounded-lg bg-black/10 dark:bg-white/10" />
) : error ? (
<p className="text-sm text-destructive">{t("blob.loadFileFailed")}</p>
) : view ? (
<ViewerBody
view={view}
filename={filename}
htmlDoc={running && !htmlFetching ? (htmlDoc ?? null) : null}
/>
) : null}
</div>
</div>
);
}
function BackLink({ repoId, preview }: { repoId: string; preview: boolean }) {
const { t } = useI18n();
return (
<Link
to="/repos/$repoId"
params={{ repoId }}
search={preview ? { preview: "repositories" } : undefined}
className="inline-flex items-center gap-1 text-sm text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
{t("blob.backToRepository")}
</Link>
);
}
@@ -0,0 +1,76 @@
import { GitCommit } from "lucide-react";
import { relativeTime } from "@/shared/lib/relative-time";
import { useI18n } from "@/shared/i18n/I18nProvider";
import type { CommitInfo } from "../git-client";
function CommitRow({ commit }: { commit: CommitInfo }) {
const { locale, t } = useI18n();
const firstLine = commit.message.split("\n")[0];
return (
<div className="flex items-start gap-3 border-b border-black/10 px-3 py-2.5 text-sm text-black last:border-b-0 dark:border-white/10 dark:text-white">
<GitCommit className="mt-0.5 h-4 w-4 shrink-0 text-black/50 dark:text-white/50" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{firstLine}</p>
<p className="mt-0.5 text-xs text-black/50 dark:text-white/50">
{t("repos.committed", {
author: commit.author.name,
time: relativeTime(commit.author.timestamp, locale),
})}
</p>
</div>
<code className="shrink-0 self-center rounded bg-black/5 px-1.5 py-0.5 font-mono text-xs text-black/50 dark:bg-white/10 dark:text-white/50">
{commit.oid.slice(0, 7)}
</code>
</div>
);
}
export function RepoCommitsSection({
commits,
isLoading,
}: {
commits: CommitInfo[] | undefined;
isLoading: boolean;
}) {
const { t } = useI18n();
if (isLoading) {
return (
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
<GitCommit className="h-4 w-4" />
{t("repos.recentCommits")}
</h2>
<div className="rounded-lg border border-black/10 dark:border-white/10">
{["sk-1", "sk-2", "sk-3"].map((key) => (
<div
key={key}
className="flex items-center gap-3 border-b border-black/10 px-3 py-2.5 last:border-b-0 dark:border-white/10"
>
<div className="h-4 w-4 animate-pulse rounded bg-black/10 dark:bg-white/10" />
<div className="flex-1 space-y-1">
<div className="h-4 w-48 animate-pulse rounded bg-black/10 dark:bg-white/10" />
<div className="h-3 w-32 animate-pulse rounded bg-black/10 dark:bg-white/10" />
</div>
</div>
))}
</div>
</div>
);
}
if (!commits || commits.length === 0) return null;
return (
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
<GitCommit className="h-4 w-4" />
{t("repos.recentCommits")}
</h2>
<div className="overflow-hidden rounded-lg border border-black/10 bg-white/50 dark:border-white/10 dark:bg-white/5">
{commits.map((commit) => (
<CommitRow key={commit.oid} commit={commit} />
))}
</div>
</div>
);
}
@@ -0,0 +1,414 @@
import {
ArrowLeft,
BookMarked,
Check,
Copy,
ExternalLink,
MessageSquare,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useParams } from "@tanstack/react-router";
import { toast } from "sonner";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { relativeTime } from "@/shared/lib/relative-time";
import { useI18n } from "@/shared/i18n/I18nProvider";
import { useRepoRefs } from "../use-repo-refs";
import { useRepo } from "../use-repos";
import {
getMockRepo,
mockRepoCommits,
mockRepoReadme,
mockRepoTree,
} from "../mock-repos";
import type { CommitInfo, ReadmeResult, TreeEntry } from "../git-client";
import { useGitTree, useGitLog, useGitReadme } from "../use-git-browse";
import { ConnectButton } from "./ConnectButton";
import { PubkeyAvatar } from "./PubkeyAvatar";
import { RepoRefsSection } from "./RepoRefsSection";
import { RepoTreeSection } from "./RepoTreeSection";
import { RepoCommitsSection } from "./RepoCommitsSection";
import { RepoReadmeSection } from "./RepoReadmeSection";
function CopyableUrl({ url }: { url: string }) {
const [copied, setCopied] = useState(false);
const { t } = useI18n();
async function handleCopy() {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
toast.success(t("common.copied"));
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error(t("common.copyFailed"));
}
}
return (
<div className="flex items-center gap-2 rounded-md border border-black/10 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5">
<code className="min-w-0 flex-1 truncate text-sm text-black dark:text-white">
{url}
</code>
<button
type="button"
onClick={handleCopy}
className="shrink-0 text-black/50 hover:text-black dark:text-white/50 dark:hover:text-white"
aria-label={t("repos.copyCloneUrl")}
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
);
}
function DetailSkeleton() {
return (
<div className="flex w-full flex-1 gap-8 bg-[#F3F3F3] px-4 py-8 dark:bg-[#171717]">
<div className="min-w-0 flex-1">
<div className="h-5 w-24 animate-pulse rounded bg-black/10 dark:bg-white/10" />
<div className="mt-6 h-8 w-64 animate-pulse rounded bg-black/10 dark:bg-white/10" />
<div className="mt-3 h-5 w-96 animate-pulse rounded bg-black/10 dark:bg-white/10" />
<div className="mt-8 space-y-3">
<div className="h-4 w-32 animate-pulse rounded bg-black/10 dark:bg-white/10" />
<div className="h-10 w-full animate-pulse rounded bg-black/10 dark:bg-white/10" />
</div>
</div>
<aside className="hidden w-72 shrink-0 lg:block" />
</div>
);
}
function BackToRepositories({
mockPreview = false,
}: {
mockPreview?: boolean;
}) {
const { t } = useI18n();
const className =
"inline-flex items-center gap-1 text-sm text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white";
const content = (
<>
<ArrowLeft className="h-4 w-4" />
{t("repos.backToList")}
</>
);
return (
<Link
to="/"
search={mockPreview ? { preview: "repositories" } : undefined}
className={className}
>
{content}
</Link>
);
}
type Tab = "code" | "commits";
function RepoTabs({
repoId,
treeEntries,
treeLoading,
commits,
commitsLoading,
readme,
readmeLoading,
preview,
}: {
repoId: string;
treeEntries: TreeEntry[] | undefined;
treeLoading: boolean;
commits: CommitInfo[] | undefined;
commitsLoading: boolean;
readme: ReadmeResult | null | undefined;
readmeLoading: boolean;
preview: boolean;
}) {
const [tab, setTab] = useState<Tab>("code");
const { t } = useI18n();
return (
<div className="mt-6">
{/* Tab bar */}
<div className="flex gap-1 border-b border-black/10 dark:border-white/10">
<button
type="button"
onClick={() => setTab("code")}
className={`px-4 py-2 text-sm font-medium transition-colors ${
tab === "code"
? "border-b-2 border-black text-black dark:border-white dark:text-white"
: "text-black/50 hover:text-black dark:text-white/50 dark:hover:text-white"
}`}
>
{t("repos.code")}
</button>
<button
type="button"
onClick={() => setTab("commits")}
className={`px-4 py-2 text-sm font-medium transition-colors ${
tab === "commits"
? "border-b-2 border-black text-black dark:border-white dark:text-white"
: "text-black/50 hover:text-black dark:text-white/50 dark:hover:text-white"
}`}
>
{t("repos.commits")}
</button>
</div>
{/* Tab content */}
{tab === "code" && (
<>
<RepoTreeSection
entries={treeEntries}
isLoading={treeLoading}
repoId={repoId}
preview={preview}
/>
<RepoReadmeSection readme={readme} isLoading={readmeLoading} />
</>
)}
{tab === "commits" && (
<RepoCommitsSection commits={commits} isLoading={commitsLoading} />
)}
</div>
);
}
export function RepoDetailPage() {
const { locale, t } = useI18n();
const { repoId } = useParams({ from: "/repos/$repoId" });
const preview =
import.meta.env.DEV &&
new URLSearchParams(window.location.search).get("preview") ===
"repositories";
const mockRepo = preview ? getMockRepo(repoId) : undefined;
const showMockRepo = Boolean(mockRepo);
const {
data: repo,
isLoading,
error,
} = useRepo(repoId, {
preview: showMockRepo,
});
const { data: refs, isLoading: refsLoading } = useRepoRefs(repoId, {
preview: showMockRepo,
});
const defaultRef = refs?.head?.ref ?? "main";
const owner = repo?.owner ?? "";
const repoName = repo?.id ?? "";
const browseOwner = showMockRepo ? "" : owner;
const {
data: fetchedTreeEntries,
isLoading: isTreeLoading,
error: treeError,
} = useGitTree(browseOwner, repoName, defaultRef);
const {
data: fetchedCommits,
isLoading: areCommitsLoading,
error: commitsError,
} = useGitLog(browseOwner, repoName, defaultRef);
const { data: fetchedReadme, isLoading: isReadmeLoading } = useGitReadme(
browseOwner,
repoName,
defaultRef,
);
const treeEntries = showMockRepo ? mockRepoTree : fetchedTreeEntries;
const commits = showMockRepo ? mockRepoCommits : fetchedCommits;
const readme = showMockRepo ? mockRepoReadme : fetchedReadme;
const treeLoading = showMockRepo ? false : isTreeLoading;
const commitsLoading = showMockRepo ? false : areCommitsLoading;
const readmeLoading = showMockRepo ? false : isReadmeLoading;
// Surface clone/browse errors — these are otherwise silent
const browseError = treeError || commitsError;
useEffect(() => {
if (browseError) {
console.error("[git-browse]", browseError);
}
}, [browseError]);
useEffect(() => {
if (error) {
toast.error(t("repos.loadRepositoryFailed"), {
description: t("common.tryAgain"),
});
}
}, [error, t]);
if (isLoading) return <DetailSkeleton />;
if (!repo) {
return (
<div className="flex w-full flex-1 gap-8 bg-[#F3F3F3] px-4 py-8 text-black dark:bg-[#171717] dark:text-white">
<div className="min-w-0 flex-1">
<BackToRepositories />
<div className="mt-12 text-center">
<BookMarked className="mx-auto h-10 w-10 text-black/50 dark:text-white/50" />
<h1 className="mt-4 text-xl font-semibold text-black dark:text-white">
{t("repos.notFoundTitle")}
</h1>
<p className="mt-1 text-sm text-black/60 dark:text-white/60">
{t("repos.notFoundDescription")}
</p>
</div>
</div>
<aside className="hidden w-72 shrink-0 lg:block" />
</div>
);
}
return (
<div className="flex w-full flex-1 gap-8 bg-[#F3F3F3] px-4 py-8 text-black dark:bg-[#171717] dark:text-white">
{/* Main content */}
<div className="min-w-0 flex-1">
{/* Back link */}
<BackToRepositories mockPreview={preview} />
{/* Mobile-only connect button */}
<div className="mt-4 lg:hidden">
<ConnectButton className="w-full" />
</div>
{/* Header */}
<div className="mt-6">
<div className="flex items-center gap-3">
<BookMarked className="h-6 w-6 shrink-0 text-black/50 dark:text-white/50" />
<h1 className="text-2xl font-semibold tracking-tight text-black dark:text-white">
{repo.name}
</h1>
<Badge
variant="outline"
className="border-black/15 text-black/60 dark:border-white/15 dark:text-white/60"
>
{t("common.public")}
</Badge>
</div>
{repo.description && (
<p className="mt-2 text-sm leading-relaxed text-black/60 dark:text-white/60">
{repo.description}
</p>
)}
<p className="mt-2 text-xs text-black/50 dark:text-white/50">
{t("common.updated", {
time: relativeTime(repo.createdAt, locale),
})}
</p>
</div>
{/* Refs & HEAD */}
<RepoRefsSection refs={refs} isLoading={refsLoading} />
{/* Clone/browse error banner */}
{browseError && (
<div className="mt-6 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{t("repos.loadContentsFailed")}
</div>
)}
{/* Tabs */}
<RepoTabs
repoId={repoId}
treeEntries={treeEntries}
treeLoading={treeLoading}
commits={commits}
commitsLoading={commitsLoading}
readme={readme}
readmeLoading={readmeLoading}
preview={showMockRepo}
/>
{/* Clone URLs */}
{repo.cloneUrls.length > 0 && (
<div className="mt-8">
<h2 className="mb-3 text-sm font-semibold text-black dark:text-white">
{t("repos.clone")}
</h2>
<div className="space-y-2">
{repo.cloneUrls.map((url) => (
<CopyableUrl key={url} url={url} />
))}
</div>
</div>
)}
{/* External link — validate scheme to prevent javascript: XSS */}
{(() => {
if (!repo.webUrl) return null;
let safe: string | null = null;
try {
safe = /^https?:/.test(new URL(repo.webUrl).protocol)
? repo.webUrl
: null;
} catch {
safe = null;
}
if (!safe) return null;
return (
<div className="mt-6">
<Button
variant="outline"
className="border-black/10 bg-white text-black hover:bg-black/5 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
asChild
>
<a href={safe} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
{t("repos.viewOnWeb")}
</a>
</Button>
</div>
);
})()}
{/* Channel link */}
{repo.channelId && (
<div className="mt-8">
<Button
variant="outline"
className="border-black/10 bg-white text-black hover:bg-black/5 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
asChild
>
<a href={`/channels/${repo.channelId}`}>
<MessageSquare className="h-4 w-4" />
{t("repos.viewChannel")}
</a>
</Button>
</div>
)}
</div>
{/* Sidebar */}
<aside className="hidden w-72 shrink-0 border-l border-black/10 pl-8 dark:border-white/10 lg:block">
<div className="space-y-6">
{/* Open in Buzz */}
<ConnectButton className="w-full" />
{/* People */}
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
<Users className="h-4 w-4" />
{t("common.people")}
</h3>
<div className="flex flex-wrap gap-2">
<PubkeyAvatar pubkey={repo.owner} />
{repo.contributors
.filter((c) => c !== repo.owner)
.map((c) => (
<PubkeyAvatar key={c} pubkey={c} />
))}
</div>
</div>
</div>
</aside>
</div>
);
}
@@ -0,0 +1,63 @@
import { BookMarked } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { Badge } from "@/shared/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { relativeTime } from "@/shared/lib/relative-time";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { useI18n } from "@/shared/i18n/I18nProvider";
import type { Repo } from "../use-repos";
export function RepoListItem({
repo,
preview = false,
}: {
repo: Repo;
preview?: boolean;
}) {
const { locale, t } = useI18n();
return (
<div className="py-6 text-black dark:text-white">
{/* Row 1: Name + badge */}
<div className="flex items-center gap-2">
<BookMarked className="h-4 w-4 shrink-0 text-black/50 dark:text-white/50" />
<Link
to="/repos/$repoId"
params={{ repoId: repo.id }}
search={preview ? { preview: "repositories" } : undefined}
className="text-lg font-semibold text-black underline-offset-4 hover:text-black/70 hover:underline dark:text-white dark:hover:text-white/70"
>
{repo.name}
</Link>
<Badge
variant="outline"
className="ml-1 border-black/15 text-black/60 dark:border-white/15 dark:text-white/60"
>
{t("common.public")}
</Badge>
</div>
{/* Row 2: Description */}
{repo.description && (
<p className="mt-1 line-clamp-2 text-sm text-black/60 dark:text-white/60">
{repo.description}
</p>
)}
{/* Row 3: Metadata */}
<div className="mt-2 flex items-center gap-4 text-xs text-black/50 dark:text-white/50">
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default font-mono">
{truncatePubkey(repo.owner)}
</span>
</TooltipTrigger>
<TooltipContent>{repo.owner}</TooltipContent>
</Tooltip>
<span>
{t("common.updated", { time: relativeTime(repo.createdAt, locale) })}
</span>
</div>
</div>
);
}
@@ -0,0 +1,42 @@
import { BookOpen } from "lucide-react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { ReadmeResult } from "../git-client";
export function RepoReadmeSection({
readme,
isLoading,
}: {
readme: ReadmeResult | null | undefined;
isLoading: boolean;
}) {
if (isLoading) {
return (
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
<BookOpen className="h-4 w-4" />
README
</h2>
<div className="space-y-2 rounded-lg border border-black/10 p-4 dark:border-white/10">
<div className="h-4 w-3/4 animate-pulse rounded bg-black/10 dark:bg-white/10" />
<div className="h-4 w-1/2 animate-pulse rounded bg-black/10 dark:bg-white/10" />
<div className="h-4 w-5/6 animate-pulse rounded bg-black/10 dark:bg-white/10" />
</div>
</div>
);
}
if (!readme) return null;
return (
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
<BookOpen className="h-4 w-4" />
{readme.filename}
</h2>
<div className="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-black/10 bg-white/50 p-4 dark:border-white/10 dark:bg-white/5">
<Markdown remarkPlugins={[remarkGfm]}>{readme.content}</Markdown>
</div>
</div>
);
}
@@ -0,0 +1,70 @@
import { GitBranch, Hash, Tag } from "lucide-react";
import { Badge } from "@/shared/ui/badge";
import { useI18n } from "@/shared/i18n/I18nProvider";
import type { RepoRefs } from "../use-repo-refs";
export function RepoRefsSection({
refs,
isLoading,
}: {
refs: RepoRefs | undefined;
isLoading: boolean;
}) {
const { t } = useI18n();
if (isLoading) return null;
const hasRefs = refs && (refs.branches.length > 0 || refs.tags.length > 0);
return (
<div className="mt-6">
{hasRefs ? (
<div className="flex flex-wrap items-center gap-2 text-sm text-black/60 dark:text-white/60">
{refs.head && (
<>
<div className="flex items-center gap-1.5">
<Badge
variant="secondary"
className="bg-black/5 text-black dark:bg-white/10 dark:text-white"
>
<GitBranch className="mr-1 h-4 w-4" />
{refs.head.ref}
</Badge>
{refs.head.sha && (
<Badge
variant="outline"
className="border-black/15 font-mono text-xs text-black/60 dark:border-white/15 dark:text-white/60"
>
<Hash className="mr-0.5 h-4 w-4" />
{refs.head.sha.slice(0, 7)}
</Badge>
)}
</div>
<span className="text-black/30 dark:text-white/30">&middot;</span>
</>
)}
<span className="flex items-center gap-1">
<GitBranch className="h-4 w-4" />
{t(
refs.branches.length === 1
? "repos.branch.one"
: "repos.branch.other",
{ count: refs.branches.length },
)}
</span>
<span className="text-black/30 dark:text-white/30">&middot;</span>
<span className="flex items-center gap-1">
<Tag className="h-4 w-4" />
{t(refs.tags.length === 1 ? "repos.tag.one" : "repos.tag.other", {
count: refs.tags.length,
})}
</span>
</div>
) : (
<p className="text-sm text-black/60 dark:text-white/60">
{t("repos.noCommits")}
</p>
)}
</div>
);
}
@@ -0,0 +1,86 @@
import { File, Folder } from "lucide-react";
import { Link } from "@tanstack/react-router";
import type { TreeEntry } from "../git-client";
function TreeRow({
entry,
repoId,
preview,
}: {
entry: TreeEntry;
repoId: string;
preview: boolean;
}) {
if (entry.type === "tree") {
// Sub-tree navigation is deferred — show folders as visibly non-clickable
// so the affordance matches the behaviour.
return (
<div
className="flex items-center gap-2 border-b border-black/10 px-3 py-2 text-sm text-black/60 last:border-b-0 dark:border-white/10 dark:text-white/60"
aria-disabled="true"
>
<Folder className="h-4 w-4 shrink-0 text-black/50 dark:text-white/50" />
<span className="font-medium">{entry.name}</span>
</div>
);
}
return (
<Link
to="/repos/$repoId/blob/$"
params={{ repoId, _splat: entry.name }}
search={preview ? { preview: "repositories" } : undefined}
className="flex items-center gap-2 border-b border-black/10 px-3 py-2 text-sm text-black last:border-b-0 hover:bg-black/5 dark:border-white/10 dark:text-white dark:hover:bg-white/5"
>
<File className="h-4 w-4 shrink-0 text-black/50 dark:text-white/50" />
<span>{entry.name}</span>
</Link>
);
}
export function RepoTreeSection({
entries,
isLoading,
repoId,
preview = false,
}: {
entries: TreeEntry[] | undefined;
isLoading: boolean;
repoId: string;
preview?: boolean;
}) {
if (isLoading) {
return (
<div className="mt-8">
<div className="rounded-lg border border-black/10 dark:border-white/10">
{["sk-1", "sk-2", "sk-3", "sk-4", "sk-5"].map((key) => (
<div
key={key}
className="flex items-center gap-2 border-b border-black/10 px-3 py-2 last:border-b-0 dark:border-white/10"
>
<div className="h-4 w-4 animate-pulse rounded bg-black/10 dark:bg-white/10" />
<div className="h-4 w-32 animate-pulse rounded bg-black/10 dark:bg-white/10" />
</div>
))}
</div>
</div>
);
}
if (!entries || entries.length === 0) return null;
return (
<div className="mt-8">
<div className="overflow-hidden rounded-lg border border-black/10 bg-white/50 dark:border-white/10 dark:bg-white/5">
{entries.map((entry) => (
<TreeRow
key={entry.name}
entry={entry}
repoId={repoId}
preview={preview}
/>
))}
</div>
</div>
);
}
+216
View File
@@ -0,0 +1,216 @@
import { BookMarked, GitBranch } from "lucide-react";
import { toast } from "sonner";
import { useEffect, useMemo, useState } from "react";
import buzzAppIcon from "@/assets/app-icon@3x.png";
import { useI18n } from "@/shared/i18n/I18nProvider";
import { Input } from "@/shared/ui/input";
import { mockRepos } from "../mock-repos";
import { useRepos } from "../use-repos";
import { ConnectButton } from "./ConnectButton";
import { OrgSidebar } from "./OrgSidebar";
import { RepoListItem } from "./RepoListItem";
type SortOrder = "newest" | "oldest" | "name";
function ListItemSkeleton() {
return (
<div className="py-6">
<div className="flex items-center gap-2">
<div className="h-4 w-4 shrink-0 animate-pulse rounded bg-black/10 dark:bg-white/10" />
<div className="h-5 w-48 animate-pulse rounded bg-black/10 dark:bg-white/10" />
<div className="h-5 w-14 animate-pulse rounded bg-black/10 dark:bg-white/10" />
</div>
<div className="mt-2 h-4 w-3/4 animate-pulse rounded bg-black/10 dark:bg-white/10" />
<div className="mt-2 flex gap-4">
<div className="h-3 w-24 animate-pulse rounded bg-black/10 dark:bg-white/10" />
<div className="h-3 w-20 animate-pulse rounded bg-black/10 dark:bg-white/10" />
</div>
</div>
);
}
function SearchEmptyState() {
const { t } = useI18n();
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-black/5 dark:bg-white/10">
<GitBranch className="h-7 w-7 text-black/50 dark:text-white/50" />
</div>
<h2 className="mt-4 text-lg font-semibold text-black dark:text-white">
{t("repos.searchEmptyTitle")}
</h2>
<p className="mt-1 max-w-sm text-sm text-black/60 dark:text-white/60">
{t("repos.searchEmptyDescription")}
</p>
</div>
);
}
function CommunityEmptyState({
authRequired = false,
}: {
authRequired?: boolean;
}) {
const { t } = useI18n();
return (
<div className="flex flex-1 items-center justify-center bg-[#F3F3F3] px-4 py-16 text-center dark:bg-[#171717]">
<div className="flex w-full max-w-xl flex-col items-center px-6 py-10 sm:px-12 sm:py-12">
<div
className="h-16 w-16 overflow-hidden bg-black"
style={{ borderRadius: "22.37%" }}
>
<img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
</div>
<h1 className="mt-6 text-2xl font-semibold tracking-tight text-black dark:text-white">
{t(
authRequired
? "repos.authRequiredTitle"
: "repos.communityEmptyTitle",
)}
</h1>
<p className="mt-2 max-w-md text-sm leading-relaxed text-black/60 dark:text-white/60">
{t(
authRequired
? "repos.authRequiredDescription"
: "repos.communityEmptyDescription",
)}
</p>
<ConnectButton className="mt-6" />
</div>
</div>
);
}
export function ReposPage() {
const { locale, t } = useI18n();
const preview = import.meta.env.DEV
? new URLSearchParams(window.location.search).get("preview")
: null;
const showMockRepos = preview === "repositories";
const showMockEmptyState = preview === "empty";
const {
data: fetchedRepos,
isLoading: isLoadingRepos,
error,
} = useRepos({ enabled: !showMockRepos && !showMockEmptyState });
const repos = showMockRepos
? mockRepos
: showMockEmptyState
? []
: fetchedRepos;
const isLoading = preview ? false : isLoadingRepos;
const [search, setSearch] = useState("");
const [sort, setSort] = useState<SortOrder>("newest");
const authRequired =
error instanceof Error && error.message.startsWith("auth-required");
useEffect(() => {
if (error && !authRequired) {
toast.error(t("repos.loadFailed"), {
description: t("common.tryAgain"),
});
}
}, [authRequired, error, t]);
const filteredRepos = useMemo(() => {
if (!repos) return [];
const term = search.toLowerCase();
let result = repos.filter(
(r) =>
r.name.toLowerCase().includes(term) ||
r.description.toLowerCase().includes(term),
);
switch (sort) {
case "newest":
result = result.sort((a, b) => b.createdAt - a.createdAt);
break;
case "oldest":
result = result.sort((a, b) => a.createdAt - b.createdAt);
break;
case "name":
result = result.sort((a, b) =>
a.name.localeCompare(b.name, locale, { sensitivity: "base" }),
);
break;
}
return result;
}, [locale, repos, search, sort]);
if (isLoading) {
return (
<div className="flex w-full flex-1 gap-8 bg-[#F3F3F3] px-4 py-8 dark:bg-[#171717]">
<div className="min-w-0 flex-1">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-black dark:text-white">
<BookMarked className="h-4 w-4" /> {t("repos.title")}
</h2>
<div className="divide-y">
{["a", "b", "c", "d", "e"].map((key) => (
<ListItemSkeleton key={key} />
))}
</div>
</div>
<aside className="hidden w-72 shrink-0 lg:block" />
</div>
);
}
if (!repos || repos.length === 0) {
return <CommunityEmptyState authRequired={authRequired} />;
}
return (
<div className="flex w-full flex-1 gap-8 bg-[#F3F3F3] px-4 py-8 dark:bg-[#171717]">
{/* Main content */}
<div className="min-w-0 flex-1">
{/* Mobile-only connect button */}
<div className="mb-4 lg:hidden">
<ConnectButton className="w-full" />
</div>
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-black dark:text-white">
<BookMarked className="h-4 w-4" /> {t("repos.title")}
</h2>
{/* Search + Sort bar */}
<div className="mb-4 flex gap-3">
<Input
placeholder={t("repos.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 border-black/10 bg-white text-black placeholder:text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white dark:placeholder:text-white/40"
/>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortOrder)}
aria-label={t("repos.sortLabel")}
className="rounded-md border border-black/10 bg-white px-3 py-1 text-sm text-black shadow-xs focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-black dark:border-white/10 dark:bg-white/5 dark:text-white dark:focus-visible:ring-white"
>
<option value="newest">{t("repos.sort.newest")}</option>
<option value="oldest">{t("repos.sort.oldest")}</option>
<option value="name">{t("repos.sort.name")}</option>
</select>
</div>
{/* Repo list */}
{filteredRepos.length > 0 ? (
<div className="divide-y divide-black/10 dark:divide-white/10">
{filteredRepos.map((repo) => (
<RepoListItem key={repo.id} repo={repo} preview={showMockRepos} />
))}
</div>
) : (
<SearchEmptyState />
)}
</div>
{/* Sidebar */}
<aside className="hidden w-72 shrink-0 border-l border-black/10 pl-8 dark:border-white/10 lg:block">
<OrgSidebar repos={repos} />
</aside>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
/**
* React Query hooks for browsing git repos via isomorphic-git.
*
* All hooks depend on `useGitClone` which ensures the repo is shallow-cloned
* into IndexedDB before any reads happen.
*/
import { useQuery } from "@tanstack/react-query";
import { resolveRef } from "isomorphic-git";
import {
ensureClone,
findReadme,
getCommitLog,
readBlobView,
readTreeEntries,
resolveHtmlAssets,
} from "./git-client";
/**
* Ensure the repo is cloned (or fetched) into IndexedDB.
* Other hooks depend on this to get `fs` and `dir`.
*/
export function useGitClone(owner: string, repoName: string, ref: string) {
return useQuery({
queryKey: ["git-clone", owner, repoName, ref],
queryFn: () => ensureClone(owner, repoName, ref),
staleTime: 5 * 60_000,
enabled: !!owner && !!repoName && !!ref,
retry: false,
});
}
/** Read tree entries at a path (or root). Directories first, then files, alphabetical. */
export function useGitTree(
owner: string,
repoName: string,
ref: string,
path?: string,
) {
const cloneQuery = useGitClone(owner, repoName, ref);
return useQuery({
queryKey: ["git-tree", owner, repoName, ref, path ?? ""],
queryFn: async () => {
if (!cloneQuery.data) throw new Error("unreachable: enabled guards data");
const { fs, dir } = cloneQuery.data;
const oid = await resolveRef({ fs, dir, ref });
const entries = await readTreeEntries(fs, dir, oid, path || undefined);
// Sort: directories first, then files, alphabetical within each group
return entries.sort((a, b) => {
if (a.type === "tree" && b.type !== "tree") return -1;
if (a.type !== "tree" && b.type === "tree") return 1;
return a.name.localeCompare(b.name);
});
},
enabled: !!cloneQuery.data,
staleTime: 5 * 60_000,
});
}
/** Get recent commits for the given ref. */
export function useGitLog(owner: string, repoName: string, ref: string) {
const cloneQuery = useGitClone(owner, repoName, ref);
return useQuery({
queryKey: ["git-log", owner, repoName, ref],
queryFn: async () => {
if (!cloneQuery.data) throw new Error("unreachable: enabled guards data");
const { fs, dir } = cloneQuery.data;
return getCommitLog(fs, dir, ref);
},
enabled: !!cloneQuery.data,
staleTime: 5 * 60_000,
});
}
/** Find and read the README from the repo root. */
export function useGitReadme(owner: string, repoName: string, ref: string) {
const cloneQuery = useGitClone(owner, repoName, ref);
return useQuery({
queryKey: ["git-readme", owner, repoName, ref],
queryFn: async () => {
if (!cloneQuery.data) throw new Error("unreachable: enabled guards data");
const { fs, dir } = cloneQuery.data;
return findReadme(fs, dir, ref);
},
enabled: !!cloneQuery.data,
staleTime: 5 * 60_000,
});
}
/** Read a single file's content as a classified `BlobView`. */
export function useGitBlob(
owner: string,
repoName: string,
ref: string,
filepath: string,
) {
const cloneQuery = useGitClone(owner, repoName, ref);
return useQuery({
queryKey: ["git-blob", owner, repoName, ref, filepath],
queryFn: async () => {
if (!cloneQuery.data) throw new Error("unreachable: enabled guards data");
const { fs, dir } = cloneQuery.data;
const oid = await resolveRef({ fs, dir, ref });
return readBlobView(fs, dir, oid, filepath);
},
enabled: !!cloneQuery.data && !!filepath,
staleTime: 5 * 60_000,
});
}
/**
* Resolve an HTML file into a self-contained doc (relative assets inlined),
* ready to drop into a sandboxed iframe. Lazy: `enabled` is caller-gated so
* we only do the inlining work when the user clicks "Run". The decoded HTML
* is passed in (the blob view already has it) to avoid a second read.
*/
export function useGitHtmlDoc(
owner: string,
repoName: string,
ref: string,
filepath: string,
html: string,
enabled: boolean,
) {
const cloneQuery = useGitClone(owner, repoName, ref);
return useQuery({
queryKey: ["git-html-doc", owner, repoName, ref, filepath],
queryFn: async () => {
if (!cloneQuery.data) throw new Error("unreachable: enabled guards data");
const { fs, dir } = cloneQuery.data;
const oid = await resolveRef({ fs, dir, ref });
return resolveHtmlAssets(fs, dir, oid, filepath, html);
},
enabled: enabled && !!cloneQuery.data && !!filepath,
staleTime: 5 * 60_000,
});
}
@@ -0,0 +1,40 @@
/**
* Shared resolver for the `(owner, repoName, defaultRef)` triple every
* repo-scoped page needs. Combines the NIP-34 announcement (`useRepo`) with
* the refs query (`useRepoRefs`) so callers don't duplicate that wiring.
*
* `defaultRef` falls back to `"main"` until refs load, matching the
* pre-existing behaviour in `RepoDetailPage`.
*/
import { useRepo } from "./use-repos";
import { useRepoRefs } from "./use-repo-refs";
export interface RepoContext {
owner: string;
repoName: string;
defaultRef: string;
isLoading: boolean;
error: Error | null;
}
export function useRepoContext(
repoId: string,
{ preview = false } = {},
): RepoContext {
const {
data: repo,
isLoading: repoLoading,
error: repoError,
} = useRepo(repoId, { preview });
const { data: refs, isLoading: refsLoading } = useRepoRefs(repoId, {
preview,
});
return {
owner: repo?.owner ?? "",
repoName: repo?.id ?? "",
defaultRef: refs?.head?.ref ?? "main",
isLoading: repoLoading || refsLoading,
error: (repoError as Error | null) ?? null,
};
}
+75
View File
@@ -0,0 +1,75 @@
import { useQuery } from "@tanstack/react-query";
import { queryEvents, type NostrEvent } from "@/shared/lib/nostr-client";
import { relayWsUrl } from "@/shared/lib/relay-url";
import { dedup } from "./use-repos";
export interface RepoRefs {
branches: string[];
tags: string[];
head: { ref: string; sha: string } | null;
}
function parseRefs(events: NostrEvent[]): RepoRefs {
const latest = dedup(events);
const branches: string[] = [];
const tags: string[] = [];
let head: RepoRefs["head"] = null;
for (const event of latest) {
for (const tag of event.tags) {
const [name, value] = tag;
if (!name || !value) continue;
if (name === "HEAD" && value.startsWith("ref: refs/heads/")) {
// HEAD points to a branch ref — find its SHA from a matching branch tag
const branchName = value.replace("ref: refs/heads/", "");
head = { ref: branchName, sha: "" };
} else if (name.startsWith("refs/heads/")) {
branches.push(name.replace("refs/heads/", ""));
} else if (name.startsWith("refs/tags/")) {
tags.push(name.replace("refs/tags/", ""));
}
}
}
// Resolve HEAD SHA from the matching branch
if (head) {
for (const event of latest) {
for (const tag of event.tags) {
if (tag[0] === `refs/heads/${head.ref}` && tag[1]) {
head = { ref: head.ref, sha: tag[1] };
break;
}
}
if (head.sha) break;
}
}
return { branches, tags, head };
}
async function fetchRepoRefs(repoId: string): Promise<RepoRefs> {
// TODO: Filter by `authors: [relayPubkey]` once the relay's own pubkey is
// exposed to the client. Without this, a user with ReposWrite permission
// could publish fake kind:30618 events with spoofed refs.
const events = await queryEvents(relayWsUrl(), {
kinds: [30618],
"#d": [repoId],
});
return parseRefs(events);
}
export function useRepoRefs(repoId: string, { preview = false } = {}) {
const mockRefs: RepoRefs = {
branches: ["main"],
tags: ["v0.1.0"],
head: { ref: "main", sha: "a".repeat(40) },
};
return useQuery({
queryKey: preview ? ["repo-refs", "mock", repoId] : ["repo-refs", repoId],
queryFn: preview ? async () => mockRefs : () => fetchRepoRefs(repoId),
initialData: preview ? mockRefs : undefined,
staleTime: 60_000,
});
}
+89
View File
@@ -0,0 +1,89 @@
import { useQuery } from "@tanstack/react-query";
import { queryEvents, type NostrEvent } from "@/shared/lib/nostr-client";
import { relayWsUrl } from "@/shared/lib/relay-url";
import { mockRepos } from "./mock-repos";
export interface Repo {
id: string;
name: string;
description: string;
cloneUrls: string[];
webUrl: string | null;
channelId: string | null;
owner: string;
contributors: string[];
createdAt: number;
}
/** Extract the first value for a given tag name from a Nostr event. */
export function getTag(event: NostrEvent, name: string): string | undefined {
return event.tags.find((t) => t[0] === name)?.[1];
}
/** Extract all values for a given tag name from a Nostr event. */
function getAllTags(event: NostrEvent, name: string): string[] {
return event.tags.filter((t) => t[0] === name).map((t) => t[1]);
}
function eventToRepo(event: NostrEvent): Repo {
const d = getTag(event, "d") ?? event.id;
const name = getTag(event, "name") || d;
const description = getTag(event, "description") || event.content || "";
const cloneUrls = getAllTags(event, "clone");
const webUrl = getTag(event, "web") ?? null;
const channelId = getTag(event, "buzz-channel") ?? null;
const contributors = getAllTags(event, "p");
const owner = event.pubkey;
return {
id: d,
name,
description,
cloneUrls,
webUrl,
channelId,
owner,
contributors,
createdAt: event.created_at,
};
}
/** Deduplicate NIP-33 parameterized replaceable events, keeping the latest per (pubkey, kind, d-tag). */
export function dedup(events: NostrEvent[]): NostrEvent[] {
const best = new Map<string, NostrEvent>();
for (const e of events) {
const d = getTag(e, "d") ?? "";
const key = `${e.pubkey}:${e.kind}:${d}`;
const prev = best.get(key);
if (!prev || e.created_at > prev.created_at) {
best.set(key, e);
}
}
return [...best.values()];
}
async function fetchRepos(): Promise<Repo[]> {
const events = await queryEvents(relayWsUrl(), { kinds: [30617] });
return dedup(events)
.map(eventToRepo)
.sort((a, b) => b.createdAt - a.createdAt);
}
export function useRepos({ enabled = true }: { enabled?: boolean } = {}) {
return useQuery({
queryKey: ["repos"],
queryFn: fetchRepos,
enabled,
staleTime: 60_000,
});
}
export function useRepo(repoId: string, { preview = false } = {}) {
return useQuery({
queryKey: preview ? ["repos", "mock"] : ["repos"],
queryFn: preview ? async () => mockRepos : fetchRepos,
initialData: preview ? mockRepos : undefined,
staleTime: 60_000,
select: (repos) => repos.find((r) => r.id === repoId),
});
}
+39
View File
@@ -0,0 +1,39 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App } from "@/app/App";
import "@fontsource-variable/inter/wght.css";
import "@/shared/styles/globals.css";
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
import { Toaster } from "@/shared/ui/sonner";
import { TooltipProvider } from "@/shared/ui/tooltip";
import { I18nProvider } from "@/shared/i18n/I18nProvider";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
networkMode: "always",
gcTime: 5 * 60 * 1_000,
},
mutations: {
networkMode: "always",
},
},
});
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<I18nProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<TooltipProvider delayDuration={300}>
<App />
<Toaster />
</TooltipProvider>
</ThemeProvider>
</QueryClientProvider>
</I18nProvider>
</React.StrictMode>,
);
+63
View File
@@ -0,0 +1,63 @@
import * as React from "react";
import { en, type TranslationKey, zhHans } from "./catalogs";
export type Locale = "en" | "zh-Hans";
export type TranslationParams = Record<string, number | string>;
const DEFAULT_LOCALE: Locale = "zh-Hans";
const STORAGE_KEY = "buzz.web.locale";
const catalogs = { en, "zh-Hans": zhHans } as const;
function isLocale(value: string | null): value is Locale {
return value === "en" || value === "zh-Hans";
}
function initialLocale(): Locale {
const stored = window.localStorage.getItem(STORAGE_KEY);
return isLocale(stored) ? stored : DEFAULT_LOCALE;
}
export function translate(
locale: Locale,
key: TranslationKey,
params: TranslationParams = {},
): string {
return catalogs[locale][key].replace(/\{(\w+)\}/g, (token, name) =>
Object.keys(params).includes(name) ? String(params[name]) : token,
);
}
type I18nContextValue = {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: TranslationKey, params?: TranslationParams) => string;
};
const I18nContext = React.createContext<I18nContextValue | null>(null);
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocale] = React.useState<Locale>(initialLocale);
React.useEffect(() => {
document.documentElement.lang = locale;
window.localStorage.setItem(STORAGE_KEY, locale);
}, [locale]);
const value = React.useMemo<I18nContextValue>(
() => ({
locale,
setLocale,
t: (key, params) => translate(locale, key, params),
}),
[locale],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const context = React.useContext(I18nContext);
if (!context) throw new Error("useI18n must be used within I18nProvider");
return context;
}
+26
View File
@@ -0,0 +1,26 @@
import { Languages } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { useI18n } from "./I18nProvider";
export function LocaleToggle() {
const { locale, setLocale, t } = useI18n();
const nextLocale = locale === "zh-Hans" ? "en" : "zh-Hans";
const label =
nextLocale === "en"
? t("locale.switchToEnglish")
: t("locale.switchToChinese");
return (
<Button
aria-label={label}
className="h-8 gap-1.5 border-black/10 bg-white/80 px-2.5 text-xs text-black shadow-sm backdrop-blur-sm hover:bg-white dark:border-white/10 dark:bg-black/70 dark:text-white dark:hover:bg-black"
onClick={() => setLocale(nextLocale)}
title={label}
variant="outline"
>
<Languages aria-hidden="true" className="h-3.5 w-3.5" />
{nextLocale === "en" ? "EN" : "中文"}
</Button>
);
}
+187
View File
@@ -0,0 +1,187 @@
export const en = {
"common.close": "Close",
"common.copy": "Copy",
"common.download": "Download",
"common.public": "Public",
"common.people": "People",
"common.openInBuzz": "Open in Buzz",
"common.copied": "Copied to clipboard",
"common.copyFailed": "Failed to copy to clipboard",
"common.tryAgain": "Please try again later.",
"common.updated": "Updated {time}",
"locale.switchToEnglish": "Switch to English",
"locale.switchToChinese": "切换为简体中文",
"theme.switch": "Theme: {theme}. Click to switch.",
"theme.light": "light",
"theme.dark": "dark",
"theme.system": "system",
"invite.title": "You're invited to",
"invite.joinInBrowser": "Join in browser",
"invite.joining": "Joining…",
"invite.accept": "Accept invite in Buzz",
"invite.noApp": "Don't have the app?",
"invite.downloadNow": "Download it now",
"invite.ageLabel": "I am 18 years of age or older.",
"invite.agreementLabel":
"I agree to the Buzz Terms of Service and Privacy Policy.",
"invite.agreementPrefix": "I agree to the Buzz ",
"invite.agreementJoin": " and ",
"invite.agreementSuffix": ".",
"invite.terms": "Terms of Service",
"invite.privacy": "Privacy Policy",
"invite.mac.title": "Which Mac do you have?",
"invite.mac.description": "Choose based on when your Mac was released.",
"invite.mac.newer": "Newer Mac",
"invite.mac.newerDescription":
"2021 or later, or a late-2020 Mac with an Apple M1 chip",
"invite.mac.older": "Older Mac",
"invite.mac.olderDescription":
"2019 or earlier, or a 2020 Mac with an Intel processor",
"invite.mac.notSure": "Not sure?",
"invite.mac.help":
"Open the Apple menu and choose About This Mac. “Chip: Apple M…” means Newer Mac. “Processor: Intel” means Older Mac.",
"invite.error.exhausted":
"This invite has reached its use limit. Ask for a new invite.",
"invite.error.expired": "This invite has expired. Ask for a new invite.",
"invite.error.invalid":
"This invite is invalid. Check the link or ask for a new invite.",
"invite.error.claimFailed": "Could not claim this invite.",
"repos.title": "Repositories",
"repos.searchPlaceholder": "Find a repository...",
"repos.sortLabel": "Sort repositories",
"repos.sort.newest": "Newest",
"repos.sort.oldest": "Oldest",
"repos.sort.name": "Name",
"repos.searchEmptyTitle": "No matching repositories",
"repos.searchEmptyDescription": "Try adjusting your search term.",
"repos.communityEmptyTitle": "This community is empty",
"repos.communityEmptyDescription":
"Repositories pushed to this community will show up here. Open this community in the Buzz desktop app to start pushing code.",
"repos.authRequiredTitle": "Sign in to view repositories",
"repos.authRequiredDescription":
"This community requires member authentication. Open it in the Buzz desktop app or use a signed-in Nostr wallet.",
"repos.loadFailed": "Failed to load repositories",
"repos.backToList": "Back to repositories",
"repos.notFoundTitle": "Repository not found",
"repos.notFoundDescription":
"This repository may have been removed or doesn't exist on this relay.",
"repos.loadRepositoryFailed": "Failed to load repository",
"repos.loadContentsFailed":
"Failed to load repository contents. Please try again later.",
"repos.code": "Code",
"repos.commits": "Commits",
"repos.clone": "Clone",
"repos.viewOnWeb": "View on web",
"repos.viewChannel": "View channel",
"repos.copyCloneUrl": "Copy clone URL",
"repos.recentCommits": "Recent commits",
"repos.committed": "{author} committed {time}",
"repos.branch.one": "{count} branch",
"repos.branch.other": "{count} branches",
"repos.tag.one": "{count} tag",
"repos.tag.other": "{count} tags",
"repos.noCommits": "No commits yet",
"repos.peopleCount": "{count} people",
"blob.sandboxTitle": "Repository page (sandboxed)",
"blob.binary": "Binary file, {size}. Use Download above to save it.",
"blob.tooLarge":
"File is {size}, over the {limit} preview limit. Use Download above to save it.",
"blob.showSource": "Show source",
"blob.run": "Run",
"blob.loadRepositoryFailed":
"Failed to load repository. Please try again later.",
"blob.loadFileFailed": "Failed to load file. Please try again later.",
"blob.backToRepository": "Back to repository",
} as const;
export type TranslationKey = keyof typeof en;
export const zhHans = {
"common.close": "关闭",
"common.copy": "复制",
"common.download": "下载",
"common.public": "公开",
"common.people": "成员",
"common.openInBuzz": "在 Buzz 中打开",
"common.copied": "已复制到剪贴板",
"common.copyFailed": "复制失败",
"common.tryAgain": "请稍后重试。",
"common.updated": "更新于{time}",
"locale.switchToEnglish": "切换为 English",
"locale.switchToChinese": "切换为简体中文",
"theme.switch": "当前主题:{theme}。点击切换。",
"theme.light": "浅色",
"theme.dark": "深色",
"theme.system": "跟随系统",
"invite.title": "你受邀加入",
"invite.joinInBrowser": "在浏览器中加入",
"invite.joining": "正在加入…",
"invite.accept": "在 Buzz 中接受邀请",
"invite.noApp": "还没有安装应用?",
"invite.downloadNow": "立即下载",
"invite.ageLabel": "我已年满 18 周岁。",
"invite.agreementLabel": "我同意 Buzz 的服务条款和隐私政策。",
"invite.agreementPrefix": "我同意 Buzz 的",
"invite.agreementJoin": "和",
"invite.agreementSuffix": "。",
"invite.terms": "服务条款",
"invite.privacy": "隐私政策",
"invite.mac.title": "你的 Mac 是哪一款?",
"invite.mac.description": "请根据 Mac 的发布年份选择。",
"invite.mac.newer": "较新的 Mac",
"invite.mac.newerDescription":
"2021 年或之后发布,或配备 Apple M1 芯片的 2020 年末机型",
"invite.mac.older": "较早的 Mac",
"invite.mac.olderDescription":
"2019 年或之前发布,或配备 Intel 处理器的 2020 年机型",
"invite.mac.notSure": "不确定?",
"invite.mac.help":
"打开 Apple 菜单并选择“关于本机”。显示“芯片:Apple M…”请选择较新的 Mac;显示“处理器:Intel”请选择较早的 Mac。",
"invite.error.exhausted": "此邀请已达到使用次数上限,请索取新邀请。",
"invite.error.expired": "此邀请已过期,请索取新邀请。",
"invite.error.invalid": "此邀请无效,请检查链接或索取新邀请。",
"invite.error.claimFailed": "无法接受此邀请。",
"repos.title": "代码仓库",
"repos.searchPlaceholder": "查找代码仓库…",
"repos.sortLabel": "代码仓库排序",
"repos.sort.newest": "最新",
"repos.sort.oldest": "最早",
"repos.sort.name": "名称",
"repos.searchEmptyTitle": "没有匹配的代码仓库",
"repos.searchEmptyDescription": "请尝试调整搜索关键词。",
"repos.communityEmptyTitle": "这个社区还是空的",
"repos.communityEmptyDescription":
"推送到此社区的代码仓库会显示在这里。请在 Buzz 桌面应用中打开此社区并开始推送代码。",
"repos.authRequiredTitle": "登录后才能查看代码仓库",
"repos.authRequiredDescription":
"此社区需要成员认证,请在 Buzz 桌面应用中打开,或使用已登录的 Nostr 钱包。",
"repos.loadFailed": "加载代码仓库失败",
"repos.backToList": "返回代码仓库列表",
"repos.notFoundTitle": "未找到代码仓库",
"repos.notFoundDescription": "此代码仓库可能已被删除,或不存在于当前 Relay。",
"repos.loadRepositoryFailed": "加载代码仓库失败",
"repos.loadContentsFailed": "加载代码仓库内容失败,请稍后重试。",
"repos.code": "代码",
"repos.commits": "提交记录",
"repos.clone": "克隆",
"repos.viewOnWeb": "在网页中查看",
"repos.viewChannel": "查看频道",
"repos.copyCloneUrl": "复制克隆地址",
"repos.recentCommits": "最近提交",
"repos.committed": "{author} 提交于{time}",
"repos.branch.one": "{count} 个分支",
"repos.branch.other": "{count} 个分支",
"repos.tag.one": "{count} 个标签",
"repos.tag.other": "{count} 个标签",
"repos.noCommits": "暂无提交",
"repos.peopleCount": "{count} 位成员",
"blob.sandboxTitle": "代码仓库页面(沙箱)",
"blob.binary": "二进制文件,大小为 {size}。请使用上方的下载按钮保存。",
"blob.tooLarge":
"文件大小为 {size},超过 {limit} 的预览上限。请使用上方的下载按钮保存。",
"blob.showSource": "显示源码",
"blob.run": "运行",
"blob.loadRepositoryFailed": "加载代码仓库失败,请稍后重试。",
"blob.loadFileFailed": "加载文件失败,请稍后重试。",
"blob.backToRepository": "返回代码仓库",
} as const satisfies Record<TranslationKey, string>;
+17
View File
@@ -0,0 +1,17 @@
/** Normalize Vite's public base URL for client-side routing. */
export function normalizeAppBasePath(baseUrl: string): string {
const pathname = new URL(baseUrl, "http://buzz.local").pathname;
const trimmed = pathname.replace(/\/+$/, "");
return trimmed || "/";
}
/** Public path where the web SPA is mounted. */
export function appBasePath(): string {
return normalizeAppBasePath(import.meta.env.BASE_URL);
}
/** Browser href for the SPA root, including its configured base path. */
export function appRootHref(): string {
const basePath = appBasePath();
return basePath === "/" ? "/" : `${basePath}/`;
}
+195
View File
@@ -0,0 +1,195 @@
export const BUZZ_RELEASES_URL = "https://github.com/block/buzz/releases";
const BUZZ_RELEASES_API_URL =
"https://api.github.com/repos/block/buzz/releases?per_page=10";
const CACHE_KEY = "buzz.latestDownload.v1";
const CACHE_TTL_MS = 60 * 60 * 1000;
export type BuzzDownloadPlatform = {
operatingSystem: "linux" | "macos" | "windows" | "unknown";
architecture: "arm64" | "x64" | "unknown";
};
type GitHubRelease = {
draft: boolean;
prerelease: boolean;
assets: Array<{ name: string; browser_download_url: string }>;
};
type UserAgentData = {
platform?: string;
mobile?: boolean;
getHighEntropyValues?: (
hints: string[],
) => Promise<{ architecture?: string; bitness?: string }>;
};
function normalizeOperatingSystem(
navigatorValue: Navigator,
userAgentData?: UserAgentData,
): BuzzDownloadPlatform["operatingSystem"] {
const userAgent = navigatorValue.userAgent.toLowerCase();
const platform = (
userAgentData?.platform ??
navigatorValue.platform ??
""
).toLowerCase();
// Compatibility tokens are treacherous: iPadOS can report MacIntel and a
// Macintosh UA, while Android and ChromeOS expose Linux platform strings.
// Reject non-desktop devices before admitting desktop-looking signals.
const isIPadDesktopMode =
platform === "macintel" && navigatorValue.maxTouchPoints > 1;
const isUnsupportedDevice =
userAgentData?.mobile === true ||
isIPadDesktopMode ||
/android|iphone|ipad|ipod|mobile|tablet|windows phone|iemobile|opera mini|opera mobi|webos|blackberry|bb10|kindle|silk|kaios|cros/.test(
userAgent,
);
if (isUnsupportedDevice) return "unknown";
if (
platform === "macos" ||
platform.startsWith("mac") ||
userAgent.includes("macintosh")
)
return "macos";
if (
platform === "windows" ||
platform.startsWith("win") ||
userAgent.includes("windows nt")
)
return "windows";
if (
platform === "linux" ||
platform.startsWith("linux") ||
userAgent.includes("linux")
)
return "linux";
return "unknown";
}
function normalizeArchitecture(
value: string,
): BuzzDownloadPlatform["architecture"] {
const normalized = value.toLowerCase();
if (/arm|aarch64/.test(normalized)) return "arm64";
if (/x86|x64|amd64|64/.test(normalized)) return "x64";
return "unknown";
}
export async function detectBuzzDownloadPlatform(
navigatorValue: Navigator,
): Promise<BuzzDownloadPlatform> {
const userAgentData = (
navigatorValue as Navigator & { userAgentData?: UserAgentData }
).userAgentData;
const operatingSystem = normalizeOperatingSystem(
navigatorValue,
userAgentData,
);
let architecture = normalizeArchitecture(navigatorValue.userAgent);
if (userAgentData?.getHighEntropyValues) {
try {
const values = await userAgentData.getHighEntropyValues([
"architecture",
"bitness",
]);
architecture = normalizeArchitecture(
`${values.architecture ?? ""} ${values.bitness ?? ""}`,
);
} catch {
// Privacy settings may reject high-entropy client hints. The matcher
// below applies the safest compatible fallback for the detected OS.
}
}
return { operatingSystem, architecture };
}
function assetPattern(platform: BuzzDownloadPlatform): RegExp | undefined {
switch (platform.operatingSystem) {
case "macos":
if (platform.architecture === "arm64") return /_aarch64\.dmg$/i;
if (platform.architecture === "x64") return /_x64\.dmg$/i;
return undefined;
case "windows":
return /_x64-setup[^/]*\.exe$/i;
case "linux":
return platform.architecture === "arm64"
? undefined
: /_amd64\.AppImage$/i;
default:
return undefined;
}
}
export function selectBuzzDownloadUrl(
releases: GitHubRelease[],
platform: BuzzDownloadPlatform,
): string | undefined {
const pattern = assetPattern(platform);
if (!pattern) return undefined;
for (const release of releases) {
if (release.draft || release.prerelease) continue;
const asset = release.assets.find(({ name }) => pattern.test(name));
if (asset) return asset.browser_download_url;
}
return undefined;
}
export async function resolveBuzzDownloadUrlForPlatform(
platform: BuzzDownloadPlatform,
): Promise<string> {
try {
const cached = JSON.parse(sessionStorage.getItem(CACHE_KEY) ?? "null") as {
expiresAt: number;
platform: BuzzDownloadPlatform;
url: string;
} | null;
if (
cached &&
cached.expiresAt > Date.now() &&
cached.platform.operatingSystem === platform.operatingSystem &&
cached.platform.architecture === platform.architecture
) {
return cached.url;
}
} catch {
// Storage is only an optimization.
}
try {
const response = await fetch(BUZZ_RELEASES_API_URL, {
headers: { Accept: "application/vnd.github+json" },
});
if (!response.ok) return BUZZ_RELEASES_URL;
const url = selectBuzzDownloadUrl(
(await response.json()) as GitHubRelease[],
platform,
);
if (!url) return BUZZ_RELEASES_URL;
try {
sessionStorage.setItem(
CACHE_KEY,
JSON.stringify({
expiresAt: Date.now() + CACHE_TTL_MS,
platform,
url,
}),
);
} catch {
// Storage is only an optimization.
}
return url;
} catch {
return BUZZ_RELEASES_URL;
}
}
export async function resolveBuzzDownloadUrl(): Promise<string> {
return resolveBuzzDownloadUrlForPlatform(
await detectBuzzDownloadPlatform(navigator),
);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+48
View File
@@ -0,0 +1,48 @@
/**
* NIP-98 HTTP Auth helper — signs a kind:27235 event for authenticating
* HTTP requests to the relay (used by isomorphic-git for smart HTTP transport).
*/
import { signNostrEvent } from "./nostr-signer";
async function sha256Hex(value: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(value),
);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
/**
* Build a NIP-98 Authorization header value.
*
* Signed POST bodies include the payload digest required by invite endpoints.
*/
export async function makeNip98AuthHeader(
url: string,
method: string,
options?: { body?: string; requireNip07?: boolean },
): Promise<string> {
const tags = [
["u", url],
["method", method],
];
if (options?.body !== undefined) {
tags.push(["payload", await sha256Hex(options.body)]);
tags.push(["nonce", crypto.randomUUID()]);
}
const event = await signNostrEvent(
{
kind: 27235,
tags,
content: "",
},
{ requireNip07: options?.requireNip07 },
);
const json = JSON.stringify(event);
const base64 = btoa(json);
return `Nostr ${base64}`;
}
+175
View File
@@ -0,0 +1,175 @@
/**
* Minimal Nostr client with NIP-01 queries and NIP-42 AUTH.
*
* Uses NIP-07 when a browser extension is available, with an ephemeral
* page-lifetime identity as the fallback for read-only queries on open relays.
*/
import { makeAuthEvent } from "nostr-tools/nip42";
import {
type SignedNostrEvent,
signNostrEvent,
} from "@/shared/lib/nostr-signer";
export interface NostrFilter {
ids?: string[];
authors?: string[];
kinds?: number[];
since?: number;
until?: number;
limit?: number;
[tag: `#${string}`]: string[] | undefined;
}
export type NostrEvent = SignedNostrEvent;
const QUERY_TIMEOUT_MS = 10_000;
/**
* Open a WebSocket to `wsUrl`, authenticate via NIP-42 if challenged,
* send a REQ with the given filter, collect EVENTs until EOSE, then
* close and return them.
*/
export function queryEvents(
wsUrl: string,
filter: NostrFilter,
): Promise<NostrEvent[]> {
return new Promise((resolve, reject) => {
const events: NostrEvent[] = [];
const subId = `q-${Date.now().toString(36)}`;
let settled = false;
let reqSent = false;
let authEventId: string | null = null;
let unauthenticatedReqTimer: ReturnType<typeof setTimeout> | null = null;
const ws = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
ws.close();
reject(new Error(`Relay query timed out after ${QUERY_TIMEOUT_MS}ms`));
}
}, QUERY_TIMEOUT_MS);
const cleanup = () => {
clearTimeout(timeout);
if (unauthenticatedReqTimer) {
clearTimeout(unauthenticatedReqTimer);
}
try {
ws.close();
} catch {
// ignore
}
};
const sendReq = () => {
if (!reqSent) {
reqSent = true;
ws.send(JSON.stringify(["REQ", subId, filter]));
}
};
ws.addEventListener("open", () => {
// Wait briefly for an AUTH challenge before sending REQ.
// Buzz relays always send AUTH, but other relays may not.
unauthenticatedReqTimer = setTimeout(() => sendReq(), 100);
});
ws.addEventListener("message", async (msg) => {
let data: unknown;
try {
data = JSON.parse(String(msg.data));
} catch {
return;
}
if (!Array.isArray(data)) return;
const [type] = data;
if (type === "AUTH" && typeof data[1] === "string") {
// NIP-42: relay sent an AUTH challenge — sign and respond.
if (unauthenticatedReqTimer) {
clearTimeout(unauthenticatedReqTimer);
unauthenticatedReqTimer = null;
}
const challenge = data[1];
const template = makeAuthEvent(wsUrl, challenge);
try {
const signed = await signNostrEvent(template);
if (settled) return;
authEventId = signed.id;
ws.send(JSON.stringify(["AUTH", signed]));
} catch (error) {
if (!settled) {
settled = true;
cleanup();
reject(
error instanceof Error
? error
: new Error("Failed to sign relay authentication."),
);
}
}
return;
}
if (type === "OK" && data[1] === authEventId) {
if (data[2] === true) {
sendReq();
} else if (!settled) {
settled = true;
cleanup();
reject(
new Error(
typeof data[3] === "string"
? data[3]
: "Relay authentication failed.",
),
);
}
return;
}
if (type === "EVENT" && data[1] === subId && data[2]) {
events.push(data[2] as NostrEvent);
} else if (type === "EOSE" && data[1] === subId) {
if (!settled) {
settled = true;
cleanup();
resolve(events);
}
} else if (type === "CLOSED" && data[1] === subId) {
// Subscription was rejected (e.g. auth failed).
if (!settled) {
settled = true;
cleanup();
const reason =
typeof data[2] === "string"
? data[2]
: "subscription closed by relay";
reject(new Error(reason));
}
} else if (type === "NOTICE") {
// Informational notice from relay — ignore for now.
}
});
ws.addEventListener("error", () => {
if (!settled) {
settled = true;
cleanup();
reject(new Error("WebSocket connection failed"));
}
});
ws.addEventListener("close", () => {
if (!settled) {
settled = true;
clearTimeout(timeout);
resolve(events);
}
});
});
}
+106
View File
@@ -0,0 +1,106 @@
import {
finalizeEvent,
generateSecretKey,
getPublicKey,
} from "nostr-tools/pure";
export type UnsignedNostrEvent = {
kind: number;
created_at: number;
tags: string[][];
content: string;
};
export type SignedNostrEvent = UnsignedNostrEvent & {
id: string;
pubkey: string;
sig: string;
};
type Nip07Provider = {
getPublicKey(): Promise<string>;
signEvent(event: UnsignedNostrEvent): Promise<SignedNostrEvent>;
};
declare global {
interface Window {
nostr?: Nip07Provider;
}
}
export class Nip07UnavailableError extends Error {
constructor() {
super("A NIP-07 browser extension is required to join in the browser.");
this.name = "Nip07UnavailableError";
}
}
let ephemeralSecretKey: Uint8Array | null = null;
function getEphemeralSecretKey(): Uint8Array {
if (!ephemeralSecretKey) {
ephemeralSecretKey = generateSecretKey();
}
return ephemeralSecretKey;
}
export function hasNip07Provider(): boolean {
return typeof window !== "undefined" && window.nostr != null;
}
function sameUnsignedEvent(
expected: UnsignedNostrEvent,
actual: SignedNostrEvent,
): boolean {
return (
actual.kind === expected.kind &&
actual.created_at === expected.created_at &&
actual.content === expected.content &&
JSON.stringify(actual.tags) === JSON.stringify(expected.tags)
);
}
/**
* Sign with NIP-07 when available, otherwise use a page-lifetime key.
*
* The ephemeral fallback preserves anonymous browsing on open relays. Flows
* that create durable membership must set `requireNip07` so a reload cannot
* orphan a relay-membership row.
*/
export async function signNostrEvent(
template: Omit<UnsignedNostrEvent, "created_at"> & {
created_at?: number;
},
options?: { requireNip07?: boolean },
): Promise<SignedNostrEvent> {
const unsigned: UnsignedNostrEvent = {
...template,
created_at: template.created_at ?? Math.floor(Date.now() / 1000),
};
const provider = typeof window === "undefined" ? undefined : window.nostr;
if (provider) {
const expectedPubkey = await provider.getPublicKey();
const signed = await provider.signEvent(unsigned);
if (
signed.pubkey !== expectedPubkey ||
!sameUnsignedEvent(unsigned, signed) ||
typeof signed.id !== "string" ||
typeof signed.sig !== "string"
) {
throw new Error("The NIP-07 extension returned an invalid signed event.");
}
return signed;
}
if (options?.requireNip07) {
throw new Nip07UnavailableError();
}
const secretKey = getEphemeralSecretKey();
const signed = finalizeEvent(unsigned, secretKey);
if (signed.pubkey !== getPublicKey(secretKey)) {
throw new Error("Failed to create the ephemeral browser identity.");
}
return signed;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* The ONE canonical compact display form for a pubkey: `abcd1234…wxyz`.
* Mirrors desktop's `@/shared/lib/pubkey`. A truncated pubkey is a
* recognition aid, never an identity proof — security decisions need the
* full npub.
*/
export function truncatePubkey(pubkey: string): string {
if (pubkey.length <= 12) {
return pubkey;
}
return `${pubkey.slice(0, 8)}${pubkey.slice(-4)}`;
}
+20
View File
@@ -0,0 +1,20 @@
import type { Locale } from "@/shared/i18n/I18nProvider";
/** Format a Unix timestamp (seconds) using the active UI locale. */
export function relativeTime(unix: number, locale: Locale = "zh-Hans"): string {
const diff = Date.now() - unix * 1000;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
if (days > 30) {
const months = Math.floor(days / 30);
return formatter.format(-months, "month");
}
if (days > 0) return formatter.format(-days, "day");
if (hours > 0) return formatter.format(-hours, "hour");
if (minutes > 0) return formatter.format(-minutes, "minute");
return formatter.format(0, "second");
}
+29
View File
@@ -0,0 +1,29 @@
/** Convert a WebSocket relay URL to its HTTP equivalent. */
export function relayHttpUrl(wsUrl: string): string {
if (wsUrl.startsWith("wss://")) {
return `https://${wsUrl.slice(6)}`;
}
if (wsUrl.startsWith("ws://")) {
return `http://${wsUrl.slice(5)}`;
}
return wsUrl;
}
/** Read the relay WebSocket URL from environment or derive from window.location. */
export function relayWsUrl(): string {
const envUrl = import.meta.env.VITE_RELAY_URL?.trim();
if (envUrl) return envUrl.replace(/\/+$/, "");
// The relay and SPA share the Vite base path. This supports both a relay at
// the origin root and reverse-proxy mounts such as https://host/buzz/.
const url = new URL(import.meta.env.BASE_URL, window.location.origin);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.search = "";
url.hash = "";
return url.toString().replace(/\/+$/, "");
}
/** HTTP base URL for the relay (derived from the WS URL). */
export function relayHttpBaseUrl(): string {
return relayHttpUrl(relayWsUrl());
}
+103
View File
@@ -0,0 +1,103 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
@config "../../../tailwind.config.js";
@layer base {
:root {
/* Catppuccin Latte (mauve accent) */
--radius: 0.625rem;
--background: 220 23.08% 94.9%;
--foreground: 234 16.02% 35.49%;
--card: 220 23.08% 94.9%;
--card-foreground: 234 16.02% 35.49%;
--popover: 220 23.08% 94.9%;
--popover-foreground: 234 16.02% 35.49%;
--primary: 266 85.05% 58.04%;
--primary-foreground: 220 23.08% 94.9%;
--secondary: 223 15.91% 82.75%;
--secondary-foreground: 234 16.02% 35.49%;
--muted: 223 15.91% 82.75%;
--muted-foreground: 233 12.8% 41.37%;
--accent: 223 15.91% 82.75%;
--accent-foreground: 234 16.02% 35.49%;
--destructive: 347 86.67% 44.12%;
--destructive-foreground: 220 23.08% 94.9%;
--border: 225 13.56% 76.86%;
--input: 225 13.56% 76.86%;
--ring: 234 16.02% 35.49%;
--chart-1: 345 57.65% 74.12%;
--chart-2: 185 37.25% 70.59%;
--chart-3: 219 81.96% 78.43%;
--chart-4: 35 61.18% 76.86%;
--chart-5: 113 32.94% 73.33%;
--sidebar: 218 25.1% 93.73%;
--sidebar-background: 218 25.1% 93.73%;
--sidebar-foreground: 234 16.02% 35.49%;
--sidebar-primary: 266 85.05% 58.04%;
--sidebar-primary-foreground: 220 23.08% 94.9%;
--sidebar-accent: 220 23.08% 94.9%;
--sidebar-accent-foreground: 234 16.02% 35.49%;
--sidebar-border: 225 13.56% 76.86%;
--sidebar-ring: 225 13.56% 76.86%;
}
.dark {
/* Catppuccin Macchiato (mauve accent) */
--radius: 0.625rem;
--background: 232 23.4% 18.43%;
--foreground: 227 68.25% 87.65%;
--card: 232 23.4% 18.43%;
--card-foreground: 227 68.25% 87.65%;
--popover: 232 23.4% 18.43%;
--popover-foreground: 227 68.25% 87.65%;
--primary: 267 82.69% 79.61%;
--primary-foreground: 232 23.4% 18.43%;
--secondary: 230 18.8% 26.08%;
--secondary-foreground: 227 68.25% 87.65%;
--muted: 230 18.8% 26.08%;
--muted-foreground: 228 39.22% 80%;
--accent: 230 18.8% 26.08%;
--accent-foreground: 227 68.25% 87.65%;
--destructive: 351 73.91% 72.94%;
--destructive-foreground: 232 23.4% 18.43%;
--border: 231 15.61% 33.92%;
--input: 231 15.61% 33.92%;
--ring: 227 68.25% 87.65%;
--chart-1: 333 20.78% 38.04%;
--chart-2: 193 19.61% 38.04%;
--chart-3: 224 26.67% 41.18%;
--chart-4: 30 8.24% 42.35%;
--chart-5: 139 10.98% 38.82%;
--sidebar: 234 22.75% 17.25%;
--sidebar-background: 234 22.75% 17.25%;
--sidebar-foreground: 227 68.25% 87.65%;
--sidebar-primary: 267 82.69% 79.61%;
--sidebar-primary-foreground: 232 23.4% 18.43%;
--sidebar-accent: 232 23.4% 18.43%;
--sidebar-accent-foreground: 227 68.25% 87.65%;
--sidebar-border: 231 15.61% 33.92%;
--sidebar-ring: 231 15.61% 33.92%;
}
* {
@apply border-border;
}
mark {
background-color: transparent;
color: inherit;
}
html.dark {
color-scheme: dark;
}
body {
@apply bg-background text-foreground antialiased;
font-family:
"Inter Variable", Inter, "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei UI", "Microsoft YaHei", "Noto Sans CJK SC",
"Source Han Sans SC", "Avenir Next", "Segoe UI", sans-serif;
}
}
+86
View File
@@ -0,0 +1,86 @@
import {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
type Theme = "light" | "dark" | "system";
type ThemeContextValue = {
theme: Theme;
isDark: boolean;
setTheme: (theme: Theme) => void;
};
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function getSystemDark(): boolean {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
function getInitialTheme(): Theme {
if (!import.meta.env.DEV) return "system";
const previewTheme = new URLSearchParams(window.location.search).get(
"previewTheme",
);
return previewTheme === "light" || previewTheme === "dark"
? previewTheme
: "system";
}
function applyClass(isDark: boolean) {
const root = document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(isDark ? "dark" : "light");
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
const [isDark, setIsDark] = useState<boolean>(() => {
const initialTheme = getInitialTheme();
const dark =
initialTheme === "system" ? getSystemDark() : initialTheme === "dark";
applyClass(dark);
return dark;
});
useEffect(() => {
const dark = theme === "system" ? getSystemDark() : theme === "dark";
applyClass(dark);
setIsDark(dark);
}, [theme]);
useEffect(() => {
if (theme !== "system") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
applyClass(e.matches);
setIsDark(e.matches);
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [theme]);
const setTheme = useCallback((t: Theme) => {
setThemeState(t);
}, []);
return (
<ThemeContext.Provider value={{ theme, isDark, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
+34
View File
@@ -0,0 +1,34 @@
import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "@/shared/theme/ThemeProvider";
import { Button } from "@/shared/ui/button";
import { useI18n } from "@/shared/i18n/I18nProvider";
const icons = {
light: Sun,
dark: Moon,
system: Monitor,
} as const;
const next: Record<string, "dark" | "system" | "light"> = {
light: "dark",
dark: "system",
system: "light",
};
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const { t } = useI18n();
const Icon = icons[theme];
return (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setTheme(next[theme])}
aria-label={t("theme.switch", { theme: t(`theme.${theme}`) })}
>
<Icon className="h-4 w-4" />
</Button>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { cn } from "@/shared/lib/cn";
import { type VariantProps, cva } from "class-variance-authority";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
},
},
defaultVariants: { variant: "default" },
},
);
interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+83
View File
@@ -0,0 +1,83 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border border-border/70 bg-card/80 text-card-foreground shadow-xs",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
+20
View File
@@ -0,0 +1,20 @@
import { cn } from "@/shared/lib/cn";
import { type InputHTMLAttributes, forwardRef } from "react";
const Input = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>(({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
));
Input.displayName = "Input";
export { Input };
+29
View File
@@ -0,0 +1,29 @@
import { Toaster as Sonner } from "sonner";
import { useTheme } from "@/shared/theme/ThemeProvider";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { isDark } = useTheme();
return (
<Sonner
theme={isDark ? "dark" : "light"}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };
+30
View File
@@ -0,0 +1,30 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/shared/lib/cn";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-tooltip-content-transform-origin)",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />