Add url routing

This commit is contained in:
Jon Staab
2026-06-12 14:46:52 -07:00
parent af4d53269a
commit 9fe9da75e0
8 changed files with 232 additions and 53 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
The user logs in via NIP-07, NIP-46, or private key. See https://gitea.coracle.social/coracle/caravel/raw/branch/master/frontend/src/views/Login.tsx for an example login component.
The app has a sidebar with an "inbox", a list of existing quorums, and a "create quorum" button. The bottom shows the user's profile name/nip05 and a logout button. On mobile, an icon opens a quorum list in a drawer, and a plus button opens the quorum creation form.
The app has a sidebar with an "inbox", a list of existing quora, and a "create quorum" button. The bottom shows the user's profile name/nip05 and a logout button. On mobile, an icon opens a quorum list in a drawer, and a plus button opens the quorum creation form.
The inbox shows all events pending signature across all quorums, including events the user has not yet signed. Each event shows x/y signed. Events not yet signed by the user have options for signing and declining, each with an optional message posted to the NIP-17 quorum chat. The inbox also lists quorum invitations with options to join or decline, each with a message.
The inbox shows all events pending signature across all quora, including events the user has not yet signed. Each event shows x/y signed. Events not yet signed by the user have options for signing and declining, each with an optional message posted to the NIP-17 quorum chat. The inbox also lists quorum invitations with options to join or decline, each with a message.
Create quorum opens a modal for selecting quorum members and threshold, the quorum's outbox relays, and the quorum's kind 0 profile information.
+1 -1
View File
@@ -8,7 +8,7 @@ FROST Multisig Quorum Protocol
## Abstract
This NIP defines a protocol for creating and operating FROST threshold signature quorums over Nostr keys. A quorum is a group of n participants who collectively control a shared Nostr keypair via a (t,n) threshold signing scheme, where any t members can produce a valid signature but no fewer. The quorum's private key is never known to any single party.
This NIP defines a protocol for creating and operating FROST threshold signature quora over Nostr keys. A quorum is a group of n participants who collectively control a shared Nostr keypair via a (t,n) threshold signing scheme, where any t members can produce a valid signature but no fewer. The quorum's private key is never known to any single party.
## Participant Indexing
+1 -1
View File
@@ -1,6 +1,6 @@
# nq
This is a web application which allows for creating, rotating, and signing under multisig quorums for nostr keys.
This is a web application which allows for creating, rotating, and signing under multisig quora for nostr keys.
# Protocol
+6 -35
View File
@@ -7,6 +7,7 @@ import AccountPage from "./components/AccountPage"
import { ProposeQuorum } from "./components/forms/DkgForms"
import { ProposeResharing } from "./components/forms/ResharingForms"
import { ProposeSign } from "./components/forms/SigningForms"
import QuoraLanding from "./components/QuoraLanding"
import Avatar from "./components/Avatar"
import Modal from "./components/Modal"
import { displayProfile } from "@welshman/util"
@@ -62,7 +63,7 @@ export default function Layout() {
{/* Desktop sidebar */}
<aside class="hidden md:flex flex-col w-64 shrink-0 border-r border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800">
<div class="px-4 py-4 border-b border-gray-200 dark:border-neutral-700">
<span class="text-base font-bold text-gray-900 dark:text-white">NQ</span>
<button class="text-base font-bold text-gray-900 dark:text-white hover:opacity-70 transition-opacity" onClick={() => setView("home")}>NQ</button>
</div>
<SidebarContent onNew={() => setShowProposeQuorum(true)} onNavigate={() => setDrawerOpen(false)} />
</aside>
@@ -78,7 +79,7 @@ export default function Layout() {
{/* Mobile drawer — always mounted, slides in/out via transform */}
<aside class={`fixed inset-y-0 left-0 z-50 w-64 flex flex-col bg-white dark:bg-neutral-800 shadow-xl md:hidden transition-transform duration-300 ease-in-out ${drawerOpen() ? "translate-x-0" : "-translate-x-full"}`}>
<div class="px-4 py-4 border-b border-gray-200 dark:border-neutral-700 flex items-center justify-between">
<span class="text-base font-bold text-gray-900 dark:text-white">NQ</span>
<button class="text-base font-bold text-gray-900 dark:text-white hover:opacity-70 transition-opacity" onClick={() => { setView("home"); setDrawerOpen(false) }}>NQ</button>
<button
class="p-1 rounded-md text-gray-400 hover:text-gray-600"
onClick={() => setDrawerOpen(false)}
@@ -96,22 +97,7 @@ export default function Layout() {
{/* Col 2: main content */}
<div class="flex-1 min-w-0 overflow-y-auto">
<Show when={view() === "home"}>
<div class="flex items-center justify-center min-h-full p-6">
<div class="flex flex-col items-center text-center gap-4 max-w-sm">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Welcome to NQ</h1>
<p class="text-sm text-gray-500 dark:text-neutral-400">
FROST threshold multisig for Nostr keys. Create a quorum to share control of a
key with a group, where any threshold of members can sign.
</p>
<button
type="button"
class="px-5 py-2.5 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
onClick={() => setShowProposeQuorum(true)}
>
Create your first quorum
</button>
</div>
</div>
<QuoraLanding onNew={() => setShowProposeQuorum(true)} />
</Show>
<Show when={view() === "account"}>
<AccountPage />
@@ -148,27 +134,12 @@ export default function Layout() {
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span class="text-base font-semibold text-gray-900 dark:text-white">NQ</span>
<button class="text-base font-semibold text-gray-900 dark:text-white hover:opacity-70 transition-opacity" onClick={() => setView("home")}>NQ</button>
</header>
<main class="flex-1 overflow-y-auto">
<Show when={view() === "home"}>
<div class="flex items-center justify-center min-h-full p-6">
<div class="flex flex-col items-center text-center gap-4 max-w-sm">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Welcome to NQ</h1>
<p class="text-sm text-gray-500 dark:text-neutral-400">
FROST threshold multisig for Nostr keys. Create a quorum to share control of a
key with a group, where any threshold of members can sign.
</p>
<button
type="button"
class="px-5 py-2.5 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
onClick={() => setShowProposeQuorum(true)}
>
Create your first quorum
</button>
</div>
</div>
<QuoraLanding onNew={() => setShowProposeQuorum(true)} />
</Show>
<Show when={view() === "account"}>
<AccountPage />
+53
View File
@@ -0,0 +1,53 @@
import { createSignal, Show } from "solid-js"
import toast from "solid-toast"
import { fullNpub, shortNpub } from "../lib/pubkey"
/**
* Renders a pubkey as a truncated npub with a copy button. Clicking copies the FULL npub to
* the clipboard and shows a success toast. Use this anywhere a raw pubkey would otherwise be
* shown as hex; where a profile name is the primary label, show the name and use this for the
* secondary key line.
*/
export default function Npub(props: { pubkey: string; class?: string }) {
const [copied, setCopied] = createSignal(false)
async function copy(e: MouseEvent) {
// Stop the click from triggering an enclosing button/link (cards, nav, modal backdrops).
e.preventDefault()
e.stopPropagation()
try {
await navigator.clipboard.writeText(fullNpub(props.pubkey))
setCopied(true)
setTimeout(() => setCopied(false), 1500)
toast.success("Copied npub to clipboard")
} catch {
toast.error("Failed to copy to clipboard")
}
}
return (
<span class={`inline-flex items-center gap-1 min-w-0 ${props.class ?? ""}`}>
<span class="font-mono truncate">{shortNpub(props.pubkey)}</span>
<button
type="button"
title="Copy npub"
class="p-0.5 rounded text-gray-400 dark:text-neutral-500 hover:text-gray-600 dark:hover:text-neutral-300 transition-colors shrink-0"
onClick={copy}
>
<Show
when={copied()}
fallback={
<svg class="size-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
}
>
<svg class="size-3.5 text-green-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</Show>
</button>
</span>
)
}
+95
View File
@@ -0,0 +1,95 @@
import { For, Show } from "solid-js"
import { displayedQuora, openQuorum } from "../store"
import { useProfileDisplay } from "../hooks"
import { quorumHasActivity } from "../engine/notifications"
import Npub from "./Npub"
import type { DisplayedQuorum } from "../models"
const badgeClass: Record<DisplayedQuorum["status"], string> = {
complete: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300",
sending: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
failed: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
pending: "bg-gray-100 text-gray-600 dark:bg-neutral-700 dark:text-neutral-300",
}
function QuorumCard(props: { quorum: DisplayedQuorum }) {
const name = useProfileDisplay(() => props.quorum.quorumPubkey ?? "")
const label = () => (props.quorum.quorumPubkey ? name() : "New quorum")
// A div (not a button) so the Npub copy button can live inside it without nesting buttons.
return (
<div
role="button"
tabindex={0}
class="flex flex-col gap-2 p-4 rounded-xl border border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 hover:border-blue-400 dark:hover:border-blue-500 hover:shadow-sm cursor-pointer transition-colors"
onClick={() => openQuorum(props.quorum.id)}
onKeyDown={e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openQuorum(props.quorum.id) } }}
>
<div class="flex items-start justify-between gap-2">
<span class="font-semibold text-gray-900 dark:text-white truncate flex items-center gap-1.5 min-w-0">
<span class="truncate">{label()}</span>
<Show when={quorumHasActivity(props.quorum)}>
<span class="size-2 rounded-full bg-blue-500 shrink-0" title="New activity" />
</Show>
</span>
<span class={`px-2 py-0.5 rounded-full text-[11px] font-medium shrink-0 ${badgeClass[props.quorum.status]}`}>
{props.quorum.statusLabel}
</span>
</div>
<Show
when={props.quorum.quorumPubkey}
fallback={<span class="text-xs italic text-gray-400 dark:text-neutral-500">Key not generated yet</span>}
>
{pk => <Npub pubkey={pk()} class="text-xs text-gray-400 dark:text-neutral-500 max-w-full" />}
</Show>
<div class="text-xs text-gray-500 dark:text-neutral-400">
{props.quorum.threshold}-of-{props.quorum.members.length} signatures required
</div>
</div>
)
}
export default function QuoraLanding(props: { onNew: () => void }) {
return (
<Show
when={displayedQuora().length > 0}
fallback={
<div class="flex items-center justify-center min-h-full p-6">
<div class="flex flex-col items-center text-center gap-4 max-w-sm">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Welcome to NQ</h1>
<p class="text-sm text-gray-500 dark:text-neutral-400">
FROST threshold multisig for Nostr keys. Create a quorum to share control of a
key with a group, where any threshold of members can sign.
</p>
<button
type="button"
class="px-5 py-2.5 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
onClick={props.onNew}
>
Create your first quorum
</button>
</div>
</div>
}
>
<div class="p-6 max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">Your Quora</h1>
<button
type="button"
class="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
onClick={props.onNew}
>
New quorum
</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<For each={displayedQuora()}>
{(q) => <QuorumCard quorum={q} />}
</For>
</div>
</div>
</Show>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { Pubkey, displayPubkey } from "@welshman/util"
/**
* Full `npub1…` bech32 encoding of a hex pubkey — the value we put on the clipboard. Falls
* back to the raw input if it isn't a valid pubkey (so rendering can never throw).
*/
export function fullNpub(hex: string): string {
try {
return new Pubkey(hex).toNpub()
} catch {
return hex
}
}
/**
* Truncated npub for inline display (e.g. `npub1ab…wxyz`) — the same form welshman uses as
* its profile-display fallback, so npubs read consistently across named and unnamed pubkeys.
*/
export function shortNpub(hex: string): string {
try {
return displayPubkey(hex)
} catch {
return hex
}
}
+49 -14
View File
@@ -28,7 +28,10 @@ import type {
SigningSession,
} from "./models"
export type View = "home" | "account" | { type: "quorum"; id: string; tab: "log" | "members" | "chat" }
export type QuorumTab = "log" | "members" | "chat"
export type View = "home" | "account" | { type: "quorum"; id: string; tab: QuorumTab }
const QUORUM_TABS: readonly QuorumTab[] = ["log", "members", "chat"]
export function logout(): void {
// dropSession removes the session and its cached signer entirely; the sync()
@@ -38,14 +41,53 @@ export function logout(): void {
if (pk) { dropSession(pk) }
}
export const [view, setView] = createSignal<View>("home")
// ── URL-backed view ────────────────────────────────────────────────────────────
// The active view IS the URL path, so links are shareable and the browser back/forward
// buttons work. Routes: / → home (Your Quora landing) · /account · /quorum/<id>/<tab>.
// `setView` (and the openQuorum/setTab helpers built on it) pushes history; popstate syncs
// the signal back. Kept as the same `view()/setView()` API so engine + components are unchanged.
function pathToView(path: string): View {
const parts = path.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean)
if (parts[0] === "account") { return "account" }
if (parts[0] === "quorum" && parts[1]) {
const tab = QUORUM_TABS.includes(parts[2] as QuorumTab) ? (parts[2] as QuorumTab) : "log"
return { type: "quorum", id: decodeURIComponent(parts[1]), tab }
}
return "home"
}
function viewToPath(v: View): string {
if (v === "account") { return "/account" }
if (typeof v === "object" && v.type === "quorum") { return `/quorum/${encodeURIComponent(v.id)}/${v.tab}` }
return "/"
}
const [view, setViewSignal] = createSignal<View>(pathToView(window.location.pathname))
export { view }
const onPopState = () => setViewSignal(pathToView(window.location.pathname))
window.addEventListener("popstate", onPopState)
/** Navigate to a view, syncing the URL. Accepts a value or an updater, like a signal setter. */
export function setView(next: View | ((prev: View) => View), opts?: { replace?: boolean }): void {
const v = typeof next === "function" ? next(view()) : next
const path = viewToPath(v)
if (path !== window.location.pathname) {
if (opts?.replace) { window.history.replaceState(null, "", path) }
else { window.history.pushState(null, "", path) }
}
setViewSignal(v)
}
export function openQuorum(id: string): void {
setView({ type: "quorum", id, tab: "log" })
}
export function setTab(tab: "log" | "members" | "chat"): void {
setView(v => (typeof v === "object" && v.type === "quorum") ? { ...v, tab } : v)
// Switching tabs replaces (rather than pushes) history, so Back leaves the quorum instead of
// cycling through every tab the user clicked.
export function setTab(tab: QuorumTab): void {
setView(v => (typeof v === "object" && v.type === "quorum") ? { ...v, tab } : v, { replace: true })
}
// Completed quorum records (carry the secret share) — populated when a DKG finishes.
@@ -73,6 +115,9 @@ export function setDelivery(inviteId: string, status: "sending" | "saved" | "fai
// they don't stack a duplicate on the surviving repository/session singletons each edit.
const track = trackForHmr(import.meta.hot)
// Drop the popstate listener (registered at module top) on hot reload.
track(() => window.removeEventListener("popstate", onPopState))
const [me, setMe] = createSignal("")
const [inviteEvents, setInviteEvents] = createSignal<TrustedEvent[]>([])
const [round1Events, setRound1Events] = createSignal<TrustedEvent[]>([])
@@ -282,16 +327,6 @@ export function membersAt(windows: MembershipWindow[], at: number): Set<string>
return active
}
// Land on the first quorum instead of the empty home view — e.g. once quora hydrate on
// load. Only fires while on "home", so it never overrides explicit navigation to a quorum
// or the account page. When there are no quora, "home" stays (and shows the hero/CTA).
trackEffect(track, () => {
const list = displayedQuora()
if (view() === "home" && list.length > 0) {
openQuorum(list[0].id)
}
})
// ── Inbox subscription ────────────────────────────────────────────────────────
// One active subscription, restarted when the active pubkey or the user's
// kind-10050 relay list changes. Subscribing to userMessagingRelayList makes it