Share hosting plans, relays and billing through a per-app Hosting plugin and route hosting errors through one helper
This commit is contained in:
parent
8a8ca65d5d
commit
8f9ff3a366
14 changed files with 401 additions and 520 deletions
|
|
@ -121,6 +121,7 @@ they cache is itself a rebinding store. `commandsByUrl` holds `fromApp` stores.
|
|||
| `SpaceManagement` (`management.ts`) | none | NIP-86 supported methods (5-minute TTL), admins and bans per URL |
|
||||
| `Chats` (`chats.ts`) | none | DM conversations by chat id (`index`, `one`), and a fuzzy `search` |
|
||||
| `LatestEvents` (`repository.ts`) | none | the most recent event held from an author (`forPubkey`) |
|
||||
| `Hosting` (`hosting.ts`) | none | the hosting backend's plans, the tenant's relays and billing, shared by every hosting view |
|
||||
|
||||
Each is exposed with `usePlugin`. `Statuses` is the minimal shape:
|
||||
|
||||
|
|
|
|||
|
|
@ -414,8 +414,8 @@ return a `Command` as well: `$deletes.deleteEvent(event, w => w.setProtected(pro
|
|||
`.publish()` in `RoomForm`. NIP-86 calls resolve to `{error}` instead of a thunk:
|
||||
`$relayManagement.forUrl(url).createRole(...)` in `RoleCreate`.
|
||||
|
||||
The hosting backend isn't nostr. Toast a readable message, and `console.error` anything that
|
||||
isn't a `HostingError`, as `HomeHosting` and `hosting/CustomDomainModal` do. Publishes are
|
||||
The hosting backend isn't nostr. Toast `hostingErrorMessage(e, fallback)` from `hosting.ts`,
|
||||
which returns a `HostingError`'s own message and logs anything else before falling back. Publishes are
|
||||
optimistic, so rows show progress in place: `ContentActions`, `CommentActions` and `ArticleActionBar`
|
||||
wrap their contents in `ThunkStatusOrDeleted`, and chat pushes a `ThunkToast`.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import Add from "@assets/icons/add.svg?dataurl"
|
||||
import Server from "@assets/icons/server.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
|
@ -9,30 +8,17 @@
|
|||
import HomeSection from "@app/components/HomeSection.svelte"
|
||||
import RelayListItem from "@app/components/hosting/RelayListItem.svelte"
|
||||
import RelayCreate from "@app/components/hosting/RelayCreate.svelte"
|
||||
import {user} from "@app/core"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {HostingError, listTenantRelays, type HostedRelay} from "@app/hosting"
|
||||
import {hosting} from "@app/hosting"
|
||||
import {PLATFORM_NAME} from "@app/env"
|
||||
|
||||
const {class: className = ""}: {class?: string} = $props()
|
||||
|
||||
let relays = $state<HostedRelay[]>([])
|
||||
let loading = $state(true)
|
||||
const tenantRelays = $hosting.relays
|
||||
|
||||
const relays = $derived($tenantRelays.relays)
|
||||
|
||||
const openCreate = () => pushModal(RelayCreate)
|
||||
|
||||
// A pubkey with no tenant answers with a HostingError.
|
||||
onMount(async () => {
|
||||
try {
|
||||
relays = await listTenantRelays($user.pubkey)
|
||||
} catch (e) {
|
||||
if (!(e instanceof HostingError)) {
|
||||
console.error(e)
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<HomeSection title="Hosting" icon={Server} class={className}>
|
||||
|
|
@ -41,7 +27,7 @@
|
|||
<Link href="/settings/hosting" class="button button-neutral button-xs">Manage</Link>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#if loading}
|
||||
{#if $tenantRelays.loading}
|
||||
<div class="flex justify-center px-4 pb-8">
|
||||
<Spinner>Checking your hosted spaces…</Spinner>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,40 +8,31 @@
|
|||
import Close from "@assets/icons/close.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import {errorMessage} from "@lib/util"
|
||||
import PaymentDialog from "@app/components/hosting/PaymentDialog.svelte"
|
||||
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {autopayConfigured, getInvoice, type Invoice, type Tenant} from "@app/hosting"
|
||||
import {
|
||||
autopayConfigured,
|
||||
getInvoice,
|
||||
hostingErrorMessage,
|
||||
type Invoice,
|
||||
type Tenant,
|
||||
} from "@app/hosting"
|
||||
|
||||
type Props = {
|
||||
tenant: Tenant | undefined
|
||||
openInvoice: Invoice | undefined
|
||||
hasPaidSubscription: boolean
|
||||
suppressInline?: boolean
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
const {
|
||||
tenant,
|
||||
openInvoice,
|
||||
hasPaidSubscription,
|
||||
suppressInline = false,
|
||||
onRefresh,
|
||||
}: Props = $props()
|
||||
const {tenant, openInvoice, hasPaidSubscription}: Props = $props()
|
||||
|
||||
let dismissedAutopay = $state(false)
|
||||
|
||||
const openPayInvoice = (invoice: Invoice) =>
|
||||
pushModal(PaymentDialog, {invoice, onPaid: () => onRefresh?.()})
|
||||
const openPayInvoice = (invoice: Invoice) => pushModal(PaymentDialog, {invoice})
|
||||
|
||||
const openSetup = (initialTab: "nwc" | "card") =>
|
||||
pushModal(PaymentSetup, {
|
||||
initialTab,
|
||||
nwcConnected: Boolean(tenant?.nwc_is_set),
|
||||
onSaved: () => onRefresh?.(),
|
||||
})
|
||||
const openSetup = (initialTab: "nwc" | "card") => pushModal(PaymentSetup, {initialTab})
|
||||
|
||||
const setUpNwc = () => openSetup("nwc")
|
||||
|
||||
|
|
@ -69,7 +60,9 @@
|
|||
|
||||
getInvoice(id)
|
||||
.then(openPayInvoice)
|
||||
.catch(e => pushToast({theme: "error", message: errorMessage(e) || "Failed to load invoice"}))
|
||||
.catch(e =>
|
||||
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to load invoice")}),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -136,7 +129,7 @@
|
|||
"Your account is past due and some relays are paused. Pay your balance or update your payment method to restore service.",
|
||||
churnedActions,
|
||||
)}
|
||||
{:else if openInvoice && !suppressInline}
|
||||
{:else if openInvoice}
|
||||
{@render card(
|
||||
"card-secondary",
|
||||
DangerCircle,
|
||||
|
|
@ -157,7 +150,7 @@
|
|||
"Your card couldn't be charged. Update your payment method.",
|
||||
updateMethodActions,
|
||||
)}
|
||||
{:else if hasPaidSubscription && !autopayConfigured(tenant) && !suppressInline && !dismissedAutopay}
|
||||
{:else if hasPaidSubscription && !autopayConfigured(tenant) && !dismissedAutopay}
|
||||
{@render card(
|
||||
"card-primary",
|
||||
InfoCircle,
|
||||
|
|
|
|||
|
|
@ -20,17 +20,17 @@
|
|||
import {
|
||||
canonicalRelayHost,
|
||||
flagToBool,
|
||||
followHostedRelay,
|
||||
hostingErrorMessage,
|
||||
updateRelay,
|
||||
HostingError,
|
||||
type HostedRelay,
|
||||
} from "@app/hosting"
|
||||
|
||||
type Props = {
|
||||
relay: HostedRelay
|
||||
onUpdate?: (relay: HostedRelay) => void
|
||||
}
|
||||
|
||||
const {relay, onUpdate}: Props = $props()
|
||||
const {relay}: Props = $props()
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
|
|
@ -43,21 +43,15 @@
|
|||
loading = true
|
||||
|
||||
try {
|
||||
const updated = await updateRelay(current.id, {custom_domain: value})
|
||||
const updated = await updateRelay(relay.id, {custom_domain: value})
|
||||
|
||||
current = updated
|
||||
domain = updated.custom_domain ?? ""
|
||||
onUpdate?.(updated)
|
||||
pushToast({message: value ? "Custom domain saved." : "Custom domain removed."})
|
||||
back()
|
||||
|
||||
// Removing a verified domain moves the relay back to its subdomain.
|
||||
await followHostedRelay(relay, updated)
|
||||
} catch (e) {
|
||||
const isHostingError = e instanceof HostingError
|
||||
|
||||
pushToast({theme: "error", message: isHostingError ? e.message : "Failed to save domain."})
|
||||
|
||||
if (!isHostingError) {
|
||||
console.error(e)
|
||||
}
|
||||
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to save domain.")})
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
|
|
@ -66,8 +60,6 @@
|
|||
|
||||
const onSubmit = () => submit(domain.trim())
|
||||
|
||||
// Local copy so the verified state updates in place after save/verify.
|
||||
let current = $state(relay)
|
||||
let domain = $state(relay.custom_domain ?? "")
|
||||
let loading = $state(false)
|
||||
</script>
|
||||
|
|
@ -78,7 +70,7 @@
|
|||
<ModalTitle>Custom domain</ModalTitle>
|
||||
<ModalSubtitle>
|
||||
Point a domain you control at this relay instead of the default
|
||||
<span class="text-primary">{canonicalRelayHost(current)}</span> address.
|
||||
<span class="text-primary">{canonicalRelayHost(relay)}</span> address.
|
||||
</ModalSubtitle>
|
||||
</ModalHeader>
|
||||
|
||||
|
|
@ -88,7 +80,7 @@
|
|||
<p>Domain</p>
|
||||
{/snippet}
|
||||
{#snippet secondary()}
|
||||
{#if current.custom_domain && flagToBool(current.custom_domain_verified, false)}
|
||||
{#if relay.custom_domain && flagToBool(relay.custom_domain_verified, false)}
|
||||
<Badge variant="primary">
|
||||
<Icon icon={VerifiedCheck} size={3} />
|
||||
Verified
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
import {
|
||||
exportRelayData,
|
||||
importRelayData,
|
||||
HostingError,
|
||||
hostingErrorMessage,
|
||||
type HostedRelay,
|
||||
type ImportResult,
|
||||
} from "@app/hosting"
|
||||
|
|
@ -47,10 +47,7 @@
|
|||
try {
|
||||
await downloadText(`${relay.subdomain}.jsonl`, await exportRelayData(relay.id))
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: e instanceof HostingError ? e.message : "Failed to export relay data.",
|
||||
})
|
||||
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to export relay data.")})
|
||||
} finally {
|
||||
exporting = false
|
||||
}
|
||||
|
|
@ -63,10 +60,7 @@
|
|||
try {
|
||||
result = await importRelayData(relay.id, file)
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: e instanceof HostingError ? e.message : "Failed to import relay data.",
|
||||
})
|
||||
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to import relay data.")})
|
||||
} finally {
|
||||
importing = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,13 +16,15 @@
|
|||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||
import {errorMessage} from "@lib/util"
|
||||
import QRCode from "@app/components/QRCode.svelte"
|
||||
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
|
||||
import InvoiceItemsList from "@app/components/hosting/InvoiceItemsList.svelte"
|
||||
import {clip, pushToast} from "@app/toast"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {
|
||||
hosting,
|
||||
hostingErrorMessage,
|
||||
logUnexpectedError,
|
||||
ensureInvoiceBolt11,
|
||||
reconcileInvoice,
|
||||
createInvoiceCheckout,
|
||||
|
|
@ -35,10 +37,9 @@
|
|||
|
||||
type Props = {
|
||||
invoice: Invoice
|
||||
onPaid?: () => void
|
||||
}
|
||||
|
||||
const {invoice, onPaid}: Props = $props()
|
||||
const {invoice}: Props = $props()
|
||||
|
||||
type Bolt11Status = "idle" | "loading" | "ready" | "error"
|
||||
|
||||
|
|
@ -83,7 +84,7 @@
|
|||
|
||||
paid = true
|
||||
stopPolling()
|
||||
onPaid?.()
|
||||
void $hosting.refresh()
|
||||
}
|
||||
|
||||
const loadBolt11 = async () => {
|
||||
|
|
@ -98,15 +99,16 @@
|
|||
bolt11Status = "ready"
|
||||
} catch (e) {
|
||||
bolt11Status = "error"
|
||||
bolt11Error = errorMessage(e) || "Failed to generate Lightning invoice"
|
||||
bolt11Error = hostingErrorMessage(e, "Failed to generate Lightning invoice")
|
||||
}
|
||||
}
|
||||
|
||||
const loadItems = async () => {
|
||||
try {
|
||||
items = await listInvoiceItems(invoice.id)
|
||||
} catch {
|
||||
// line items are cosmetic; don't block payment
|
||||
} catch (e) {
|
||||
// Line items are cosmetic, so a failure doesn't block payment.
|
||||
logUnexpectedError(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,19 +154,15 @@
|
|||
pushToast({message: "Payment not yet confirmed. Please try again after sending."})
|
||||
}
|
||||
} catch (e) {
|
||||
pushToast({theme: "error", message: errorMessage(e) || "Failed to check payment status"})
|
||||
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to check payment status")})
|
||||
} finally {
|
||||
checking = false
|
||||
}
|
||||
}
|
||||
|
||||
// Nested so this dialog stays mounted, with onSaved collecting and the poll as the backstop.
|
||||
// Nested so this dialog stays mounted, and its poll collects once the wallet is saved.
|
||||
const connectWallet = () => {
|
||||
pushModal(
|
||||
PaymentSetup,
|
||||
{initialTab: "nwc", onSaved: () => void reconcile().catch(() => {})},
|
||||
{nested: true},
|
||||
)
|
||||
pushModal(PaymentSetup, {initialTab: "nwc"}, {nested: true})
|
||||
}
|
||||
|
||||
const payWithCard = async () => {
|
||||
|
|
@ -179,7 +177,7 @@
|
|||
|
||||
window.location.href = url
|
||||
} catch (e) {
|
||||
pushToast({theme: "error", message: errorMessage(e) || "Failed to open checkout"})
|
||||
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to open checkout")})
|
||||
} finally {
|
||||
redirecting = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,17 +19,19 @@
|
|||
import {user} from "@app/core"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {getNwcClient, wallet} from "@app/lightning"
|
||||
import {updateTenant, createPortalSession} from "@app/hosting"
|
||||
import {hosting, hostingErrorMessage, updateTenant, createPortalSession} from "@app/hosting"
|
||||
|
||||
type Tab = "nwc" | "card"
|
||||
|
||||
type Props = {
|
||||
onSaved?: () => void
|
||||
initialTab?: Tab
|
||||
nwcConnected?: boolean
|
||||
}
|
||||
|
||||
const {onSaved, initialTab, nwcConnected = false}: Props = $props()
|
||||
const {initialTab}: Props = $props()
|
||||
|
||||
const billing = $hosting.billing
|
||||
|
||||
const nwcConnected = $derived(Boolean($billing.tenant?.nwc_is_set))
|
||||
|
||||
const back = () => history.back()
|
||||
|
||||
|
|
@ -63,11 +65,11 @@
|
|||
try {
|
||||
await updateTenant($user.pubkey, {nwc_url: url})
|
||||
saved = true
|
||||
onSaved?.()
|
||||
void $hosting.refresh()
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: e instanceof Error ? e.message : "Failed to save wallet connection",
|
||||
message: hostingErrorMessage(e, "Failed to save wallet connection"),
|
||||
})
|
||||
} finally {
|
||||
saving = false
|
||||
|
|
@ -87,14 +89,11 @@
|
|||
|
||||
try {
|
||||
await updateTenant($user.pubkey, {nwc_url: ""})
|
||||
onSaved?.()
|
||||
void $hosting.refresh()
|
||||
pushToast({message: "Lightning wallet disconnected."})
|
||||
back()
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: e instanceof Error ? e.message : "Failed to disconnect wallet",
|
||||
})
|
||||
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to disconnect wallet")})
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
|
|
@ -108,10 +107,7 @@
|
|||
|
||||
window.location.href = url
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: e instanceof Error ? e.message : "Failed to open billing portal",
|
||||
})
|
||||
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to open billing portal")})
|
||||
} finally {
|
||||
redirecting = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,29 +13,23 @@
|
|||
import PricingTable from "@app/components/hosting/PricingTable.svelte"
|
||||
import PaymentDialog from "@app/components/hosting/PaymentDialog.svelte"
|
||||
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
|
||||
import {user} from "@app/core"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {
|
||||
autopayConfigured,
|
||||
derivePlans,
|
||||
getTenant,
|
||||
HostingError,
|
||||
listTenantInvoices,
|
||||
reconcileTenant,
|
||||
selectPayableInvoice,
|
||||
hosting,
|
||||
hostingErrorMessage,
|
||||
isPaidPlan,
|
||||
updateRelay,
|
||||
type HostedRelay,
|
||||
} from "@app/hosting"
|
||||
|
||||
type Props = {
|
||||
relay: HostedRelay
|
||||
onUpdate?: (relay: HostedRelay) => void
|
||||
}
|
||||
|
||||
const {relay, onUpdate}: Props = $props()
|
||||
const {relay}: Props = $props()
|
||||
|
||||
const plans = derivePlans()
|
||||
const plans = $hosting.plans
|
||||
|
||||
let selected = $state(relay.plan_id)
|
||||
let saving = $state(false)
|
||||
|
|
@ -49,51 +43,35 @@
|
|||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave) {
|
||||
return
|
||||
}
|
||||
if (canSave) {
|
||||
saving = true
|
||||
|
||||
saving = true
|
||||
try {
|
||||
// Leaving the paid plans turns off the features only they include.
|
||||
const updated = await updateRelay(
|
||||
relay.id,
|
||||
isPaidPlan(selected)
|
||||
? {plan_id: selected}
|
||||
: {plan_id: selected, blossom_enabled: 0, livekit_enabled: 0},
|
||||
)
|
||||
|
||||
try {
|
||||
// Downgrading to free turns off the features that plan doesn't include.
|
||||
const updated = await updateRelay(
|
||||
relay.id,
|
||||
selected === "free"
|
||||
? {plan_id: selected, blossom_enabled: 0, livekit_enabled: 0}
|
||||
: {plan_id: selected},
|
||||
)
|
||||
$hosting.setRelay(updated)
|
||||
pushToast({message: "Plan updated."})
|
||||
|
||||
onUpdate?.(updated)
|
||||
pushToast({message: "Plan updated."})
|
||||
const due = isPaidPlan(selected) ? await $hosting.findPaymentDue() : undefined
|
||||
|
||||
if (selected === "free") {
|
||||
back()
|
||||
return
|
||||
}
|
||||
|
||||
await reconcileTenant($user.pubkey)
|
||||
|
||||
const tenant = await getTenant($user.pubkey)
|
||||
|
||||
if (autopayConfigured(tenant)) {
|
||||
back()
|
||||
} else {
|
||||
const invoice = selectPayableInvoice(await listTenantInvoices($user.pubkey))
|
||||
|
||||
if (invoice) {
|
||||
pushModal(PaymentDialog, {invoice}, {replaceState: true})
|
||||
} else {
|
||||
if (due?.invoice) {
|
||||
pushModal(PaymentDialog, {invoice: due.invoice}, {replaceState: true})
|
||||
} else if (due) {
|
||||
pushModal(PaymentSetup, {}, {replaceState: true})
|
||||
} else {
|
||||
back()
|
||||
}
|
||||
} catch (e) {
|
||||
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to update plan.")})
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: e instanceof HostingError ? e.message : "Failed to update plan.",
|
||||
})
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -3,70 +3,30 @@
|
|||
import RelayForm, {type RelayFormValues} from "@app/components/hosting/RelayForm.svelte"
|
||||
import PaymentDialog from "@app/components/hosting/PaymentDialog.svelte"
|
||||
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
|
||||
import {roomLists, user} from "@app/core"
|
||||
import {roomLists} from "@app/core"
|
||||
import {navigate, pushModal} from "@app/modal"
|
||||
import {makeSpacePath} from "@app/routes"
|
||||
import {HOSTING_RELAY_DOMAIN} from "@app/env"
|
||||
import {
|
||||
autopayConfigured,
|
||||
createRelay,
|
||||
ensureSessionTenant,
|
||||
getHostedRelayUrl,
|
||||
getTenant,
|
||||
listTenantInvoices,
|
||||
reconcileTenant,
|
||||
selectPayableInvoice,
|
||||
} from "@app/hosting"
|
||||
import {createHostedRelay, getHostedRelayUrl, hosting, isPaidPlan} from "@app/hosting"
|
||||
|
||||
const createRelayForActiveTenant = (values: RelayFormValues) => {
|
||||
const defaults = {
|
||||
info_name: "",
|
||||
info_icon: "",
|
||||
info_description: "",
|
||||
policy_public_read: 0,
|
||||
policy_public_write: 0,
|
||||
policy_public_join: 0,
|
||||
policy_strip_signatures: 0,
|
||||
groups_enabled: 1,
|
||||
management_enabled: 1,
|
||||
members_can_invite: 1,
|
||||
push_enabled: 1,
|
||||
}
|
||||
|
||||
const overrides = {
|
||||
tenant_pubkey: $user.pubkey,
|
||||
zooid_domain: HOSTING_RELAY_DOMAIN,
|
||||
blossom_enabled: values.plan_id === "free" ? 0 : 1,
|
||||
livekit_enabled: values.plan_id === "free" ? 0 : 1,
|
||||
}
|
||||
|
||||
return createRelay({...defaults, ...values, ...overrides})
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: RelayFormValues) => {
|
||||
await ensureSessionTenant()
|
||||
|
||||
const relay = await createRelayForActiveTenant(values)
|
||||
const submit = async (values: RelayFormValues) => {
|
||||
const relay = await createHostedRelay(values)
|
||||
const url = getHostedRelayUrl(relay)
|
||||
|
||||
$hosting.setRelay(relay)
|
||||
|
||||
// Join the space now, or the space layout prompts for it and displaces the payment modals.
|
||||
await $roomLists.addRelay(url).then(publish)
|
||||
await reconcileTenant($user.pubkey)
|
||||
|
||||
const tenant = await getTenant($user.pubkey)
|
||||
const due = isPaidPlan(values.plan_id) ? await $hosting.findPaymentDue() : undefined
|
||||
|
||||
await navigate(makeSpacePath(url, "admin"), {replaceState: true})
|
||||
|
||||
if (values.plan_id !== "free" && !autopayConfigured(tenant)) {
|
||||
const invoice = selectPayableInvoice(await listTenantInvoices($user.pubkey))
|
||||
|
||||
if (invoice) {
|
||||
pushModal(PaymentDialog, {invoice})
|
||||
} else {
|
||||
pushModal(PaymentSetup, {})
|
||||
}
|
||||
if (due?.invoice) {
|
||||
pushModal(PaymentDialog, {invoice: due.invoice})
|
||||
} else if (due) {
|
||||
pushModal(PaymentSetup)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<RelayForm mode="create" submitLabel="Create Space" onSubmit={handleSubmit} />
|
||||
<RelayForm mode="create" submitLabel="Create Space" onSubmit={submit} />
|
||||
|
|
|
|||
|
|
@ -28,24 +28,25 @@
|
|||
import CustomDomainModal from "@app/components/hosting/CustomDomainModal.svelte"
|
||||
import DataTransferModal from "@app/components/hosting/DataTransferModal.svelte"
|
||||
import PlanModal from "@app/components/hosting/PlanModal.svelte"
|
||||
import {roomLists} from "@app/core"
|
||||
import RelayForm from "@app/components/hosting/RelayForm.svelte"
|
||||
import type {RelayFormValues} from "@app/components/hosting/RelayForm.svelte"
|
||||
import {pushModal, clearModals} from "@app/modal"
|
||||
import {clip, pushToast} from "@app/toast"
|
||||
import {goToMovedSpace} from "@app/routes"
|
||||
import {
|
||||
HostingError,
|
||||
RELAY_FLAG_DEFAULTS,
|
||||
flagToBool,
|
||||
boolToFlag,
|
||||
canonicalRelayHost,
|
||||
relayHost,
|
||||
followHostedRelay,
|
||||
getHostedRelayUrl,
|
||||
hosting,
|
||||
hostingErrorMessage,
|
||||
isPaidPlan,
|
||||
updateRelay,
|
||||
getRelay,
|
||||
deactivateRelay,
|
||||
reactivateRelay,
|
||||
derivePlans,
|
||||
deriveRelayMembers,
|
||||
type HostedRelay,
|
||||
type RelayFlag,
|
||||
|
|
@ -58,96 +59,64 @@
|
|||
|
||||
const {relay}: Props = $props()
|
||||
|
||||
const PLAN_GATED_TOOLTIP = "Not available on your current plan"
|
||||
|
||||
const plans = derivePlans()
|
||||
const plans = $hosting.plans
|
||||
const members = deriveRelayMembers(relay.id)
|
||||
|
||||
let current = $state(relay)
|
||||
let showMenu = $state(false)
|
||||
let loading = $state(false)
|
||||
|
||||
const host = $derived(relayHost(current))
|
||||
const relayLabel = $derived(current.info_name || current.subdomain)
|
||||
const domainVerified = $derived(flagToBool(current.custom_domain_verified, false))
|
||||
const recordTarget = $derived(canonicalRelayHost(current))
|
||||
const host = $derived(relayHost(relay))
|
||||
const relayLabel = $derived(relay.info_name || relay.subdomain)
|
||||
const domainVerified = $derived(flagToBool(relay.custom_domain_verified, false))
|
||||
const recordTarget = $derived(canonicalRelayHost(relay))
|
||||
// DNS forbids a CNAME at a zone apex, so those use the provider's ALIAS or ANAME record.
|
||||
const isApex = $derived(current.custom_domain.split(".").length === 2)
|
||||
const isApex = $derived(relay.custom_domain.split(".").length === 2)
|
||||
const recordType = $derived(isApex ? "ALIAS" : "CNAME")
|
||||
const isPaidPlan = $derived(current.plan_id !== "free")
|
||||
const paidPlan = $derived(isPaidPlan(relay.plan_id))
|
||||
|
||||
// A custom domain moves the relay's host, so migrate the groups list and follow the space.
|
||||
const setCurrentAndFollowHost = async (next: HostedRelay) => {
|
||||
const previousUrl = getHostedRelayUrl(current)
|
||||
const nextUrl = getHostedRelayUrl(next)
|
||||
const setFlag = (flag: RelayFlag) => async (checked: boolean) => {
|
||||
const previous = relay
|
||||
const input: UpdateRelayInput = {[flag]: boolToFlag(checked)}
|
||||
|
||||
current = next
|
||||
|
||||
if (previousUrl !== nextUrl) {
|
||||
await $roomLists.migrateRelay(previousUrl, nextUrl).then(command => command?.publish())
|
||||
await goToMovedSpace(previousUrl, nextUrl)
|
||||
}
|
||||
}
|
||||
|
||||
const persist = async (next: HostedRelay, input: UpdateRelayInput) => {
|
||||
const previous = current
|
||||
|
||||
current = next
|
||||
$hosting.setRelay({...previous, ...input})
|
||||
|
||||
try {
|
||||
current = await updateRelay(previous.id, input)
|
||||
$hosting.setRelay(await updateRelay(previous.id, input))
|
||||
} catch (e) {
|
||||
current = previous
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: e instanceof HostingError ? e.message : "Failed to save changes.",
|
||||
})
|
||||
$hosting.setRelay(previous)
|
||||
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to save changes.")})
|
||||
}
|
||||
}
|
||||
|
||||
const setFlag = (field: RelayFlag) => (checked: boolean) => {
|
||||
const input: UpdateRelayInput = {[field]: boolToFlag(checked)}
|
||||
|
||||
persist({...current, ...input}, input)
|
||||
}
|
||||
|
||||
const setCurrent = (next: HostedRelay) => {
|
||||
current = next
|
||||
}
|
||||
|
||||
const openPlan = () =>
|
||||
pushModal(PlanModal, {relay: current, onUpdate: setCurrent}, {size: "large"})
|
||||
const openPlan = () => pushModal(PlanModal, {relay}, {size: "large"})
|
||||
|
||||
const openEdit = () => {
|
||||
const previous = relay
|
||||
const initialValues: RelayFormValues = {
|
||||
info_name: current.info_name,
|
||||
subdomain: current.subdomain,
|
||||
info_icon: current.info_icon,
|
||||
info_description: current.info_description,
|
||||
plan_id: current.plan_id,
|
||||
info_name: relay.info_name,
|
||||
subdomain: relay.subdomain,
|
||||
info_icon: relay.info_icon,
|
||||
info_description: relay.info_description,
|
||||
plan_id: relay.plan_id,
|
||||
}
|
||||
|
||||
pushModal(RelayForm, {
|
||||
mode: "edit",
|
||||
initialValues,
|
||||
zooidDomain: current.zooid_domain,
|
||||
zooidDomain: relay.zooid_domain,
|
||||
onSubmit: async (values: RelayFormValues) => {
|
||||
const updated = await updateRelay(current.id, values)
|
||||
const updated = await updateRelay(previous.id, values)
|
||||
|
||||
clearModals()
|
||||
|
||||
// The subdomain is editable here, so the host may have moved.
|
||||
await setCurrentAndFollowHost(updated)
|
||||
await followHostedRelay(previous, updated)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openDataTransfer = () => {
|
||||
pushModal(DataTransferModal, {relay: current})
|
||||
pushModal(DataTransferModal, {relay})
|
||||
}
|
||||
|
||||
const openCustomDomain = () => {
|
||||
pushModal(CustomDomainModal, {relay: current, onUpdate: setCurrentAndFollowHost})
|
||||
pushModal(CustomDomainModal, {relay})
|
||||
}
|
||||
|
||||
const openMenu = () => {
|
||||
|
|
@ -165,7 +134,7 @@
|
|||
loading = true
|
||||
|
||||
try {
|
||||
const updated = await getRelay(current.id)
|
||||
const updated = await getRelay(relay.id)
|
||||
|
||||
pushToast({
|
||||
message: flagToBool(updated.custom_domain_verified, false)
|
||||
|
|
@ -173,12 +142,9 @@
|
|||
: "Not verified yet. DNS changes can take a while to propagate.",
|
||||
})
|
||||
|
||||
await setCurrentAndFollowHost(updated)
|
||||
await followHostedRelay(relay, updated)
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: e instanceof HostingError ? e.message : "Verification failed.",
|
||||
})
|
||||
pushToast({theme: "error", message: hostingErrorMessage(e, "Verification failed.")})
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
|
|
@ -192,13 +158,13 @@
|
|||
"All client connections are dropped and members can't read or write, but all data and settings are preserved. You can reactivate at any time.",
|
||||
confirm: async () => {
|
||||
try {
|
||||
await deactivateRelay(current.id)
|
||||
current = await getRelay(current.id)
|
||||
await deactivateRelay(relay.id)
|
||||
$hosting.setRelay(await getRelay(relay.id))
|
||||
clearModals()
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: e instanceof HostingError ? e.message : "Failed to deactivate relay.",
|
||||
message: hostingErrorMessage(e, "Failed to deactivate relay."),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
@ -212,39 +178,57 @@
|
|||
message: "The relay will start accepting connections again.",
|
||||
confirm: async () => {
|
||||
try {
|
||||
await reactivateRelay(current.id)
|
||||
current = await getRelay(current.id)
|
||||
await reactivateRelay(relay.id)
|
||||
$hosting.setRelay(await getRelay(relay.id))
|
||||
clearModals()
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: e instanceof HostingError ? e.message : "Failed to reactivate relay.",
|
||||
message: hostingErrorMessage(e, "Failed to reactivate relay."),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let showMenu = $state(false)
|
||||
let loading = $state(false)
|
||||
</script>
|
||||
|
||||
{#snippet flagToggle(title: string, flag: RelayFlag, gated = false)}
|
||||
<FieldInline class={cx({"opacity-50": gated})}>
|
||||
{#snippet label()}
|
||||
<span>{title}</span>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<ToggleInput
|
||||
checked={flagToBool(relay[flag], Boolean(RELAY_FLAG_DEFAULTS[flag]))}
|
||||
disabled={gated}
|
||||
tooltip={gated ? "Not available on your current plan" : undefined}
|
||||
onchange={setFlag(flag)} />
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{/snippet}
|
||||
|
||||
<div class="card flex flex-col gap-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex min-w-0 items-start gap-4">
|
||||
<RelayIcon url={getHostedRelayUrl(current)} icon={current.info_icon} size={12} />
|
||||
<RelayIcon url={getHostedRelayUrl(relay)} icon={relay.info_icon} size={12} />
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="text-xl font-bold">{relayLabel}</h2>
|
||||
<Badge variant={current.status === "active" ? "primary" : "neutral"}>
|
||||
{ucFirst(current.status.replace(/_/g, " "))}
|
||||
<Badge variant={relay.status === "active" ? "primary" : "neutral"}>
|
||||
{ucFirst(relay.status.replace(/_/g, " "))}
|
||||
</Badge>
|
||||
<Badge variant={current.plan_id === "free" ? "neutral" : "primary"}>
|
||||
{ucFirst(current.plan_id)}
|
||||
<Badge variant={relay.plan_id === "free" ? "neutral" : "primary"}>
|
||||
{ucFirst(relay.plan_id)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Link external href={`https://${host}`} class="break-all text-sm text-primary">
|
||||
wss://{host}
|
||||
</Link>
|
||||
{#if current.info_description.trim()}
|
||||
<p class="mt-2 text-sm text-content-muted">{current.info_description}</p>
|
||||
{#if relay.info_description.trim()}
|
||||
<p class="mt-2 text-sm text-content-muted">{relay.info_description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -280,7 +264,7 @@
|
|||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
{#if current.status === "active"}
|
||||
{#if relay.status === "active"}
|
||||
<Button class="text-error" onclick={requestDeactivate}>
|
||||
<Icon icon={Power} />
|
||||
Deactivate
|
||||
|
|
@ -298,13 +282,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if current.sync_error}
|
||||
{#if relay.sync_error}
|
||||
<div class="flex flex-col gap-1 rounded-2xl border border-error bg-surface-less p-4">
|
||||
<p class="flex items-center gap-2 text-sm font-semibold text-error">
|
||||
<Icon icon={Danger} size={4} />
|
||||
Provisioning error
|
||||
</p>
|
||||
<p class="break-all font-mono text-sm text-error">{current.sync_error}</p>
|
||||
<p class="break-all font-mono text-sm text-error">{relay.sync_error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -313,9 +297,9 @@
|
|||
<div class="flex flex-col gap-3">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Custom Domain</h3>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
{#if current.custom_domain}
|
||||
{#if relay.custom_domain}
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate font-medium">{current.custom_domain}</span>
|
||||
<span class="truncate font-medium">{relay.custom_domain}</span>
|
||||
{#if domainVerified}
|
||||
<Badge variant="primary">
|
||||
<Icon icon={VerifiedCheck} size={3} />
|
||||
|
|
@ -338,7 +322,7 @@
|
|||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if current.custom_domain && !domainVerified}
|
||||
{#if relay.custom_domain && !domainVerified}
|
||||
<div class="flex flex-col gap-3 rounded-2xl border border-warning bg-surface-less p-4">
|
||||
<div class="flex items-start gap-2">
|
||||
<Icon icon={ShieldWarning} size={4} class="mt-0.5 shrink-0 text-warning" />
|
||||
|
|
@ -348,7 +332,7 @@
|
|||
</div>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-line bg-surface px-3 py-2">
|
||||
<code class="min-w-0 flex-1 break-all font-mono text-xs text-content">
|
||||
{current.custom_domain}
|
||||
{relay.custom_domain}
|
||||
{recordType}
|
||||
{recordTarget}
|
||||
</code>
|
||||
|
|
@ -383,56 +367,11 @@
|
|||
<div class="flex flex-col gap-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Policy</h3>
|
||||
<div class="grid gap-x-8 gap-y-4 sm:grid-cols-2">
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<span>Public read</span>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<ToggleInput
|
||||
checked={flagToBool(current.policy_public_read, false)}
|
||||
onchange={setFlag("policy_public_read")} />
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<span>Public write</span>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<ToggleInput
|
||||
checked={flagToBool(current.policy_public_write, false)}
|
||||
onchange={setFlag("policy_public_write")} />
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<span>Public join</span>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<ToggleInput
|
||||
checked={flagToBool(current.policy_public_join, false)}
|
||||
onchange={setFlag("policy_public_join")} />
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<span>Members can invite others</span>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<ToggleInput
|
||||
checked={flagToBool(current.members_can_invite, true)}
|
||||
onchange={setFlag("members_can_invite")} />
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<span>Strip signatures</span>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<ToggleInput
|
||||
checked={flagToBool(current.policy_strip_signatures, false)}
|
||||
onchange={setFlag("policy_strip_signatures")} />
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{@render flagToggle("Public read", "policy_public_read")}
|
||||
{@render flagToggle("Public write", "policy_public_write")}
|
||||
{@render flagToggle("Public join", "policy_public_join")}
|
||||
{@render flagToggle("Members can invite others", "members_can_invite")}
|
||||
{@render flagToggle("Strip signatures", "policy_strip_signatures")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -441,60 +380,11 @@
|
|||
<div class="flex flex-col gap-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Features</h3>
|
||||
<div class="grid gap-x-8 gap-y-4 sm:grid-cols-2">
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<span>Rooms</span>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<ToggleInput
|
||||
checked={flagToBool(current.groups_enabled, true)}
|
||||
onchange={setFlag("groups_enabled")} />
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<span>Management API</span>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<ToggleInput
|
||||
checked={flagToBool(current.management_enabled, true)}
|
||||
onchange={setFlag("management_enabled")} />
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<span>Push notifications</span>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<ToggleInput
|
||||
checked={flagToBool(current.push_enabled, true)}
|
||||
onchange={setFlag("push_enabled")} />
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline class={cx({"opacity-50": !isPaidPlan})}>
|
||||
{#snippet label()}
|
||||
<span>Media storage</span>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<ToggleInput
|
||||
checked={flagToBool(current.blossom_enabled, false)}
|
||||
disabled={!isPaidPlan}
|
||||
tooltip={isPaidPlan ? undefined : PLAN_GATED_TOOLTIP}
|
||||
onchange={setFlag("blossom_enabled")} />
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
<FieldInline class={cx({"opacity-50": !isPaidPlan})}>
|
||||
{#snippet label()}
|
||||
<span>LiveKit support</span>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<ToggleInput
|
||||
checked={flagToBool(current.livekit_enabled, false)}
|
||||
disabled={!isPaidPlan}
|
||||
tooltip={isPaidPlan ? undefined : PLAN_GATED_TOOLTIP}
|
||||
onchange={setFlag("livekit_enabled")} />
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{@render flagToggle("Rooms", "groups_enabled")}
|
||||
{@render flagToggle("Management API", "management_enabled")}
|
||||
{@render flagToggle("Push notifications", "push_enabled")}
|
||||
{@render flagToggle("Media storage", "blossom_enabled", !paidPlan)}
|
||||
{@render flagToggle("LiveKit support", "livekit_enabled", !paidPlan)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -516,7 +406,7 @@
|
|||
<span>Member limit</span>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
{@const plan = $plans.find(spec({id: current.plan_id}))}
|
||||
{@const plan = $plans.find(spec({id: relay.plan_id}))}
|
||||
<span class="font-medium">
|
||||
{typeof plan?.members === "number" ? plan.members : "∞"}
|
||||
</span>
|
||||
|
|
@ -534,7 +424,7 @@
|
|||
<span>Current plan</span>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<span class="font-medium">{ucFirst(current.plan_id)}</span>
|
||||
<span class="font-medium">{ucFirst(relay.plan_id)}</span>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||
import PricingTable from "@app/components/hosting/PricingTable.svelte"
|
||||
import {derivePlans} from "@app/hosting"
|
||||
import {hosting, hostingErrorMessage} from "@app/hosting"
|
||||
import {HOSTING_RELAY_DOMAIN} from "@app/env"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {resolveImageInput} from "@app/uploads"
|
||||
|
|
@ -59,7 +59,7 @@
|
|||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
|
||||
const plans = derivePlans()
|
||||
const plans = $hosting.plans
|
||||
|
||||
let loading = $state(false)
|
||||
let name = $state(initialValues.info_name ?? "")
|
||||
|
|
@ -139,10 +139,7 @@
|
|||
info_description: description,
|
||||
})
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
theme: "error",
|
||||
message: e instanceof Error ? e.message : "Failed to save space",
|
||||
})
|
||||
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to save space")})
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import {derived, readable} from "svelte/store"
|
||||
import {derived, readable, writable} from "svelte/store"
|
||||
import type {Readable} from "svelte/store"
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import {sortBy, int, MINUTE, ms} from "@welshman/lib"
|
||||
import {sortBy, spec, int, MINUTE, ms} from "@welshman/lib"
|
||||
import {makeHttpAuth, makeHttpAuthHeader, normalizeRelayUrl} from "@welshman/util"
|
||||
import {user} from "@app/core"
|
||||
import {HOSTING_BACKEND_URL, PLATFORM_URL} from "@app/env"
|
||||
import {User} from "@welshman/app"
|
||||
import type {IApp} from "@welshman/app"
|
||||
import {roomLists, usePlugin, user} from "@app/core"
|
||||
import {HOSTING_BACKEND_URL, HOSTING_RELAY_DOMAIN, PLATFORM_URL} from "@app/env"
|
||||
import {goToMovedSpace} from "@app/routes"
|
||||
|
||||
// Apple doesn't allow selling hosting outside their payment system.
|
||||
export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios"
|
||||
|
|
@ -59,46 +62,22 @@ export type RelayFlag =
|
|||
| "livekit_enabled"
|
||||
| "push_enabled"
|
||||
|
||||
export type CreateRelayInput = {
|
||||
tenant_pubkey?: string
|
||||
subdomain: string
|
||||
zooid_domain: string
|
||||
plan_id: string
|
||||
info_name?: string
|
||||
info_icon?: string
|
||||
info_description?: string
|
||||
policy_public_read?: number
|
||||
policy_public_write?: number
|
||||
policy_public_join?: number
|
||||
policy_strip_signatures?: number
|
||||
groups_enabled?: number
|
||||
management_enabled?: number
|
||||
members_can_invite?: number
|
||||
blossom_enabled?: number
|
||||
livekit_enabled?: number
|
||||
push_enabled?: number
|
||||
custom_domain?: string
|
||||
}
|
||||
export type UpdateRelayInput = Partial<
|
||||
Pick<
|
||||
HostedRelay,
|
||||
| "subdomain"
|
||||
| "zooid_domain"
|
||||
| "plan_id"
|
||||
| "info_name"
|
||||
| "info_icon"
|
||||
| "info_description"
|
||||
| "custom_domain"
|
||||
| RelayFlag
|
||||
>
|
||||
>
|
||||
|
||||
export type UpdateRelayInput = {
|
||||
subdomain?: string
|
||||
zooid_domain?: string
|
||||
plan_id?: string
|
||||
info_name?: string
|
||||
info_icon?: string
|
||||
info_description?: string
|
||||
policy_public_read?: number
|
||||
policy_public_write?: number
|
||||
policy_public_join?: number
|
||||
policy_strip_signatures?: number
|
||||
groups_enabled?: number
|
||||
management_enabled?: number
|
||||
members_can_invite?: number
|
||||
blossom_enabled?: number
|
||||
livekit_enabled?: number
|
||||
push_enabled?: number
|
||||
custom_domain?: string
|
||||
}
|
||||
export type CreateRelayInput = UpdateRelayInput &
|
||||
Pick<HostedRelay, "tenant_pubkey" | "subdomain" | "zooid_domain" | "plan_id">
|
||||
|
||||
export type Tenant = {
|
||||
pubkey: string
|
||||
|
|
@ -186,6 +165,19 @@ export class HostingError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export const logUnexpectedError = (e: unknown) => {
|
||||
if (!(e instanceof HostingError)) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// A HostingError carries the backend's own explanation; anything else is a bug worth logging.
|
||||
export const hostingErrorMessage = (e: unknown, fallback: string) => {
|
||||
logUnexpectedError(e)
|
||||
|
||||
return e instanceof HostingError ? e.message : fallback
|
||||
}
|
||||
|
||||
// Requests
|
||||
|
||||
const AUTH_TTL = ms(int(10, MINUTE))
|
||||
|
|
@ -347,6 +339,23 @@ export const flagToBool = (value: number | undefined, fallback: boolean): boolea
|
|||
|
||||
export const boolToFlag = (value: boolean): 0 | 1 => (value ? 1 : 0)
|
||||
|
||||
// What a new relay starts with, and what a toggle shows when the backend leaves a flag unset.
|
||||
export const RELAY_FLAG_DEFAULTS: Pick<HostedRelay, RelayFlag> = {
|
||||
policy_public_read: 0,
|
||||
policy_public_write: 0,
|
||||
policy_public_join: 0,
|
||||
policy_strip_signatures: 0,
|
||||
groups_enabled: 1,
|
||||
management_enabled: 1,
|
||||
members_can_invite: 1,
|
||||
blossom_enabled: 0,
|
||||
livekit_enabled: 0,
|
||||
push_enabled: 1,
|
||||
}
|
||||
|
||||
// Paid plans are the ones that bill, and the only ones with media storage and livekit.
|
||||
export const isPaidPlan = (planId: string) => planId !== "free"
|
||||
|
||||
export const autopayConfigured = (
|
||||
t: Pick<Tenant, "nwc_is_set" | "stripe_payment_method_id">,
|
||||
): boolean => t.nwc_is_set || Boolean(t.stripe_payment_method_id)
|
||||
|
|
@ -402,13 +411,151 @@ export const ensureSessionTenant = async () => {
|
|||
await tenantPromise.promise
|
||||
}
|
||||
|
||||
export const createHostedRelay = async (
|
||||
values: Omit<CreateRelayInput, "tenant_pubkey" | "zooid_domain">,
|
||||
) => {
|
||||
await ensureSessionTenant()
|
||||
|
||||
const features = boolToFlag(isPaidPlan(values.plan_id))
|
||||
|
||||
return createRelay({
|
||||
...RELAY_FLAG_DEFAULTS,
|
||||
...values,
|
||||
tenant_pubkey: user.get().pubkey,
|
||||
zooid_domain: HOSTING_RELAY_DOMAIN,
|
||||
blossom_enabled: features,
|
||||
livekit_enabled: features,
|
||||
})
|
||||
}
|
||||
|
||||
// Stores
|
||||
|
||||
export const derivePlans = () =>
|
||||
readable<Plan[]>([], set => {
|
||||
listPlans().then(set, () => set([]))
|
||||
export type TenantRelays = {loading: boolean; relays: HostedRelay[]}
|
||||
|
||||
export type TenantBilling = {
|
||||
tenant: Maybe<Tenant>
|
||||
invoices: Invoice[]
|
||||
draftInvoice: Maybe<Invoice>
|
||||
}
|
||||
|
||||
// The tenant is the signed-in pubkey, so what's loaded for it lives on the app and goes with a login.
|
||||
export class Hosting {
|
||||
private plansRequest: Maybe<Promise<Plan[]>>
|
||||
|
||||
private relayState = writable<TenantRelays>({loading: true, relays: []})
|
||||
|
||||
billing = writable<TenantBilling>({tenant: undefined, invoices: [], draftInvoice: undefined})
|
||||
|
||||
constructor(private readonly app: IApp) {}
|
||||
|
||||
loadPlans = () => {
|
||||
this.plansRequest ??= listPlans().catch((e): Plan[] => {
|
||||
this.plansRequest = undefined
|
||||
logUnexpectedError(e)
|
||||
return []
|
||||
})
|
||||
|
||||
return this.plansRequest
|
||||
}
|
||||
|
||||
plans = readable<Plan[]>([], set => {
|
||||
this.loadPlans().then(set)
|
||||
})
|
||||
|
||||
loadRelays = async () => {
|
||||
this.relayState.update($state => ({...$state, loading: true}))
|
||||
|
||||
try {
|
||||
const relays = await listTenantRelays(User.require(this.app).pubkey)
|
||||
|
||||
this.relayState.set({loading: false, relays})
|
||||
} catch (e) {
|
||||
this.relayState.update($state => ({...$state, loading: false}))
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// A pubkey with no tenant answers with a HostingError, which leaves the list empty.
|
||||
relays = readable<TenantRelays>({loading: true, relays: []}, set => {
|
||||
this.loadRelays().catch(logUnexpectedError)
|
||||
|
||||
return this.relayState.subscribe(set)
|
||||
})
|
||||
|
||||
setRelay = (relay: HostedRelay) =>
|
||||
this.relayState.update($state => ({
|
||||
...$state,
|
||||
relays: $state.relays.some(spec({id: relay.id}))
|
||||
? $state.relays.map(r => (r.id === relay.id ? relay : r))
|
||||
: [...$state.relays, relay],
|
||||
}))
|
||||
|
||||
// Each request lands on its own, so one failure doesn't blank what the others loaded.
|
||||
refresh = async () => {
|
||||
const {pubkey} = User.require(this.app)
|
||||
const results = await Promise.allSettled([
|
||||
getTenant(pubkey),
|
||||
listTenantInvoices(pubkey),
|
||||
getDraftInvoice(pubkey),
|
||||
this.loadRelays(),
|
||||
])
|
||||
|
||||
const [tenant, invoices, draftInvoice] = results
|
||||
|
||||
this.billing.update($billing => ({
|
||||
tenant: tenant.status === "fulfilled" ? tenant.value : $billing.tenant,
|
||||
invoices:
|
||||
invoices.status === "fulfilled"
|
||||
? sortBy(invoice => -invoice.created_at, invoices.value)
|
||||
: $billing.invoices,
|
||||
draftInvoice:
|
||||
draftInvoice.status === "fulfilled" ? draftInvoice.value : $billing.draftInvoice,
|
||||
}))
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected") {
|
||||
logUnexpectedError(result.reason)
|
||||
}
|
||||
}
|
||||
|
||||
return results.every(spec({status: "fulfilled"}))
|
||||
}
|
||||
|
||||
// Settles the balance, then says what a tenant without autopay still owes: an open invoice, or a method.
|
||||
findPaymentDue = async (): Promise<Maybe<{invoice: Maybe<Invoice>}>> => {
|
||||
const {pubkey} = User.require(this.app)
|
||||
|
||||
await reconcileTenant(pubkey)
|
||||
|
||||
const tenant = await getTenant(pubkey)
|
||||
|
||||
this.billing.update($billing => ({...$billing, tenant}))
|
||||
|
||||
if (!autopayConfigured(tenant)) {
|
||||
return {invoice: selectPayableInvoice(await listTenantInvoices(pubkey))}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hosting = usePlugin(Hosting)
|
||||
|
||||
// A new subdomain or a verified custom domain moves the relay, so the room list and the page follow it.
|
||||
export const followHostedRelay = async (previous: HostedRelay, next: HostedRelay) => {
|
||||
const previousUrl = getHostedRelayUrl(previous)
|
||||
const nextUrl = getHostedRelayUrl(next)
|
||||
|
||||
if (previousUrl !== nextUrl) {
|
||||
await roomLists
|
||||
.get()
|
||||
.migrateRelay(previousUrl, nextUrl)
|
||||
.then(command => command?.publish())
|
||||
await goToMovedSpace(previousUrl, nextUrl)
|
||||
}
|
||||
|
||||
hosting.get().setRelay(next)
|
||||
}
|
||||
|
||||
export const deriveRelayMembers = (id: string) =>
|
||||
readable<string[]>([], set => {
|
||||
listRelayMembers(id).then(
|
||||
|
|
@ -442,15 +589,7 @@ export const deriveRelayActivity = (id: Readable<Maybe<string>>) =>
|
|||
export type HostedRelayState = {loading: boolean; relay: Maybe<HostedRelay>}
|
||||
|
||||
export const deriveHostedRelay = (url: string) =>
|
||||
derived<typeof user, HostedRelayState>(
|
||||
user,
|
||||
($user, set) => {
|
||||
set({loading: true, relay: undefined})
|
||||
|
||||
listTenantRelays($user.pubkey).then(
|
||||
relays => set({loading: false, relay: relays.find(r => getHostedRelayUrl(r) === url)}),
|
||||
() => set({loading: false, relay: undefined}),
|
||||
)
|
||||
},
|
||||
{loading: true, relay: undefined},
|
||||
)
|
||||
derived<Readable<TenantRelays>, HostedRelayState>(hosting.get().relays, ({loading, relays}) => ({
|
||||
loading,
|
||||
relay: relays.find(r => getHostedRelayUrl(r) === url),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {App} from "@capacitor/app"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import {indexBy, sortBy, spec} from "@welshman/lib"
|
||||
import {indexBy} from "@welshman/lib"
|
||||
import Add from "@assets/icons/add.svg?dataurl"
|
||||
import Server from "@assets/icons/server.svg?dataurl"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
|
|
@ -15,7 +14,6 @@
|
|||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import {errorMessage} from "@lib/util"
|
||||
import RelayListItem from "@app/components/hosting/RelayListItem.svelte"
|
||||
import RelayCreate from "@app/components/hosting/RelayCreate.svelte"
|
||||
import BillingPrompts from "@app/components/hosting/BillingPrompts.svelte"
|
||||
|
|
@ -28,32 +26,29 @@
|
|||
import {
|
||||
autopayConfigured,
|
||||
ensureSessionTenant,
|
||||
getDraftInvoice,
|
||||
getTenant,
|
||||
listTenantInvoices,
|
||||
listTenantRelays,
|
||||
derivePlans,
|
||||
hosting,
|
||||
hostingErrorMessage,
|
||||
reconcileTenant,
|
||||
selectPayableInvoice,
|
||||
type HostedRelay,
|
||||
type Invoice,
|
||||
type Tenant,
|
||||
} from "@app/hosting"
|
||||
|
||||
type PaymentMethodState = {kind: "not_set_up"} | {kind: "ok"} | {kind: "error"; message: string}
|
||||
|
||||
const INVOICE_PAGE_SIZE = 10
|
||||
|
||||
const plans = derivePlans()
|
||||
const plans = $hosting.plans
|
||||
const tenantRelays = $hosting.relays
|
||||
const billing = $hosting.billing
|
||||
|
||||
let tenant = $state<Maybe<Tenant>>()
|
||||
let invoices = $state<Invoice[]>([])
|
||||
let relays = $state<HostedRelay[]>([])
|
||||
let draftInvoice = $state<Maybe<Invoice>>()
|
||||
let loading = $state(true)
|
||||
let error = $state("")
|
||||
let showAllInvoices = $state(false)
|
||||
|
||||
const tenant = $derived($billing.tenant)
|
||||
const invoices = $derived($billing.invoices)
|
||||
const draftInvoice = $derived($billing.draftInvoice)
|
||||
const relays = $derived($tenantRelays.relays)
|
||||
const openInvoice = $derived(selectPayableInvoice(invoices))
|
||||
|
||||
// Whether any active relay is on a paid plan.
|
||||
|
|
@ -107,39 +102,6 @@
|
|||
showAllInvoices ? invoices : invoices.slice(0, INVOICE_PAGE_SIZE),
|
||||
)
|
||||
|
||||
const refetch = async () => {
|
||||
const pk = user.get().pubkey
|
||||
if (!pk) {
|
||||
return
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
getTenant(pk),
|
||||
listTenantInvoices(pk),
|
||||
listTenantRelays(pk),
|
||||
getDraftInvoice(pk),
|
||||
])
|
||||
|
||||
const [t, inv, rel, draft] = results
|
||||
|
||||
if (t.status === "fulfilled") {
|
||||
tenant = t.value
|
||||
}
|
||||
if (inv.status === "fulfilled") {
|
||||
invoices = sortBy(invoice => -invoice.created_at, inv.value)
|
||||
}
|
||||
if (rel.status === "fulfilled") {
|
||||
relays = rel.value
|
||||
}
|
||||
if (draft.status === "fulfilled") {
|
||||
draftInvoice = draft.value
|
||||
}
|
||||
|
||||
if (results.some(spec({status: "rejected"}))) {
|
||||
pushToast({theme: "error", message: "Failed to refresh billing data"})
|
||||
}
|
||||
}
|
||||
|
||||
// Guard so the mount effect and the foreground listener don't race two reconciles.
|
||||
let reconciling = false
|
||||
|
||||
|
|
@ -160,7 +122,7 @@
|
|||
await ensureSessionTenant()
|
||||
await reconcileTenant(pk)
|
||||
} catch (e) {
|
||||
const message = errorMessage(e) || "Failed to update billing"
|
||||
const message = hostingErrorMessage(e, "Failed to update billing")
|
||||
|
||||
if (initialized) {
|
||||
pushToast({theme: "error", message})
|
||||
|
|
@ -168,21 +130,18 @@
|
|||
error = message
|
||||
}
|
||||
} finally {
|
||||
await refetch()
|
||||
if (!(await $hosting.refresh())) {
|
||||
pushToast({theme: "error", message: "Failed to refresh billing data"})
|
||||
}
|
||||
|
||||
reconciling = false
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
const openPayInvoice = (invoice: Invoice) =>
|
||||
pushModal(PaymentDialog, {invoice, onPaid: () => void refetch()})
|
||||
const openPayInvoice = (invoice: Invoice) => pushModal(PaymentDialog, {invoice})
|
||||
|
||||
const openSetup = (initialTab: "nwc" | "card") =>
|
||||
pushModal(PaymentSetup, {
|
||||
initialTab,
|
||||
nwcConnected: Boolean(tenant?.nwc_is_set),
|
||||
onSaved: () => void refetch(),
|
||||
})
|
||||
const openSetup = (initialTab: "nwc" | "card") => pushModal(PaymentSetup, {initialTab})
|
||||
|
||||
const openNwcSetup = () => openSetup("nwc")
|
||||
|
||||
|
|
@ -190,8 +149,6 @@
|
|||
|
||||
const openCreateRelay = () => pushModal(RelayCreate)
|
||||
|
||||
const refresh = () => void refetch()
|
||||
|
||||
const toggleAllInvoices = () => {
|
||||
showAllInvoices = !showAllInvoices
|
||||
}
|
||||
|
|
@ -255,7 +212,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
{#if tenant}
|
||||
<BillingPrompts {tenant} {openInvoice} {hasPaidSubscription} onRefresh={refresh} />
|
||||
<BillingPrompts {tenant} {openInvoice} {hasPaidSubscription} />
|
||||
{/if}
|
||||
|
||||
{#if relays.length === 0}
|
||||
|
|
|
|||
Loading…
Reference in a new issue