UI tweaks, change how role colors work

This commit is contained in:
Jon Staab 2026-07-01 10:23:23 -07:00
parent 182e2ce6fd
commit c88a981fb6
23 changed files with 149 additions and 89 deletions

3
.env
View file

@ -8,10 +8,9 @@ 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="The chat app built for self-hosted communities."
VITE_PUSH_SERVER=https://nps.flotilla.social/ VITE_PUSH_SERVER=https://nps.flotilla.social/
VITE_PUSH_BRIDGE=wss://npb.coracle.social/ VITE_PUSH_BRIDGE=wss://npb.coracle.social/
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one,blastr.f7z.xyz,relay.current.fyi

View file

@ -9,7 +9,7 @@
import CardButton from "@lib/components/CardButton.svelte" import CardButton from "@lib/components/CardButton.svelte"
import LogIn from "@app/components/LogIn.svelte" import LogIn from "@app/components/LogIn.svelte"
import SignUp from "@app/components/SignUp.svelte" import SignUp from "@app/components/SignUp.svelte"
import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME} from "@app/env" import {PLATFORM_TERMS, PLATFORM_PRIVACY, PLATFORM_NAME, PLATFORM_DESCRIPTION} from "@app/env"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const logIn = () => pushModal(LogIn) const logIn = () => pushModal(LogIn)
@ -21,7 +21,7 @@
<ModalBody> <ModalBody>
<div class="py-2"> <div class="py-2">
<h1 class="heading">Welcome to {PLATFORM_NAME}!</h1> <h1 class="heading">Welcome to {PLATFORM_NAME}!</h1>
<p class="text-center">The chat app built for self-hosted communities.</p> <p class="text-center">{PLATFORM_DESCRIPTION}</p>
</div> </div>
<Button onclick={logIn}> <Button onclick={logIn}>
<CardButton primary> <CardButton primary>

View file

@ -37,7 +37,7 @@
{#if PLATFORM_RELAYS.length > 0} {#if PLATFORM_RELAYS.length > 0}
<Divider /> <Divider />
{/if} {/if}
<div class="flex flex-col"> <div class="flex flex-col items-center">
<PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings"> <PrimaryNavItem title="Settings" href="/settings/profile" prefix="/settings">
{#if $userProfile?.picture} {#if $userProfile?.picture}
<ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} /> <ImageIcon alt="Settings" src={$userProfile?.picture} class="rounded-full" size={10} />

View file

@ -29,7 +29,7 @@
<svelte:window bind:innerHeight={windowHeight} /> <svelte:window bind:innerHeight={windowHeight} />
<div class="flex flex-col"> <div class="flex flex-col items-center">
{#each PLATFORM_RELAYS as url (url)} {#each PLATFORM_RELAYS as url (url)}
<PrimaryNavItemSpace {url} /> <PrimaryNavItemSpace {url} />
{:else} {:else}

View file

@ -3,6 +3,7 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Card from "@lib/components/Card.svelte" import Card from "@lib/components/Card.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
import {displayUrl} from "@welshman/lib" import {displayUrl} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {deriveRelay, deriveRelayStats} from "@welshman/app" import {deriveRelay, deriveRelayStats} from "@welshman/app"
@ -23,7 +24,7 @@
{@render children?.()} {@render children?.()}
</div> </div>
{#if $relay?.description} {#if $relay?.description}
<p class="ellipsize">{$relay.description}</p> <ContentMinimal singleLine event={{content: $relay.description, tags: []}} />
{/if} {/if}
<span class="flex items-center gap-1 whitespace-nowrap text-sm"> <span class="flex items-center gap-1 whitespace-nowrap text-sm">
{#if $relay?.contact} {#if $relay?.contact}

View file

@ -9,7 +9,7 @@
</script> </script>
<div <div
class="badge min-w-0" class="badge w-fit min-w-0"
style="background-color: {roleColorSoft(role.color)}; border-color: {roleColor( style="background-color: {roleColorSoft(role.color)}; border-color: {roleColor(
role.color, role.color,
)}; color: {roleColor(role.color)};"> )}; color: {roleColor(role.color)};">

View file

@ -1,5 +1,5 @@
<script module lang="ts"> <script module lang="ts">
import type {SpaceRole} from "@app/members" import type {SpaceRole, SpaceRoleColor} from "@app/members"
export type Values = Pick<SpaceRole, "label" | "description" | "color"> export type Values = Pick<SpaceRole, "label" | "description" | "color">
</script> </script>
@ -21,16 +21,34 @@
const {initialValues = {}, loading = false, onSubmit}: Props = $props() const {initialValues = {}, loading = false, onSubmit}: Props = $props()
const values: Values = $state({ const values = $state({
label: "", label: initialValues.label ?? "",
description: "", description: initialValues.description ?? "",
color: Math.floor(Math.random() * 256), })
...initialValues,
// Preserve any unedited components from the existing color; the form edits hue and lightness.
const baseColor: SpaceRoleColor = {
hue: "",
saturation: "",
lightness: "",
...initialValues.color,
}
const initialHue = parseInt(baseColor.hue, 10)
const initialLightness = parseFloat(baseColor.lightness)
let hue = $state(isNaN(initialHue) ? Math.floor(Math.random() * 360) : initialHue)
let lightness = $state(isNaN(initialLightness) ? 0.5 : initialLightness)
const color = $derived<SpaceRoleColor>({
...baseColor,
hue: String(hue),
lightness: String(lightness),
}) })
const back = () => history.back() const back = () => history.back()
const submit = () => onSubmit(values) const submit = () => onSubmit({...values, color})
</script> </script>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
@ -58,15 +76,25 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div <div
class="h-8 w-8 shrink-0 rounded-full border-2" class="h-8 w-8 shrink-0 rounded-full border-2"
style="background-color: {roleColor(values.color)}; border-color: var(--line)"> style="background-color: {roleColor(color)}; border-color: var(--line)">
</div>
<div class="flex grow flex-col gap-2">
<input
type="range"
min="0"
max="360"
bind:value={hue}
class="range"
style="color: {roleColor(color)}; --range-shdw: {roleColor(color)}" />
<input
type="range"
min="0"
max="1"
step="0.01"
bind:value={lightness}
class="range"
style="color: {roleColor(color)}; --range-shdw: {roleColor(color)}" />
</div> </div>
<input
type="range"
min="0"
max="255"
bind:value={values.color}
class="range grow"
style="color: {roleColor(values.color)}; --range-shdw: {roleColor(values.color)}" />
</div> </div>
{/snippet} {/snippet}
</Field> </Field>

View file

@ -9,7 +9,7 @@
const {role}: Props = $props() const {role}: Props = $props()
</script> </script>
<div class="flex min-w-0 flex-col gap-2"> <div class="flex min-w-0 flex-col gap-2 justify-between">
<RoleBadge {role} /> <RoleBadge {role} />
{#if role.description} {#if role.description}
<p class="text-sm opacity-70">{role.description}</p> <p class="text-sm opacity-70">{role.description}</p>

View file

@ -69,7 +69,7 @@
<Card sm class="flex flex-col gap-4"> <Card sm class="flex flex-col gap-4">
<div class="flex justify-between gap-2"> <div class="flex justify-between gap-2">
<div> <div>
<Button class="button button-link inline text-primary" onclick={showProfile}> <Button class="link text-primary" onclick={showProfile}>
<ProfileName pubkey={event.pubkey} {url} /> <ProfileName pubkey={event.pubkey} {url} />
</Button> </Button>
<span> <span>

View file

@ -2,7 +2,6 @@
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, waitForThunkCompletion} 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"
@ -48,7 +47,7 @@
await attemptRelayAccess(url, claim) await attemptRelayAccess(url, claim)
return url return url
}) }),
) )
// Add default outbox/inbox/messaging relays, profile, spaces // Add default outbox/inbox/messaging relays, profile, spaces

View file

@ -65,9 +65,8 @@
</ModalHeader> </ModalHeader>
<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="link" onclick={showInfoSignatures}
class="link" >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>
<p> <p>

View file

@ -52,7 +52,7 @@ 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(item => { export const DEFAULT_SPACES = fromCsv(import.meta.env.VITE_DEFAULT_SPACES).map(item => {
const [url, claim = ""] = item.split('|') const [url, claim = ""] = item.split("|")
return [normalizeRelayUrl(url), claim] return [normalizeRelayUrl(url), claim]
}) })

View file

@ -32,20 +32,60 @@ export const deriveSpaceMembers = (url: string) =>
export const RELAY_ROLE = 33534 export const RELAY_ROLE = 33534
// An hsl color tuple. Any component may be an empty string, in which case the
// client supplies its own default. Usually only `hue` is set per the spec.
export type SpaceRoleColor = {
hue: string // 0 to 360
saturation: string // 0 to 1
lightness: string // 0 to 1
}
export type SpaceRole = { export type SpaceRole = {
id: string id: string
label: string label: string
description: string description: string
color: number color: SpaceRoleColor
order: number order: number
} }
// hue is 0-255; map to 0-360deg. Saturation/lightness chosen to read on both themes. // Defaults filled in for empty color components, chosen to read on both themes.
export const roleColorHue = (color: number) => (((color % 256) + 256) % 256) * (360 / 256) const DEFAULT_SATURATION = 0.7
const DEFAULT_LIGHTNESS = 0.5
export const roleColor = (color: number) => `hsl(${roleColorHue(color)}, 70%, 50%)` const roleColorValue = (value: string, fallback: number) => {
const parsed = parseFloat(value)
export const roleColorSoft = (color: number) => `hsl(${roleColorHue(color)}, 70%, 90%)` return isNaN(parsed) ? fallback : parsed
}
// Parse the ["color", hue, saturation, lightness] tag, preserving empty values.
export const parseRoleColor = (tags: string[][]): SpaceRoleColor => {
const tag = first(getTags("color", tags)) ?? []
return {
hue: tag[1] ?? "",
saturation: tag[2] ?? "",
lightness: tag[3] ?? "",
}
}
// Build an hsl() string from a role color, using the role's values or our defaults.
export const roleColor = (color: SpaceRoleColor) => {
const h = roleColorValue(color.hue, 0)
const s = roleColorValue(color.saturation, DEFAULT_SATURATION)
const l = roleColorValue(color.lightness, DEFAULT_LIGHTNESS)
return `hsl(${h}, ${s * 100}%, ${l * 100}%)`
}
// A translucent tint of the role color for use as a background fill.
export const roleColorSoft = (color: SpaceRoleColor) => {
const h = roleColorValue(color.hue, 0)
const s = roleColorValue(color.saturation, DEFAULT_SATURATION)
const l = roleColorValue(color.lightness, DEFAULT_LIGHTNESS)
return `hsl(${h}, ${s * 100}%, ${l * 100}%, 0.15)`
}
export const deriveSpaceRoles = (url: string) => export const deriveSpaceRoles = (url: string) =>
derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_ROLE]}]), $events => { derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_ROLE]}]), $events => {
@ -59,7 +99,7 @@ export const deriveSpaceRoles = (url: string) =>
id, id,
label: getTagValue("label", event.tags) ?? "", label: getTagValue("label", event.tags) ?? "",
description: getTagValue("description", event.tags) ?? "", description: getTagValue("description", event.tags) ?? "",
color: parseInt(getTagValue("color", event.tags) ?? "0", 10) || 0, color: parseRoleColor(event.tags),
order: parseInt(getTagValue("order", event.tags) ?? "0", 10) || 0, order: parseInt(getTagValue("order", event.tags) ?? "0", 10) || 0,
}) })
} }
@ -87,17 +127,21 @@ export const deriveSpaceMemberRoles = (url: string) =>
return memberRoles return memberRoles
}) })
// Flatten a color tuple into management params, mirroring the color tag's value order.
const roleColorParams = (color: SpaceRoleColor) =>
[color.hue, color.saturation, color.lightness] as unknown as string
export const createRole = async ( export const createRole = async (
url: string, url: string,
id: string, id: string,
label: string, label: string,
description: string, description: string,
color: number, color: SpaceRoleColor,
order: number, order: number,
): Promise<string | undefined> => { ): Promise<string | undefined> => {
const {error} = await manageRelay(url, { const {error} = await manageRelay(url, {
method: "createrole" as ManagementMethod, method: "createrole" as ManagementMethod,
params: [id, label, description, color.toString(), order.toString()], params: [id, label, description, roleColorParams(color), order.toString()],
}) })
return error return error
@ -108,12 +152,12 @@ export const editRole = async (
id: string, id: string,
label: string, label: string,
description: string, description: string,
color: number, color: SpaceRoleColor,
order: number, order: number,
): Promise<string | undefined> => { ): Promise<string | undefined> => {
const {error} = await manageRelay(url, { const {error} = await manageRelay(url, {
method: "editrole" as ManagementMethod, method: "editrole" as ManagementMethod,
params: [id, label, description, color.toString(), order.toString()], params: [id, label, description, roleColorParams(color), order.toString()],
}) })
return error return error

View file

@ -18,7 +18,7 @@ import {notificationSettings} from "@app/settings"
import {chatsById} from "@app/chats" import {chatsById} from "@app/chats"
import {userGroupList, getSpaceUrlsFromGroupList} from "@app/groups" import {userGroupList, getSpaceUrlsFromGroupList} from "@app/groups"
import {hasNip29} from "@app/relays" import {hasNip29} from "@app/relays"
import {dufflepud, DUFFLEPUD_URL} from "@app/env" import {dufflepud, DUFFLEPUD_URL, PLATFORM_RELAYS} from "@app/env"
import {kv} from "@app/storage" import {kv} from "@app/storage"
import {page} from "$app/stores" import {page} from "$app/stores"
@ -224,7 +224,10 @@ export const allNotifications = derived(
} }
} }
for (const url of getSpaceUrlsFromGroupList($userGroupList)) { const urls =
PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : getSpaceUrlsFromGroupList($userGroupList)
for (const url of urls) {
const spacePath = makeSpacePath(url) const spacePath = makeSpacePath(url)
const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values()) const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values())

View file

@ -45,7 +45,7 @@ import {
hasNegentropy, hasNegentropy,
} from "@welshman/app" } from "@welshman/app"
import {REACTION_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content" import {REACTION_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content"
import {INDEXER_RELAYS} from "@app/env" import {INDEXER_RELAYS, PLATFORM_RELAYS} from "@app/env"
import {loadSettings} from "@app/settings" import {loadSettings} from "@app/settings"
import { import {
loadGroupList, loadGroupList,
@ -210,8 +210,10 @@ const syncUserData = () => {
const syncGroupList = ($userGroupList: List | undefined) => { const syncGroupList = ($userGroupList: List | undefined) => {
if ($userGroupList) { if ($userGroupList) {
const keys = new Set<string>() const keys = new Set<string>()
const urls =
PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : getSpaceUrlsFromGroupList($userGroupList)
for (const url of getSpaceUrlsFromGroupList($userGroupList)) { for (const url of urls) {
if (!unsubscribersByKey.has(url)) { if (!unsubscribersByKey.has(url)) {
unsubscribersByKey.set(url, syncUserSpaceMembership(url)) unsubscribersByKey.set(url, syncUserSpaceMembership(url))
} }
@ -310,7 +312,9 @@ const syncSpaces = () => {
const unsubscribersByUrl = new Map<string, Unsubscriber>() const unsubscribersByUrl = new Map<string, Unsubscriber>()
const unsubscribe = store.subscribe(([$userGroupList, $page]) => { const unsubscribe = store.subscribe(([$userGroupList, $page]) => {
const urls = new Set(getSpaceUrlsFromGroupList($userGroupList)) const urls = new Set(
PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : getSpaceUrlsFromGroupList($userGroupList),
)
const currentUrl = $page.params.relay ? decodeRelay($page.params.relay) : undefined const currentUrl = $page.params.relay ? decodeRelay($page.params.relay) : undefined
if (currentUrl) { if (currentUrl) {

View file

@ -58,7 +58,7 @@
<div> <div>
{#each value as item (item)} {#each value as item (item)}
<Badge variant="neutral" class="mr-1"> <Badge variant="neutral" class="mr-1">
<Button class="button button-ghost flex items-center" onclick={() => removeItem(item)}> <Button class="p-1 flex items-center" onclick={() => removeItem(item)}>
<Icon icon={CloseCircle} size={4} class="-ml-1 mt-px" /> <Icon icon={CloseCircle} size={4} class="-ml-1 mt-px" />
</Button> </Button>
<span>{item}</span> <span>{item}</span>

View file

@ -3,7 +3,7 @@
@apply ml-sai mb-sai mt-sai mr-sai relative flex min-w-0 grow flex-col md:mb-0 md:ml-0; @apply ml-sai mb-sai mt-sai mr-sai relative flex min-w-0 grow flex-col md:mb-0 md:ml-0;
.page__content { .page__content {
@apply z-feature mb-14 flex max-h-screen min-h-0 w-full min-w-0 flex-col gap-2 overflow-x-hidden overflow-y-auto sm:gap-4 md:mb-0; @apply z-feature mb-14 flex h-full max-h-screen min-h-0 w-full min-w-0 flex-col gap-2 overflow-x-hidden overflow-y-auto sm:gap-4 md:mb-0;
} }
.page__content:not(.page__content--no-pad) { .page__content:not(.page__content--no-pad) {

View file

@ -4,7 +4,7 @@
} }
.primary-nav__nav-item { .primary-nav__nav-item {
@apply text-content relative mx-auto my-1 flex h-12 w-12 cursor-pointer items-center justify-center rounded-full transition-all; @apply text-content relative my-1 flex h-12 w-12 cursor-pointer items-center justify-center rounded-full transition-all;
} }
.primary-nav__nav-item:hover, .primary-nav__nav-item:hover,

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {page} from "$app/stores" 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"

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {debounce} from "throttle-debounce"
import {createScroller, isMobile} from "@lib/html" import {createScroller, isMobile} from "@lib/html"
import {profileSearch} from "@welshman/app" import {profileSearch} from "@welshman/app"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
@ -9,23 +8,11 @@
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import ContentSearch from "@lib/components/ContentSearch.svelte" import ContentSearch from "@lib/components/ContentSearch.svelte"
import PeopleItem from "@app/components/PeopleItem.svelte" import PeopleItem from "@app/components/PeopleItem.svelte"
import {bootstrapPubkeys} from "@app/social"
let term = $state("") let term = $state("")
let limit = $state(10) let limit = $state(10)
let pubkeys = $state($bootstrapPubkeys)
let element: Element | undefined = $state() let element: Element | undefined = $state()
const search = debounce(200, (term: string) => {
if (term) {
pubkeys = $profileSearch.searchValues(term)
} else {
pubkeys = $bootstrapPubkeys
}
})
$effect(() => search(term))
onMount(() => { onMount(() => {
const scroller = createScroller({ const scroller = createScroller({
element: element!, element: element!,
@ -55,7 +42,7 @@
{/snippet} {/snippet}
{#snippet content()} {#snippet content()}
<div class="flex flex-col gap-2 h-full" bind:this={element}> <div class="flex flex-col gap-2 h-full" bind:this={element}>
{#each pubkeys.slice(0, limit) as pubkey (pubkey)} {#each $profileSearch.searchValues(term).slice(0, limit) as pubkey (pubkey)}
<PeopleItem {pubkey} /> <PeopleItem {pubkey} />
{/each} {/each}
</div> </div>

View file

@ -142,8 +142,8 @@
{/snippet} {/snippet}
{#snippet info()} {#snippet info()}
<p> <p>
Your public key is your user identifier. It also allows other people to authenticate Your public key is your user identifier. It also allows other people to authenticate your
your messages. messages.
</p> </p>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
@ -172,7 +172,7 @@
<SignerStatus /> <SignerStatus />
{#if $session?.method === SessionMethod.Pomade} {#if $session?.method === SessionMethod.Pomade}
<div class="flex flex-col lg:flex-row gap-4 lg:gap-2 justify-end"> <div class="flex flex-col lg:flex-row gap-4 lg:gap-2 justify-end">
<Button onclick={startPasswordReset}> <Button class="button button-neutral" onclick={startPasswordReset}>
<Spinner {loading}>Update your password</Spinner> <Spinner {loading}>Update your password</Spinner>
</Button> </Button>
<Button class="button button-primary" onclick={startRecovery} <Button class="button button-primary" onclick={startRecovery}

View file

@ -6,7 +6,6 @@
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {page} from "$app/stores" import {page} from "$app/stores"
import {once} from "@welshman/lib" import {once} from "@welshman/lib"
import {pubkey} from "@welshman/app"
import Page from "@lib/components/Page.svelte" import Page from "@lib/components/Page.svelte"
import SecondaryNav from "@lib/components/SecondaryNav.svelte" import SecondaryNav from "@lib/components/SecondaryNav.svelte"
import SpaceMenu from "@app/components/SpaceMenu.svelte" import SpaceMenu from "@app/components/SpaceMenu.svelte"
@ -35,16 +34,16 @@
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
// Direct links skip Discover — prompt to join when relay is not in the user's space list. // Direct links skip Discover — prompt to join when relay is not in the user's space list.
$effect(() => { $effect(() => {
void $userGroupList void $userGroupList
if ($modal) return if ($modal) return
if ($authError) return showAuthError() if ($authError) return showAuthError()
if ($relaysPendingTrust.includes(url)) return showPendingTrust() if ($relaysPendingTrust.includes(url)) return showPendingTrust()
if (!$userSpaceUrls.includes(url)) { if (!$userSpaceUrls.includes(url) && !joinPrompted.has(url)) {
joinPrompted.add(url) joinPrompted.add(url)
pushModal(SpaceJoin, {url}) pushModal(SpaceJoin, {url})
} }

View file

@ -29,24 +29,16 @@
<PageContent class="flex flex-col gap-4 p-4"> <PageContent class="flex flex-col gap-4 p-4">
<div class="card flex flex-col gap-4"> <div class="card flex flex-col gap-4">
<div class="flex justify-between"> <div class="relative flex gap-4">
<div class="relative flex gap-4"> <div class="relative">
<div class="relative"> <RelayIcon {url} size={14} class="rounded-full" />
<RelayIcon {url} size={14} class="rounded-full" /> </div>
</div> <div class="flex min-w-0 flex-col">
<div class="flex min-w-0 flex-col"> <h1 class="ellipsize whitespace-nowrap">
<h1 class="ellipsize whitespace-nowrap"> <RelayName {url} class="text-2xl font-bold" />
<RelayName {url} class="text-2xl font-bold" /> </h1>
</h1> <p class="ellipsize text-sm text-primary">{displayRelayUrl(url)}</p>
<p class="ellipsize text-sm text-primary">{displayRelayUrl(url)}</p>
</div>
</div> </div>
{#if $userIsAdmin}
<Button class="button button-primary" onclick={startEdit}>
<Icon icon={Pen} />
<span class="hidden sm:inline">Edit</span>
</Button>
{/if}
</div> </div>
<RelayDescription {url} /> <RelayDescription {url} />
{#if $relay?.terms_of_service || $relay?.privacy_policy} {#if $relay?.terms_of_service || $relay?.privacy_policy}
@ -105,6 +97,12 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{#if $userIsAdmin}
<Button class="button button-primary" onclick={startEdit}>
<Icon icon={Pen} />
<span class="hidden sm:inline">Edit Space</span>
</Button>
{/if}
</div> </div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="lg:col-span-2 flex flex-col gap-4"> <div class="lg:col-span-2 flex flex-col gap-4">