work on white labeling with acl'd default spaces

This commit is contained in:
Jon Staab 2026-06-30 10:49:17 -07:00
parent e5c752ce9e
commit 182e2ce6fd
15 changed files with 102 additions and 73 deletions

1
.env
View file

@ -8,6 +8,7 @@ VITE_PLATFORM_PRIVACY=https://flotilla.social/privacy
VITE_PLATFORM_NAME=Flotilla VITE_PLATFORM_NAME=Flotilla
VITE_PLATFORM_LOGO=static/logo.png VITE_PLATFORM_LOGO=static/logo.png
VITE_PLATFORM_RELAYS= VITE_PLATFORM_RELAYS=
VITE_PLATFORM_RELAYS_STRICT=false
VITE_PLATFORM_ACCENT="#7161FF" VITE_PLATFORM_ACCENT="#7161FF"
VITE_THEME="clay" VITE_THEME="clay"
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities." VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."

View file

@ -22,7 +22,7 @@ You can also optionally create an `.env.local` file and populate it with the fol
**Defaults** **Defaults**
- `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust - `VITE_DEFAULT_PUBKEYS` - A comma-separated list of hex pubkeys for bootstrapping web of trust
- `VITE_DEFAULT_SPACES` - A comma-separated list of relay urls that new users will be automatically joined to on signup - `VITE_DEFAULT_SPACES` - A comma-separated list of relay urls that new users will be automatically joined to on signup. Each one may optionally include an invite code, delimited by `|`, e.g. `my.space.com|CODE`.
- `VITE_DEFAULT_RELAYS` - A comma-separated list of relay urls used as default outbox/inbox relays - `VITE_DEFAULT_RELAYS` - A comma-separated list of relay urls used as default outbox/inbox relays
- `VITE_DEFAULT_MESSAGING_RELAYS` - A comma-separated list of relay urls used for encrypted direct messages - `VITE_DEFAULT_MESSAGING_RELAYS` - A comma-separated list of relay urls used for encrypted direct messages
- `VITE_DEFAULT_BLOSSOM_SERVERS` - A comma-separated list of blossom server urls used for file uploads - `VITE_DEFAULT_BLOSSOM_SERVERS` - A comma-separated list of blossom server urls used for file uploads

View file

@ -114,7 +114,7 @@
{/snippet} {/snippet}
</FieldInline> </FieldInline>
<p class="text-sm"> <p class="text-sm">
Forgot your password? <Button class="button button-link" onclick={loginWithOTP} Forgot your password? <Button class="link" onclick={loginWithOTP}
>Log in with a one-time access code</Button >Log in with a one-time access code</Button
>. >.
</p> </p>

View file

@ -1,11 +1,16 @@
<script lang="ts"> <script lang="ts">
import {deriveRelay} from "@welshman/app" import {deriveRelay} from "@welshman/app"
import Content from "@app/components/Content.svelte"
const {...props} = $props() type Props = {
url: string
}
const relay = deriveRelay(props.url) const {url}: Props = $props()
const relay = deriveRelay(url)
</script> </script>
{#if $relay?.description} {#if $relay?.description}
<p class={props.class}>{$relay.description}</p> <Content event={{content: $relay.description, tags: []}} />
{/if} {/if}

View file

@ -24,7 +24,7 @@
<div class="relative"> <div class="relative">
<div <div
class="flex justify-center items-center h-12 w-12 min-w-12 rounded-full border-2 border-solid bg-surface-more border-line"> class="flex justify-center items-center h-12 w-12 min-w-12 rounded-full border-2 border-solid bg-surface-more border-line">
<RelayIcon {url} size={10} /> <RelayIcon {url} size={10} class="rounded-full" />
</div> </div>
</div> </div>
{#if $rooms.includes(url)} {#if $rooms.includes(url)}
@ -40,7 +40,9 @@
<p class="text-xs sm:text-sm opacity-75">{url}</p> <p class="text-xs sm:text-sm opacity-75">{url}</p>
</div> </div>
</div> </div>
<RelayDescription {url} class="text-sm sm:text-md" /> <div class="text-sm sm:text-md">
<RelayDescription {url} />
</div>
</div> </div>
{#if !hideFavorites && $favorited.size > 0} {#if !hideFavorites && $favorited.size > 0}
<div class="flex gap-2 card card-sm"> <div class="flex gap-2 card card-sm">

View file

@ -2,8 +2,9 @@
import cx from "classnames" import cx from "classnames"
import type {ClientOptions} from "@pomade/core" import type {ClientOptions} from "@pomade/core"
import type {Profile} from "@welshman/util" import type {Profile} from "@welshman/util"
import {first} from "@welshman/lib"
import {makeProfile, makeSecret, RELAYS, MESSAGING_RELAYS, makeEvent} from "@welshman/util" import {makeProfile, makeSecret, RELAYS, MESSAGING_RELAYS, makeEvent} from "@welshman/util"
import {loginWithNip01, publishThunk} from "@welshman/app" import {loginWithNip01, publishThunk, waitForThunkCompletion} from "@welshman/app"
import Key from "@assets/icons/key-minimalistic.svg?dataurl" import Key from "@assets/icons/key-minimalistic.svg?dataurl"
import Letter from "@assets/icons/letter.svg?dataurl" import Letter from "@assets/icons/letter.svg?dataurl"
import {getKey, setKey} from "@lib/implicit" import {getKey, setKey} from "@lib/implicit"
@ -17,6 +18,7 @@
import SignUpProfile from "@app/components/SignUpProfile.svelte" import SignUpProfile from "@app/components/SignUpProfile.svelte"
import SignUpComplete from "@app/components/SignUpComplete.svelte" import SignUpComplete from "@app/components/SignUpComplete.svelte"
import {initProfile} from "@app/profiles" import {initProfile} from "@app/profiles"
import {attemptRelayAccess} from "@app/relays"
import { import {
POMADE_SIGNERS, POMADE_SIGNERS,
PLATFORM_NAME, PLATFORM_NAME,
@ -39,24 +41,32 @@
const login = () => pushModal(LogIn) const login = () => pushModal(LogIn)
const completeSignup = () => { const completeSignup = async () => {
// Add default outbox/inbox relays // Join all default spaces first
publishThunk({ const defaultSpaceUrls = await Promise.all(
event: makeEvent(RELAYS, {tags: DEFAULT_RELAYS.map(url => ["r", url])}), DEFAULT_SPACES.map(async ([url, claim]) => {
relays: [...INDEXER_RELAYS, ...DEFAULT_RELAYS], await attemptRelayAccess(url, claim)
})
// Add default messaging relays return url
publishThunk({ })
event: makeEvent(MESSAGING_RELAYS, {tags: DEFAULT_MESSAGING_RELAYS.map(url => ["r", url])}), )
relays: DEFAULT_RELAYS,
})
// Save the user's profile // Add default outbox/inbox/messaging relays, profile, spaces
initProfile(getKey<Profile>("signup.profile")!) const thunks = await Promise.all([
publishThunk({
event: makeEvent(RELAYS, {tags: DEFAULT_RELAYS.map(url => ["r", url])}),
relays: [...INDEXER_RELAYS, ...DEFAULT_RELAYS],
}),
publishThunk({
event: makeEvent(MESSAGING_RELAYS, {tags: DEFAULT_MESSAGING_RELAYS.map(url => ["r", url])}),
relays: DEFAULT_RELAYS,
}),
initProfile(getKey<Profile>("signup.profile")!),
setSpaces(defaultSpaceUrls),
])
// Auto-join default spaces // Wait for all the thunks to complete
setSpaces(DEFAULT_SPACES) await Promise.all(thunks.map(waitForThunkCompletion))
// Don't show any notifications for old content // Don't show any notifications for old content
setChecked("*") setChecked("*")
@ -71,12 +81,12 @@
profile: () => pushModal(SignUpProfile, {next: flows.email.complete, step: 2, totalSteps: 3}), profile: () => pushModal(SignUpProfile, {next: flows.email.complete, step: 2, totalSteps: 3}),
complete: () => complete: () =>
pushModal(SignUpComplete, {next: flows.email.finalize, step: 3, totalSteps: 3}), pushModal(SignUpComplete, {next: flows.email.finalize, step: 3, totalSteps: 3}),
finalize: () => { finalize: async () => {
const email = getKey<string>("signup.email")! const email = getKey<string>("signup.email")!
const clientOptions = getKey<ClientOptions>("signup.clientOptions")! const clientOptions = getKey<ClientOptions>("signup.clientOptions")!
loginWithPomade(clientOptions, email) loginWithPomade(clientOptions, email)
completeSignup() await completeSignup()
}, },
}, },
nostr: { nostr: {
@ -84,11 +94,11 @@
key: () => pushModal(SignUpKey, {next: flows.nostr.complete, step: 2, totalSteps: 3}), key: () => pushModal(SignUpKey, {next: flows.nostr.complete, step: 2, totalSteps: 3}),
complete: () => complete: () =>
pushModal(SignUpComplete, {next: flows.nostr.finalize, step: 3, totalSteps: 3}), pushModal(SignUpComplete, {next: flows.nostr.finalize, step: 3, totalSteps: 3}),
finalize: () => { finalize: async () => {
const secret = getKey<string>("signup.secret")! const secret = getKey<string>("signup.secret")!
loginWithNip01(secret) loginWithNip01(secret)
completeSignup() await completeSignup()
}, },
}, },
} }

View file

@ -4,6 +4,7 @@
import HomeSmile from "@assets/icons/home-smile.svg?dataurl" import HomeSmile from "@assets/icons/home-smile.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
@ -20,9 +21,21 @@
const {next, step, totalSteps}: Props = $props() const {next, step, totalSteps}: Props = $props()
const back = () => history.back() const back = () => history.back()
const submit = async () => {
loading = true
try {
await next()
} finally {
loading = false
}
}
let loading = $state(false)
</script> </script>
<Modal tag="form" onsubmit={preventDefault(next)}> <Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody> <ModalBody>
<ModalHeader> <ModalHeader>
<ModalTitle>You're all set!</ModalTitle> <ModalTitle>You're all set!</ModalTitle>
@ -44,9 +57,13 @@
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button class="button button-primary" type="submit"> <Button class="button button-primary" type="submit" disabled={loading}>
<Icon icon={HomeSmile} /> {#if loading}
Go to Dashboard <Spinner {loading}>Go to Dashboard</Spinner>
{:else}
<Icon icon={HomeSmile} />
Go to Dashboard
{/if}
</Button> </Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>

View file

@ -134,16 +134,16 @@
</ModalHeader> </ModalHeader>
<div> <div>
{#if loading} {#if loading}
<p class="flex-justify-center items-center"> <p class="flex justify-center items-center">
<Spinner {loading}>Requesting an invite link...</Spinner> <Spinner {loading}>Requesting an invite link...</Spinner>
</p> </p>
{:else if isGenericError} {:else if isGenericError}
<p class="flex-justify-center items-center"> <p class="flex justify-center items-center">
Unable to reach the relay. Please check your connection and try again. Unable to reach the space. Please check your connection and try again.
</p> </p>
{:else if isExplicitAuthError} {:else if isExplicitAuthError}
<p class="flex-justify-center items-center"> <p class="flex justify-center items-center">
Oops! It looks like you're not a member of this relay. Oops! It looks like you're not a member of this space.
</p> </p>
{:else} {:else}
<div class="flex flex-col items-center gap-6"> <div class="flex flex-col items-center gap-6">

View file

@ -66,7 +66,7 @@
<div class="m-auto flex flex-col gap-4"> <div class="m-auto flex flex-col gap-4">
<p> <p>
This space has opted not to publish <Button This space has opted not to publish <Button
class="button button-link" class="link"
onclick={showInfoSignatures}>digital signatures</Button onclick={showInfoSignatures}>digital signatures</Button
>, which means that they have the ability to forge messages from other users. >, which means that they have the ability to forge messages from other users.
</p> </p>

View file

@ -51,7 +51,11 @@ export const POMADE_SIGNERS = fromCsv(import.meta.env.VITE_POMADE_SIGNERS)
export const DEFAULT_BLOSSOM_SERVERS = fromCsv(import.meta.env.VITE_DEFAULT_BLOSSOM_SERVERS) export const DEFAULT_BLOSSOM_SERVERS = fromCsv(import.meta.env.VITE_DEFAULT_BLOSSOM_SERVERS)
export const DEFAULT_SPACES = fromCsv(import.meta.env.VITE_DEFAULT_SPACES).map(normalizeRelayUrl) export const DEFAULT_SPACES = fromCsv(import.meta.env.VITE_DEFAULT_SPACES).map(item => {
const [url, claim = ""] = item.split('|')
return [normalizeRelayUrl(url), claim]
})
export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS

View file

@ -83,22 +83,22 @@ export const makeSpacePath = (url: string, ...extra: (string | undefined)[]) =>
return path return path
} }
export const goToSpace = (url: string) => { export const goToSpace = (url: string, hash = "") => {
const prevPath = lastPageBySpaceUrl.get(encodeRelay(url)) const prevPath = lastPageBySpaceUrl.get(encodeRelay(url))
if (prevPath && prevPath !== makeSpacePath(url)) { if (prevPath && prevPath !== makeSpacePath(url)) {
return goto(prevPath, {replaceState: true}) return goto(prevPath + hash, {replaceState: true})
} }
if (!hasNip29(getRelay(url))) { if (!hasNip29(getRelay(url))) {
return goto(makeSpaceChatPath(url), {replaceState: true}) return goto(makeSpaceChatPath(url) + hash, {replaceState: true})
} }
if (window.matchMedia(`(min-width: ${theme.screens.md})`).matches) { if (window.matchMedia(`(min-width: ${theme.screens.md})`).matches) {
return goto(makeSpacePath(url, "about"), {replaceState: true}) return goto(makeSpacePath(url, "about") + hash, {replaceState: true})
} }
return goto(makeSpacePath(url), {replaceState: true}) return goto(makeSpacePath(url) + hash, {replaceState: true})
} }
// Content types, events // Content types, events

View file

@ -49,6 +49,10 @@
@apply border border-solid; @apply border border-solid;
} }
[data-fl-theme="navy"] .card-button {
@apply border border-solid;
}
[data-fl-theme="clay"] .card-button { [data-fl-theme="clay"] .card-button {
@apply border-2 border-solid shadow-sm; @apply border-2 border-solid shadow-sm;

View file

@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {page} from "$app/stores"
import {modal} from "@app/modal"
import {goToSpace} from "@app/routes" import {goToSpace} from "@app/routes"
import {PLATFORM_RELAYS} from "@app/env" import {PLATFORM_RELAYS} from "@app/env"
onMount(async () => { $effect(() => {
if (PLATFORM_RELAYS.length > 0) { if (PLATFORM_RELAYS.length > 0) {
goToSpace(PLATFORM_RELAYS[0]) goToSpace(PLATFORM_RELAYS[0], $page.url.hash)
} else { } else {
goto("/home") goto("/home" + $page.url.hash)
} }
}) })
</script> </script>

View file

@ -142,7 +142,7 @@
{/snippet} {/snippet}
{#snippet info()} {#snippet info()}
<p> <p>
Your public key is your nostr user identifier. It also allows other people to authenticate Your public key is your user identifier. It also allows other people to authenticate
your messages. your messages.
</p> </p>
{/snippet} {/snippet}

View file

@ -13,7 +13,7 @@
import SpaceAuthError from "@app/components/SpaceAuthError.svelte" import SpaceAuthError from "@app/components/SpaceAuthError.svelte"
import SpaceTrustRelay from "@app/components/SpaceTrustRelay.svelte" import SpaceTrustRelay from "@app/components/SpaceTrustRelay.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte" import SpaceJoin from "@app/components/SpaceJoin.svelte"
import {pushModal} from "@app/modal" import {modal, pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
import {decodeRelay, deriveRelayAuthError} from "@app/relays" import {decodeRelay, deriveRelayAuthError} from "@app/relays"
import {userGroupList, userSpaceUrls} from "@app/groups" import {userGroupList, userSpaceUrls} from "@app/groups"
@ -35,34 +35,19 @@
const showPendingTrust = once(() => pushModal(SpaceTrustRelay, {url}, {noEscape: true})) const showPendingTrust = once(() => pushModal(SpaceTrustRelay, {url}, {noEscape: true}))
// Watch for relay errors and notify the user // Watch for relay errors and notify the user
$effect(() => { // Direct links skip Discover — prompt to join when relay is not in the user's space list.
if ($authError) { $effect(() => {
showAuthError()
} else if ($relaysPendingTrust.includes(url)) {
showPendingTrust()
}
})
// Direct links skip Discover — prompt to join when relay is not in the user's space list.
const shouldPromptJoin = $derived.by(() => {
void $userGroupList void $userGroupList
return ( if ($modal) return
Boolean($pubkey) && if ($authError) return showAuthError()
!$userSpaceUrls.includes(url) && if ($relaysPendingTrust.includes(url)) return showPendingTrust()
!$authError &&
!$relaysPendingTrust.includes(url)
)
})
$effect(() => { if (!$userSpaceUrls.includes(url)) {
if (!shouldPromptJoin || joinPrompted.has(url)) { joinPrompted.add(url)
return pushModal(SpaceJoin, {url})
} }
joinPrompted.add(url)
pushModal(SpaceJoin, {url})
}) })
</script> </script>