Remove a lot of ceremony from frontend state management

This commit is contained in:
Jon Staab
2026-03-26 17:07:44 -07:00
parent a2f9ca9688
commit 6415bcd7b7
15 changed files with 188 additions and 415 deletions
-85
View File
@@ -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
View File
@@ -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>
)
}
+3 -14
View File
@@ -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 -2
View File
@@ -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 -1
View File
@@ -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"
+1 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 []
}
}
-159
View File
@@ -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()
},
}
}
+2 -6
View File
@@ -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 -3
View File
@@ -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">
+10 -4
View File
@@ -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">
+1 -2
View File
@@ -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
+3 -3
View File
@@ -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()}