diff --git a/.env b/.env index 27598282..0492f6bd 100644 --- a/.env +++ b/.env @@ -22,3 +22,6 @@ VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social VITE_THUMBNAIL_URL=https://vthumbs.coracle.social VITE_GLITCHTIP_API_KEY= GLITCHTIP_AUTH_TOKEN= +VITE_HOSTING_BACKEND_URL=https://api.hosting.coracle.social +VITE_HOSTING_BACKEND_URL=http://127.0.0.1:2892 +VITE_HOSTING_RELAY_DOMAIN=spaces.flotilla.social diff --git a/.gitignore b/.gitignore index f3eac712..98bab524 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,7 @@ ic_stat_notify.png .vscode .claude .local +CLAUDE.md # OS generated .DS_Store diff --git a/AGENTS.md b/AGENTS.md index eb742ba3..a15e538e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ routes/ (top layer - can depend on anything) ↓ app/components/ (can depend on app/* and lib/*) ↓ -app/core/ & app/util/ (can only depend on lib/*) +app/* (can only depend on lib/*) ↓ lib/ (can only depend on external libraries) ↓ @@ -49,63 +49,18 @@ import {Dialog} from "$lib/components" import {repository} from "$app/core/state" ``` -## File Structure +## Cleanup Pass -``` -src/ -├── lib/ # Generic reusable code -│ ├── components/ # 38 UI components (Button, Dialog, etc.) -│ ├── html.ts # DOM utilities -│ ├── indexeddb.ts # IndexedDB helpers -│ └── util.ts # Generic utilities -│ -├── app/ -│ ├── core/ -│ │ ├── state.ts # State management, stores, constants (687 lines) -│ │ ├── commands.ts # Publishing events and other write operations (440+ lines) -│ │ ├── requests.ts # Loading data from network (191 lines) -│ │ ├── sync.ts # Data synchronization (296 lines) -│ │ └── storage.ts # IndexedDB setup -│ │ -│ ├── util/ -│ │ ├── notifications.ts # Push notifications (731 lines) -│ │ ├── policies.ts # Relay policies -│ │ ├── routes.ts # Routing helpers -│ │ ├── modal.ts # Modal management -│ │ ├── toast.ts # Toast notifications -│ │ ├── theme.ts # Theme switching -│ │ └── keyboard.ts # Keyboard handling -│ │ -│ ├── editor/ # Rich text editor config -│ │ ├── index.ts # TipTap setup with Nostr integration -│ │ ├── EditorContent.svelte -│ │ └── MentionNodeView.ts -│ │ -│ └── components/ # 188 app-specific components -│ ├── Space*.svelte # Space/relay management -│ ├── Room*.svelte # Room/channel management -│ ├── Chat*.svelte # Direct messaging -│ ├── Profile*.svelte # User profiles -│ ├── Thread*.svelte # Threaded posts -│ └── ... -│ -├── routes/ # SvelteKit file-based routing -│ ├── +layout.svelte # Root layout (sync logic here) -│ ├── spaces/ # Space management -│ │ └── [relay]/ # Specific space -│ │ ├── chat/ # Space chat -│ │ ├── threads/ # Thread posts -│ │ ├── calendar/ # Events -│ │ └── [h]/ # Specific room (h = room id) -│ ├── chat/ # Direct messages -│ ├── settings/ # User settings -│ └── [bech32]/ # Bech32 entity viewer -│ -├── assets/icons/ # ~1,277 SVG icons -├── app.html # HTML template -├── app.css # Global styles -└── types.d.ts # Type definitions -``` +These are the things that most often get fixed by hand after the fact. Go through +them before handing work back. The full style reference is under Development +Conventions below. + +- **Comments** — only for genuinely surprising things: a workaround, a constraint imposed by a backend or platform, an invariant that isn't visible from the code in front of you. Never comment props, never restate what the next line does, never justify an ordinary decision. If a small refactor would make the comment stale, don't write it. +- **Used once, inlined** — a derived value, helper, type, or named constant with a single use is indirection. Write `setTimeout(pollOnce, 3500)`, not a `POLL_INTERVAL` referenced twice in one file. Name something only when the name is what makes the code readable. +- **Positive conditionals** — prefer `if (ready) { ... }` over `if (!ready) return`. Nesting is fine; when it gets deep that's the signal the function is doing too much, so split it. Many early returns belong in validation or pipeline functions, not everywhere else. +- **Truthiness** — test values directly (`if (invoice.paid_at)`) instead of comparing against `null`/`undefined`, and put the truthy branch first in a ternary. +- **Standard names** — `loading` for in-flight state, `on*` for handlers bound to an event, a plain verb (`submit`, `save`) for the action itself. Use `{prop}` shorthand when the names match. +- **Errors** — toast a human-readable message and `console.error` anything unexpected (e.g. a non-`HostingError`). Never swallow an error you didn't anticipate. ## State Management @@ -161,24 +116,27 @@ src/ - **No `null`** - only use `undefined` - Svelte 5 runes (`$state`, `$derived`, `$effect`) only in UI components -- TailwindCSS and DaisyUI styling -- Only add comments for really weird stuff -- Do not call functions in components unless a parameter is reactive. Instead, use a svelte store or rune to make it reactive. +- TailwindCSS styling with css components customized by theme. See lib/components for examples. +- Comments, naming, conditionals and single-use indirection are covered by the Cleanup Pass above. - Do not use `any`. If there are type errors related to `unknown`, they are likely because the upstream definition of the data is incorrect. - When dynamically building classes, use `cx` from `classnames` rather than embedded ternaries or svelte 4's old `class:` syntax. - When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss - Do not define svelte event handlers inline, instead name them and put them in the script section of templates +- Write a `{#if}`/`{:else if}` chain rather than hoisting display strings into a lookup `Record` in the script section. - Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly. - Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)` - Do not render a profile's `about` directly (e.g. `profile.about`); use the `ProfileAbout` component instead. - Use `type Props` instead of interface when defining props for svelte components. - When a component's value/prop shape mirrors a subset of an existing type, derive it with `Pick`/`Partial` and `export` that type from the component's ` @@ -129,25 +98,7 @@

Icon

{/snippet} {#snippet input()} -
- {#if imagePreview} -
- Selected: - -
- {:else} - No icon selected - {/if} -
- - - - -
-
+ {/snippet} @@ -157,7 +108,7 @@ {#snippet input()} diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index 77405604..23978ec4 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -10,7 +10,9 @@ import Home from "@assets/icons/home.svg?dataurl" import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl" import Danger from "@assets/icons/danger.svg?dataurl" + import ServerPath from "@assets/icons/server-path.svg?dataurl" import LinkRound from "@assets/icons/link-round.svg?dataurl" + import Pen from "@assets/icons/pen.svg?dataurl" import Exit from "@assets/icons/logout-3.svg?dataurl" import Letter from "@assets/icons/letter.svg?dataurl" import Login from "@assets/icons/login-3.svg?dataurl" @@ -33,6 +35,7 @@ import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte" import SpaceInvite from "@app/components/SpaceInvite.svelte" import SpaceExit from "@app/components/SpaceExit.svelte" + import SpaceEdit from "@app/components/SpaceEdit.svelte" import SpaceJoin from "@app/components/SpaceJoin.svelte" import RelayName from "@app/components/RelayName.svelte" import SpaceActionItems from "@app/components/SpaceActionItems.svelte" @@ -43,6 +46,7 @@ import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte" import {ENABLE_ZAPS} from "@app/env" import {CONTENT_KINDS} from "@app/content" + import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting" import {deriveUserCanCreateRoom, deriveUserIsSpaceAdmin} from "@app/members" import { deriveUserRooms, @@ -78,6 +82,7 @@ const otherRooms = deriveOtherRooms(url) const otherVoiceRooms = deriveOtherVoiceRooms(url) const userIsAdmin = deriveUserIsSpaceAdmin(url) + const hostedRelay = deriveHostedRelay(url) const actionItems = deriveSpaceActionItems(url) const spaceKinds = derived( @@ -109,6 +114,8 @@ showMenu = !showMenu } + const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}}) + const showActionItems = () => pushModal(SpaceActionItems, {url}) const openSearch = () => pushModal(SpaceSearch, {url}) @@ -208,6 +215,21 @@ {/if} + {#if HOSTING_ENABLED && $hostedRelay.relay} +
  • + + + Hosting settings + +
  • + {:else if $userIsAdmin} +
  • + +
  • + {/if}
  • {#if $userSpaceUrls.includes(url)} + + + diff --git a/src/app/components/hosting/ActivityFeed.svelte b/src/app/components/hosting/ActivityFeed.svelte new file mode 100644 index 00000000..ea38316b --- /dev/null +++ b/src/app/components/hosting/ActivityFeed.svelte @@ -0,0 +1,57 @@ + + +
    +
    + + Activity +
    + {#if loading} +

    Loading activity...

    + {:else if activity.length === 0} +

    No activity yet.

    + {:else} +
      + {#each activity as item (item.id)} +
    1. + +
      +

      + {#if item.activity_type === "create_relay"} + Relay created + {:else if item.activity_type === "update_relay"} + Relay updated + {:else if item.activity_type === "deactivate_relay"} + Relay deactivated + {:else if item.activity_type === "activate_relay"} + Relay activated + {:else if item.activity_type === "fail_relay_sync"} + Relay sync failed + {:else if item.activity_type === "complete_relay_sync"} + Relay sync completed + {:else if item.activity_type === "create_tenant"} + Account created + {:else if item.activity_type === "update_tenant"} + Account updated + {:else} + {item.activity_type.replace(/_/g, " ")} + {/if} +

      + +
      +
    2. + {/each} +
    + {/if} +
    diff --git a/src/app/components/hosting/BillingPrompts.svelte b/src/app/components/hosting/BillingPrompts.svelte new file mode 100644 index 00000000..196ceab9 --- /dev/null +++ b/src/app/components/hosting/BillingPrompts.svelte @@ -0,0 +1,157 @@ + + +{#snippet card(className: string, icon: string, message: string, actions: Snippet)} +
    +
    + +

    {message}

    +
    +
    + {@render actions()} +
    +
    +{/snippet} + +{#snippet churnedActions()} + {#if openInvoice} + + {/if} + +{/snippet} + +{#snippet payInvoiceActions()} + +{/snippet} + +{#snippet updateMethodActions()} + +{/snippet} + +{#snippet autopayActions()} + + +{/snippet} + +{#if tenant} + {#if tenant.churned_at} + {@render card( + "card-secondary", + DangerTriangle, + "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} + {@render card( + "card-secondary", + DangerCircle, + "You have an unpaid invoice. Pay it now to keep your relays running.", + payInvoiceActions, + )} + {:else if tenant.nwc_is_set && tenant.nwc_error} + {@render card( + "card-secondary", + DangerCircle, + "Your Lightning wallet couldn't be charged. Update your payment method.", + updateMethodActions, + )} + {:else if tenant.stripe_payment_method_id && tenant.stripe_error} + {@render card( + "card-secondary", + DangerCircle, + "Your card couldn't be charged. Update your payment method.", + updateMethodActions, + )} + {:else if hasPaidSubscription && !autopayConfigured(tenant) && !suppressInline && !dismissedAutopay} + {@render card( + "card-primary", + InfoCircle, + "Set up automatic payments so your subscription renews without interruption.", + autopayActions, + )} + {/if} +{/if} diff --git a/src/app/components/hosting/CustomDomainModal.svelte b/src/app/components/hosting/CustomDomainModal.svelte new file mode 100644 index 00000000..df8f2e35 --- /dev/null +++ b/src/app/components/hosting/CustomDomainModal.svelte @@ -0,0 +1,126 @@ + + + + + + Custom domain + + Point a domain you control at this relay instead of the default + {canonicalRelayHost(current)} address. + + + + + {#snippet label()} + +

    Domain

    + {/snippet} + {#snippet secondary()} + {#if current.custom_domain && flagToBool(current.custom_domain_verified, false)} + + + Verified + + {/if} + {/snippet} + {#snippet input()} + + {#snippet after()} + {#if domain} + + {/if} + {/snippet} + + {/snippet} + {#snippet info()} +

    Must be a domain you control, e.g. relay.example.com.

    + {/snippet} +
    +
    + + + + +
    diff --git a/src/app/components/hosting/InvoiceItemsList.svelte b/src/app/components/hosting/InvoiceItemsList.svelte new file mode 100644 index 00000000..937700b9 --- /dev/null +++ b/src/app/components/hosting/InvoiceItemsList.svelte @@ -0,0 +1,21 @@ + + +
    +

    On this invoice

    +
      + {#each items as item (item.id)} +
    • + {item.description} + {formatUsd(item.amount)} +
    • + {/each} +
    +
    diff --git a/src/app/components/hosting/InvoiceListItem.svelte b/src/app/components/hosting/InvoiceListItem.svelte new file mode 100644 index 00000000..7bce5275 --- /dev/null +++ b/src/app/components/hosting/InvoiceListItem.svelte @@ -0,0 +1,53 @@ + + +
  • +
    +
    + {formatUsd(invoice.amount)} + {status} + {#if invoice.method} + · paid via {invoice.method === "stripe" ? "Card" : "Lightning"} + {/if} +
    + {#if periodLabel} +

    {periodLabel}

    + {/if} + {#if isDraft} +

    + Charges accruing this period. You'll be invoiced once a balance is due. +

    + {/if} +
    + {#if isOpen && onPay} +
    + +
    + {/if} +
  • diff --git a/src/app/components/hosting/PaymentDialog.svelte b/src/app/components/hosting/PaymentDialog.svelte new file mode 100644 index 00000000..e7c36cde --- /dev/null +++ b/src/app/components/hosting/PaymentDialog.svelte @@ -0,0 +1,307 @@ + + + + + + Pay invoice + + {#if periodLabel} + Billing period {periodLabel} + {:else} + Complete your payment to keep your relays running. + {/if} + + + +

    {amountLabel}

    + + {#if paid} +
    + +

    Payment confirmed!

    +

    Thank you. Your account is up to date.

    +
    + {:else} + {#if items.length > 0} + + {/if} +
    + + +
    + {#if method === "lightning"} + {#if bolt11Status === "idle" || bolt11Status === "loading"} +
    + Generating invoice… +
    + {:else if bolt11Status === "error"} +
    +

    Unable to generate invoice

    +

    {bolt11Error}

    + +
    + {:else} +
    + +

    + Scan with a Lightning wallet, or copy the invoice manually. +

    +
    + + + {/if} + {:else} +
    +
    + +
    +

    + Pay this invoice on Stripe's secure checkout. You'll be redirected and brought back here + once it's done. +

    + +
    + {/if} + {/if} +
    + + {#if paid} +
    + + {:else} + + {#if method === "lightning"} + + {/if} + {/if} +
    +
    diff --git a/src/app/components/hosting/PaymentSetup.svelte b/src/app/components/hosting/PaymentSetup.svelte new file mode 100644 index 00000000..bcac3127 --- /dev/null +++ b/src/app/components/hosting/PaymentSetup.svelte @@ -0,0 +1,217 @@ + + + + + + Set Up Payments + + Choose how you'd like to pay once invoices are issued for your relay. + + +
    + + +
    +
    + {#if tab === "nwc"} + {#if saved} +
    + +

    Wallet connected!

    +

    Automatic payments are now enabled.

    +
    + {:else} + {#if nwcConnected} +

    + A Lightning wallet is connected. Paste a new URL to replace it, or disconnect. +

    + {/if} + + {#snippet label()} + Nostr Wallet Connect URL + {/snippet} + {#snippet input()} + + {/snippet} + + {#if nwcConnected} + + {:else if spendingWalletUrl} + + {/if} + {/if} + {:else} +
    + +

    + Add a payment card via Stripe to enable automatic billing. If an invoice is currently + due, we will retry collection after card setup. +

    +
    + {/if} +
    +
    + + {#if saved} +
    + + {:else} + + {#if tab === "nwc"} + + {:else} + + {/if} + {/if} +
    +
    diff --git a/src/app/components/hosting/PlanModal.svelte b/src/app/components/hosting/PlanModal.svelte new file mode 100644 index 00000000..eb056222 --- /dev/null +++ b/src/app/components/hosting/PlanModal.svelte @@ -0,0 +1,116 @@ + + + + + + Change plan + Pick a plan for this space. + + + + + + + + diff --git a/src/app/components/hosting/PricingTable.svelte b/src/app/components/hosting/PricingTable.svelte new file mode 100644 index 00000000..4b449fc1 --- /dev/null +++ b/src/app/components/hosting/PricingTable.svelte @@ -0,0 +1,126 @@ + + +{#snippet feature(present: boolean, label: string)} +
  • + + {label} +
  • +{/snippet} + +{#snippet cardBody(plan: Plan, isPopular: boolean)} + {#if isPopular && !selectable} + + POPULAR + + {/if} +

    {plan.name}

    +
    + {priceLabel(plan.amount)} + / mo +
    +
      +
    • + + {memberLabel(plan.members)} +
    • + {@render feature(plan.blossom, "Blossom storage")} + {@render feature(plan.livekit, "LiveKit video")} +
    + {#if !selectable} + + {/if} +{/snippet} + +
    +
    + {#each visiblePlans as plan (plan.id)} + {@const isPopular = plan.id === "basic"} + {#if selectable} + + {:else} +
    + {@render cardBody(plan, isPopular)} +
    + {/if} + {/each} + {#if !selectable} +
    +

    Custom

    +
    + Let's talk +
    +
      +
    • + + White-labeled app +
    • +
    • + + Dedicated support +
    • +
    • + + Custom feature development +
    • +
    + + Contact us + +
    + {/if} +
    +
    diff --git a/src/app/components/hosting/RelayCreate.svelte b/src/app/components/hosting/RelayCreate.svelte new file mode 100644 index 00000000..893dd222 --- /dev/null +++ b/src/app/components/hosting/RelayCreate.svelte @@ -0,0 +1,73 @@ + + + diff --git a/src/app/components/hosting/RelayDetailCard.svelte b/src/app/components/hosting/RelayDetailCard.svelte new file mode 100644 index 00000000..fb316e8f --- /dev/null +++ b/src/app/components/hosting/RelayDetailCard.svelte @@ -0,0 +1,515 @@ + + +
    +
    +
    +
    + +
    +
    +
    +

    {relayLabel}

    + + {ucFirst(current.status.replace(/_/g, " "))} + + + {ucFirst(current.plan_id)} + +
    + + wss://{host} + + {#if current.info_description.trim()} +

    {current.info_description}

    + {/if} +
    +
    + +
    + + {#if showMenu} + + + + {/if} +
    +
    + + {#if current.sync_error} +
    +

    + + Provisioning error +

    +

    {current.sync_error}

    +
    + {/if} + + + +
    +

    Custom Domain

    +
    + {#if current.custom_domain} +
    + {current.custom_domain} + {#if domainVerified} + + + Verified + + {:else} + + + Pending + + {/if} +
    + + {:else} + Not configured + + {/if} +
    + {#if current.custom_domain && !domainVerified} +
    +
    + +

    + Not yet verified — add this DNS record, then verify. +

    +
    +
    + + {current.custom_domain} CNAME {cnameTarget} + + +
    +

    + For apex domains (e.g. example.com), use an ALIAS or ANAME record instead. +

    + +
    + {/if} +
    + + + +
    +

    Policy

    +
    + + {#snippet label()} + Public read + {/snippet} + {#snippet input()} + + {/snippet} + + + {#snippet label()} + Public write + {/snippet} + {#snippet input()} + + {/snippet} + + + {#snippet label()} + Public join + {/snippet} + {#snippet input()} + + {/snippet} + + + {#snippet label()} + Strip signatures + {/snippet} + {#snippet input()} + + {/snippet} + +
    +
    + + + +
    +

    Features

    +
    + + {#snippet label()} + Rooms + {/snippet} + {#snippet input()} + + {/snippet} + + + {#snippet label()} + Management API + {/snippet} + {#snippet input()} + + {/snippet} + + + {#snippet label()} + Push notifications + {/snippet} + {#snippet input()} + + {/snippet} + + + {#snippet label()} + Media storage + {/snippet} + {#snippet input()} + + {/snippet} + + + {#snippet label()} + LiveKit support + {/snippet} + {#snippet input()} + + {/snippet} + +
    +
    + + + +
    +

    Membership

    +
    + + {#snippet label()} + Current members + {/snippet} + {#snippet input()} + {$members.length} + {/snippet} + + + {#snippet label()} + Member limit + {/snippet} + {#snippet input()} + {@const plan = $plans.find(spec({id: current.plan_id}))} + + {typeof plan?.members === "number" ? plan.members : "∞"} + + {/snippet} + +
    +
    + + + +
    +

    Plan

    + + {#snippet label()} + Current plan + {/snippet} + {#snippet input()} + {ucFirst(current.plan_id)} + {/snippet} + +
    +
    diff --git a/src/app/components/hosting/RelayForm.svelte b/src/app/components/hosting/RelayForm.svelte new file mode 100644 index 00000000..df6749b8 --- /dev/null +++ b/src/app/components/hosting/RelayForm.svelte @@ -0,0 +1,233 @@ + + + + + + + {#if header} + {@render header()} + {:else} + + {mode === "create" ? "New Space" : "Edit Space"} + + {#if mode === "create"} + Configure your hosted space and choose a plan. + {:else} + Update your space's details. + {/if} + + + {/if} +
    + + {#snippet label()} +

    Name

    + {/snippet} + {#snippet input()} + + {/snippet} +
    + + {#snippet label()} +

    Subdomain

    + {/snippet} + {#snippet input()} + + {#snippet after()} + .{zooidDomain} + {/snippet} + + {#if subdomain && subdomainError} +

    {subdomainError}

    + {/if} + {/snippet} +
    + + {#snippet label()} +

    Icon

    + {/snippet} + {#snippet input()} + + {/snippet} +
    + + {#snippet label()} +

    Description

    + {/snippet} + {#snippet input()} + + {/snippet} +
    + {#if mode === "create"} + + {#snippet label()} +

    Plan

    + {/snippet} + {#snippet input()} + + {/snippet} +
    + {/if} +
    +
    + + + + +
    diff --git a/src/app/components/hosting/RelayListItem.svelte b/src/app/components/hosting/RelayListItem.svelte new file mode 100644 index 00000000..83c758c5 --- /dev/null +++ b/src/app/components/hosting/RelayListItem.svelte @@ -0,0 +1,40 @@ + + +
    + +
    +

    {name}

    +

    {host}

    +
    +
    + {#if relay.sync_error} + Failed to sync + {:else} + + {ucFirst(relay.status.replace(/_/g, " "))} + + {/if} + + {ucFirst(relay.plan_id)} + +
    + Manage +
    diff --git a/src/app/env.ts b/src/app/env.ts index fa441c23..ccdcd593 100644 --- a/src/app/env.ts +++ b/src/app/env.ts @@ -55,6 +55,10 @@ export const DEFAULT_SPACES = fromCsv(import.meta.env.VITE_DEFAULT_SPACES).map(n export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS +export const HOSTING_BACKEND_URL = import.meta.env.VITE_HOSTING_BACKEND_URL + +export const HOSTING_RELAY_DOMAIN = import.meta.env.VITE_HOSTING_RELAY_DOMAIN + export const DUFFLEPUD_URL = "https://dufflepud.coracle.social" export const THUMBNAIL_URL = import.meta.env.VITE_THUMBNAIL_URL diff --git a/src/app/groups.ts b/src/app/groups.ts index ba8fd9af..24a62139 100644 --- a/src/app/groups.ts +++ b/src/app/groups.ts @@ -325,6 +325,41 @@ export const setSpaces = async (urls: string[]) => { return publishThunk({event, relays}) } +export const moveSpace = async (oldUrl: string, newUrl: string) => { + const from = normalizeRelayUrl(oldUrl) + const to = normalizeRelayUrl(newUrl) + const list = get(userGroupList) + + if (!list || from === to) return + + const rewrite = (tags: string[][]) => + tags.map(t => { + if (t[0] === "r" && normalizeRelayUrl(t[1]) === from) return ["r", to] + if (t[0] === "group" && normalizeRelayUrl(t[2] || "") === from) return ["group", t[1], to] + return t + }) + + const publicTags = rewrite(list.publicTags) + const privateTags = rewrite(list.privateTags) + const publicChanged = !equals(publicTags, list.publicTags) + const privateChanged = !equals(privateTags, list.privateTags) + + if (!publicChanged && !privateChanged) return + + const event = await updateList(list, { + publicTags: publicChanged ? publicTags : undefined, + privateTags: privateChanged ? privateTags : undefined, + }).reconcile(nip44EncryptToSelf) + const relays = uniq([ + oldUrl, + newUrl, + ...Router.get().FromUser().getUrls(), + ...getRelayTagValues(event.tags), + ]) + + return publishThunk({event, relays}) +} + export const addRoom = async (url: string, h: string) => { const list = get(userGroupList) || makeList({kind: ROOMS}) const newTags = [ diff --git a/src/app/hosting.ts b/src/app/hosting.ts new file mode 100644 index 00000000..6f937ed0 --- /dev/null +++ b/src/app/hosting.ts @@ -0,0 +1,418 @@ +import {derived, readable} 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 {makeHttpAuth, makeHttpAuthHeader, normalizeRelayUrl} from "@welshman/util" +import {pubkey, signer} from "@welshman/app" +import {HOSTING_BACKEND_URL, PLATFORM_URL} from "@app/env" + +// Apple doesn't allow selling hosting outside their payment system, so on iOS we +// point people at the platform's website instead. +export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios" + +export type Plan = { + id: string + name: string + amount: number + hidden: boolean + members: number | null + blossom: boolean + livekit: boolean +} + +export type Relay = { + id: string + tenant_pubkey: string + subdomain: string + zooid_domain: string + plan_id: string + status: string + sync_error: string + synced: number + custom_domain: string + custom_domain_verified: number + 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 + blossom_enabled: number + livekit_enabled: number + push_enabled: number +} + +// The relay's boolean settings, which the backend models as 0/1 columns. +export type RelayFlag = + | "policy_public_read" + | "policy_public_write" + | "policy_public_join" + | "policy_strip_signatures" + | "groups_enabled" + | "management_enabled" + | "blossom_enabled" + | "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 + blossom_enabled?: number + livekit_enabled?: number + push_enabled?: number + custom_domain?: string +} + +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 + blossom_enabled?: number + livekit_enabled?: number + push_enabled?: number + custom_domain?: string +} + +export type Tenant = { + pubkey: string + return_url: string + nwc_is_set: boolean + created_at: number + billing_anchor: number | null + stripe_customer_id: string + stripe_payment_method_id: string | null + nwc_error: string | null + stripe_error: string | null + churned_at: number | null +} + +export type UpdateTenantInput = { + nwc_url?: string +} + +export type InvoiceMethod = "nwc" | "stripe" | "oob" + +export type Invoice = { + id: string + tenant_pubkey: string + amount: number + period_start: number + period_end: number + created_at: number + paid_at: number | null + voided_at: number | null + method: InvoiceMethod | null +} + +export type InvoiceItem = { + id: string + invoice_id: string | null + activity_id: string | null + tenant_pubkey: string + relay_id: string + plan_id: string + amount: number + description: string + created_at: number + voided_at: number | null +} + +export type Bolt11 = { + id: string + invoice_id: string + lnbc: string + msats: number + created_at: number + expires_at: number + settled_at: number | null +} + +export type Activity = { + id: string + tenant_pubkey: string + created_at: number + activity_type: string + resource_type: string + resource_id: string +} + +export class HostingError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.name = "HostingError" + this.status = status + } +} + +// Requests + +const AUTH_TTL = ms(int(10, MINUTE)) + +// The backend checks the signature and `u` tag but not freshness, so one token +// per pubkey is reusable. Cache the promise rather than the value so a burst of +// concurrent requests shares a single signature, and key it on the pubkey so +// switching accounts doesn't keep signing as the old one. +let cachedAuth: Maybe<{pubkey: string; expiresAt: number; header: Promise}> + +const getAuthHeader = (): Maybe> => { + const $pubkey = pubkey.get() + const $signer = signer.get() + + if (!$pubkey || !$signer) return undefined + + if (cachedAuth?.pubkey !== $pubkey || cachedAuth.expiresAt < Date.now()) { + const header = makeHttpAuth(HOSTING_BACKEND_URL, "GET") + .then(template => $signer.sign(template)) + .then(makeHttpAuthHeader) + + // A declined signature shouldn't be cached, or the user can't retry. + header.catch(() => { + if (cachedAuth?.header === header) cachedAuth = undefined + }) + + cachedAuth = {pubkey: $pubkey, expiresAt: Date.now() + AUTH_TTL, header} + } + + return cachedAuth.header +} + +type HostingResponse = {data: T; error?: string} + +export const hostingFetch = async (method: string, path: string, body?: unknown): Promise => { + const auth = await getAuthHeader() + const headers: Record = {Accept: "application/json"} + + if (auth) headers["Authorization"] = auth + if (body !== undefined) headers["Content-Type"] = "application/json" + + const response = await fetch(new URL(path, HOSTING_BACKEND_URL).toString(), { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }) + + const payload: Maybe> = await response.json().catch(() => undefined) + + if (!response.ok || !payload) { + throw new HostingError(payload?.error || `Request failed (${response.status})`, response.status) + } + + return payload.data +} + +export const listPlans = () => hostingFetch("GET", "/plans") + +export const createTenant = (returnUrl: string) => + hostingFetch("POST", "/tenants", {return_url: returnUrl}) + +export const getTenant = (pubkey: string) => hostingFetch("GET", `/tenants/${pubkey}`) + +export const updateTenant = (pubkey: string, input: UpdateTenantInput) => + hostingFetch("PUT", `/tenants/${pubkey}`, input) + +export const listTenantRelays = (pubkey: string) => + hostingFetch("GET", `/tenants/${pubkey}/relays`) + +export const listTenantInvoices = (pubkey: string) => + hostingFetch("GET", `/tenants/${pubkey}/invoices`) + +export const reconcileTenant = (pubkey: string) => + hostingFetch("POST", `/tenants/${pubkey}/reconcile`) + +// The backend answers with null when nothing has accrued this period. +export const getDraftInvoice = async (pubkey: string): Promise> => + (await hostingFetch("GET", `/tenants/${pubkey}/invoices/draft`)) ?? undefined + +export const createPortalSession = (pubkey: string, returnUrl: string = hostingReturnUrl()) => + hostingFetch<{url: string}>( + "GET", + `/tenants/${pubkey}/stripe/session?return_url=${encodeURIComponent(returnUrl)}`, + ) + +export const createRelay = (input: CreateRelayInput) => + hostingFetch("POST", "/relays", input) + +export const getRelay = (id: string) => hostingFetch("GET", `/relays/${id}`) + +export const updateRelay = (id: string, input: UpdateRelayInput) => + hostingFetch("PUT", `/relays/${id}`, input) + +export const deactivateRelay = (id: string) => + hostingFetch("POST", `/relays/${id}/deactivate`) + +export const reactivateRelay = (id: string) => + hostingFetch("POST", `/relays/${id}/reactivate`) + +export const listRelayMembers = (id: string) => + hostingFetch<{members: string[]}>("GET", `/relays/${id}/members`) + +export const listRelayActivity = (id: string) => + hostingFetch<{activity: Activity[]}>("GET", `/relays/${id}/activity`) + +export const getInvoice = (id: string) => hostingFetch("GET", `/invoices/${id}`) + +export const listInvoiceItems = (invoiceId: string) => + hostingFetch("GET", `/invoices/${invoiceId}/items`) + +export const ensureInvoiceBolt11 = (invoiceId: string) => + hostingFetch("POST", `/invoices/${invoiceId}/bolt11`) + +export const createInvoiceCheckout = (invoiceId: string) => { + const returnUrl = hostingReturnUrl() + const queryString = `?return_url=${encodeURIComponent(returnUrl)}` + + return hostingFetch<{url: string}>("POST", `/invoices/${invoiceId}/checkout${queryString}`) +} + +export const reconcileInvoice = (invoiceId: string) => + hostingFetch("POST", `/invoices/${invoiceId}/reconcile`) + +// API utils + +export const flagToBool = (value: number | undefined, fallback: boolean): boolean => { + if (value === 0) return false + if (value === 1) return true + return fallback +} + +export const boolToFlag = (value: boolean): 0 | 1 => (value ? 1 : 0) + +export const autopayConfigured = ( + t: Pick, +): boolean => t.nwc_is_set || Boolean(t.stripe_payment_method_id) + +// The oldest open positive invoice, matching the backend's dunning order. The +// backend models the lifecycle as timestamps, not a status field. +export const selectPayableInvoice = (invoices: Invoice[]): Invoice | undefined => + sortBy(invoice => invoice.created_at, invoices).find( + invoice => !invoice.paid_at && !invoice.voided_at && invoice.amount > 0, + ) + +export const formatUsd = (cents: number) => `$${(cents / 100).toFixed(2)}` + +export const formatPeriod = (startSecs?: number, endSecs?: number) => + !startSecs || !endSecs + ? "" + : `${new Date(ms(startSecs)).toLocaleDateString()} – ${new Date(ms(endSecs)).toLocaleDateString()}` + +// A verified custom domain when present, otherwise the platform subdomain. +export const relayHost = (relay: Relay): string => + relay.custom_domain_verified && relay.custom_domain + ? relay.custom_domain + : canonicalRelayHost(relay) + +// The host a custom domain must CNAME to. +export const canonicalRelayHost = (relay: Relay): string => + `${relay.subdomain}.${relay.zooid_domain}` + +export const getHostedRelayUrl = (relay: Relay): string => + normalizeRelayUrl("wss://" + relayHost(relay)) + +// On native, redirects can't round-trip through location.origin; use the +// platform's hosted url. +export const hostingReturnUrl = (path = "/settings/hosting"): string => + Capacitor.isNativePlatform() ? PLATFORM_URL + path : location.origin + path + +// Provisioning is idempotent, so this runs once per login. The shared promise +// dedupes concurrent callers, and a failure drops the cache so the next call +// retries. +let tenantPromise: Maybe<{pubkey: string; promise: Promise}> + +export const ensureSessionTenant = async () => { + const $pubkey = pubkey.get() + + if (!$pubkey) return + + if (tenantPromise?.pubkey !== $pubkey) { + const promise = createTenant(hostingReturnUrl()) + + promise.catch(() => { + if (tenantPromise?.promise === promise) tenantPromise = undefined + }) + + tenantPromise = {pubkey: $pubkey, promise} + } + + await tenantPromise.promise +} + +// Stores + +export const derivePlans = () => + readable([], set => { + listPlans().then(set, () => set([])) + }) + +export const deriveRelayMembers = (id: string) => + readable([], set => { + listRelayMembers(id).then( + ({members}) => set(members), + () => set([]), + ) + }) + +export type RelayActivityState = {loading: boolean; activity: Activity[]} + +// Takes a store because the id comes from the hosted relay lookup, which hasn't +// resolved when the page mounts. +export const deriveRelayActivity = (id: Readable>) => + derived>, RelayActivityState>( + id, + ($id, set) => { + if (!$id) return set({loading: false, activity: []}) + + set({loading: true, activity: []}) + + listRelayActivity($id).then( + ({activity}) => set({loading: false, activity}), + () => set({loading: false, activity: []}), + ) + }, + {loading: false, activity: []}, + ) + +// Loading is distinct from "not hosted here" — both leave `relay` undefined, but +// only one of them should keep the caller waiting. +export type HostedRelayState = {loading: boolean; relay: Maybe} + +export const deriveHostedRelay = (url: string) => + derived( + pubkey, + ($pubkey, set) => { + if (!$pubkey) return set({loading: false, relay: undefined}) + + set({loading: true, relay: undefined}) + + listTenantRelays($pubkey).then( + relays => set({loading: false, relay: relays.find(r => getHostedRelayUrl(r) === url)}), + () => set({loading: false, relay: undefined}), + ) + }, + {loading: true, relay: undefined}, + ) diff --git a/src/app/members.ts b/src/app/members.ts index 155fa042..326cdce6 100644 --- a/src/app/members.ts +++ b/src/app/members.ts @@ -1,4 +1,4 @@ -import {derived, writable} from "svelte/store" +import {derived, readable, writable} from "svelte/store" import { ManagementMethod, RELAY_ADD_MEMBER, @@ -20,7 +20,7 @@ import { sortEventsAsc, } from "@welshman/util" import type {Filter, PublishedRoomMeta, TrustedEvent} from "@welshman/util" -import {first, memoize, sortBy, spec, uniq} from "@welshman/lib" +import {first, sortBy, spec, uniq} from "@welshman/lib" import {addRoomMember, manageRelay, pubkey, waitForThunkError} from "@welshman/app" import {load} from "@welshman/net" import {get} from "svelte/store" @@ -266,17 +266,14 @@ export enum MembershipStatus { Granted, } -export const deriveUserIsSpaceAdmin = memoize((url?: string) => { - const store = writable(false) - - if (url) { - manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}).then(res => - store.set(Boolean(res.result?.length)), - ) - } - - return store -}) +export const deriveUserIsSpaceAdmin = (url?: string) => + readable(false, set => { + if (url) { + manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}).then(res => + set(Boolean(res.result?.length)), + ) + } + }) export const deriveUserSpaceMembershipStatus = (url: string) => { // Fetch member list and user add/remove events directly in this derivation. diff --git a/src/app/modal.ts b/src/app/modal.ts index 4dea8e5f..c321de60 100644 --- a/src/app/modal.ts +++ b/src/app/modal.ts @@ -4,12 +4,14 @@ import {randomId, always, assoc, Emitter} from "@welshman/lib" import {deriveDeduplicated} from "@welshman/store" import {goto} from "$app/navigation" import {page} from "$app/stores" +import type {DialogSize} from "@lib/components/Dialog.svelte" export type ModalOptions = { drawer?: boolean nested?: boolean noEscape?: boolean fullscreen?: boolean + size?: DialogSize replaceState?: boolean path?: string } diff --git a/src/app/relays.ts b/src/app/relays.ts index 9f9160ea..07b6eddc 100644 --- a/src/app/relays.ts +++ b/src/app/relays.ts @@ -8,6 +8,7 @@ import { import type {List, RelayProfile} from "@welshman/util" import {AuthStateEvent, AuthStatus, Pool, SocketEvent, SocketStatus} from "@welshman/net" import {derived, readable} from "svelte/store" +import type {Maybe} from "@welshman/lib" import {call, on, simpleCache, uniq} from "@welshman/lib" import {throttled} from "@welshman/store" import {getRelay, loadRelay, manageRelay, publishThunk, waitForThunkError} from "@welshman/app" @@ -33,6 +34,25 @@ export const encodeRelay = (url: string) => export const decodeRelay = (url: string) => normalizeRelayUrl(decodeURIComponent(url)) +// A hosted relay whose custom domain changed keeps 301-ing its old host to the +// new one; the NIP-11 fetch surfaces that (the socket can't). Returns the new +// wss:// url, or undefined when there's no redirect or the fetch fails. +export const fetchRelayRedirect = async (url: string): Promise> => { + try { + const response = await fetch(url.replace(/^ws/, "http"), { + headers: {Accept: "application/nostr+json"}, + }) + + if (!response.redirected) return undefined + + const next = normalizeRelayUrl(response.url.replace(/^http/, "ws")) + + return next === normalizeRelayUrl(url) ? undefined : next + } catch { + return undefined + } +} + export const deriveSocket = (url: string) => { const socket = Pool.get().get(url) diff --git a/src/app/routes.ts b/src/app/routes.ts index 722cb6d8..95dddef7 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -102,6 +102,9 @@ export const goToSpace = (url: string, hash = "") => { return goto(makeSpacePath(url) + hash, {replaceState: true}) } +export const goToMovedSpace = (oldUrl: string, newUrl: string) => + goto(get(page).url.pathname.replace(encodeRelay(oldUrl), encodeRelay(newUrl))) + export const goToHome = () => { if (PLATFORM_RELAYS.length > 0) { goToSpace(PLATFORM_RELAYS[0], get(page).url.hash) diff --git a/src/app/social.ts b/src/app/social.ts index e4e6ca04..bd2a3497 100644 --- a/src/app/social.ts +++ b/src/app/social.ts @@ -33,7 +33,7 @@ export const isEventMuted = withGetter( const regex = mutedWords.length > 0 ? new RegExp(`\\b(${mutedWords.map(w => w.toLowerCase().trim()).join("|")})\\b`) - : null + : undefined return (e: TrustedEvent) => { if (!pubkey) return false diff --git a/src/app/title.ts b/src/app/title.ts index c5399b68..4e605200 100644 --- a/src/app/title.ts +++ b/src/app/title.ts @@ -22,6 +22,7 @@ const staticTitles = new Map([ ["/spaces/[relay]/calendar", "Calendar"], ["/spaces/[relay]/goals", "Goals"], ["/spaces/[relay]/polls", "Polls"], + ["/spaces/[relay]/admin", "Space Administration"], ["/chat", "Messages"], ["/join", "Join Space"], ["/people", "Find People"], @@ -30,6 +31,7 @@ const staticTitles = new Map([ ["/settings/content", "Content Settings"], ["/settings/privacy", "Privacy Settings"], ["/settings/relays", "Relay Settings"], + ["/settings/hosting", "Hosting"], ["/settings/alerts", "Alert Settings"], ["/settings/wallet", "Wallet Settings"], ["/[bech32]", "Opening Link"], diff --git a/src/app/toast.ts b/src/app/toast.ts index f71d9f62..4de3af57 100644 --- a/src/app/toast.ts +++ b/src/app/toast.ts @@ -1,5 +1,6 @@ import type {Component} from "svelte" import {writable} from "svelte/store" +import type {Maybe} from "@welshman/lib" import {randomId} from "@welshman/lib" import {copyToClipboard} from "@lib/html" @@ -21,7 +22,7 @@ export type Toast = ToastParams & { id: string } -export const toast = writable(null) +export const toast = writable>() export const pushToast = (params: ToastParams) => { const id = randomId() @@ -33,7 +34,7 @@ export const pushToast = (params: ToastParams) => { return id } -export const popToast = (id: string) => toast.update($t => ($t?.id === id ? null : $t)) +export const popToast = (id: string) => toast.update($t => ($t?.id === id ? undefined : $t)) export const clip = (value: string) => { copyToClipboard(value) diff --git a/src/lib/components/DateTimeInput.svelte b/src/lib/components/DateTimeInput.svelte index e048e168..101cc6d4 100644 --- a/src/lib/components/DateTimeInput.svelte +++ b/src/lib/components/DateTimeInput.svelte @@ -17,7 +17,7 @@ // selected via its time input; we expose unix seconds let element: HTMLElement let isOpen = $state(false) - let startDate: number | null = $state(value ? value * 1000 : null) + let startDate: number | undefined = $state(value ? value * 1000 : undefined) let startDateTime = $state("") // When no date is provided the library initializes its time input from the @@ -31,7 +31,7 @@ } const clear = () => { - startDate = null + startDate = undefined isOpen = false } diff --git a/src/lib/components/DateTimeRangeInput.svelte b/src/lib/components/DateTimeRangeInput.svelte index c9ef5d49..03978db9 100644 --- a/src/lib/components/DateTimeRangeInput.svelte +++ b/src/lib/components/DateTimeRangeInput.svelte @@ -16,8 +16,8 @@ // selected via its time inputs; we expose unix seconds let element: HTMLElement let isOpen = $state(false) - let startDate: number | null = $state(start ? start * 1000 : null) - let endDate: number | null = $state(end ? end * 1000 : null) + let startDate: number | undefined = $state(start ? start * 1000 : undefined) + let endDate: number | undefined = $state(end ? end * 1000 : undefined) let startDateTime = $state("") let endDateTime = $state("") diff --git a/src/lib/components/Dialog.svelte b/src/lib/components/Dialog.svelte index 930d1fbf..fb4e6b05 100644 --- a/src/lib/components/Dialog.svelte +++ b/src/lib/components/Dialog.svelte @@ -1,3 +1,7 @@ + + + +
    + {#if preview} +
    + Selected: + +
    + {:else} + No icon selected + {/if} +
    + + + + +
    +
    diff --git a/src/lib/components/ImagesInput.svelte b/src/lib/components/ImagesInput.svelte index a8e75e0e..e4134e84 100644 --- a/src/lib/components/ImagesInput.svelte +++ b/src/lib/components/ImagesInput.svelte @@ -1,4 +1,5 @@ diff --git a/src/lib/components/InputList.svelte b/src/lib/components/InputList.svelte index 2e217bed..d17510bc 100644 --- a/src/lib/components/InputList.svelte +++ b/src/lib/components/InputList.svelte @@ -1,5 +1,6 @@ diff --git a/src/lib/components/ToggleInput.svelte b/src/lib/components/ToggleInput.svelte new file mode 100644 index 00000000..d48dfb40 --- /dev/null +++ b/src/lib/components/ToggleInput.svelte @@ -0,0 +1,29 @@ + + +{#snippet toggle()} + +{/snippet} + + +{#if tooltip} + + {@render toggle()} + +{:else} + {@render toggle()} +{/if} diff --git a/src/lib/components/card.css b/src/lib/components/card.css index 25b64222..be53bb92 100644 --- a/src/lib/components/card.css +++ b/src/lib/components/card.css @@ -2,8 +2,6 @@ .card { @apply text-content bg-surface border-line rounded-3xl border-solid p-6; border-width: var(--border-thick); - /* clip fill to the padding box so the opaque border owns the rounded - corners — otherwise the bg bleeds past the arc and specks white */ background-clip: padding-box; box-shadow: var(--shadow-lg); transition: @@ -12,7 +10,7 @@ border-color 0.2s ease; &.card-sm { - @apply rounded-2xl p-4; + @apply p-4; box-shadow: var(--shadow); } @@ -27,6 +25,20 @@ &.card-flat { box-shadow: none; } + + &.card-primary { + --accent: var(--primary); + } + + &.card-secondary { + --accent: var(--secondary); + } + + &.card-primary, + &.card-secondary { + background-color: color-mix(in srgb, var(--accent) 10%, var(--surface)); + border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); + } } /* nested cards & cards in dialogs lose their shadow */ @@ -44,8 +56,22 @@ box-shadow: var(--shadow), var(--shadow-inset); } +[data-fl-theme="clay"] .card-primary, +[data-fl-theme="clay"] .card-secondary { + box-shadow: + 8px 8px 0 color-mix(in srgb, var(--accent) 28%, transparent), + var(--shadow-inset); +} + +[data-fl-theme="clay"] .card-sm.card-primary, +[data-fl-theme="clay"] .card-sm.card-secondary { + box-shadow: + 6px 6px 0 color-mix(in srgb, var(--accent) 28%, transparent), + var(--shadow-inset); +} + /* cards nested in another card (or a dialog) drop the raised clay treatment and - read flat — no offset shadow / inset, hairline border */ + read flat — no offset shadow / inset, hairline border. */ [data-fl-theme="clay"] .card .card, [data-fl-theme="clay"] .dialog .card { box-shadow: none; diff --git a/src/lib/html.ts b/src/lib/html.ts index 6056494f..3a571ca1 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -89,8 +89,7 @@ export const createScroller = ({ : element.closest(".scroll-container") const check = async () => { - const isHidden = (el: Element) => - (el as HTMLElement).offsetParent === null || el.clientHeight === 0 + const isHidden = (el: Element) => !(el as HTMLElement).offsetParent || el.clientHeight === 0 if (container && !isHidden(container)) { // While we have empty space, fill it diff --git a/src/lib/transition.ts b/src/lib/transition.ts index 4b0acbb2..66863b12 100644 --- a/src/lib/transition.ts +++ b/src/lib/transition.ts @@ -95,7 +95,3 @@ export const scale = ( return `transform: scale(${s}); opacity: ${t}` }, }) - -export const conditionalTransition = - (condition: any, transition: any) => (node: any, args?: any) => - condition ? transition(node, args) : null diff --git a/src/routes/settings/+layout.svelte b/src/routes/settings/+layout.svelte index 1418b2df..cda6f761 100644 --- a/src/routes/settings/+layout.svelte +++ b/src/routes/settings/+layout.svelte @@ -3,7 +3,8 @@ import {Capacitor} from "@capacitor/core" import UserCircle from "@assets/icons/user-circle.svg?dataurl" import Wallet from "@assets/icons/wallet.svg?dataurl" - import Server from "@assets/icons/server.svg?dataurl" + import ServerPath from "@assets/icons/server-path.svg?dataurl" + import RemoteControlMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl" import Moon from "@assets/icons/moon.svg?dataurl" import Code2 from "@assets/icons/code-2.svg?dataurl" import Exit from "@assets/icons/logout-3.svg?dataurl" @@ -16,6 +17,7 @@ import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte" import LogOut from "@app/components/LogOut.svelte" + import {HOSTING_ENABLED} from "@app/hosting" import {pushModal} from "@app/modal" import {theme} from "@app/theme" @@ -47,8 +49,13 @@ {/if} - Relays + Relays + {#if HOSTING_ENABLED} + + Hosting + + {/if} Content diff --git a/src/routes/settings/alerts/+page.svelte b/src/routes/settings/alerts/+page.svelte index 58abafa9..b1db36fa 100644 --- a/src/routes/settings/alerts/+page.svelte +++ b/src/routes/settings/alerts/+page.svelte @@ -6,6 +6,7 @@ import Bell from "@assets/icons/bell.svg?dataurl" import {preventDefault} from "@lib/html" import FieldInline from "@lib/components/FieldInline.svelte" + import ToggleInput from "@lib/components/ToggleInput.svelte" import Spinner from "@lib/components/Spinner.svelte" import Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" @@ -72,7 +73,7 @@

    Show badge for unread alerts

    {/snippet} {#snippet input()} - + {/snippet}
    {/if} @@ -83,7 +84,7 @@

    Play sound for new activity

    {/snippet} {#snippet input()} - + {/snippet} {/if} @@ -92,7 +93,7 @@

    Enable push notifications

    {/snippet} {#snippet input()} - + {/snippet} @@ -106,7 +107,7 @@

    Notify me about new activity

    {/snippet} {#snippet input()} - + {/snippet} @@ -114,7 +115,7 @@

    Always notify me when mentioned

    {/snippet} {#snippet input()} - + {/snippet}
    @@ -122,7 +123,7 @@

    Notify me about new messages

    {/snippet} {#snippet input()} - + {/snippet}
    diff --git a/src/routes/settings/content/+page.svelte b/src/routes/settings/content/+page.svelte index b043d88e..dbaeb72d 100644 --- a/src/routes/settings/content/+page.svelte +++ b/src/routes/settings/content/+page.svelte @@ -15,6 +15,7 @@ import {preventDefault} from "@lib/html" import Field from "@lib/components/Field.svelte" import FieldInline from "@lib/components/FieldInline.svelte" + import ToggleInput from "@lib/components/ToggleInput.svelte" import InputList from "@lib/components/InputList.svelte" import Icon from "@lib/components/Icon.svelte" import Card from "@lib/components/Card.svelte" @@ -68,7 +69,7 @@

    Hide sensitive content?

    {/snippet} {#snippet input()} - + {/snippet} {#snippet info()}

    @@ -81,7 +82,7 @@

    Show media?

    {/snippet} {#snippet input()} - + {/snippet} {#snippet info()}

    Use this to disable link previews and image rendering.

    diff --git a/src/routes/settings/hosting/+page.svelte b/src/routes/settings/hosting/+page.svelte new file mode 100644 index 00000000..2f4247a6 --- /dev/null +++ b/src/routes/settings/hosting/+page.svelte @@ -0,0 +1,294 @@ + + +{#snippet methodRow(title: string, icon: string, state: PaymentMethodState, onAction: () => void)} +
  • +
    + +
    +

    {title}

    + {#if state.kind === "ok"} +

    Connected

    + {:else if state.kind === "error"} +

    {state.message}

    + {:else} +

    Not set up

    + {/if} +
    +
    + +
  • +{/snippet} + + +
    + + + Your Spaces + + +
    + {#if loading} +
    + Loading your spaces… +
    + {:else if error} +
    + + {error} +
    + {:else} + {#if tenant} + + {/if} + + {#if relays.length === 0} +
    +

    You don't host any spaces yet.

    +

    Create a space to get started.

    +
    + {:else} +
    + {#each relays as relay (relay.id)} + + {/each} +
    + {/if} + +
    +
    + + + Payment Methods + + {#if tenant} + + {status} + + {/if} +
    +
      + {@render methodRow("Lightning (NWC)", Bolt, nwc, openNwcSetup)} + {@render methodRow("Card", Card, card, openCardSetup)} +
    +
    + +
    + + + Payment History + + {#if invoices.length === 0 && !draftInvoice} +

    No invoices yet.

    + {:else} +
      + {#if draftInvoice} + + {/if} + {#each visibleInvoices as invoice (invoice.id)} + openPayInvoice(invoice)} /> + {/each} + {#if invoices.length > INVOICE_PAGE_SIZE} +
    • + +
    • + {/if} +
    + {/if} +
    + {/if} +
    diff --git a/src/routes/settings/privacy/+page.svelte b/src/routes/settings/privacy/+page.svelte index b6f0de9e..8a395a4f 100644 --- a/src/routes/settings/privacy/+page.svelte +++ b/src/routes/settings/privacy/+page.svelte @@ -2,6 +2,7 @@ import ShieldMinimalistic from "@assets/icons/shield-minimalistic.svg?dataurl" import {preventDefault} from "@lib/html" import FieldInline from "@lib/components/FieldInline.svelte" + import ToggleInput from "@lib/components/ToggleInput.svelte" import Icon from "@lib/components/Icon.svelte" import Card from "@lib/components/Card.svelte" import PageContent from "@lib/components/PageContent.svelte" @@ -14,10 +15,8 @@ settings = {...$userSettingsValues} } - const onAuthModeChange = (e: Event) => { - const target = e.currentTarget as HTMLInputElement - - settings.relay_auth = target.checked ? RelayAuthMode.Aggressive : RelayAuthMode.Conservative + const onAuthModeChange = (checked: boolean) => { + settings.relay_auth = checked ? RelayAuthMode.Aggressive : RelayAuthMode.Conservative } const onsubmit = preventDefault(async () => { @@ -41,11 +40,9 @@

    Authenticate with unknown relays?

    {/snippet} {#snippet input()} - + {/snippet} {#snippet info()}

    Controls whether {PLATFORM_NAME} will identify you to relays not in your lists.

    @@ -56,7 +53,7 @@

    Report errors?

    {/snippet} {#snippet input()} - + {/snippet} {#snippet info()}

    Allow {PLATFORM_NAME} to send error reports to help improve the app.

    @@ -67,7 +64,7 @@

    Report usage?

    {/snippet} {#snippet input()} - + {/snippet} {#snippet info()}

    Allow {PLATFORM_NAME} to collect anonymous usage data.

    diff --git a/src/routes/spaces/[relay]/+layout.svelte b/src/routes/spaces/[relay]/+layout.svelte index 3b56f5a0..c54fb299 100644 --- a/src/routes/spaces/[relay]/+layout.svelte +++ b/src/routes/spaces/[relay]/+layout.svelte @@ -1,10 +1,12 @@ @@ -105,12 +97,6 @@ {/if} {/if} - {#if $userIsAdmin} - - {/if}
    diff --git a/src/routes/spaces/[relay]/admin/+page.svelte b/src/routes/spaces/[relay]/admin/+page.svelte new file mode 100644 index 00000000..6a9d3a2a --- /dev/null +++ b/src/routes/spaces/[relay]/admin/+page.svelte @@ -0,0 +1,49 @@ + + + + + {#if $hostedRelay.relay} + + + {:else if $hostedRelay.loading} +
    + Loading hosting details... +
    + {:else} +
    + +
    +

    Not a Coracle-hosted space

    +

    + This space isn't hosted by Coracle Hosting, or you don't administer it. Only the owner of + a hosted relay can manage its plan, policies, and billing here. +

    +
    + Go to Hosting settings +
    + {/if} +
    diff --git a/src/routes/spaces/create/+page.svelte b/src/routes/spaces/create/+page.svelte index 87cbda45..619026c4 100644 --- a/src/routes/spaces/create/+page.svelte +++ b/src/routes/spaces/create/+page.svelte @@ -3,11 +3,18 @@ import CheckCircle from "@assets/icons/check-circle.svg?dataurl" import ArrowRight from "@assets/icons/arrow-right.svg?dataurl" import Link from "@lib/components/Link.svelte" + import Button from "@lib/components/Button.svelte" import Badge from "@lib/components/Badge.svelte" import Icon from "@lib/components/Icon.svelte" import Page from "@lib/components/Page.svelte" import PageHeader from "@lib/components/PageHeader.svelte" import PageContent from "@lib/components/PageContent.svelte" + import RelayCreate from "@app/components/hosting/RelayCreate.svelte" + import {PLATFORM_LOGO, PLATFORM_NAME, PLATFORM_URL} from "@app/env" + import {HOSTING_ENABLED} from "@app/hosting" + import {pushModal} from "@app/modal" + + const openCreate = () => pushModal(RelayCreate, {}, {size: 'large'}) @@ -24,9 +31,55 @@
    +
    +
    +
    + {PLATFORM_NAME} Logo + Recommended +
    +
    +

    + {PLATFORM_NAME} Hosting +

    +
    FULLY MANAGED
    +
    +

    + The premium experience. We handle the infrastructure, security updates, and scaling so + you can focus on your community. +

    +
    +
      +
    • + + One-click deployment +
    • +
    • + + Automated backups & scaling +
    • +
    • + + Priority support +
    • +
    + {#if HOSTING_ENABLED} + + {:else} + + Start for free + + + {/if} +
    -
    +
    @@ -60,51 +113,14 @@
    -
    -
    -
    - Coracle Logo - Recommended -
    -
    -

    Coracle Hosting

    -
    FULLY MANAGED
    -
    -

    - The premium experience. We handle the infrastructure, security updates, and scaling so - you can focus on your community. -

    -
    -
      -
    • - - One-click deployment -
    • -
    • - - Automated backups & scaling -
    • -
    • - - Priority support -
    • -
    - - Start for free - - -
    - Want to host on other servers? + Looking for third party managed hosting? - Other hosting options + Try relay.tools