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_LOGO=static/logo.png
VITE_PLATFORM_RELAYS=
VITE_PLATFORM_RELAYS_STRICT=false
VITE_PLATFORM_ACCENT="#7161FF"
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_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

View file

@ -9,7 +9,7 @@
import CardButton from "@lib/components/CardButton.svelte"
import LogIn from "@app/components/LogIn.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"
const logIn = () => pushModal(LogIn)
@ -21,7 +21,7 @@
<ModalBody>
<div class="py-2">
<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>
<Button onclick={logIn}>
<CardButton primary>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<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">
</script>
@ -21,16 +21,34 @@
const {initialValues = {}, loading = false, onSubmit}: Props = $props()
const values: Values = $state({
label: "",
description: "",
color: Math.floor(Math.random() * 256),
...initialValues,
const values = $state({
label: initialValues.label ?? "",
description: initialValues.description ?? "",
})
// 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 submit = () => onSubmit(values)
const submit = () => onSubmit({...values, color})
</script>
<div class="flex flex-col gap-4">
@ -58,15 +76,25 @@
<div class="flex items-center gap-3">
<div
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>
<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>
{/snippet}
</Field>

View file

@ -9,7 +9,7 @@
const {role}: Props = $props()
</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} />
{#if role.description}
<p class="text-sm opacity-70">{role.description}</p>

View file

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

View file

@ -2,7 +2,6 @@
import cx from "classnames"
import type {ClientOptions} from "@pomade/core"
import type {Profile} from "@welshman/util"
import {first} from "@welshman/lib"
import {makeProfile, makeSecret, RELAYS, MESSAGING_RELAYS, makeEvent} from "@welshman/util"
import {loginWithNip01, publishThunk, waitForThunkCompletion} from "@welshman/app"
import Key from "@assets/icons/key-minimalistic.svg?dataurl"
@ -48,7 +47,7 @@
await attemptRelayAccess(url, claim)
return url
})
}),
)
// Add default outbox/inbox/messaging relays, profile, spaces

View file

@ -65,9 +65,8 @@
</ModalHeader>
<div class="m-auto flex flex-col gap-4">
<p>
This space has opted not to publish <Button
class="link"
onclick={showInfoSignatures}>digital signatures</Button
This space has opted not to publish <Button class="link" onclick={showInfoSignatures}
>digital signatures</Button
>, which means that they have the ability to forge messages from other users.
</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_SPACES = fromCsv(import.meta.env.VITE_DEFAULT_SPACES).map(item => {
const [url, claim = ""] = item.split('|')
const [url, claim = ""] = item.split("|")
return [normalizeRelayUrl(url), claim]
})

View file

@ -32,20 +32,60 @@ export const deriveSpaceMembers = (url: string) =>
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 = {
id: string
label: string
description: string
color: number
color: SpaceRoleColor
order: number
}
// hue is 0-255; map to 0-360deg. Saturation/lightness chosen to read on both themes.
export const roleColorHue = (color: number) => (((color % 256) + 256) % 256) * (360 / 256)
// Defaults filled in for empty color components, chosen to read on both themes.
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) =>
derived(deriveRelaySignedEvents(url, [{kinds: [RELAY_ROLE]}]), $events => {
@ -59,7 +99,7 @@ export const deriveSpaceRoles = (url: string) =>
id,
label: getTagValue("label", 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,
})
}
@ -87,17 +127,21 @@ export const deriveSpaceMemberRoles = (url: string) =>
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 (
url: string,
id: string,
label: string,
description: string,
color: number,
color: SpaceRoleColor,
order: number,
): Promise<string | undefined> => {
const {error} = await manageRelay(url, {
method: "createrole" as ManagementMethod,
params: [id, label, description, color.toString(), order.toString()],
params: [id, label, description, roleColorParams(color), order.toString()],
})
return error
@ -108,12 +152,12 @@ export const editRole = async (
id: string,
label: string,
description: string,
color: number,
color: SpaceRoleColor,
order: number,
): Promise<string | undefined> => {
const {error} = await manageRelay(url, {
method: "editrole" as ManagementMethod,
params: [id, label, description, color.toString(), order.toString()],
params: [id, label, description, roleColorParams(color), order.toString()],
})
return error

View file

@ -18,7 +18,7 @@ import {notificationSettings} from "@app/settings"
import {chatsById} from "@app/chats"
import {userGroupList, getSpaceUrlsFromGroupList} from "@app/groups"
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 {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 events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values())

View file

@ -45,7 +45,7 @@ import {
hasNegentropy,
} from "@welshman/app"
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 {
loadGroupList,
@ -210,8 +210,10 @@ const syncUserData = () => {
const syncGroupList = ($userGroupList: List | undefined) => {
if ($userGroupList) {
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)) {
unsubscribersByKey.set(url, syncUserSpaceMembership(url))
}
@ -310,7 +312,9 @@ const syncSpaces = () => {
const unsubscribersByUrl = new Map<string, Unsubscriber>()
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
if (currentUrl) {

View file

@ -58,7 +58,7 @@
<div>
{#each value as item (item)}
<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" />
</Button>
<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;
.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) {

View file

@ -4,7 +4,7 @@
}
.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,

View file

@ -1,7 +1,6 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import {modal} from "@app/modal"
import {goToSpace} from "@app/routes"
import {PLATFORM_RELAYS} from "@app/env"

View file

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

View file

@ -142,8 +142,8 @@
{/snippet}
{#snippet info()}
<p>
Your public key is your user identifier. It also allows other people to authenticate
your messages.
Your public key is your user identifier. It also allows other people to authenticate your
messages.
</p>
{/snippet}
</FieldInline>
@ -172,7 +172,7 @@
<SignerStatus />
{#if $session?.method === SessionMethod.Pomade}
<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>
</Button>
<Button class="button button-primary" onclick={startRecovery}

View file

@ -6,7 +6,6 @@
import type {Snippet} from "svelte"
import {page} from "$app/stores"
import {once} from "@welshman/lib"
import {pubkey} from "@welshman/app"
import Page from "@lib/components/Page.svelte"
import SecondaryNav from "@lib/components/SecondaryNav.svelte"
import SpaceMenu from "@app/components/SpaceMenu.svelte"
@ -35,16 +34,16 @@
const showPendingTrust = once(() => pushModal(SpaceTrustRelay, {url}, {noEscape: true}))
// 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.
$effect(() => {
// 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.
$effect(() => {
void $userGroupList
if ($modal) return
if ($authError) return showAuthError()
if ($relaysPendingTrust.includes(url)) return showPendingTrust()
if (!$userSpaceUrls.includes(url)) {
if (!$userSpaceUrls.includes(url) && !joinPrompted.has(url)) {
joinPrompted.add(url)
pushModal(SpaceJoin, {url})
}

View file

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