Remove a lot of ceremony from frontend state management
This commit is contained in:
@@ -1,85 +0,0 @@
|
||||
Nostr integration layer for the frontend. It initializes shared Nostr primitives (event store, relay pool, account manager), wires signer transport, persists local account state, and exposes reactive helpers for auth/account/profile data.
|
||||
|
||||
## Constants
|
||||
|
||||
### `API_URL`
|
||||
|
||||
- Reads `VITE_API_URL` from environment.
|
||||
|
||||
### `PLATFORM_NAME`
|
||||
|
||||
- Reads `VITE_PLATFORM_NAME` from environment.
|
||||
- Falls back to `"Caravel"`.
|
||||
|
||||
### Storage keys
|
||||
|
||||
- `caravel.accounts`: serialized account list.
|
||||
- `caravel.activeAccount`: currently selected account id.
|
||||
|
||||
## Shared singletons
|
||||
|
||||
### `eventStore`
|
||||
|
||||
- Global `EventStore` used to cache and observe Nostr events (profiles, relay lists, etc).
|
||||
|
||||
### `pool`
|
||||
|
||||
- Global `RelayPool` used for relay connections, subscriptions, and publishing.
|
||||
|
||||
### `accounts`
|
||||
|
||||
- Global `AccountManager` that holds all known local accounts and active account state.
|
||||
|
||||
## Startup wiring
|
||||
|
||||
- Registers common account types with `registerCommonAccountTypes(accounts)`.
|
||||
- Configures event loading via `createEventLoaderForStore(eventStore, pool, ...)` with lookup and extra relay seeds.
|
||||
- Binds `NostrConnectSigner` network methods to the app relay pool:
|
||||
- `subscriptionMethod = pool.subscription.bind(pool)`
|
||||
- `publishMethod = pool.publish.bind(pool)`
|
||||
|
||||
## `function restoreAccounts()`
|
||||
|
||||
- Restores serialized accounts from localStorage.
|
||||
- Ignores corrupted local data safely (catch-and-continue).
|
||||
- Restores the active account id if it still exists in the restored account set.
|
||||
|
||||
## `function activateAccount(account: IAccount)`
|
||||
|
||||
- Adds the account to `accounts`.
|
||||
- Sets it as active.
|
||||
- Persists account state to localStorage via `persistAccounts()`.
|
||||
|
||||
## `function persistAccounts()`
|
||||
|
||||
- Serializes all accounts into `caravel.accounts`.
|
||||
- Persists active account id to `caravel.activeAccount`.
|
||||
- Removes `caravel.activeAccount` if no active account exists.
|
||||
|
||||
## `function useActiveAccount()`
|
||||
|
||||
- Solid helper that returns a reactive signal for current active account.
|
||||
- Subscribes to `accounts.active$` and cleans up the subscription automatically.
|
||||
|
||||
## `function useProfilePicture(pubkey: () => string | undefined)`
|
||||
|
||||
- Reactive helper to resolve a user profile picture URL from a pubkey.
|
||||
- If pubkey is missing, clears picture to `undefined`.
|
||||
- Subscribes to profile updates in `eventStore` and maps profile to a picture via `getProfilePicture`.
|
||||
- Calls `primeProfiles([pubkey])` to proactively fetch profile data.
|
||||
- Cleans up both profile and network subscriptions when dependencies change/unmount.
|
||||
|
||||
## `function primeProfiles(pubkeys: string[])`
|
||||
|
||||
- Preloads Nostr profile events (`kind: 0`) for a set of pubkeys.
|
||||
- Deduplicates and filters invalid pubkeys.
|
||||
- Early-returns a no-op unsubscribable when no pubkeys are provided.
|
||||
- Uses currently connected relays as seeds (`pool.relays.keys()`).
|
||||
- Optionally fetches relay list events (`kind: 10002`) from seed relays to improve mailbox/outbox routing.
|
||||
- Builds optimized relay routing by:
|
||||
- including mailbox pointers from event store,
|
||||
- applying fallback relays when needed,
|
||||
- selecting optimal relays with connection limits,
|
||||
- converting to an outbox map.
|
||||
- Opens an outbox subscription for profile events and stores incoming events in `eventStore` (ignoring `EOSE`).
|
||||
- Returns an object with `unsubscribe()` that tears down both profile and mailbox-seed subscriptions.
|
||||
+17
-29
@@ -14,11 +14,10 @@ import AdminTenantDetail from "./pages/admin/AdminTenantDetail"
|
||||
import AdminRelayList from "./pages/admin/AdminRelayList"
|
||||
import AdminRelayDetail from "./pages/admin/AdminRelayDetail"
|
||||
import AdminRelayEdit from "./pages/admin/AdminRelayEdit"
|
||||
import { useActiveAccount } from "./lib/nostr"
|
||||
import { account, identity } from "./lib/hooks"
|
||||
|
||||
function Layout(props: { children?: any }) {
|
||||
const location = useLocation()
|
||||
const account = useActiveAccount()
|
||||
|
||||
const usesAppShell = () => {
|
||||
const path = location.pathname
|
||||
@@ -41,50 +40,39 @@ function Layout(props: { children?: any }) {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const withTenantAuth = (Page: Component): Component => {
|
||||
const requireCondition = (Page: Component, condition: () => boolean): Component => {
|
||||
return () => {
|
||||
const navigate = useNavigate()
|
||||
const account = useActiveAccount()
|
||||
|
||||
createEffect(() => {
|
||||
if (!account()) navigate("/login", { replace: true })
|
||||
})
|
||||
|
||||
return <Show when={account()}><Page /></Show>
|
||||
}
|
||||
}
|
||||
|
||||
const withAdminAuth = (Page: Component): Component => {
|
||||
return () => {
|
||||
const navigate = useNavigate()
|
||||
const account = useActiveAccount()
|
||||
|
||||
createEffect(() => {
|
||||
if (!account()) navigate("/login", { replace: true })
|
||||
if (!condition()) navigate("/", { replace: true })
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={account()}>
|
||||
<Show when={condition()}>
|
||||
<Page />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const requireAdmin = (Page: Component) => requireCondition(Page, () => Boolean(account() && identity()?.is_admin))
|
||||
const requireTenant = (Page: Component) => requireCondition(Page, () => Boolean(account() && identity()?.is_tenant))
|
||||
|
||||
return (
|
||||
<Router root={Layout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/relays" component={withTenantAuth(RelayList)} />
|
||||
<Route path="/relays/new" component={withTenantAuth(RelayNew)} />
|
||||
<Route path="/relays/:id" component={withTenantAuth(RelayDetail)} />
|
||||
<Route path="/relays/:id/edit" component={withTenantAuth(RelayEdit)} />
|
||||
<Route path="/account" component={withTenantAuth(Account)} />
|
||||
<Route path="/admin/tenants" component={withAdminAuth(AdminTenantList)} />
|
||||
<Route path="/admin/tenants/:id" component={withAdminAuth(AdminTenantDetail)} />
|
||||
<Route path="/admin/relays" component={withAdminAuth(AdminRelayList)} />
|
||||
<Route path="/admin/relays/:id" component={withAdminAuth(AdminRelayDetail)} />
|
||||
<Route path="/admin/relays/:id/edit" component={withAdminAuth(AdminRelayEdit)} />
|
||||
<Route path="/relays" component={requireTenant(RelayList)} />
|
||||
<Route path="/relays/new" component={requireTenant(RelayNew)} />
|
||||
<Route path="/relays/:id" component={requireTenant(RelayDetail)} />
|
||||
<Route path="/relays/:id/edit" component={requireTenant(RelayEdit)} />
|
||||
<Route path="/account" component={requireTenant(Account)} />
|
||||
<Route path="/admin/tenants" component={requireAdmin(AdminTenantList)} />
|
||||
<Route path="/admin/tenants/:id" component={requireAdmin(AdminTenantDetail)} />
|
||||
<Route path="/admin/relays" component={requireAdmin(AdminRelayList)} />
|
||||
<Route path="/admin/relays/:id" component={requireAdmin(AdminRelayDetail)} />
|
||||
<Route path="/admin/relays/:id/edit" component={requireAdmin(AdminRelayEdit)} />
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { A, useLocation, useNavigate } from "@solidjs/router"
|
||||
import { A, useLocation } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
||||
import Fuse from "fuse.js"
|
||||
import { eventStore, primeProfiles, useActiveAccount, useProfilePicture } from "../lib/nostr"
|
||||
import { useIdentity, useTenantRelays, type Relay } from "../lib/hooks"
|
||||
import { account, eventStore, identity, primeProfiles, useProfilePicture, useTenantRelays, type Relay } from "../lib/hooks"
|
||||
import serverIcon from "../assets/server.svg"
|
||||
import Modal from "./Modal"
|
||||
|
||||
@@ -32,16 +31,12 @@ function RelayIcon() {
|
||||
|
||||
export default function AppShell(props: { children?: any }) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const account = useActiveAccount()
|
||||
const picture = useProfilePicture(() => account()?.pubkey)
|
||||
const [identity] = useIdentity(() => account()?.pubkey)
|
||||
const [tenantRelays] = useTenantRelays()
|
||||
const [profile, setProfile] = createSignal<Profile>({})
|
||||
const [searchOpen, setSearchOpen] = createSignal(false)
|
||||
const [searchQuery, setSearchQuery] = createSignal("")
|
||||
|
||||
const isAdmin = createMemo(() => !!identity()?.is_admin)
|
||||
const username = createMemo(() => profile().name || profile().display_name || shortenPubkey(account()?.pubkey))
|
||||
const nip05 = createMemo(() => profile().nip05 || "No NIP-05")
|
||||
const searchedRelays = createMemo<Relay[]>(() => {
|
||||
@@ -83,12 +78,6 @@ export default function AppShell(props: { children?: any }) {
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const currentIdentity = identity()
|
||||
if (!currentIdentity || currentIdentity.is_admin || currentIdentity.is_tenant || location.pathname === "/") return
|
||||
navigate("/", { replace: true })
|
||||
})
|
||||
|
||||
const myResources = [{ href: "/relays", label: "My Relays" }]
|
||||
const adminResources = [
|
||||
{ href: "/admin/tenants", label: "Tenants" },
|
||||
@@ -117,7 +106,7 @@ export default function AppShell(props: { children?: any }) {
|
||||
<For each={myResources}>{(item) => <li><A href={item.href} class={navItemClass(item.href)}>{item.label}</A></li>}</For>
|
||||
</ul>
|
||||
|
||||
<Show when={isAdmin()}>
|
||||
<Show when={identity()?.is_admin}>
|
||||
<div class="mt-8">
|
||||
<h2 class="px-3 text-xs font-semibold uppercase tracking-wider text-white/60">Administration</h2>
|
||||
<ul class="mt-2 space-y-1">
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Show } from "solid-js"
|
||||
import { A } from "@solidjs/router"
|
||||
import { PLATFORM_NAME, useActiveAccount, useProfilePicture } from "../lib/nostr"
|
||||
import { PLATFORM_NAME, useProfilePicture, account } from "../lib/hooks"
|
||||
|
||||
export default function Navbar() {
|
||||
const account = useActiveAccount()
|
||||
const picture = useProfilePicture(() => account()?.pubkey)
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { A } from "@solidjs/router"
|
||||
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import type { Relay } from "../lib/hooks"
|
||||
import type { Relay } from "../lib/api"
|
||||
import menuDotsIcon from "../assets/menu-dots-2.svg"
|
||||
import Modal from "./Modal"
|
||||
import PricingTable from "./PricingTable"
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
import { render } from "solid-js/web"
|
||||
import "./index.css"
|
||||
import App from "./App"
|
||||
import { PLATFORM_NAME, restoreAccounts } from "./lib/nostr"
|
||||
import { PLATFORM_NAME } from "./lib/hooks"
|
||||
|
||||
const root = document.getElementById("root")!
|
||||
|
||||
document.title = PLATFORM_NAME
|
||||
|
||||
import("preline").then(() => {
|
||||
restoreAccounts()
|
||||
render(() => <App />, root)
|
||||
})
|
||||
|
||||
+10
-11
@@ -1,4 +1,6 @@
|
||||
import { API_URL, getActivePubkey, getActiveSigner } from "./nostr"
|
||||
import { account } from "./hooks"
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL
|
||||
|
||||
type ApiOk<T> = {
|
||||
data: T
|
||||
@@ -108,19 +110,16 @@ export type UpdateRelayInput = {
|
||||
push_enabled?: number
|
||||
}
|
||||
|
||||
export async function makeAuth<T = unknown>(): Promise<string | undefined> {
|
||||
void (undefined as T | undefined)
|
||||
|
||||
const pubkey = getActivePubkey()
|
||||
const signer = getActiveSigner()
|
||||
if (!pubkey || !signer) return undefined
|
||||
export async function makeAuth(): Promise<string | undefined> {
|
||||
const current = account()
|
||||
if (!current) return undefined
|
||||
|
||||
const now = Date.now()
|
||||
if (authCache && authCache.pubkey === pubkey && authCache.expiresAt > now) {
|
||||
if (authCache && authCache.pubkey === current.pubkey && authCache.expiresAt > now) {
|
||||
return authCache.value
|
||||
}
|
||||
|
||||
const event = await signer.signEvent({
|
||||
const event = await current.signer.signEvent({
|
||||
kind: 27235,
|
||||
content: "",
|
||||
created_at: Math.floor(now / 1000),
|
||||
@@ -129,7 +128,7 @@ export async function makeAuth<T = unknown>(): Promise<string | undefined> {
|
||||
|
||||
const value = `Nostr ${btoa(JSON.stringify(event))}`
|
||||
authCache = {
|
||||
pubkey,
|
||||
pubkey: current.pubkey,
|
||||
value,
|
||||
expiresAt: now + 10 * 60 * 1000,
|
||||
}
|
||||
@@ -141,7 +140,7 @@ export async function callApi<TRequest = unknown, TResponse = unknown>(
|
||||
path: string,
|
||||
body?: TRequest,
|
||||
): Promise<TResponse> {
|
||||
const auth = await makeAuth<TRequest>()
|
||||
const auth = await makeAuth()
|
||||
const url = new URL(path, API_URL).toString()
|
||||
|
||||
const response = await fetch(url, {
|
||||
|
||||
+130
-87
@@ -1,5 +1,16 @@
|
||||
import { createResource } from "solid-js"
|
||||
import { createEffect, createResource, createSignal, onCleanup } from "solid-js"
|
||||
import { AccountManager } from "applesauce-accounts"
|
||||
import type { IAccount } from "applesauce-accounts"
|
||||
import { registerCommonAccountTypes } from "applesauce-accounts/accounts"
|
||||
import { EventStore } from "applesauce-core"
|
||||
import { getProfilePicture } from "applesauce-core/helpers/profile"
|
||||
import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection"
|
||||
import { includeMailboxes } from "applesauce-core/observable"
|
||||
import { createEventLoaderForStore } from "applesauce-loaders/loaders"
|
||||
import { Relay as NostrRelay, RelayManagement } from "applesauce-relay"
|
||||
import { RelayPool } from "applesauce-relay"
|
||||
import { NostrConnectSigner } from "applesauce-signers"
|
||||
import { map, of } from "rxjs"
|
||||
import {
|
||||
createRelay,
|
||||
deactivateRelay,
|
||||
@@ -13,130 +24,162 @@ import {
|
||||
updateRelay,
|
||||
updateTenantBilling,
|
||||
type CreateRelayInput,
|
||||
type Identity,
|
||||
type Relay,
|
||||
type Tenant,
|
||||
type UpdateRelayInput,
|
||||
} from "./api"
|
||||
import { getActivePubkey, getActiveSigner } from "./nostr"
|
||||
|
||||
type IdentityCache = {
|
||||
export type UnsignedEvent = {
|
||||
kind: number
|
||||
content: string
|
||||
created_at: number
|
||||
tags: string[][]
|
||||
}
|
||||
|
||||
export type SignedEvent = UnsignedEvent & {
|
||||
id: string
|
||||
pubkey: string
|
||||
value: Identity
|
||||
sig: string
|
||||
}
|
||||
|
||||
let identityCache: IdentityCache | undefined
|
||||
let identityInflight: Promise<Identity> | undefined
|
||||
let identityInflightPubkey: string | undefined
|
||||
|
||||
function requireActivePubkey() {
|
||||
const pubkey = getActivePubkey()
|
||||
if (!pubkey) throw new Error("Not logged in")
|
||||
return pubkey
|
||||
export type EventSigner = {
|
||||
signEvent(event: UnsignedEvent): Promise<SignedEvent>
|
||||
}
|
||||
|
||||
async function loadIdentity(pubkey?: string) {
|
||||
if (!pubkey) throw new Error("Not logged in")
|
||||
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME || "Caravel"
|
||||
|
||||
if (identityCache?.pubkey === pubkey) {
|
||||
return identityCache.value
|
||||
}
|
||||
export const eventStore = new EventStore()
|
||||
export const pool = new RelayPool()
|
||||
|
||||
if (identityInflight && identityInflightPubkey === pubkey) {
|
||||
return identityInflight
|
||||
}
|
||||
createEventLoaderForStore(eventStore, pool, {
|
||||
lookupRelays: ["wss://purplepag.es/", "wss://relay.damus.io/", "wss://indexer.coracle.social/"],
|
||||
extraRelays: ["wss://relay.damus.io/", "wss://nos.lol/", "wss://relay.primal.net/"],
|
||||
})
|
||||
|
||||
const request = getIdentity().then((identity) => {
|
||||
identityCache = { pubkey, value: identity }
|
||||
return identity
|
||||
}).finally(() => {
|
||||
if (identityInflightPubkey === pubkey) {
|
||||
identityInflight = undefined
|
||||
identityInflightPubkey = undefined
|
||||
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
||||
NostrConnectSigner.publishMethod = pool.publish.bind(pool)
|
||||
|
||||
export const accountManager = new AccountManager()
|
||||
|
||||
registerCommonAccountTypes(accountManager)
|
||||
|
||||
export const [account, setAccount] = createSignal<IAccount | undefined>()
|
||||
|
||||
export const [identity, setIdentity] = createSignal<Identity | undefined>()
|
||||
|
||||
;(() => {
|
||||
accountManager.active$.subscribe(account => {
|
||||
setAccount(account)
|
||||
|
||||
localStorage.setItem("caravel.accounts", JSON.stringify(accountManager.toJSON(true)))
|
||||
|
||||
if (account) {
|
||||
localStorage.setItem("caravel.accounts.active", account.id)
|
||||
getIdentity().then(setIdentity)
|
||||
} else {
|
||||
localStorage.removeItem("caravel.accounts.active")
|
||||
setIdentity(undefined)
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
||||
identityInflight = request
|
||||
identityInflightPubkey = pubkey
|
||||
return request
|
||||
}
|
||||
export function useProfilePicture(pubkey: () => string | undefined) {
|
||||
const [picture, setPicture] = createSignal<string | undefined>()
|
||||
|
||||
export function useIdentity(source: () => string | undefined) {
|
||||
return createResource(source, loadIdentity)
|
||||
}
|
||||
createEffect(() => {
|
||||
const pk = pubkey()
|
||||
|
||||
export function useTenant() {
|
||||
return createResource(async () => {
|
||||
const pubkey = requireActivePubkey()
|
||||
return getTenant(pubkey)
|
||||
if (!pk) {
|
||||
setPicture(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const profileSub = eventStore.profile(pk).subscribe((profile) => {
|
||||
setPicture(getProfilePicture(profile))
|
||||
})
|
||||
|
||||
const reqSub = primeProfiles([pk])
|
||||
|
||||
onCleanup(() => {
|
||||
profileSub.unsubscribe()
|
||||
reqSub.unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
return picture
|
||||
}
|
||||
|
||||
export function useTenantRelays() {
|
||||
return createResource(async () => {
|
||||
const pubkey = requireActivePubkey()
|
||||
return listTenantRelays(pubkey)
|
||||
export function primeProfiles(pubkeys: string[]) {
|
||||
const uniquePubkeys = Array.from(new Set(pubkeys.filter(Boolean)))
|
||||
if (uniquePubkeys.length === 0) {
|
||||
return { unsubscribe() {} }
|
||||
}
|
||||
|
||||
const seedRelays = Array.from(pool.relays.keys())
|
||||
const mailboxSeedSub = seedRelays.length
|
||||
? pool.request(seedRelays, { kinds: [10002], authors: uniquePubkeys }).subscribe((event) => {
|
||||
eventStore.add(event)
|
||||
})
|
||||
: undefined
|
||||
|
||||
const outboxMap$ = of(uniquePubkeys.map((pubkey) => ({ pubkey }))).pipe(
|
||||
includeMailboxes(eventStore),
|
||||
map((pointers) => (seedRelays.length > 0 ? setFallbackRelays(pointers, seedRelays) : pointers)),
|
||||
map((pointers) => selectOptimalRelays(pointers, { maxConnections: 8, maxRelaysPerUser: 3 })),
|
||||
map(createOutboxMap),
|
||||
)
|
||||
|
||||
const profileSub = pool.outboxSubscription(outboxMap$, { kinds: [0] }).subscribe((message) => {
|
||||
if (message !== "EOSE") eventStore.add(message)
|
||||
})
|
||||
|
||||
return {
|
||||
unsubscribe() {
|
||||
profileSub.unsubscribe()
|
||||
mailboxSeedSub?.unsubscribe()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function useTenantInvoices() {
|
||||
return createResource(async () => {
|
||||
const pubkey = requireActivePubkey()
|
||||
return listTenantInvoices(pubkey)
|
||||
})
|
||||
type Identity = {
|
||||
pubkey: string
|
||||
is_admin: boolean
|
||||
is_tenant: boolean
|
||||
}
|
||||
|
||||
export function useRelay(relayId: () => string) {
|
||||
return createResource(relayId, getRelay)
|
||||
}
|
||||
export const useTenant = () => createResource(() => getTenant(account()!.pubkey))
|
||||
|
||||
export function useAdminTenants() {
|
||||
return createResource(listTenants)
|
||||
}
|
||||
export const useTenantRelays = () => createResource(() => listTenantRelays(account()!.pubkey))
|
||||
|
||||
export function useAdminRelays() {
|
||||
return createResource(listRelays)
|
||||
}
|
||||
export const useTenantInvoices = () => createResource(() => listTenantInvoices(account()!.pubkey))
|
||||
|
||||
export function useAdminTenantDetail(pubkey: () => string) {
|
||||
return createResource(pubkey, async (id) => {
|
||||
const [tenant, relays] = await Promise.all([getTenant(id), listTenantRelays(id)])
|
||||
return { tenant, relays }
|
||||
})
|
||||
}
|
||||
export const useRelay = (relayId: () => string) => createResource(relayId, getRelay)
|
||||
|
||||
export async function createRelayForActiveTenant(input: CreateRelayInput) {
|
||||
const pubkey = requireActivePubkey()
|
||||
return createRelay({ ...input, tenant: pubkey })
|
||||
}
|
||||
export const useAdminTenants = () => createResource(listTenants)
|
||||
|
||||
export function updateActiveTenantBilling(nwc_url: string) {
|
||||
const pubkey = requireActivePubkey()
|
||||
return updateTenantBilling(pubkey, { nwc_url })
|
||||
}
|
||||
export const useAdminRelays = () => createResource(listRelays)
|
||||
|
||||
export function updateRelayById(id: string, input: UpdateRelayInput) {
|
||||
return updateRelay(id, input)
|
||||
}
|
||||
export const useAdminTenant = (pubkey: () => string) => createResource(pubkey, getTenant)
|
||||
|
||||
export function updateRelayPlanById(id: string, plan: string) {
|
||||
return updateRelay(id, { plan })
|
||||
}
|
||||
export const useAdminTenantRelays = (pubkey: () => string) => createResource(pubkey, listTenantRelays)
|
||||
|
||||
export function deactivateRelayById(id: string) {
|
||||
return deactivateRelay(id)
|
||||
}
|
||||
export const createRelayForActiveTenant = (input: CreateRelayInput) => createRelay({ ...input, tenant: account()!.pubkey })
|
||||
|
||||
export async function getRelayMemberCount(relayUrl: string): Promise<number | undefined> {
|
||||
const signer = getActiveSigner()
|
||||
if (!signer) throw new Error("Not logged in")
|
||||
export const updateActiveTenantBilling = (nwc_url: string) => updateTenantBilling(account()!.pubkey, { nwc_url })
|
||||
|
||||
export const updateRelayById = (id: string, input: UpdateRelayInput) => updateRelay(id, input)
|
||||
|
||||
export const updateRelayPlanById = (id: string, plan: string) => updateRelay(id, { plan })
|
||||
|
||||
export const deactivateRelayById = (id: string) => deactivateRelay(id)
|
||||
|
||||
export async function getRelayMembers(url: string) {
|
||||
const management = new RelayManagement(new NostrRelay(url), account()!.signer)
|
||||
|
||||
const management = new RelayManagement(new NostrRelay(relayUrl), signer)
|
||||
try {
|
||||
const members = await management.listAllowedPubkeys()
|
||||
return members.length
|
||||
return await management.listAllowedPubkeys()
|
||||
} catch {
|
||||
return undefined
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { EventStore } from "applesauce-core"
|
||||
import { getProfilePicture } from "applesauce-core/helpers/profile"
|
||||
import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection"
|
||||
import { includeMailboxes } from "applesauce-core/observable"
|
||||
import { RelayPool } from "applesauce-relay"
|
||||
import { AccountManager } from "applesauce-accounts"
|
||||
import type { IAccount, SerializedAccount } from "applesauce-accounts"
|
||||
import { registerCommonAccountTypes } from "applesauce-accounts/accounts"
|
||||
import { createEventLoaderForStore } from "applesauce-loaders/loaders"
|
||||
import { NostrConnectSigner } from "applesauce-signers"
|
||||
import { map, of } from "rxjs"
|
||||
|
||||
type NostrTag = string[]
|
||||
|
||||
export type UnsignedEvent = {
|
||||
kind: number
|
||||
content: string
|
||||
created_at: number
|
||||
tags: NostrTag[]
|
||||
}
|
||||
|
||||
export type SignedEvent = UnsignedEvent & {
|
||||
id: string
|
||||
pubkey: string
|
||||
sig: string
|
||||
}
|
||||
|
||||
export type EventSigner = {
|
||||
signEvent(event: UnsignedEvent): Promise<SignedEvent>
|
||||
}
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL
|
||||
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME || "Caravel"
|
||||
|
||||
export const eventStore = new EventStore()
|
||||
export const pool = new RelayPool()
|
||||
export const accounts = new AccountManager()
|
||||
|
||||
createEventLoaderForStore(eventStore, pool, {
|
||||
lookupRelays: ["wss://purplepag.es/", "wss://relay.damus.io/", "wss://indexer.coracle.social/"],
|
||||
extraRelays: ["wss://relay.damus.io/", "wss://nos.lol/", "wss://relay.primal.net/"],
|
||||
})
|
||||
|
||||
const ACCOUNTS_STORAGE_KEY = "caravel.accounts"
|
||||
const ACTIVE_ACCOUNT_STORAGE_KEY = "caravel.activeAccount"
|
||||
|
||||
registerCommonAccountTypes(accounts)
|
||||
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
||||
NostrConnectSigner.publishMethod = pool.publish.bind(pool)
|
||||
|
||||
export function restoreAccounts() {
|
||||
const raw = localStorage.getItem(ACCOUNTS_STORAGE_KEY)
|
||||
if (raw) {
|
||||
try {
|
||||
const saved = JSON.parse(raw) as SerializedAccount[]
|
||||
accounts.fromJSON(saved, true)
|
||||
} catch {
|
||||
// ignore corrupted local state
|
||||
}
|
||||
}
|
||||
|
||||
const activeId = localStorage.getItem(ACTIVE_ACCOUNT_STORAGE_KEY)
|
||||
if (activeId && accounts.getAccount(activeId)) {
|
||||
accounts.setActive(activeId)
|
||||
}
|
||||
}
|
||||
|
||||
export function activateAccount(account: IAccount) {
|
||||
accounts.addAccount(account)
|
||||
accounts.setActive(account)
|
||||
persistAccounts()
|
||||
}
|
||||
|
||||
export function persistAccounts() {
|
||||
localStorage.setItem(ACCOUNTS_STORAGE_KEY, JSON.stringify(accounts.toJSON(true)))
|
||||
const active = accounts.getActive()
|
||||
if (active) {
|
||||
localStorage.setItem(ACTIVE_ACCOUNT_STORAGE_KEY, active.id)
|
||||
} else {
|
||||
localStorage.removeItem(ACTIVE_ACCOUNT_STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
export function useActiveAccount() {
|
||||
const [account, setAccount] = createSignal(accounts.active)
|
||||
const sub = accounts.active$.subscribe(setAccount)
|
||||
onCleanup(() => sub.unsubscribe())
|
||||
return account
|
||||
}
|
||||
|
||||
export function getActiveSigner(): EventSigner | undefined {
|
||||
const account = accounts.getActive() as { signer?: EventSigner } | undefined
|
||||
return account?.signer
|
||||
}
|
||||
|
||||
export function getActivePubkey(): string | undefined {
|
||||
const account = accounts.getActive() as { pubkey?: string } | undefined
|
||||
return account?.pubkey
|
||||
}
|
||||
|
||||
export function useProfilePicture(pubkey: () => string | undefined) {
|
||||
const [picture, setPicture] = createSignal<string | undefined>()
|
||||
|
||||
createEffect(() => {
|
||||
const pk = pubkey()
|
||||
|
||||
if (!pk) {
|
||||
setPicture(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
// Subscribe to profile changes in the event store
|
||||
const profileSub = eventStore.profile(pk).subscribe(profile => {
|
||||
setPicture(getProfilePicture(profile))
|
||||
})
|
||||
|
||||
const reqSub = primeProfiles([pk])
|
||||
|
||||
onCleanup(() => {
|
||||
profileSub.unsubscribe()
|
||||
reqSub.unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
return picture
|
||||
}
|
||||
|
||||
export function primeProfiles(pubkeys: string[]) {
|
||||
const uniquePubkeys = Array.from(new Set(pubkeys.filter(Boolean)))
|
||||
if (uniquePubkeys.length === 0) {
|
||||
return { unsubscribe() {} }
|
||||
}
|
||||
|
||||
const seedRelays = Array.from(pool.relays.keys())
|
||||
const mailboxSeedSub = seedRelays.length
|
||||
? pool.request(seedRelays, { kinds: [10002], authors: uniquePubkeys }).subscribe((event) => {
|
||||
eventStore.add(event)
|
||||
})
|
||||
: undefined
|
||||
|
||||
const outboxMap$ = of(uniquePubkeys.map((pubkey) => ({ pubkey }))).pipe(
|
||||
includeMailboxes(eventStore),
|
||||
map((pointers) => (seedRelays.length > 0 ? setFallbackRelays(pointers, seedRelays) : pointers)),
|
||||
map((pointers) => selectOptimalRelays(pointers, { maxConnections: 8, maxRelaysPerUser: 3 })),
|
||||
map(createOutboxMap),
|
||||
)
|
||||
|
||||
const profileSub = pool.outboxSubscription(outboxMap$, { kinds: [0] }).subscribe((message) => {
|
||||
if (message !== "EOSE") eventStore.add(message)
|
||||
})
|
||||
|
||||
return {
|
||||
unsubscribe() {
|
||||
profileSub.unsubscribe()
|
||||
mailboxSeedSub?.unsubscribe()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import PageContainer from "../components/PageContainer"
|
||||
import LoadingState from "../components/LoadingState"
|
||||
import useMinLoading from "../components/useMinLoading"
|
||||
import { accounts, persistAccounts } from "../lib/nostr"
|
||||
import { updateActiveTenantBilling, useTenant, useTenantInvoices } from "../lib/hooks"
|
||||
|
||||
export default function Account() {
|
||||
const navigate = useNavigate()
|
||||
const [tenant, { refetch: refetchTenant }] = useTenant()
|
||||
const [invoices] = useTenantInvoices()
|
||||
const [nwcUrl, setNwcUrl] = createSignal("")
|
||||
@@ -40,9 +37,8 @@ export default function Account() {
|
||||
}
|
||||
|
||||
function logout() {
|
||||
accounts.clearActive()
|
||||
persistAccounts()
|
||||
navigate("/")
|
||||
localStorage.clear()
|
||||
window.location.href = "/"
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { A } from "@solidjs/router"
|
||||
import PricingTable from "../components/PricingTable"
|
||||
import { useActiveAccount } from "../lib/nostr"
|
||||
import { account } from "../lib/hooks"
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
@@ -21,8 +21,6 @@ function ExternalLinkIcon() {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const account = useActiveAccount()
|
||||
|
||||
return (
|
||||
<div class="min-h-screen bg-white text-gray-900 overflow-x-hidden">
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import { ExtensionAccount, NostrConnectAccount, PasswordAccount, PrivateKeyAccou
|
||||
import { PasswordSigner } from "applesauce-signers"
|
||||
import QrScanner from "qr-scanner"
|
||||
import QRCode from "qrcode"
|
||||
import { activateAccount } from "../lib/nostr"
|
||||
import { PLATFORM_NAME } from "../lib/nostr"
|
||||
import { accountManager, identity, PLATFORM_NAME } from "../lib/hooks"
|
||||
|
||||
const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc']
|
||||
|
||||
@@ -58,8 +57,15 @@ export default function Login() {
|
||||
let abortController: AbortController | undefined
|
||||
|
||||
async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) {
|
||||
activateAccount(account)
|
||||
navigate("/relays")
|
||||
accountManager.addAccount(account)
|
||||
accountManager.setActive(account)
|
||||
|
||||
const dispose = createEffect(() => {
|
||||
if (identity()) {
|
||||
navigate("/relays")
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function loginWithNip07() {
|
||||
|
||||
@@ -4,13 +4,14 @@ import BackLink from "../../components/BackLink"
|
||||
import PageContainer from "../../components/PageContainer"
|
||||
import ResourceState from "../../components/ResourceState"
|
||||
import useMinLoading from "../../components/useMinLoading"
|
||||
import { useAdminTenantDetail } from "../../lib/hooks"
|
||||
import { useAdminTenant, useAdminTenantRelays } from "../../lib/hooks"
|
||||
|
||||
export default function AdminTenantDetail() {
|
||||
const params = useParams()
|
||||
const tenantId = () => params.id ?? ""
|
||||
const [detail] = useAdminTenantDetail(tenantId)
|
||||
const loading = useMinLoading(() => detail.loading)
|
||||
const [tenant] = useAdminTenant(tenantId)
|
||||
const [relays] = useAdminTenantRelays(tenantId)
|
||||
const loading = useMinLoading(() => tenant.loading || relays.loading)
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -19,7 +20,7 @@ export default function AdminTenantDetail() {
|
||||
|
||||
<ResourceState
|
||||
loading={loading()}
|
||||
error={detail.error}
|
||||
error={tenant.error || relays.error}
|
||||
loadingText="Loading tenant..."
|
||||
errorText="Failed to load tenant."
|
||||
class="mb-4"
|
||||
@@ -29,7 +30,7 @@ export default function AdminTenantDetail() {
|
||||
<div class="space-y-6">
|
||||
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Status</h2>
|
||||
<Show when={detail()}>
|
||||
<Show when={tenant()}>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-gray-700">
|
||||
Current: <span class="font-medium uppercase tracking-wide">tenant</span>
|
||||
@@ -40,9 +41,9 @@ export default function AdminTenantDetail() {
|
||||
|
||||
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Relays</h2>
|
||||
<Show when={(detail()?.relays.length ?? 0) > 0} fallback={<p class="text-gray-500">No relays.</p>}>
|
||||
<Show when={(relays()?.length ?? 0) > 0} fallback={<p class="text-gray-500">No relays.</p>}>
|
||||
<ul class="space-y-3">
|
||||
<For each={detail()?.relays ?? []}>
|
||||
<For each={relays() ?? []}>
|
||||
{(relay) => (
|
||||
<li>
|
||||
<A href={`/admin/relays/${relay.id}`} class="block rounded-lg border border-gray-200 p-3 hover:border-gray-300">
|
||||
|
||||
@@ -2,11 +2,10 @@ import { A } from "@solidjs/router"
|
||||
import Fuse from "fuse.js"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
||||
import { getProfilePicture } from "applesauce-core/helpers/profile"
|
||||
import { eventStore, primeProfiles } from "../../lib/nostr"
|
||||
import PageContainer from "../../components/PageContainer"
|
||||
import ResourceState from "../../components/ResourceState"
|
||||
import useMinLoading from "../../components/useMinLoading"
|
||||
import { useAdminTenants } from "../../lib/hooks"
|
||||
import { eventStore, primeProfiles, useAdminTenants } from "../../lib/hooks"
|
||||
|
||||
function shortenPubkey(pubkey: string) {
|
||||
if (pubkey.length <= 16) return pubkey
|
||||
|
||||
@@ -6,7 +6,7 @@ import PageContainer from "../../components/PageContainer"
|
||||
import RelayDetailCard from "../../components/RelayDetailCard"
|
||||
import ResourceState from "../../components/ResourceState"
|
||||
import useMinLoading from "../../components/useMinLoading"
|
||||
import { deactivateRelayById, getRelayMemberCount, updateRelayById, updateRelayPlanById, useRelay, type Relay } from "../../lib/hooks"
|
||||
import { deactivateRelayById, getRelayMembers, updateRelayById, updateRelayPlanById, useRelay, type Relay } from "../../lib/hooks"
|
||||
|
||||
export default function RelayDetail() {
|
||||
const params = useParams()
|
||||
@@ -16,7 +16,7 @@ export default function RelayDetail() {
|
||||
const subdomain = relay()?.subdomain
|
||||
return subdomain ? `wss://${subdomain}.spaces.coracle.social` : undefined
|
||||
})
|
||||
const [memberCount] = createResource(relayUrl, async (url) => getRelayMemberCount(url))
|
||||
const [members] = createResource(relayUrl, getRelayMembers)
|
||||
const [busy, setBusy] = createSignal(false)
|
||||
const [error, setError] = createSignal("")
|
||||
const loading = useMinLoading(() => relay.loading && !relay())
|
||||
@@ -174,7 +174,7 @@ export default function RelayDetail() {
|
||||
<div class="mb-6">
|
||||
<RelayDetailCard
|
||||
relay={r()}
|
||||
currentMembers={memberCount()}
|
||||
currentMembers={members.length}
|
||||
editHref={`/relays/${params.id}/edit`}
|
||||
onDeactivate={handleDeactivate}
|
||||
deactivating={busy()}
|
||||
|
||||
Reference in New Issue
Block a user