import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"; import { api, joinedSpaces, joinSpace, type Config, type InviteInfo, type Me, type SpaceMembership } from "./api"; import { theme, toggleTheme } from "./theme"; import { SpacePage } from "./SpacePage"; type Route = | { kind: "home" } | { kind: "space"; uri: string } | { kind: "join"; space: string; invite: string }; /** Hash routes: #/ , #/s/ , #/join// (both encoded). */ function parseRoute(hash: string): Route { const h = hash.replace(/^#/, ""); if (h.startsWith("/s/")) return { kind: "space", uri: decodeURIComponent(h.slice(3)) }; if (h.startsWith("/join/")) { const [space, invite] = h.slice(6).split("/").map(decodeURIComponent); if (space && invite) return { kind: "join", space, invite }; } return { kind: "home" }; } function spaceName(uri: string): string { return uri.split("/").pop() ?? uri; } export function App() { const [me, setMe] = createSignal(null); const [booted, setBooted] = createSignal(false); const [spaces, setSpaces] = createSignal([]); const [route, setRoute] = createSignal(parseRoute(location.hash)); const [menuOpen, setMenuOpen] = createSignal(false); const onHash = () => setRoute(parseRoute(location.hash)); onMount(() => window.addEventListener("hashchange", onHash)); onCleanup(() => window.removeEventListener("hashchange", onHash)); // Server is the source of truth (membership lives in zds); URIs // joined via links or pasted before survive via localStorage merge. const loadSpaces = async () => { const mine = (await api.mySpaces()).spaces; const local = joinedSpaces() .filter((uri) => !mine.some((m) => m.uri === uri)) .map((uri) => ({ uri, isOwner: false })); setSpaces([...mine, ...local]); }; onMount(async () => { try { setMe(await api.me()); await loadSpaces(); } catch { // not logged in } setBooted(true); }); const activeUri = (): string | null => { const r = route(); if (r.kind === "space") return r.uri; return spaces()[0]?.uri ?? null; }; // Display rule: the owner's name wins, the skey (last URI segment) is // the fallback everywhere a space is labelled. const activeName = (): string => { const uri = activeUri(); if (!uri) return "linji"; return spaces().find((s) => s.uri === uri)?.name ?? spaceName(uri); }; return ( { setMe(m); void loadSpaces(); }} />} > {(m) => ( document.getElementById("sidebar")?.classList.add("open")} /> } > { joinSpace(space); await loadSpaces(); location.hash = `#/s/${encodeURIComponent(space)}`; }} /> )} ); } function Topbar(props: { spaceName: string; onMenu: () => void }) { return (
{props.spaceName}
); } function Sidebar(props: { me: Me; spaces: SpaceMembership[]; active: string | null; onSelect: (uri: string) => void; onSpacesChanged: () => Promise; onOpenMenu: () => void; }) { const [addOpen, setAddOpen] = createSignal(false); return ( ); } /** Create a space, redeem an invite link, or (advanced) open a space URI. */ function AddSpaceDialog(props: { open: boolean; onClose: () => void; onCreated: () => Promise; onSelect: (uri: string) => void; }) { let dialog!: HTMLDialogElement; const [name, setName] = createSignal(""); const [link, setLink] = createSignal(""); const [advanced, setAdvanced] = createSignal(false); const [uri, setUri] = createSignal(""); const [error, setError] = createSignal(""); const [busy, setBusy] = createSignal(false); // Native is not reactive; drive it from the open prop. createEffect(() => { if (!dialog) return; if (props.open && !dialog.open) dialog.showModal(); if (!props.open && dialog.open) dialog.close(); }); const close = () => dialog.close(); const create = async () => { const skey = name().trim(); if (!skey) return; setBusy(true); setError(""); try { const { uri } = await api.createSpace(skey); await props.onCreated(); props.onSelect(uri); setName(""); close(); } catch (err) { setError(err instanceof Error ? err.message : "Could not create the space"); } setBusy(false); }; const redeemLink = () => { const text = link().trim(); // Accept a full linji invite URL (#/join//) or the two // raw at:// URIs separated by whitespace. try { let m = text.match(/#\/join\/([^/]+)\/([^/?#]+)/); if (m) { location.hash = `#/join/${m[1]}/${m[2]}`; close(); return; } const parts = text.split(/\s+/); if (parts.length === 2 && parts.every((p) => p.startsWith("at://"))) { location.hash = `#/join/${encodeURIComponent(parts[0])}/${encodeURIComponent(parts[1])}`; close(); return; } setError("That doesn't look like an invite link"); } catch { setError("That doesn't look like an invite link"); } }; const openUri = async () => { const u = uri().trim(); if (!u.startsWith("at://")) return; joinSpace(u); await props.onCreated(); props.onSelect(u); setUri(""); close(); }; return ( { if (e.target === dialog) props.onClose(); }}>

Add space

{ e.preventDefault(); void create(); }} > setName(e.currentTarget.value)} />

{ e.preventDefault(); redeemLink(); }} >

Have an invite link? Paste it here.

setLink(e.currentTarget.value)} />

{error()}


setAdvanced(true)}>Advanced: open by space URI } >
{ e.preventDefault(); void openUri(); }} > setUri(e.currentTarget.value)} />
); } /** Account menu: theme, notifications, data rights, sign out. */ function AccountMenu(props: { me: Me; open: boolean; onClose: () => void }) { let dialog!: HTMLDialogElement; const [busy, setBusy] = createSignal(false); // Native is not reactive; drive it from the open prop. const sync = (open: boolean) => { if (!dialog) return; if (open && !dialog.open) dialog.showModal(); if (!open && dialog.open) dialog.close(); }; createEffect(() => sync(props.open)); return ( { if (e.target === dialog) props.onClose(); }} >

{props.me.handle}

); } /** Logged-out landing: what linji is + sign in. Stumblers arrive here. */ function Landing(props: { route: Route; onLogin: (m: Me) => void }) { const [name, setName] = createSignal(""); const [password, setPassword] = createSignal(""); const [config, setConfig] = createSignal({ handleDomain: "linji.at" }); const [error, setError] = createSignal(""); const [busy, setBusy] = createSignal(false); onMount(async () => { try { setConfig(await api.config()); } catch { // defaults are fine } }); return (

linji

A calmer place for topical conversation.

You've been invited to a space — sign in to join it.

Spaces hold threads, threads hold the conversation. Sign in with{" "} any atproto account (Bluesky included) — your identity and your words stay yours. Spaces are member-only; you get in with an invite link.

); } /** Invite redemption: preview the invite, then join. */ function JoinScreen(props: { route: { kind: "join"; space: string; invite: string }; me: Me; onJoined: (space: string) => Promise; }) { const [info, setInfo] = createSignal(null); const [error, setError] = createSignal(""); const [busy, setBusy] = createSignal(false); onMount(async () => { try { setInfo(await api.inviteInfo(props.route.space, props.route.invite)); } catch (err) { setError(err instanceof Error ? err.message : "Could not load the invite"); } }); const expiryText = (iso: string): string => { const ms = new Date(iso).getTime() - Date.now(); if (ms <= 0) return "expired"; const days = Math.floor(ms / 86_400_000); if (days >= 1) return `expires in ${days} day${days === 1 ? "" : "s"}`; const hours = Math.ceil(ms / 3_600_000); return `expires in ${hours} hour${hours === 1 ? "" : "s"}`; }; const reasonText = (reason?: string): string => ({ expired: "This invite has expired. Ask for a fresh link.", "not-found": "This invite no longer exists — it may have been revoked.", "wrong-authority": "This invite wasn't issued by the space owner.", "wrong-space": "This invite is for a different space.", })[reason ?? ""] ?? "This invite can't be used."; return (

{error()}

{reasonText(info()!.reason)}

Go home

{info()!.name ?? info()!.skey ?? "A space"}

Invited by {info()!.inviterHandle ?? info()!.inviter} {info()!.expiresAt ? ` · ${expiryText(info()!.expiresAt!)}` : ""}

{error()}

); } /** Notifications toggle. "on" = notifications enabled (permission * granted) — these always work while the app is open. A background * push subscription is attempted too, but is best-effort: when the * app is closed we don't guarantee delivery. */ function PushBell() { const [state, setState] = createSignal<"off" | "on" | "denied" | "unsupported">("off"); onMount(async () => { if (!("serviceWorker" in navigator) || !("Notification" in window)) { setState("unsupported"); return; } if (Notification.permission === "denied") { setState("denied"); return; } setState(Notification.permission === "granted" ? "on" : "off"); }); const toggle = async () => { if (state() === "on") { const reg = await navigator.serviceWorker.ready; const existing = await reg.pushManager.getSubscription(); if (existing) { await existing.unsubscribe(); await api.pushUnsubscribe(existing.endpoint); } setState("off"); return; } if (Notification.permission !== "granted") { const perm = await Notification.requestPermission(); if (perm !== "granted") { setState("denied"); return; } } try { const config = await api.config(); if (config.vapidKey) { const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64ToBytes(config.vapidKey) as BufferSource, }); await api.pushSubscribe(sub.toJSON()); } } catch { // background push unavailable (headless, no FCM, …) — fine } setState("on"); }; return ( ); } /** base64url (VAPID key) -> Uint8Array for applicationServerKey. */ function urlB64ToBytes(b64: string): Uint8Array { const pad = "=".repeat((4 - (b64.length % 4)) % 4); const raw = atob(b64.replace(/-/g, "+").replace(/_/g, "/") + pad); const out = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; }