Convert to simple relay-based groups from nip29

This commit is contained in:
Jon Staab 2024-09-11 10:48:34 -07:00
parent ed365f7e38
commit 4ad67921a0
20 changed files with 363 additions and 525 deletions

View file

@ -1,12 +1,5 @@
import {uniqBy, uniq, now, choice} from "@welshman/lib" import {uniqBy, equals, uniq, now, choice} from "@welshman/lib"
import { import {getRelayTagValues, createEvent, displayProfile} from "@welshman/util"
GROUPS,
GROUP_JOIN,
getGroupTags,
getRelayTagValues,
createEvent,
displayProfile,
} from "@welshman/util"
import {PublishStatus} from "@welshman/net" import {PublishStatus} from "@welshman/net"
import { import {
pubkey, pubkey,
@ -22,7 +15,7 @@ import {
loadFollows, loadFollows,
loadMutes, loadMutes,
} from "@welshman/app" } from "@welshman/app"
import {loadGroup, loadGroupMembership, INDEXER_RELAYS} from "@app/state" import {MEMBERSHIPS, INDEXER_RELAYS} from "@app/state"
// Utils // Utils
@ -57,11 +50,7 @@ export const makeIMeta = (url: string, data: Record<string, string>) => [
// Loaders // Loaders
export const loadUserData = (pubkey: string, hints: string[] = []) => export const loadUserData = (pubkey: string, hints: string[] = []) =>
Promise.all([ Promise.all([loadProfile(pubkey), loadFollows(pubkey), loadMutes(pubkey)])
loadProfile(pubkey),
loadFollows(pubkey),
loadMutes(pubkey),
])
// Updates // Updates
@ -80,26 +69,14 @@ export const updateList = async (kind: number, modifyTags: ModifyTags) => {
publishThunk(makeThunk({event, relays})) publishThunk(makeThunk({event, relays}))
} }
export const addGroupMemberships = (newTags: string[][]) => export const addSpaceMembership = (url: string) =>
updateList(GROUPS, (tags: string[][]) => uniqBy(t => t.join(""), [...tags, ...newTags])) updateList(MEMBERSHIPS, (tags: string[][]) => uniqBy(t => t.join(""), [...tags, ["r", url]]))
export const removeGroupMemberships = (noms: string[]) => export const addRoomMembership = (url: string, topic: string) =>
updateList(GROUPS, (tags: string[][]) => tags.filter(t => !noms.includes(t[1]))) updateList(MEMBERSHIPS, (tags: string[][]) => uniqBy(t => t.join(""), [...tags, ["t", topic, url]]))
export const sendJoinRequest = async (nom: string, url: string): Promise<[boolean, string]> => { export const removeSpaceMembership = (url: string) =>
const relays = [url] updateList(MEMBERSHIPS, (tags: string[][]) => tags.filter(t => !equals(["r", url], t) && t[2] !== url))
const filters = [{kinds: [9000], "#h": [nom], "#p": [pubkey.get()!], since: now() - 30}]
const event = createEvent(GROUP_JOIN, {tags: [["h", nom]]}) export const removeRoomMembership = (url: string, topic: string) =>
const statusData = await publishThunk(makeThunk({event, relays})) updateList(MEMBERSHIPS, (tags: string[][]) => tags.filter(t => !equals(["t", topic, url], t)))
const {status, message} = statusData[url]
if (message.includes("already a member")) return [true, ""]
if (status !== PublishStatus.Success) return [false, message]
if (await load({filters, relays})) return [true, ""]
return [
false,
"Your request was not automatically approved, but may be approved manually later. Please try again later or contact the group admin.",
]
}

View file

@ -3,17 +3,17 @@
import type {Readable} from "svelte/store" import type {Readable} from "svelte/store"
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {createEditor, type Editor, EditorContent} from "svelte-tiptap" import {createEditor, type Editor, EditorContent} from "svelte-tiptap"
import {NProfileExtension, TagExtension as TopicExtension, ImageExtension} from "nostr-editor" import {NProfileExtension, ImageExtension} from "nostr-editor"
import {createEvent, CHAT_MESSAGE} from "@welshman/util" import {createEvent, CHAT_MESSAGE} from "@welshman/util"
import {publishThunk, makeThunk} from "@welshman/app" import {publishThunk, makeThunk} from "@welshman/app"
import {findNodes} from "@lib/tiptap" import {findNodes} from "@lib/tiptap"
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 {userRelayUrlsByNom} from "@app/state"
import {makeMention, makeIMeta} from "@app/commands" import {makeMention, makeIMeta} from "@app/commands"
import {getChatEditorOptions, addFile} from "@app/editor" import {getChatEditorOptions, addFile} from "@app/editor"
export let nom export let url
export let topic = ""
const uploading = writable(false) const uploading = writable(false)
@ -21,22 +21,20 @@
const sendMessage = () => { const sendMessage = () => {
const json = $editor.getJSON() const json = $editor.getJSON()
const relays = $userRelayUrlsByNom.get(nom) const topicTag = topic ? ["t", topic] : []
const mentionTags = findNodes(NProfileExtension.name, json).map(m =>
makeMention(m.attrs!.pubkey, m.attrs!.relays),
)
const imetaTags = findNodes(ImageExtension.name, json).map(({attrs: {src, sha256: x}}: any) =>
makeIMeta(src, {x, ox: x}),
)
const event = createEvent(CHAT_MESSAGE, { const event = createEvent(CHAT_MESSAGE, {
content: $editor.getText(), content: $editor.getText(),
tags: [ tags: [topicTag, ...mentionTags, ...imetaTags],
["h", nom],
...findNodes(TopicExtension.name, json).map(t => ["t", t.attrs!.name.toLowerCase()]),
...findNodes(NProfileExtension.name, json).map(m =>
makeMention(m.attrs!.pubkey, m.attrs!.relays),
),
...findNodes(ImageExtension.name, json).map(({attrs: {src, sha256: x}}: any) =>
makeIMeta(src, {x, ox: x}),
),
],
}) })
publishThunk(makeThunk({event, relays})) publishThunk(makeThunk({event, relays: [url]}))
$editor.chain().clearContent().run() $editor.chain().clearContent().run()
} }

View file

@ -27,7 +27,7 @@
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 Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
import {deriveEvent} from "@app/state" import {deriveEvent, displayReaction} from "@app/state"
import {getChatViewOptions} from "@app/editor" import {getChatViewOptions} from "@app/editor"
export let event: TrustedEvent export let event: TrustedEvent
@ -66,12 +66,6 @@
const [colorName, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length] const [colorName, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const ps = derived(publishStatusData, $m => Object.values($m[event.id] || {})) const ps = derived(publishStatusData, $m => Object.values($m[event.id] || {}))
const displayReaction = (content: string) => {
if (content === "+") return "❤️"
if (content === "-") return "👎"
return content
}
const findStatus = ($ps: PublishStatusData[], statuses: PublishStatus[]) => const findStatus = ($ps: PublishStatusData[], statuses: PublishStatus[]) =>
$ps.find(({status}) => statuses.includes(status)) $ps.find(({status}) => statuses.includes(status))

View file

@ -3,7 +3,6 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {DEFAULT_RELAYS} from "@app/state"
import {clip} from "@app/toast" import {clip} from "@app/toast"
</script> </script>
@ -17,22 +16,8 @@
This means that anyone can host their own data, making the web more decentralized and resilient. This means that anyone can host their own data, making the web more decentralized and resilient.
</p> </p>
<p> <p>
Only some relays support spaces. You can find a list of suggested relays below, or you can <Link Different relays have different policies for access control and content retention. Be sure to
external double check that you have access to the relays you try to use by visiting their website.
href="https://coracle.tools">host your own</Link
>. If you do decide to join someone else's, make sure to follow their directions for registering
as a user.
</p> </p>
{#each DEFAULT_RELAYS as url}
<div class="card2 card2-alt card2-sm flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon icon="remote-controller-minimalistic" />
{displayRelayUrl(url)}
</div>
<Button on:click={() => clip(url)}>
<Icon icon="copy" />
</Button>
</div>
{/each}
<Button class="btn btn-primary" on:click={() => history.back()}>Got it</Button> <Button class="btn btn-primary" on:click={() => history.back()}>Got it</Button>
</div> </div>

View file

@ -60,7 +60,7 @@
const pubkey = await getNip07()?.getPublicKey() const pubkey = await getNip07()?.getPublicKey()
if (pubkey) { if (pubkey) {
await onSuccess({method: "extension", pubkey}) await onSuccess({method: "nip07", pubkey})
} else { } else {
pushToast({ pushToast({
theme: "error", theme: "error",

View file

@ -9,13 +9,15 @@
import {page} from "$app/stores" import {page} from "$app/stores"
import {tweened} from "svelte/motion" import {tweened} from "svelte/motion"
import {quintOut} from "svelte/easing" import {quintOut} from "svelte/easing"
import {displayRelayUrl} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Avatar from "@lib/components/Avatar.svelte" import Avatar from "@lib/components/Avatar.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte" import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import SpaceAdd from "@app/components/SpaceAdd.svelte" import SpaceAdd from "@app/components/SpaceAdd.svelte"
import {userProfile, displayGroup, userGroupsByNom} from "@app/state" import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
import {userProfile, userMembership} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {getPrimaryNavItemIndex} from "@app/routes" import {makeSpacePath, getPrimaryNavItemIndex} from "@app/routes"
const activeOffset = tweened(-44, { const activeOffset = tweened(-44, {
duration: 300, duration: 300,
@ -49,15 +51,9 @@
class="!h-10 !w-10 border border-solid border-base-300" class="!h-10 !w-10 border border-solid border-base-300"
size={7} /> size={7} />
</PrimaryNavItem> </PrimaryNavItem>
{#each $userGroupsByNom.entries() as [nom, qualifiedGroups] (nom)} {#each $userMembership?.topicsByUrl.keys() || [] as url (url)}
{@const qualifiedGroup = qualifiedGroups[0]} <PrimaryNavItem title={displayRelayUrl(url)} href={makeSpacePath(url)}>
<PrimaryNavItem title={displayGroup(qualifiedGroup?.group)} href="/spaces/{nom}"> <SpaceAvatar {url} />
<Avatar
icon="ghost"
class="!h-10 !w-10 border border-solid border-base-300"
alt={displayGroup(qualifiedGroup?.group)}
src={qualifiedGroup?.group.picture}
size={7} />
</PrimaryNavItem> </PrimaryNavItem>
{/each} {/each}
<PrimaryNavItem title="Add Space" on:click={addSpace}> <PrimaryNavItem title="Add Space" on:click={addSpace}>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {append, remove} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import Field from "@lib/components/Field.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {addRoomMembership} from "@app/commands"
import {makeSpacePath} from '@app/routes'
export let url
const relay = deriveRelay(url)
const back = () => history.back()
const tryCreate = async () => {
await addRoomMembership(url, topic)
goto(makeSpacePath(url, topic))
}
const create = async () => {
loading = true
try {
await tryCreate()
} finally {
loading = false
}
}
let topic = ""
let loading = false
</script>
<form class="column gap-4" on:submit|preventDefault={create}>
<h1 class="heading">
Create a Room
</h1>
<p class="text-center">
On <span class="text-primary">{displayRelayUrl(url)}</span>
</p>
<Field>
<p slot="label">Room Name</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="hashtag" />
<input bind:value={topic} class="grow" type="text" />
</label>
</Field>
<div class="flex flex-row items-center justify-between gap-4">
<Button class="btn btn-link" on:click={back}>
<Icon icon="alt-arrow-left" />
Go back
</Button>
<Button type="submit" class="btn btn-primary" disabled={!topic || loading}>
<Spinner {loading}>Create Room</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
</div>
</form>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import Avatar from "@lib/components/Avatar.svelte"
import {deriveRelay} from "@welshman/app"
export let url
const relay = deriveRelay(url)
</script>
<Avatar
icon="ghost"
class="!h-10 !w-10 border border-solid border-base-300"
alt={displayRelayUrl(url)}
src={$relay?.profile?.icon}
size={7} />

View file

@ -3,7 +3,7 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import InfoNip29 from "@app/components/InfoNip29.svelte" import InfoRelay from "@app/components/InfoRelay.svelte"
import SpaceCreateFinish from "@app/components/SpaceCreateFinish.svelte" import SpaceCreateFinish from "@app/components/SpaceCreateFinish.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
@ -38,8 +38,8 @@
<input bind:value={relay} class="grow" type="text" /> <input bind:value={relay} class="grow" type="text" />
</label> </label>
<p slot="info"> <p slot="info">
This should be a NIP-29 compatible nostr relay where you'd like to host your space. This can be any nostr relay where you'd like to host your space.
<Button class="link" on:click={() => pushModal(InfoNip29)}>What is a relay?</Button> <Button class="link" on:click={() => pushModal(InfoRelay)}>What is a relay?</Button>
</p> </p>
</Field> </Field>
<div class="flex flex-row items-center justify-between gap-4"> <div class="flex flex-row items-center justify-between gap-4">

View file

@ -1,14 +1,12 @@
<script lang="ts"> <script lang="ts">
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {displayRelayUrl} from "@welshman/util"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {deriveGroup} from "@app/state" import {removeSpaceMembership} from "@app/commands"
import {removeGroupMemberships} from "@app/commands"
export let nom export let url
const group = deriveGroup(nom)
const back = () => history.back() const back = () => history.back()
@ -16,7 +14,7 @@
loading = true loading = true
try { try {
await removeGroupMemberships([nom]) await removeSpaceMembership(url)
} finally { } finally {
loading = false loading = false
} }
@ -29,7 +27,7 @@
<form class="column gap-4" on:submit|preventDefault={exit}> <form class="column gap-4" on:submit|preventDefault={exit}>
<h1 class="heading"> <h1 class="heading">
You are leaving <span class="text-primary">{$group?.name || "[no name]"}</span> You are leaving <span class="text-primary">{displayRelayUrl(url)}</span>
</h1> </h1>
<p class="text-center">Are you sure you want to leave?</p> <p class="text-center">Are you sure you want to leave?</p>
<div class="flex flex-row items-center justify-between gap-4"> <div class="flex flex-row items-center justify-between gap-4">

View file

@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {tryCatch} from "@welshman/lib"
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {loadRelay} from "@welshman/app" import {loadRelay} from "@welshman/app"
import CardButton from "@lib/components/CardButton.svelte" import CardButton from "@lib/components/CardButton.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
@ -7,43 +9,28 @@
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {splitGroupId, loadGroup} from "@app/state" import {addSpaceMembership} from "@app/commands"
import {addGroupMemberships} from "@app/commands" import {makeSpacePath} from "@app/routes"
const back = () => history.back() const back = () => history.back()
const browse = () => goto("/discover") const browse = () => goto("/discover")
const joinQualifiedGroup = async (id: string) => { const joinRelay = async (url: string) => {
const [url, nom] = splitGroupId(id) url = normalizeRelayUrl(url)
const relay = await loadRelay(url) const relay = await loadRelay(url)
if (!relay) { if (!relay?.profile) {
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: "Sorry, we weren't able to find that relay.", message: "Sorry, we weren't able to find that relay.",
}) })
} }
if (!relay.profile?.supported_nips?.includes(29)) { await addSpaceMembership(url)
return pushToast({
theme: "error",
message: "Sorry, it looks like that relay doesn't support nostr spaces.",
})
}
const group = await loadGroup(nom, [url]) goto(makeSpacePath(url))
if (!group) {
return pushToast({
theme: "error",
message: "Sorry, we weren't able to find that space.",
})
}
await addGroupMemberships([["group", nom, url]])
goto(`/spaces/${nom}`)
pushToast({ pushToast({
message: "Welcome to the space!", message: "Welcome to the space!",
}) })
@ -53,16 +40,16 @@
loading = true loading = true
try { try {
await joinQualifiedGroup(id) await joinRelay(url)
} finally { } finally {
loading = false loading = false
} }
} }
let id = "" let url = ""
let loading = false let loading = false
$: linkIsValid = Boolean(id.match(/.+\..+'.+/)) $: linkIsValid = Boolean(tryCatch(() => isRelayUrl(normalizeRelayUrl(url))))
</script> </script>
<form class="column gap-4" on:submit|preventDefault={join}> <form class="column gap-4" on:submit|preventDefault={join}>
@ -74,7 +61,7 @@
<p slot="label">Invite Link*</p> <p slot="label">Invite Link*</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input"> <label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="link-round" /> <Icon icon="link-round" />
<input bind:value={id} class="grow" type="text" /> <input bind:value={url} class="grow" type="text" />
</label> </label>
</Field> </Field>
<CardButton icon="compass" title="Don't have an invite?" on:click={browse}> <CardButton icon="compass" title="Don't have an invite?" on:click={browse}>

View file

@ -1,37 +1,22 @@
<script lang="ts"> <script lang="ts">
import {append, remove} from "@welshman/lib" import {append, remove} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import InfoNip29 from "@app/components/InfoNip29.svelte"
import {pushModal, clearModal} from "@app/modal" import {pushModal, clearModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {deriveGroup, displayGroup, relayUrlsByNom} from "@app/state" import {addSpaceMembership} from "@app/commands"
import {sendJoinRequest, addGroupMemberships} from "@app/commands"
export let nom export let url
const group = deriveGroup(nom) const relay = deriveRelay(url)
const back = () => history.back() const back = () => history.back()
const onUrlChange = (e: any) => {
urls = urls.includes(e.target.value)
? remove(e.target.value, urls)
: append(e.target.value, urls)
}
const tryJoin = async () => { const tryJoin = async () => {
for (const url of urls) { await addSpaceMembership(url)
const [ok, message] = await sendJoinRequest(nom, url)
if (!ok) {
return pushToast({theme: "error", message})
}
}
await addGroupMemberships(urls.map(url => ["group", nom, url]))
clearModal() clearModal()
} }
@ -47,40 +32,19 @@
} }
let loading = false let loading = false
let urls: string[] = $relayUrlsByNom.get(nom) || []
$: hasUrls = urls.length > 0
$: urlOptions = $relayUrlsByNom.get(nom)?.toSorted() || []
</script> </script>
<form class="column gap-4" on:submit|preventDefault={join}> <form class="column gap-4" on:submit|preventDefault={join}>
<h1 class="heading"> <h1 class="heading">
Joining <span class="text-primary">{displayGroup($group)}</span> Joining <span class="text-primary">{displayRelayUrl(url)}</span>
</h1> </h1>
<p class="text-center"> <p class="text-center">Are you sure you'd like to join this space?</p>
Please select which relays you'd like to use for this group.
<Button class="link" on:click={() => pushModal(InfoNip29)}>What is a relay?</Button>
</p>
{#each urlOptions as url}
<div class="alert !flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon icon="remote-controller-minimalistic" />
{displayRelayUrl(url)}
</div>
<input
type="checkbox"
value={url}
class="toggle toggle-primary"
checked={urls.includes(url)}
on:change={onUrlChange} />
</div>
{/each}
<div class="flex flex-row items-center justify-between gap-4"> <div class="flex flex-row items-center justify-between gap-4">
<Button class="btn btn-link" on:click={back}> <Button class="btn btn-link" on:click={back}>
<Icon icon="alt-arrow-left" /> <Icon icon="alt-arrow-left" />
Go back Go back
</Button> </Button>
<Button type="submit" class="btn btn-primary" disabled={!hasUrls || loading}> <Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Join Space</Spinner> <Spinner {loading}>Join Space</Spinner>
<Icon icon="alt-arrow-right" /> <Icon icon="alt-arrow-right" />
</Button> </Button>

View file

@ -19,7 +19,6 @@ import {
ImageExtension, ImageExtension,
VideoExtension, VideoExtension,
FileUploadExtension, FileUploadExtension,
TagExtension as TopicExtension,
} from "nostr-editor" } from "nostr-editor"
import type {StampedEvent} from "@welshman/util" import type {StampedEvent} from "@welshman/util"
import {signer, topicSearch, profileSearch} from "@welshman/app" import {signer, topicSearch, profileSearch} from "@welshman/app"
@ -111,35 +110,6 @@ export const getChatEditorOptions = ({uploading, sendMessage}: ChatComposeEditor
VideoExtension.extend( VideoExtension.extend(
asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeVideo)}), asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeVideo)}),
).configure({defaultUploadUrl: "https://nostr.build", defaultUploadType: "nip96"}), ).configure({defaultUploadUrl: "https://nostr.build", defaultUploadType: "nip96"}),
TopicExtension.extend({
addNodeView: () => SvelteNodeViewRenderer(GroupComposeTopic),
renderHTML({mark, HTMLAttributes}) {
const attrs = {
...mark.attrs,
...HTMLAttributes,
target: "_blank",
rel: "noopener noreferer",
href: `https://coracle.social/topics/${mark.attrs.tag.toLowerCase()}`,
class: "underline",
}
return ["a", attrs, 0]
},
addProseMirrorPlugins() {
return [
createSuggestions({
char: "#",
name: "topic",
editor: this.editor,
search: topicSearch,
select: (name: string, props: any) => props.command({name}),
allowCreate: true,
suggestionComponent: GroupComposeTopicSuggestion,
suggestionsComponent: GroupComposeSuggestions,
}),
]
},
}),
FileUploadExtension.configure({ FileUploadExtension.configure({
immediateUpload: false, immediateUpload: false,
sign: (event: StampedEvent) => { sign: (event: StampedEvent) => {
@ -180,8 +150,5 @@ export const getChatViewOptions = (content: string) => ({
NAddrExtension.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeEvent)})), NAddrExtension.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeEvent)})),
ImageExtension.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeImage)})), ImageExtension.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeImage)})),
VideoExtension.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeVideo)})), VideoExtension.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeVideo)})),
TopicExtension.extend({
addNodeView: () => SvelteNodeViewRenderer(GroupComposeTopic),
}),
], ],
}) })

View file

@ -1,18 +1,31 @@
import {nip19} from 'nostr-tools'
import type {Page} from "@sveltejs/kit" import type {Page} from "@sveltejs/kit"
import {userGroupsByNom} from "@app/state" import {userMembership, decodeNEvent} from "@app/state"
export const makeSpacePath = (nom: string) => `/spaces/${nom}` export const makeSpacePath = (url: string, extra = "") => {
let path = `/spaces/${nip19.nrelayEncode(url)}`
if (extra) {
path += '/' + extra
}
return path
}
export const getPrimaryNavItem = ($page: Page) => $page.route?.id?.split("/")[1] export const getPrimaryNavItem = ($page: Page) => $page.route?.id?.split("/")[1]
export const getPrimaryNavItemIndex = ($page: Page) => { export const getPrimaryNavItemIndex = ($page: Page) => {
const urls = Array.from(userMembership.get()?.topicsByUrl.keys() || [])
switch (getPrimaryNavItem($page)) { switch (getPrimaryNavItem($page)) {
case "discover": case "discover":
return userGroupsByNom.get().size + 2 return urls.length + 2
case "spaces": case "spaces":
return Array.from(userGroupsByNom.get().keys()).findIndex(nom => nom === $page.params.nom) + 1 const routeUrl = decodeNEvent($page.params.nrelay)
return urls.findIndex(url => url === routeUrl) + 1
case "settings": case "settings":
return userGroupsByNom.get().size + 3 return urls.length + 3
default: default:
return 0 return 0
} }

View file

@ -1,18 +1,29 @@
import {nip19} from 'nostr-tools'
import type {FuseResult} from "fuse.js" import type {FuseResult} from "fuse.js"
import {get, derived} from "svelte/store" import {get, derived, writable} from "svelte/store"
import type {Maybe} from "@welshman/lib" import type {Maybe} from "@welshman/lib"
import {setContext, max, between, groupBy, pushToMapKey, nthEq, stripProtocol, indexBy} from "@welshman/lib" import {
setContext,
max,
between,
groupBy,
pushToMapKey,
nthEq,
stripProtocol,
indexBy,
uniq,
} from "@welshman/lib"
import { import {
getIdFilters, getIdFilters,
getIdentifier, getIdentifier,
normalizeRelayUrl, normalizeRelayUrl,
GROUP_META, RELAYS,
GROUPS, APP_DATA,
getGroupTags,
GROUP_JOIN,
GROUP_ADD_USER,
REACTION, REACTION,
ZAP_RESPONSE, ZAP_RESPONSE,
getRelayTagValues,
getTopicTagValues,
isShareableRelayUrl,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import { import {
@ -20,6 +31,7 @@ import {
repository, repository,
createSearch, createSearch,
load, load,
subscribe,
collection, collection,
loadRelay, loadRelay,
relaysByPubkey, relaysByPubkey,
@ -32,15 +44,14 @@ import {
makeRouter, makeRouter,
} from "@welshman/app" } from "@welshman/app"
import type {Relay} from "@welshman/app" import type {Relay} from "@welshman/app"
import type {SubscribeRequest} from "@welshman/net" import type {SubscribeRequestWithHandlers} from "@welshman/net"
import {deriveEvents, deriveEventsMapped, withGetter} from "@welshman/store" import {deriveEvents, deriveEventsMapped, withGetter} from "@welshman/store"
export const DEFAULT_RELAYS = [ export const MESSAGE = 209
"wss://groups.fiatjaf.com/",
"wss://relay29.galaxoidlabs.com/", export const REPLY = 210
"wss://devrelay.highlighter.com/",
"wss://relay.groups.nip29.com/", export const MEMBERSHIPS = 30209
]
export const INDEXER_RELAYS = ["wss://purplepag.es/", "wss://relay.damus.io/", "wss://nos.lol/"] export const INDEXER_RELAYS = ["wss://purplepag.es/", "wss://relay.damus.io/", "wss://nos.lol/"]
@ -58,7 +69,6 @@ setContext({
}), }),
}) })
export const deriveEvent = (idOrAddress: string, hints: string[] = []) => { export const deriveEvent = (idOrAddress: string, hints: string[] = []) => {
let attempted = false let attempted = false
@ -78,237 +88,122 @@ export const deriveEvent = (idOrAddress: string, hints: string[] = []) => {
) )
} }
// Groups // Persist relay/event mappings
export const GROUP_DELIMITER = `'` export const relaysByMessage = withGetter(writable(new Map<string, string[]>()))
export const makeGroupId = (url: string, nom: string) => // Topics
[stripProtocol(url).replace(/\/$/, ""), nom].join(GROUP_DELIMITER)
export const splitGroupId = (groupId: string) => { export const topicsByUrl = withGetter(writable(new Map<string, string[]>()))
const [url, nom] = groupId.split(GROUP_DELIMITER)
return [normalizeRelayUrl(url), nom] // Membership
}
export const getGroupUrl = (groupId: string) => splitGroupId(groupId)[0] export type Membership = {
topicsByUrl: Map<string, string[]>
export const getGroupNom = (groupId: string) => splitGroupId(groupId)[1]
export const getGroupName = (e?: TrustedEvent) => e?.tags.find(nthEq(0, "name"))?.[1]
export const getGroupPicture = (e?: TrustedEvent) => e?.tags.find(nthEq(0, "picture"))?.[1]
export const displayGroup = (group?: Group) => group?.name || group?.nom || "[no name]"
export type Group = {
nom: string
name?: string
about?: string
picture?: string
event?: TrustedEvent event?: TrustedEvent
} }
export type PublishedGroup = Omit<Group, "event"> & { export type PublishedMembership = Omit<Membership, "event"> & {
event: TrustedEvent event: TrustedEvent
} }
export const readGroup = (event: TrustedEvent) => { export const readMembership = (event: TrustedEvent): PublishedMembership => {
const nom = getIdentifier(event)! const topicsByUrl = new Map<string, string[]>()
const name = event?.tags.find(nthEq(0, "name"))?.[1]
const about = event?.tags.find(nthEq(0, "about"))?.[1]
const picture = event?.tags.find(nthEq(0, "picture"))?.[1]
return {nom, name, about, picture, event} for (const tag of event.tags.filter(nthEq(0, "r"))) {
} topicsByUrl.set(tag[1], [])
export const groups = deriveEventsMapped<PublishedGroup>(repository, {
filters: [{kinds: [GROUP_META]}],
eventToItem: readGroup,
itemToEvent: item => item.event,
})
export const {
indexStore: groupsByNom,
deriveItem: deriveGroup,
loadItem: loadGroup,
} = collection({
name: "groups",
store: groups,
getKey: (group: PublishedGroup) => group.nom,
load: async (nom: string, hints: string[] = [], request: Partial<SubscribeRequest> = {}) => {
if (hints.length === 0) {
hints = relayUrlsByNom.get().get(nom) || []
}
await Promise.all([
...hints.map(loadRelay),
load({
...request,
relays: hints,
filters: [{kinds: [GROUP_META], "#d": [nom]}],
}),
])
},
})
export const searchGroups = derived(groups, $groups =>
createSearch($groups, {
getValue: (group: PublishedGroup) => group.nom,
sortFn: (result: FuseResult<PublishedGroup>) => {
const scale = result.item.picture ? 0.5 : 1
return result.score! * scale
},
fuseOptions: {
keys: ["name", {name: "about", weight: 0.3}],
},
}),
)
// Qualified groups
export type QualifiedGroup = {
id: string
relay: Relay
group: PublishedGroup
}
export const qualifiedGroups = derived([relaysByPubkey, groups], ([$relaysByPubkey, $groups]) =>
$groups.flatMap((group: PublishedGroup) => {
const relays = $relaysByPubkey.get(group.event.pubkey) || []
return relays.map(relay => ({id: makeGroupId(relay.url, group.nom), relay, group}))
}),
)
export const qualifiedGroupsById = derived(qualifiedGroups, $qualifiedGroups =>
indexBy($qg => $qg.id, $qualifiedGroups),
)
export const qualifiedGroupsByNom = derived(qualifiedGroups, $qualifiedGroups =>
groupBy($qg => $qg.group.nom, $qualifiedGroups),
)
export const relayUrlsByNom = withGetter(
derived(qualifiedGroups, $qualifiedGroups => {
const $relayUrlsByNom = new Map()
for (const {relay, group} of $qualifiedGroups) {
pushToMapKey($relayUrlsByNom, group.nom, relay.url)
}
return $relayUrlsByNom
}),
)
// Group membership
export type GroupMembership = {
ids: Set<string>
noms: Set<string>
urls: Set<string>
event?: TrustedEvent
}
export type PublishedGroupMembership = Omit<GroupMembership, "event"> & {
event: TrustedEvent
}
export const readGroupMembership = (event: TrustedEvent) => {
const ids = new Set<string>()
const noms = new Set<string>()
const urls = new Set<string>()
for (const [_, nom, url] of getGroupTags(event.tags)) {
ids.add(makeGroupId(url, nom))
noms.add(nom)
urls.add(url)
} }
return {event, ids, noms, urls} for (const tag of event.tags.filter(nthEq(0, "t"))) {
pushToMapKey(topicsByUrl, tag[2], tag[1])
}
return {event, topicsByUrl}
} }
export const groupMemberships = deriveEventsMapped<PublishedGroupMembership>(repository, { export const memberships = deriveEventsMapped<PublishedMembership>(repository, {
filters: [{kinds: [GROUPS]}], filters: [{kinds: [MEMBERSHIPS]}],
eventToItem: readGroupMembership, eventToItem: readMembership,
itemToEvent: item => item.event, itemToEvent: item => item.event,
}) })
export const { export const {
indexStore: groupMembershipByPubkey, indexStore: membershipByPubkey,
deriveItem: deriveGroupMembership, deriveItem: deriveMembership,
loadItem: loadGroupMembership, loadItem: loadMembership,
} = collection({ } = collection({
name: "groupMemberships", name: "memberships",
store: groupMemberships, store: memberships,
getKey: groupMembership => groupMembership.event.pubkey, getKey: membership => membership.event.pubkey,
load: async (pubkey: string, hints = [], request: Partial<SubscribeRequest> = {}) => { load: (pubkey: string, request: Partial<SubscribeRequestWithHandlers> = {}) =>
const relays = getWriteRelayUrls(await loadRelaySelections(pubkey, hints)) load({
return load({
...request, ...request,
relays: [...hints, ...relays, ...INDEXER_RELAYS], filters: [{kinds: [MEMBERSHIPS], authors: [pubkey]}],
filters: [{kinds: [GROUPS], authors: [pubkey]}], }),
})
},
}) })
// Group Messages // Messages
export type GroupMessage = { export type Message = {
nom: string url: string
chat: string
topic: string
event: TrustedEvent event: TrustedEvent
} }
export const readGroupMessage = (event: TrustedEvent): Maybe<GroupMessage> => { export const readMessage = (event: TrustedEvent): Maybe<Message[]> => {
const nom = event.tags.find(nthEq(0, "h"))?.[1] const topics = getTopicTagValues(event.tags)
if ( if (topics.length !== 1) return undefined
!nom ||
between(GROUP_ADD_USER - 1, GROUP_JOIN + 1, event.kind) ||
REACTION_KINDS.includes(event.kind)
) {
return undefined
}
return {nom, event} const topic = topics[0]
const urls = relaysByMessage.get().get(event.id) || []
return urls.map(url => ({url, topic, chat: getChatId(url, topic), event}))
} }
export const groupMessages = deriveEventsMapped<GroupMessage>(repository, { export const messages = deriveEventsMapped<Message>(repository, {
filters: [{}], filters: [{}],
eventToItem: readGroupMessage, eventToItem: readMessage,
itemToEvent: item => item.event, itemToEvent: item => item.event,
}) })
// Group Chats // Chats
export type GroupChat = { export type Chat = {
nom: string id: string
messages: GroupMessage[] url: string
topic: string
messages: Message[]
} }
export const groupChats = derived(groupMessages, $groupMessages => { export const getChatId = (url: string, topic: string) => `${url}'${topic}`
const groupMessagesByNom = groupBy($groupMessage => $groupMessage.nom, $groupMessages)
return Array.from(groupMessagesByNom.entries()).map(([nom, messages]) => ({nom, messages})) export const splitChatId = (id: string) => id.split("'")
})
export const chats = derived(messages, $messages =>
Array.from(groupBy($message => $message.chat, $messages).values()).map(messages => {
const {chat, url, topic} = messages[0]
return {id: chat, url, topic, messages}
}),
)
export const { export const {
indexStore: groupChatByNom, indexStore: chatsById,
deriveItem: deriveGroupChat, deriveItem: deriveChat,
loadItem: loadGroupChat, loadItem: loadChat,
} = collection({ } = collection({
name: "groupChats", name: "chats",
store: groupChats, store: chats,
getKey: groupChat => groupChat.nom, getKey: chat => chat.id,
load: (nom: string, hints = [], request: Partial<SubscribeRequest> = {}) => { load: (id: string, request: Partial<SubscribeRequestWithHandlers> = {}) => {
const relays = [...hints, ...(get(relayUrlsByNom).get(nom) || [])] const [url, topic] = splitChatId(id)
const chat = get(groupChats).find(c => c.nom === nom) const chat = get(chatsById).get(id)
const timestamps = chat?.messages.map(m => m.event.created_at) || [] const timestamps = chat?.messages.map(m => m.event.created_at) || []
const since = Math.max(0, max(timestamps) - 3600) const since = Math.max(0, max(timestamps) - 3600)
return load({...request, relays, filters: [{"#h": [nom], since}]}) return load({...request, relays: [url], filters: [{"#t": [topic], since}]})
}, },
}) })
@ -322,47 +217,34 @@ export const userProfile = derived([pubkey, profilesByPubkey], ([$pubkey, $profi
return $profilesByPubkey.get($pubkey) return $profilesByPubkey.get($pubkey)
}) })
export const userMembership = derived( export const userMembership = withGetter(
[pubkey, groupMembershipByPubkey], derived([pubkey, membershipByPubkey], ([$pubkey, $membershipByPubkey]) => {
([$pubkey, $groupMembershipByPubkey]) => {
if (!$pubkey) return null if (!$pubkey) return null
loadGroupMembership($pubkey) loadMembership($pubkey)
return $groupMembershipByPubkey.get($pubkey) return $membershipByPubkey.get($pubkey)
},
)
export const userGroupsByNom = withGetter(
derived([userMembership, qualifiedGroupsById], ([$userMembership, $qualifiedGroupsById]) => {
const $userGroupsByNom = new Map()
for (const id of $userMembership?.ids || []) {
const [url, nom] = splitGroupId(id)
const group = $qualifiedGroupsById.get(id)
const groups = $userGroupsByNom.get(nom) || []
loadGroup(nom, [url])
if (group) {
groups.push(group)
}
$userGroupsByNom.set(nom, groups)
}
return $userGroupsByNom
}), }),
) )
export const userRelayUrlsByNom = derived(userGroupsByNom, $userGroupsByNom => { // Other utils
const $userRelayUrlsByNom = new Map()
for (const [nom, groups] of $userGroupsByNom.entries()) { export const decodeNEvent = (nevent: string) => nip19.decode(nevent).data as string
for (const group of groups) {
pushToMapKey($userRelayUrlsByNom, nom, group.relay.url)
}
}
return $userRelayUrlsByNom export const displayReaction = (content: string) => {
}) if (content === "+") return "❤️"
if (content === "-") return "👎"
return content
}
export const discoverRelays = () =>
subscribe({
filters: [{kinds: [RELAYS]}],
onEvent: (event: TrustedEvent) => {
for (const url of getRelayTagValues(event.tags)) {
if (isShareableRelayUrl(url)) {
loadRelay(url)
}
}
},
})

View file

@ -10,6 +10,7 @@
relays, relays,
handles, handles,
loadRelay, loadRelay,
db,
initStorage, initStorage,
repository, repository,
session, session,
@ -27,7 +28,7 @@
import PrimaryNav from "@app/components/PrimaryNav.svelte" import PrimaryNav from "@app/components/PrimaryNav.svelte"
import {modals, clearModal} from "@app/modal" import {modals, clearModal} from "@app/modal"
import {theme} from "@app/theme" import {theme} from "@app/theme"
import {DEFAULT_RELAYS} from "@app/state" import {INDEXER_RELAYS, topicsByUrl, relaysByMessage} from "@app/state"
import {loadUserData} from "@app/commands" import {loadUserData} from "@app/commands"
import * as state from "@app/state" import * as state from "@app/state"
@ -60,23 +61,27 @@
onMount(() => { onMount(() => {
Object.assign(window, {get, state}) Object.assign(window, {get, state})
ready = initStorage('flotilla', 3, { ready = db
events: { ? Promise.resolve()
keyPath: "id", : initStorage("flotilla", 2, {
store: createEventStore(repository), events: {
}, keyPath: "id",
relays: { store: createEventStore(repository),
keyPath: "url", },
store: relays, relays: {
}, keyPath: "url",
handles: { store: relays,
keyPath: "nip05", },
store: handles, handles: {
}, keyPath: "nip05",
publishStatus: storageAdapters.fromObjectStore(publishStatusData), store: handles,
freshness: storageAdapters.fromObjectStore(freshness), },
plaintext: storageAdapters.fromObjectStore(plaintext), topicsByUrl: storageAdapters.fromMapStore(topicsByUrl),
}).then(() => sleep(300)) // Wait an extra few ms because of repository throttle relaysByMessage: storageAdapters.fromMapStore(relaysByMessage),
publishStatus: storageAdapters.fromObjectStore(publishStatusData),
freshness: storageAdapters.fromObjectStore(freshness),
plaintext: storageAdapters.fromObjectStore(plaintext),
}).then(() => sleep(300)) // Wait an extra few ms because of repository throttle
dialog.addEventListener("close", () => { dialog.addEventListener("close", () => {
if (modal) { if (modal) {
@ -85,7 +90,7 @@
}) })
ready.then(() => { ready.then(() => {
for (const url of DEFAULT_RELAYS) { for (const url of INDEXER_RELAYS) {
loadRelay(url) loadRelay(url)
} }

View file

@ -1,27 +1,20 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import Masonry from "svelte-bricks" import Masonry from "svelte-bricks"
import {GROUP_META, displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {load, relays} from "@welshman/app" import {load, relaySearch} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
import { import {userMembership, discoverRelays} from "@app/state"
displayGroup,
searchGroups,
relayUrlsByNom,
userMembership,
DEFAULT_RELAYS,
} from "@app/state"
let term = "" let term = ""
$: groups = $searchGroups.searchOptions(term).filter(g => $relayUrlsByNom.get(g.nom)?.length > 0) $: relays = $relaySearch.searchOptions(term)
onMount(() => { onMount(() => {
load({ const sub = discoverRelays()
relays: [...DEFAULT_RELAYS, ...$relays.map(r => r.url)],
filters: [{kinds: [GROUP_META]}], return () => sub.close()
})
}) })
</script> </script>
@ -34,26 +27,26 @@
</label> </label>
<Masonry <Masonry
animate={false} animate={false}
items={groups} items={relays}
minColWidth={250} minColWidth={250}
maxColWidth={800} maxColWidth={800}
gap={16} gap={16}
idKey="nom" idKey="url"
let:item={group}> let:item={relay}>
<a <a
href={makeSpacePath(group.nom)} href={makeSpacePath(relay.url)}
class="card bg-base-100 shadow-xl transition-all hover:shadow-2xl hover:brightness-[1.1]"> class="card bg-base-100 shadow-xl transition-all hover:shadow-2xl hover:brightness-[1.1]">
<div class="center avatar mt-8"> <div class="center avatar mt-8">
<div <div
class="center relative !flex w-20 rounded-full border-2 border-solid border-base-300 bg-base-300"> class="center relative !flex w-20 rounded-full border-2 border-solid border-base-300 bg-base-300">
{#if group.picture} {#if relay.profile?.icon}
<img alt="" src={group.picture} /> <img alt="" src={relay.profile.icon} />
{:else} {:else}
<Icon icon="ghost" size={7} /> <Icon icon="ghost" size={7} />
{/if} {/if}
</div> </div>
</div> </div>
{#if $userMembership?.noms.has(group.nom)} {#if $userMembership?.topicsByUrl.has(relay.url)}
<div class="center absolute flex w-full"> <div class="center absolute flex w-full">
<div <div
class="tooltip relative left-8 top-[38px] h-5 w-5 rounded-full bg-primary" class="tooltip relative left-8 top-[38px] h-5 w-5 rounded-full bg-primary"
@ -63,14 +56,9 @@
</div> </div>
{/if} {/if}
<div class="card-body"> <div class="card-body">
<h2 class="card-title justify-center">{displayGroup(group)}</h2> <h2 class="card-title justify-center">{displayRelayUrl(relay.url)}</h2>
<div class="text-center text-sm"> {#if relay.profile?.description}
{#each $relayUrlsByNom.get(group.nom) || [] as url} <p class="py-4 text-sm text-center">{relay.profile.description}</p>
<div class="badge badge-neutral">{displayRelayUrl(url)}</div>
{/each}
</div>
{#if group.about}
<p class="py-4 text-sm">{group.about}</p>
{/if} {/if}
</div> </div>
</a> </a>

View file

@ -6,9 +6,9 @@
import {subscribe, loadRelay, relaySearch} from "@welshman/app" import {subscribe, loadRelay, relaySearch} from "@welshman/app"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import {DEFAULT_RELAYS, INDEXER_RELAYS} from "@app/state" import {INDEXER_RELAYS, discoverRelays} from "@app/state"
const relays = readable(DEFAULT_RELAYS) const relays = readable(INDEXER_RELAYS)
const removeRelay = (url: string) => null const removeRelay = (url: string) => null
@ -17,18 +17,7 @@
let term = "" let term = ""
onMount(() => { onMount(() => {
const sub = subscribe({ const sub = discoverRelays()
filters: [{kinds: [30166], "#N": ["29"]}],
relays: [...INDEXER_RELAYS, ...DEFAULT_RELAYS],
})
sub.emitter.on("event", (url: string, event: SignedEvent) => {
const d = event.tags.find(t => t[0] === "d")?.[1] || ""
if (isShareableRelayUrl(d)) {
loadRelay(d)
}
})
return () => sub.close() return () => sub.close()
}) })

View file

@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import {nip19} from 'nostr-tools'
import {page} from "$app/stores" import {page} from "$app/stores"
import {sort} from '@welshman/lib'
import {displayRelayUrl} from '@welshman/util'
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Page from "@lib/components/Page.svelte" import Page from "@lib/components/Page.svelte"
@ -11,8 +14,10 @@
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte" import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
import SpaceExit from "@app/components/SpaceExit.svelte" import SpaceExit from "@app/components/SpaceExit.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte" import SpaceJoin from "@app/components/SpaceJoin.svelte"
import {deriveGroup, userMembership, displayGroup} from "@app/state" import RoomCreate from "@app/components/RoomCreate.svelte"
import {userMembership, decodeNEvent} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes"
const openMenu = () => { const openMenu = () => {
showMenu = true showMenu = true
@ -22,22 +27,24 @@
showMenu = !showMenu showMenu = !showMenu
} }
const leaveSpace = () => pushModal(SpaceExit, {nom}) const leaveSpace = () => pushModal(SpaceExit, {url})
const joinSpace = () => pushModal(SpaceJoin, {nom}) const joinSpace = () => pushModal(SpaceJoin, {url})
const addRoom = () => pushModal(RoomCreate, {url})
let showMenu = false let showMenu = false
$: nom = $page.params.nom $: url = decodeNEvent($page.params.nrelay)
$: group = deriveGroup(nom) $: rooms = sort($userMembership?.topicsByUrl?.get(url) || [])
</script> </script>
{#key nom} {#key url}
<SecondaryNav> <SecondaryNav>
<SecondaryNavSection> <SecondaryNavSection>
<div> <div>
<SecondaryNavItem class="w-full !justify-between" on:click={openMenu}> <SecondaryNavItem class="w-full !justify-between" on:click={openMenu}>
<strong>{displayGroup($group)}</strong> <strong>{displayRelayUrl(url)}</strong>
<Icon icon="alt-arrow-down" /> <Icon icon="alt-arrow-down" />
</SecondaryNavItem> </SecondaryNavItem>
{#if showMenu} {#if showMenu}
@ -45,7 +52,7 @@
<ul <ul
transition:fly|local transition:fly|local
class="menu absolute z-popover mt-2 w-full rounded-box bg-base-100 p-2 shadow-xl"> class="menu absolute z-popover mt-2 w-full rounded-box bg-base-100 p-2 shadow-xl">
{#if $userMembership?.noms.has(nom)} {#if $userMembership?.topicsByUrl.has(url)}
<li class="text-error"> <li class="text-error">
<Button on:click={leaveSpace}> <Button on:click={leaveSpace}>
<Icon icon="exit" /> <Icon icon="exit" />
@ -66,22 +73,22 @@
</div> </div>
<div class="my-3 h-px bg-base-200" /> <div class="my-3 h-px bg-base-200" />
<div in:fly|local> <div in:fly|local>
<SecondaryNavItem href="/spaces/{nom}"> <SecondaryNavItem href={makeSpacePath(url)}>
<Icon icon="chat-round" /> Chat <Icon icon="chat-round" /> Chat
</SecondaryNavItem> </SecondaryNavItem>
</div> </div>
<div in:fly|local={{delay: 50}}> <div in:fly|local={{delay: 50}}>
<SecondaryNavItem href="/spaces/{nom}/threads"> <SecondaryNavItem href={makeSpacePath(url, "threads")}>
<Icon icon="notes-minimalistic" /> Threads <Icon icon="notes-minimalistic" /> Threads
</SecondaryNavItem> </SecondaryNavItem>
</div> </div>
<div in:fly|local={{delay: 100}}> <div in:fly|local={{delay: 100}}>
<SecondaryNavItem href="/spaces/{nom}/events"> <SecondaryNavItem href={makeSpacePath(url, "events")}>
<Icon icon="calendar-minimalistic" /> Calendar <Icon icon="calendar-minimalistic" /> Calendar
</SecondaryNavItem> </SecondaryNavItem>
</div> </div>
<div in:fly|local={{delay: 150}}> <div in:fly|local={{delay: 150}}>
<SecondaryNavItem href="/spaces/{nom}/listings"> <SecondaryNavItem href={makeSpacePath(url, "listings")}>
<Icon icon="shop-minimalistic" /> Market <Icon icon="shop-minimalistic" /> Market
</SecondaryNavItem> </SecondaryNavItem>
</div> </div>
@ -89,11 +96,18 @@
<div class="h-2" /> <div class="h-2" />
<SecondaryNavHeader> <SecondaryNavHeader>
Rooms Rooms
<Button on:click={() => alert("Uh, I don't know how to do rooms on NIP 29")}> <Button on:click={addRoom}>
<Icon icon="add-circle" /> <Icon icon="add-circle" />
</Button> </Button>
</SecondaryNavHeader> </SecondaryNavHeader>
</div> </div>
{#each rooms as topic, i (topic)}
<div transition:fly|local={{delay: 250 + i * 50}}>
<SecondaryNavItem href={makeSpacePath(url, topic)}>
<Icon icon="hashtag" /> {topic}
</SecondaryNavItem>
</div>
{/each}
</SecondaryNavSection> </SecondaryNavSection>
</SecondaryNav> </SecondaryNav>
<Page> <Page>

View file

@ -11,16 +11,16 @@
import {onMount} from "svelte" import {onMount} from "svelte"
import {page} from "$app/stores" import {page} from "$app/stores"
import {sortBy, now} from "@welshman/lib" import {sortBy, now} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent, Filter} from "@welshman/util"
import {subscribe, formatTimestampAsDate} from "@welshman/app" import {subscribe, formatTimestampAsDate} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import GroupNote from "@app/components/GroupNote.svelte" import GroupNote from "@app/components/GroupNote.svelte"
import GroupCompose from "@app/components/GroupCompose.svelte" import GroupCompose from "@app/components/GroupCompose.svelte"
import {deriveGroupChat, userRelayUrlsByNom} from "@app/state" import {deriveChat, userMembership, MESSAGE, REPLY} from "@app/state"
const {nom} = $page.params const {url, topic} = $page.params
const chat = deriveGroupChat(nom) const chat = deriveChat(url)
const assertEvent = (e: any) => e as TrustedEvent const assertEvent = (e: any) => e as TrustedEvent
@ -60,10 +60,10 @@
}, 3000) }, 3000)
onMount(() => { onMount(() => {
const sub = subscribe({ const since = now() - 30
filters: [{"#h": [nom], since: now() - 30}], const kinds = [MESSAGE, REPLY]
relays: $userRelayUrlsByNom.get(nom) || [], const filter = topic ? {kinds, since, "#t": [topic]} : {kinds, since} as Filter
}) const sub = subscribe({filters: [filter], relays: [url]})
return () => sub.close() return () => sub.close()
}) })
@ -100,5 +100,5 @@
</Spinner> </Spinner>
</p> </p>
</div> </div>
<GroupCompose {nom} /> <GroupCompose {url} {topic} />
</div> </div>