Add new onboarding workflow

This commit is contained in:
Jonathan Staab 2023-03-07 17:16:33 -06:00
parent 6a8763f777
commit 6714eac7e0
18 changed files with 498 additions and 160 deletions

View File

@ -1,5 +1,7 @@
# Current
- [ ] Collapse relaycard and relaycardsimple?
- [ ] Go over onboarding process, suggest some good relays for newcomers
- [ ] Submit blog post with new onboarding process built in
- [ ] Fix hover on notes in modal

View File

@ -55,7 +55,7 @@
import ConnectUser from "src/views/login/ConnectUser.svelte"
import PrivKeyLogin from "src/views/login/PrivKeyLogin.svelte"
import PubKeyLogin from "src/views/login/PubKeyLogin.svelte"
import SignUp from "src/views/login/SignUp.svelte"
import Onboarding from "src/views/onboarding/Onboarding.svelte"
import NoteCreate from "src/views/notes/NoteCreate.svelte"
import NoteDetail from "src/views/notes/NoteDetail.svelte"
import PersonList from "src/views/person/PersonList.svelte"
@ -244,8 +244,8 @@
<NoteCreate pubkey={$modal.pubkey} />
{:else if $modal.type === 'relay/add'}
<AddRelay />
{:else if $modal.type === 'signUp'}
<SignUp />
{:else if $modal.type === 'onboarding'}
<Onboarding stage={$modal.stage} />
{:else if $modal.type === 'room/edit'}
<ChatEdit {...$modal} />
{:else if $modal.type === 'login/privkey'}

View File

@ -1,8 +1,8 @@
import type {DisplayEvent} from 'src/util/types'
import {navigate} from 'svelte-routing'
import {omit, sortBy} from 'ramda'
import {createMap, ellipsize} from 'hurdak/lib/hurdak'
import {renderContent} from 'src/util/html'
import {sleep} from 'src/util/misc'
import {displayPerson, findReplyId} from 'src/util/nostr'
import {getUserFollows} from 'src/agent/social'
import {getUserReadRelays} from 'src/agent/relays'
@ -14,6 +14,9 @@ import {routes, modal, toast} from 'src/app/ui'
export const loadAppData = async pubkey => {
if (getUserReadRelays().length > 0) {
// Delay since this gets in the way of quickly loading feeds very often
await sleep(5000)
await Promise.all([
alerts.listen(pubkey),
network.loadPeople(getUserFollows()),
@ -27,12 +30,6 @@ export const login = (method, key) => {
modal.set({type: 'login/connect', noEscape: true})
}
export const signup = privkey => {
keys.login('privkey', privkey)
navigate('/notes/follows')
}
export const renderNote = (note, {showEntire = false}) => {
let content

View File

@ -0,0 +1,48 @@
<script lang="ts">
import {last} from 'ramda'
import {fly} from 'svelte/transition'
import {ellipsize} from 'hurdak/lib/hurdak'
import {renderContent, noEvent} from "src/util/html"
import {displayPerson} from "src/util/nostr"
import Anchor from 'src/partials/Anchor.svelte'
import {routes} from "src/app/ui"
export let person
export let addPetname = null
export let removePetname = null
</script>
<a
in:fly={{y: 20}}
href={routes.person(person.pubkey)}
class="flex gap-4 border-l-2 border-solid border-dark hover:bg-black hover:border-accent transition-all py-3 px-4 overflow-hidden">
<div
class="overflow-hidden w-12 h-12 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
style="background-image: url({person.kind0?.picture})" />
<div class="flex-grow flex flex-col gap-4 min-w-0">
<div class="flex gap-2 items-start justify-between">
<div class="flex flex-col gap-2">
<h1 class="text-xl">{displayPerson(person)}</h1>
{#if person.verified_as}
<div class="flex gap-1 text-sm">
<i class="fa fa-user-check text-accent" />
<span class="text-light">{last(person.verified_as.split('@'))}</span>
</div>
{/if}
</div>
{#if removePetname}
<Anchor type="button-accent" on:click={noEvent(() => removePetname(person))}>
Following
</Anchor>
{/if}
{#if addPetname}
<Anchor type="button" on:click={noEvent(() => addPetname(person))}>
Follow
</Anchor>
{/if}
</div>
<p class="overflow-hidden text-ellipsis">
{@html renderContent(ellipsize(person.kind0?.about || '', 140))}
</p>
</div>
</a>

View File

@ -0,0 +1,82 @@
<script lang="ts">
import cx from 'classnames'
import {last} from 'ramda'
import {onMount} from 'svelte'
import {poll, stringToColor} from "src/util/misc"
import {between} from 'hurdak/lib/hurdak'
import {fly} from 'svelte/transition'
import Anchor from "src/partials/Anchor.svelte"
import pool from 'src/agent/pool'
export let relay
export let theme = 'dark'
export let removeRelay = null
export let addRelay = null
let quality = null
let message = null
let showStatus = false
onMount(() => {
return poll(10_000, async () => {
const conn = await pool.getConnection(relay.url)
if (conn) {
[quality, message] = conn.getQuality()
} else {
quality = null
message = "Not connected"
}
})
})
</script>
<div
class={cx(
`bg-${theme}`,
"rounded border border-l-2 border-solid border-medium shadow flex flex-col justify-between gap-3 py-3 px-6"
)}
style={`border-left-color: ${stringToColor(relay.url)}`}
in:fly={{y: 20}}>
<div class="flex gap-2 items-center justify-between">
<div class="flex gap-2 items-center text-xl">
<i class={relay.url.startsWith('wss') ? "fa fa-lock" : "fa fa-unlock"} />
<Anchor type="unstyled" href={`/relays/${btoa(relay.url)}`}>
{last(relay.url.split('://'))}
</Anchor>
<span
on:mouseout={() => {showStatus = false}}
on:mouseover={() => {showStatus = true}}
class="w-2 h-2 rounded-full bg-medium cursor-pointer"
class:bg-medium={message === 'Not connected'}
class:bg-danger={quality <= 0.3 && message !== 'Not connected'}
class:bg-warning={between(0.3, 0.7, quality)}
class:bg-success={quality > 0.7}>
</span>
<p
class="text-light text-sm transition-all hidden sm:block"
class:opacity-0={!showStatus}
class:opacity-1={showStatus}>
{message}
</p>
</div>
{#if removeRelay}
<button
class="flex gap-3 items-center text-light"
on:click={() => removeRelay(relay)}>
<i class="fa fa-right-from-bracket" /> Leave
</button>
{/if}
{#if addRelay}
<button
class="flex gap-3 items-center text-light"
on:click={() => addRelay(relay)}>
<i class="fa fa-right-to-bracket" /> Join
</button>
{/if}
</div>
{#if relay.description}
<p>{relay.description}</p>
{/if}
<slot name="controls" />
</div>

View File

@ -1,8 +1,10 @@
<script lang="ts">
import {fly} from 'svelte/transition'
import {navigate} from 'svelte-routing'
import Anchor from "src/partials/Anchor.svelte"
import Content from "src/partials/Content.svelte"
import Heading from 'src/partials/Heading.svelte'
import user from 'src/agent/user'
import {modal} from "src/app/ui"
import {login} from "src/app"
@ -19,13 +21,17 @@
}
const signUp = () => {
modal.set({type: 'signUp'})
modal.set({type: 'onboarding', stage: 'intro'})
}
const pubkeyLogIn = () => {
modal.set({type: 'login/pubkey'})
}
if (user.getPubkey()) {
navigate('/')
}
document.title = "Log In"
</script>

View File

@ -9,7 +9,7 @@
import Spinner from 'src/partials/Spinner.svelte'
import Input from 'src/partials/Input.svelte'
import Heading from 'src/partials/Heading.svelte'
import RelayCardSimple from 'src/views/relays/RelayCardSimple.svelte'
import RelayCardSimple from 'src/partials/RelayCardSimple.svelte'
import Anchor from 'src/partials/Anchor.svelte'
import Modal from 'src/partials/Modal.svelte'
import {getUserReadRelays} from 'src/agent/relays'

View File

@ -1,44 +0,0 @@
<script lang="ts">
import {nip19, generatePrivateKey} from 'nostr-tools'
import {copyToClipboard} from "src/util/html"
import {toHex} from "src/util/nostr"
import Input from 'src/partials/Input.svelte'
import Anchor from 'src/partials/Anchor.svelte'
import Content from 'src/partials/Content.svelte'
import Heading from 'src/partials/Heading.svelte'
import {toast} from "src/app/ui"
import {signup} from "src/app"
const nsec = nip19.nsecEncode(generatePrivateKey())
const nip07 = "https://github.com/nostr-protocol/nips/blob/master/07.md"
const logIn = () => signup(toHex(nsec))
const copyKey = () => {
copyToClipboard(nsec)
toast.show("info", "Your private key has been copied to the clipboard.")
}
</script>
<Content size="lg" class="text-center">
<Heading>Create an Account</Heading>
<p>
Don't have a nostr account? We've created a brand new private key for you below.
Make sure to click to copy and store it somewhere safe - this is your account's password!
</p>
<div class="flex gap-2">
<div class="flex-grow">
<Input disabled placeholder={"•".repeat(63)}>
<i slot="before" class="fa fa-key" />
<button slot="after" class="cursor-pointer fa fa-copy" on:click={copyKey} />
</Input>
</div>
<Anchor type="button" on:click={logIn}>Log In</Anchor>
</div>
<p class="bg-black rounded border-2 border-solid border-warning py-3 px-6">
Note that sharing your private key directly is not recommended, instead you should use
a <Anchor href={nip07} external>compatible browser extension</Anchor> to securely store your key.
</p>
</Content>

View File

@ -11,7 +11,7 @@
import ImageInput from "src/partials/ImageInput.svelte"
import Preview from "src/partials/Preview.svelte"
import Input from "src/partials/Input.svelte"
import RelayCardSimple from "src/views/relays/RelayCardSimple.svelte"
import RelayCardSimple from "src/partials/RelayCardSimple.svelte"
import Content from "src/partials/Content.svelte"
import Modal from "src/partials/Modal.svelte"
import Heading from 'src/partials/Heading.svelte'

View File

@ -0,0 +1,81 @@
<script lang="ts">
import {uniq} from 'ramda'
import {onMount} from 'svelte'
import {generatePrivateKey} from 'nostr-tools'
import {fly} from 'svelte/transition'
import {navigate} from 'svelte-routing'
import {shuffle} from 'src/util/misc'
import {displayPerson} from 'src/util/nostr'
import OnboardingIntro from 'src/views/onboarding/OnboardingIntro.svelte'
import OnboardingKey from 'src/views/onboarding/OnboardingKey.svelte'
import OnboardingRelays from 'src/views/onboarding/OnboardingRelays.svelte'
import OnboardingFollows from 'src/views/onboarding/OnboardingFollows.svelte'
import OnboardingComplete from 'src/views/onboarding/OnboardingComplete.svelte'
import {getFollows} from 'src/agent/social'
import {getPubkeyWriteRelays, sampleRelays} from 'src/agent/relays'
import database from 'src/agent/database'
import network from 'src/agent/network'
import user from 'src/agent/user'
import keys from "src/agent/keys"
import {loadAppData} from "src/app"
import {modal} from "src/app/ui"
export let stage
let relays = [
{url: 'wss://nostr-pub.wellorder.net'},
{url: 'wss://nostr.zebedee.cloud'},
{url: 'wss://nos.lol'},
{url: 'wss://brb.io'},
]
let follows = [
"97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322", // hodlbod
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", // fiatjaf
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", // jack
"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", // Gigi
]
const privkey = generatePrivateKey()
const signup = async () => {
await keys.login('privkey', privkey)
await user.updateRelays(() => relays)
await user.updatePetnames(() => follows.map(pubkey => {
const [{url}] = sampleRelays(getPubkeyWriteRelays(pubkey))
const name = displayPerson(database.getPersonWithFallback(pubkey))
return ["p", pubkey, url, name]
}))
loadAppData(user.getPubkey())
modal.set(null)
navigate('/notes/follows')
}
// Prime our people cache for hardcoded follows and a sample of people they follow
onMount(async () => {
await network.loadPeople(follows, {relays})
const others = shuffle(uniq(follows.flatMap(getFollows))).slice(0, 256)
await network.loadPeople(others, {relays})
})
</script>
{#key stage}
<div in:fly={{y: 20}}>
{#if stage === 'intro'}
<OnboardingIntro />
{:else if stage === 'key'}
<OnboardingKey {privkey} />
{:else if stage === 'relays'}
<OnboardingRelays bind:relays={relays} />
{:else if stage === 'follows'}
<OnboardingFollows bind:follows={follows} />
{:else}
<OnboardingComplete {signup} />
{/if}
</div>
{/key}

View File

@ -0,0 +1,27 @@
<script lang="ts">
import Anchor from 'src/partials/Anchor.svelte'
import Heading from 'src/partials/Heading.svelte'
import Content from 'src/partials/Content.svelte'
import Spinner from 'src/partials/Spinner.svelte'
export let signup
let loading = false
const startSignup = () => {
loading = true
signup()
}
</script>
<Content size="lg" class="text-center">
<Heading>Welcome to Nostr</Heading>
<p>
Youre all set! If have any questions, or need any help, just ask. Your fellow
nostriches are always happy to lend a hand.
</p>
<Anchor {loading} type="button-accent" on:click={startSignup}>Get on Nostr</Anchor>
{#if loading}
<Spinner />
{/if}
</Content>

View File

@ -0,0 +1,72 @@
<script lang="ts">
import {without} from 'ramda'
import {fuzzy} from "src/util/misc"
import Input from 'src/partials/Input.svelte'
import Anchor from 'src/partials/Anchor.svelte'
import Heading from 'src/partials/Heading.svelte'
import Content from 'src/partials/Content.svelte'
import PersonInfo from 'src/partials/PersonInfo.svelte'
import database from 'src/agent/database'
import {modal} from "src/app/ui"
export let follows
let q = ""
let search
const knownPeople = database.watch('people', t => t.all({'kind0.name:!nil': null}))
$: search = fuzzy(
$knownPeople.filter(p => !follows.includes(p.pubkey)),
{keys: ["kind0.name", "kind0.about", "pubkey"]}
)
const removePetname = ({pubkey}) => {
follows = without([pubkey], follows)
}
const addPetname = ({pubkey}) => {
follows = follows.concat(pubkey)
}
</script>
<Content>
<Content class="text-center">
<Heading>Find Your People</Heading>
<p>
To get you started, weve added some interesting people to your follow list.
You can update your follows list at any time.
</p>
<Anchor
type="button-accent"
on:click={() => modal.set({type: 'onboarding', stage: 'complete'})}>
Continue
</Anchor>
</Content>
<div class="flex gap-2 items-center">
<i class="fa fa-user-astronaut fa-lg" />
<h2 class="staatliches text-2xl">Your follows</h2>
</div>
{#if follows.length === 0}
<div class="text-center mt-8 flex gap-2 justify-center items-center">
<i class="fa fa-triangle-exclamation" />
<span>No follows selected</span>
</div>
{:else}
{#each follows as pubkey}
<PersonInfo person={database.getPersonWithFallback(pubkey)} {removePetname} />
{/each}
{/if}
<div class="flex gap-2 items-center">
<i class="fa fa-earth-asia fa-lg" />
<h2 class="staatliches text-2xl">Other people</h2>
</div>
<Input bind:value={q} type="text" wrapperClass="flex-grow" placeholder="Type to search">
<i slot="before" class="fa-solid fa-search" />
</Input>
{#each search(q).slice(0, 50) as person (person.pubkey)}
<PersonInfo {person} {addPetname} />
{/each}
</Content>

View File

@ -0,0 +1,24 @@
<script lang="ts">
import Anchor from 'src/partials/Anchor.svelte'
import Heading from 'src/partials/Heading.svelte'
import Content from 'src/partials/Content.svelte'
import {modal} from "src/app/ui"
const tutorialUrl = "https://nostr.how/"
</script>
<Content size="lg" class="text-center">
<Heading>Create an Account</Heading>
<p>
New to Nostr? Click <Anchor external href={tutorialUrl}>here</Anchor> or watch the video
below for a crash course on what it is, and how to use it.
</p>
<video controls src="" class="object-center object-contain" />
<p>
When youre ready to dive in, click below and well guide you through
the process of creating an account.
</p>
<Anchor type="button-accent" on:click={() => modal.set({type: 'onboarding', stage: 'key'})}>
Let's go!
</Anchor>
</Content>

View File

@ -0,0 +1,41 @@
<script lang="ts">
import {nip19} from 'nostr-tools'
import {copyToClipboard} from "src/util/html"
import Input from 'src/partials/Input.svelte'
import Anchor from 'src/partials/Anchor.svelte'
import Heading from 'src/partials/Heading.svelte'
import Content from 'src/partials/Content.svelte'
import {modal, toast} from "src/app/ui"
export let privkey
const nsec = nip19.nsecEncode(privkey)
const nip07 = "https://github.com/nostr-protocol/nips/blob/master/07.md"
const copyKey = () => {
copyToClipboard(nsec)
toast.show("info", "Your private key has been copied to the clipboard. Keep it secret!")
}
</script>
<Content size="lg" class="text-center">
<Heading>Generate a Key</Heading>
<p>
Your private key is your password, and gives you total control over your Nostr account.
We've generated a fresh one for you below store it somewhere safe!
</p>
<div class="flex gap-2">
<Input disabled placeholder={"•".repeat(53)} wrapperClass="flex-grow">
<i slot="before" class="fa fa-lock" />
<button slot="after" class="cursor-pointer fa fa-copy" on:click={copyKey} />
</Input>
<Anchor type="button-accent" on:click={() => modal.set({type: 'onboarding', stage: 'relays'})}>
Log in
</Anchor>
</div>
<p>
Avoid pasting your key into too many apps and websites. Instead, use
a <Anchor href={nip07} external>compatible browser extension</Anchor> to
securely store your key.
</p>
</Content>

View File

@ -0,0 +1,81 @@
<script lang="ts">
import {reject, pluck, whereEq} from 'ramda'
import {fuzzy} from "src/util/misc"
import Input from 'src/partials/Input.svelte'
import Anchor from 'src/partials/Anchor.svelte'
import Heading from 'src/partials/Heading.svelte'
import Content from 'src/partials/Content.svelte'
import RelayCard from 'src/partials/RelayCard.svelte'
import database from 'src/agent/database'
import {modal} from "src/app/ui"
export let relays
let q = ""
let search
const knownRelays = database.watch('relays', t => t.all())
$: {
const joined = new Set(pluck('url', relays))
search = fuzzy(
$knownRelays.filter(r => !joined.has(r.url)),
{keys: ["name", "description", "url"]}
)
}
const removeRelay = ({url}) => {
relays = reject(whereEq({url}), relays)
}
const addRelay = relay => {
relays = relays.concat(relay)
}
</script>
<Content>
<Content class="text-center">
<Heading>Get Connected</Heading>
<p>
Nostr is a protocol, not a platform. This means that <i>you</i> choose where to store your
data. Select your preferred storage relays below, or click "continue" to use some
reasonable defaults. You can change your selection any time.
</p>
<Anchor
type="button-accent"
on:click={() => modal.set({type: 'onboarding', stage: 'follows'})}>
Continue
</Anchor>
</Content>
<div class="flex gap-2 items-center">
<i class="fa fa-server fa-lg" />
<h2 class="staatliches text-2xl">Your relays</h2>
</div>
{#if relays.length === 0}
<div class="text-center mt-8 flex gap-2 justify-center items-center">
<i class="fa fa-triangle-exclamation" />
<span>No relays connected</span>
</div>
{:else}
<div class="grid grid-cols-1 gap-4">
{#each relays as relay (relay.url)}
<RelayCard {relay} {removeRelay} />
{/each}
</div>
{/if}
<div class="flex gap-2 items-center">
<i class="fa fa-earth-asia fa-lg" />
<h2 class="staatliches text-2xl">Other relays</h2>
</div>
<Input bind:value={q} type="text" wrapperClass="flex-grow" placeholder="Type to search">
<i slot="before" class="fa-solid fa-search" />
</Input>
{#each (search(q) || []).slice(0, 50) as relay (relay.url)}
<RelayCard {relay} {addRelay} />
{/each}
<small class="text-center">
Showing {Math.min($knownRelays.length - relays.length, 50)}
of {$knownRelays.length - relays.length} known relays
</small>
</Content>

View File

@ -1,55 +1,25 @@
<script lang="ts">
import {last} from 'ramda'
import {fly} from 'svelte/transition'
import {ellipsize} from 'hurdak/lib/hurdak'
import {renderContent, noEvent} from "src/util/html"
import {displayPerson} from "src/util/nostr"
import Anchor from 'src/partials/Anchor.svelte'
import PersonInfo from 'src/partials/PersonInfo.svelte'
import {getPubkeyWriteRelays, sampleRelays} from 'src/agent/relays'
import user from 'src/agent/user'
import {routes} from "src/app/ui"
export let person
const {petnamePubkeys} = user
const addPetname = pubkey => {
const addPetname = ({pubkey}) => {
const [{url}] = sampleRelays(getPubkeyWriteRelays(pubkey))
user.addPetname(pubkey, url, displayPerson(person))
}
const removePetname = ({pubkey}) => {
user.removePetname(pubkey)
}
</script>
<a
in:fly={{y: 20}}
href={routes.person(person.pubkey)}
class="flex gap-4 border-l-2 border-solid border-dark hover:bg-black hover:border-accent transition-all py-3 px-6 overflow-hidden">
<div
class="overflow-hidden w-12 h-12 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
style="background-image: url({person.kind0?.picture})" />
<div class="flex-grow flex flex-col gap-4 min-w-0">
<div class="flex gap-2 items-start justify-between">
<div class="flex flex-col gap-2">
<h1 class="text-xl">{displayPerson(person)}</h1>
{#if person.verified_as}
<div class="flex gap-1 text-sm">
<i class="fa fa-user-check text-accent" />
<span class="text-light">{last(person.verified_as.split('@'))}</span>
</div>
{/if}
</div>
{#if $petnamePubkeys.includes(person.pubkey)}
<Anchor type="button-accent" on:click={noEvent(() => user.removePetname(person.pubkey))}>
Following
</Anchor>
{:else}
<Anchor type="button" on:click={noEvent(() => addPetname(person.pubkey))}>
Follow
</Anchor>
{/if}
</div>
<p class="overflow-hidden text-ellipsis">
{@html renderContent(ellipsize(person.kind0?.about || '', 140))}
</p>
</div>
</a>
<PersonInfo
{person}
addPetname={$petnamePubkeys.includes(person.pubkey) ? addPetname : null}
removePetname={$petnamePubkeys.includes(person.pubkey) ? null : removePetname} />

View File

@ -1,12 +1,9 @@
<script lang="ts">
import cx from 'classnames'
import {last, find, propEq} from 'ramda'
import {find, propEq} from 'ramda'
import {onMount} from 'svelte'
import {poll, stringToColor} from "src/util/misc"
import {between} from 'hurdak/lib/hurdak'
import {fly} from 'svelte/transition'
import {poll} from "src/util/misc"
import Toggle from "src/partials/Toggle.svelte"
import Anchor from "src/partials/Anchor.svelte"
import RelayCard from "src/partials/RelayCard.svelte"
import pool from 'src/agent/pool'
import user from "src/agent/user"
import {loadAppData} from 'src/app'
@ -17,17 +14,16 @@
let quality = null
let message = null
let showStatus = false
let joined = false
const {relays, canPublish} = user
$: joined = find(propEq('url', relay.url), $relays)
const removeRelay = () => user.removeRelay(relay.url)
const removeRelay = ({url}) => user.removeRelay(url)
const addRelay = async () => {
await user.addRelay(relay.url)
const addRelay = async ({url}) => {
await user.addRelay(url)
if (!user.getProfile()?.kind0) {
loadAppData(user.getPubkey())
@ -48,61 +44,16 @@
})
</script>
<div
class={cx(
`bg-${theme}`,
"rounded border border-l-2 border-solid border-medium shadow flex flex-col justify-between gap-3 py-3 px-6"
)}
style={`border-left-color: ${stringToColor(relay.url)}`}
in:fly={{y: 20}}>
<div class="flex gap-2 items-center justify-between">
<div class="flex gap-2 items-center text-xl">
<i class={relay.url.startsWith('wss') ? "fa fa-lock" : "fa fa-unlock"} />
<Anchor type="unstyled" href={`/relays/${btoa(relay.url)}`}>
{last(relay.url.split('://'))}
</Anchor>
<span
on:mouseout={() => {showStatus = false}}
on:mouseover={() => {showStatus = true}}
class="w-2 h-2 rounded-full bg-medium cursor-pointer"
class:bg-medium={message === 'Not connected'}
class:bg-danger={quality <= 0.3 && message !== 'Not connected'}
class:bg-warning={between(0.3, 0.7, quality)}
class:bg-success={quality > 0.7}>
</span>
<p
class="text-light text-sm transition-all hidden sm:block"
class:opacity-0={!showStatus}
class:opacity-1={showStatus}>
{message}
</p>
</div>
{#if joined}
{#if $relays.length > 1}
<button
class="flex gap-3 items-center text-light"
on:click={removeRelay}>
<i class="fa fa-right-from-bracket" /> Leave
</button>
{/if}
{:else}
<button
class="flex gap-3 items-center text-light"
on:click={addRelay}>
<i class="fa fa-right-to-bracket" /> Join
</button>
{/if}
</div>
{#if relay.description}
<p>{relay.description}</p>
{/if}
{#if joined && showControls && $canPublish}
<div class="border-b border-solid border-medium -mx-6" />
<div class="flex justify-between gap-2">
<RelayCard
{relay} {theme}
addRelay={!joined && $canPublish ? addRelay : null}
removeRelay={joined && $relays.length > 1 && canPublish ? removeRelay : null}>
<div slot="controls" class="flex justify-between gap-2">
{#if showControls && $canPublish}
<span>Publish to this relay?</span>
<Toggle
value={relay.write}
on:change={() => user.setRelayWriteCondition(relay.url, !relay.write)} />
{/if}
</div>
{/if}
</div>
</RelayCard>