Add url routing
This commit is contained in:
@@ -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
@@ -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,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
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user