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
+12 -35
View File
@@ -1,12 +1,5 @@
import {uniqBy, uniq, now, choice} from "@welshman/lib"
import {
GROUPS,
GROUP_JOIN,
getGroupTags,
getRelayTagValues,
createEvent,
displayProfile,
} from "@welshman/util"
import {uniqBy, equals, uniq, now, choice} from "@welshman/lib"
import {getRelayTagValues, createEvent, displayProfile} from "@welshman/util"
import {PublishStatus} from "@welshman/net"
import {
pubkey,
@@ -22,7 +15,7 @@ import {
loadFollows,
loadMutes,
} from "@welshman/app"
import {loadGroup, loadGroupMembership, INDEXER_RELAYS} from "@app/state"
import {MEMBERSHIPS, INDEXER_RELAYS} from "@app/state"
// Utils
@@ -57,11 +50,7 @@ export const makeIMeta = (url: string, data: Record<string, string>) => [
// Loaders
export const loadUserData = (pubkey: string, hints: string[] = []) =>
Promise.all([
loadProfile(pubkey),
loadFollows(pubkey),
loadMutes(pubkey),
])
Promise.all([loadProfile(pubkey), loadFollows(pubkey), loadMutes(pubkey)])
// Updates
@@ -80,26 +69,14 @@ export const updateList = async (kind: number, modifyTags: ModifyTags) => {
publishThunk(makeThunk({event, relays}))
}
export const addGroupMemberships = (newTags: string[][]) =>
updateList(GROUPS, (tags: string[][]) => uniqBy(t => t.join(""), [...tags, ...newTags]))
export const addSpaceMembership = (url: string) =>
updateList(MEMBERSHIPS, (tags: string[][]) => uniqBy(t => t.join(""), [...tags, ["r", url]]))
export const removeGroupMemberships = (noms: string[]) =>
updateList(GROUPS, (tags: string[][]) => tags.filter(t => !noms.includes(t[1])))
export const addRoomMembership = (url: string, topic: string) =>
updateList(MEMBERSHIPS, (tags: string[][]) => uniqBy(t => t.join(""), [...tags, ["t", topic, url]]))
export const sendJoinRequest = async (nom: string, url: string): Promise<[boolean, string]> => {
const relays = [url]
const filters = [{kinds: [9000], "#h": [nom], "#p": [pubkey.get()!], since: now() - 30}]
export const removeSpaceMembership = (url: string) =>
updateList(MEMBERSHIPS, (tags: string[][]) => tags.filter(t => !equals(["r", url], t) && t[2] !== url))
const event = createEvent(GROUP_JOIN, {tags: [["h", nom]]})
const statusData = await publishThunk(makeThunk({event, relays}))
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.",
]
}
export const removeRoomMembership = (url: string, topic: string) =>
updateList(MEMBERSHIPS, (tags: string[][]) => tags.filter(t => !equals(["t", topic, url], t)))
+13 -15
View File
@@ -3,17 +3,17 @@
import type {Readable} from "svelte/store"
import {writable} from "svelte/store"
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 {publishThunk, makeThunk} from "@welshman/app"
import {findNodes} from "@lib/tiptap"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {userRelayUrlsByNom} from "@app/state"
import {makeMention, makeIMeta} from "@app/commands"
import {getChatEditorOptions, addFile} from "@app/editor"
export let nom
export let url
export let topic = ""
const uploading = writable(false)
@@ -21,22 +21,20 @@
const sendMessage = () => {
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, {
content: $editor.getText(),
tags: [
["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}),
),
],
tags: [topicTag, ...mentionTags, ...imetaTags],
})
publishThunk(makeThunk({event, relays}))
publishThunk(makeThunk({event, relays: [url]}))
$editor.chain().clearContent().run()
}
+1 -7
View File
@@ -27,7 +27,7 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import {deriveEvent} from "@app/state"
import {deriveEvent, displayReaction} from "@app/state"
import {getChatViewOptions} from "@app/editor"
export let event: TrustedEvent
@@ -66,12 +66,6 @@
const [colorName, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
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[]) =>
$ps.find(({status}) => statuses.includes(status))
@@ -3,7 +3,6 @@
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import {DEFAULT_RELAYS} from "@app/state"
import {clip} from "@app/toast"
</script>
@@ -17,22 +16,8 @@
This means that anyone can host their own data, making the web more decentralized and resilient.
</p>
<p>
Only some relays support spaces. You can find a list of suggested relays below, or you can <Link
external
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.
Different relays have different policies for access control and content retention. Be sure to
double check that you have access to the relays you try to use by visiting their website.
</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>
</div>
+1 -1
View File
@@ -60,7 +60,7 @@
const pubkey = await getNip07()?.getPublicKey()
if (pubkey) {
await onSuccess({method: "extension", pubkey})
await onSuccess({method: "nip07", pubkey})
} else {
pushToast({
theme: "error",
+7 -11
View File
@@ -9,13 +9,15 @@
import {page} from "$app/stores"
import {tweened} from "svelte/motion"
import {quintOut} from "svelte/easing"
import {displayRelayUrl} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.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 {getPrimaryNavItemIndex} from "@app/routes"
import {makeSpacePath, getPrimaryNavItemIndex} from "@app/routes"
const activeOffset = tweened(-44, {
duration: 300,
@@ -49,15 +51,9 @@
class="!h-10 !w-10 border border-solid border-base-300"
size={7} />
</PrimaryNavItem>
{#each $userGroupsByNom.entries() as [nom, qualifiedGroups] (nom)}
{@const qualifiedGroup = qualifiedGroups[0]}
<PrimaryNavItem title={displayGroup(qualifiedGroup?.group)} href="/spaces/{nom}">
<Avatar
icon="ghost"
class="!h-10 !w-10 border border-solid border-base-300"
alt={displayGroup(qualifiedGroup?.group)}
src={qualifiedGroup?.group.picture}
size={7} />
{#each $userMembership?.topicsByUrl.keys() || [] as url (url)}
<PrimaryNavItem title={displayRelayUrl(url)} href={makeSpacePath(url)}>
<SpaceAvatar {url} />
</PrimaryNavItem>
{/each}
<PrimaryNavItem title="Add Space" on:click={addSpace}>
+65
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>
+16
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} />
+3 -3
View File
@@ -3,7 +3,7 @@
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.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 {pushModal} from "@app/modal"
@@ -38,8 +38,8 @@
<input bind:value={relay} class="grow" type="text" />
</label>
<p slot="info">
This should be a NIP-29 compatible nostr relay where you'd like to host your space.
<Button class="link" on:click={() => pushModal(InfoNip29)}>What is a relay?</Button>
This can be any nostr relay where you'd like to host your space.
<Button class="link" on:click={() => pushModal(InfoRelay)}>What is a relay?</Button>
</p>
</Field>
<div class="flex flex-row items-center justify-between gap-4">
+5 -7
View File
@@ -1,14 +1,12 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {displayRelayUrl} from "@welshman/util"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import {deriveGroup} from "@app/state"
import {removeGroupMemberships} from "@app/commands"
import {removeSpaceMembership} from "@app/commands"
export let nom
const group = deriveGroup(nom)
export let url
const back = () => history.back()
@@ -16,7 +14,7 @@
loading = true
try {
await removeGroupMemberships([nom])
await removeSpaceMembership(url)
} finally {
loading = false
}
@@ -29,7 +27,7 @@
<form class="column gap-4" on:submit|preventDefault={exit}>
<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>
<p class="text-center">Are you sure you want to leave?</p>
<div class="flex flex-row items-center justify-between gap-4">
+14 -27
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {tryCatch} from "@welshman/lib"
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
import {loadRelay} from "@welshman/app"
import CardButton from "@lib/components/CardButton.svelte"
import Spinner from "@lib/components/Spinner.svelte"
@@ -7,43 +9,28 @@
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import {pushToast} from "@app/toast"
import {splitGroupId, loadGroup} from "@app/state"
import {addGroupMemberships} from "@app/commands"
import {addSpaceMembership} from "@app/commands"
import {makeSpacePath} from "@app/routes"
const back = () => history.back()
const browse = () => goto("/discover")
const joinQualifiedGroup = async (id: string) => {
const [url, nom] = splitGroupId(id)
const joinRelay = async (url: string) => {
url = normalizeRelayUrl(url)
const relay = await loadRelay(url)
if (!relay) {
if (!relay?.profile) {
return pushToast({
theme: "error",
message: "Sorry, we weren't able to find that relay.",
})
}
if (!relay.profile?.supported_nips?.includes(29)) {
return pushToast({
theme: "error",
message: "Sorry, it looks like that relay doesn't support nostr spaces.",
})
}
await addSpaceMembership(url)
const group = await loadGroup(nom, [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}`)
goto(makeSpacePath(url))
pushToast({
message: "Welcome to the space!",
})
@@ -53,16 +40,16 @@
loading = true
try {
await joinQualifiedGroup(id)
await joinRelay(url)
} finally {
loading = false
}
}
let id = ""
let url = ""
let loading = false
$: linkIsValid = Boolean(id.match(/.+\..+'.+/))
$: linkIsValid = Boolean(tryCatch(() => isRelayUrl(normalizeRelayUrl(url))))
</script>
<form class="column gap-4" on:submit|preventDefault={join}>
@@ -74,7 +61,7 @@
<p slot="label">Invite Link*</p>
<label class="input input-bordered flex w-full items-center gap-2" slot="input">
<Icon icon="link-round" />
<input bind:value={id} class="grow" type="text" />
<input bind:value={url} class="grow" type="text" />
</label>
</Field>
<CardButton icon="compass" title="Don't have an invite?" on:click={browse}>
+8 -44
View File
@@ -1,37 +1,22 @@
<script lang="ts">
import {append, remove} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import InfoNip29 from "@app/components/InfoNip29.svelte"
import {pushModal, clearModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {deriveGroup, displayGroup, relayUrlsByNom} from "@app/state"
import {sendJoinRequest, addGroupMemberships} from "@app/commands"
import {addSpaceMembership} from "@app/commands"
export let nom
export let url
const group = deriveGroup(nom)
const relay = deriveRelay(url)
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 () => {
for (const url of urls) {
const [ok, message] = await sendJoinRequest(nom, url)
if (!ok) {
return pushToast({theme: "error", message})
}
}
await addGroupMemberships(urls.map(url => ["group", nom, url]))
await addSpaceMembership(url)
clearModal()
}
@@ -47,40 +32,19 @@
}
let loading = false
let urls: string[] = $relayUrlsByNom.get(nom) || []
$: hasUrls = urls.length > 0
$: urlOptions = $relayUrlsByNom.get(nom)?.toSorted() || []
</script>
<form class="column gap-4" on:submit|preventDefault={join}>
<h1 class="heading">
Joining <span class="text-primary">{displayGroup($group)}</span>
Joining <span class="text-primary">{displayRelayUrl(url)}</span>
</h1>
<p class="text-center">
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}
<p class="text-center">Are you sure you'd like to join this space?</p>
<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={!hasUrls || loading}>
<Button type="submit" class="btn btn-primary" disabled={loading}>
<Spinner {loading}>Join Space</Spinner>
<Icon icon="alt-arrow-right" />
</Button>
-33
View File
@@ -19,7 +19,6 @@ import {
ImageExtension,
VideoExtension,
FileUploadExtension,
TagExtension as TopicExtension,
} from "nostr-editor"
import type {StampedEvent} from "@welshman/util"
import {signer, topicSearch, profileSearch} from "@welshman/app"
@@ -111,35 +110,6 @@ export const getChatEditorOptions = ({uploading, sendMessage}: ChatComposeEditor
VideoExtension.extend(
asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeVideo)}),
).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({
immediateUpload: false,
sign: (event: StampedEvent) => {
@@ -180,8 +150,5 @@ export const getChatViewOptions = (content: string) => ({
NAddrExtension.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeEvent)})),
ImageExtension.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeImage)})),
VideoExtension.extend(asInline({addNodeView: () => SvelteNodeViewRenderer(GroupComposeVideo)})),
TopicExtension.extend({
addNodeView: () => SvelteNodeViewRenderer(GroupComposeTopic),
}),
],
})
+18 -5
View File
@@ -1,18 +1,31 @@
import {nip19} from 'nostr-tools'
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 getPrimaryNavItemIndex = ($page: Page) => {
const urls = Array.from(userMembership.get()?.topicsByUrl.keys() || [])
switch (getPrimaryNavItem($page)) {
case "discover":
return userGroupsByNom.get().size + 2
return urls.length + 2
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":
return userGroupsByNom.get().size + 3
return urls.length + 3
default:
return 0
}
+118 -236
View File
@@ -1,18 +1,29 @@
import {nip19} from 'nostr-tools'
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 {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 {
getIdFilters,
getIdentifier,
normalizeRelayUrl,
GROUP_META,
GROUPS,
getGroupTags,
GROUP_JOIN,
GROUP_ADD_USER,
RELAYS,
APP_DATA,
REACTION,
ZAP_RESPONSE,
getRelayTagValues,
getTopicTagValues,
isShareableRelayUrl,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {
@@ -20,6 +31,7 @@ import {
repository,
createSearch,
load,
subscribe,
collection,
loadRelay,
relaysByPubkey,
@@ -32,15 +44,14 @@ import {
makeRouter,
} 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"
export const DEFAULT_RELAYS = [
"wss://groups.fiatjaf.com/",
"wss://relay29.galaxoidlabs.com/",
"wss://devrelay.highlighter.com/",
"wss://relay.groups.nip29.com/",
]
export const MESSAGE = 209
export const REPLY = 210
export const MEMBERSHIPS = 30209
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[] = []) => {
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) =>
[stripProtocol(url).replace(/\/$/, ""), nom].join(GROUP_DELIMITER)
// Topics
export const splitGroupId = (groupId: string) => {
const [url, nom] = groupId.split(GROUP_DELIMITER)
export const topicsByUrl = withGetter(writable(new Map<string, string[]>()))
return [normalizeRelayUrl(url), nom]
}
// Membership
export const getGroupUrl = (groupId: string) => splitGroupId(groupId)[0]
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
export type Membership = {
topicsByUrl: Map<string, string[]>
event?: TrustedEvent
}
export type PublishedGroup = Omit<Group, "event"> & {
export type PublishedMembership = Omit<Membership, "event"> & {
event: TrustedEvent
}
export const readGroup = (event: TrustedEvent) => {
const nom = getIdentifier(event)!
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]
export const readMembership = (event: TrustedEvent): PublishedMembership => {
const topicsByUrl = new Map<string, string[]>()
return {nom, name, about, picture, event}
}
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)
for (const tag of event.tags.filter(nthEq(0, "r"))) {
topicsByUrl.set(tag[1], [])
}
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, {
filters: [{kinds: [GROUPS]}],
eventToItem: readGroupMembership,
export const memberships = deriveEventsMapped<PublishedMembership>(repository, {
filters: [{kinds: [MEMBERSHIPS]}],
eventToItem: readMembership,
itemToEvent: item => item.event,
})
export const {
indexStore: groupMembershipByPubkey,
deriveItem: deriveGroupMembership,
loadItem: loadGroupMembership,
indexStore: membershipByPubkey,
deriveItem: deriveMembership,
loadItem: loadMembership,
} = collection({
name: "groupMemberships",
store: groupMemberships,
getKey: groupMembership => groupMembership.event.pubkey,
load: async (pubkey: string, hints = [], request: Partial<SubscribeRequest> = {}) => {
const relays = getWriteRelayUrls(await loadRelaySelections(pubkey, hints))
return load({
name: "memberships",
store: memberships,
getKey: membership => membership.event.pubkey,
load: (pubkey: string, request: Partial<SubscribeRequestWithHandlers> = {}) =>
load({
...request,
relays: [...hints, ...relays, ...INDEXER_RELAYS],
filters: [{kinds: [GROUPS], authors: [pubkey]}],
})
},
filters: [{kinds: [MEMBERSHIPS], authors: [pubkey]}],
}),
})
// Group Messages
// Messages
export type GroupMessage = {
nom: string
export type Message = {
url: string
chat: string
topic: string
event: TrustedEvent
}
export const readGroupMessage = (event: TrustedEvent): Maybe<GroupMessage> => {
const nom = event.tags.find(nthEq(0, "h"))?.[1]
export const readMessage = (event: TrustedEvent): Maybe<Message[]> => {
const topics = getTopicTagValues(event.tags)
if (
!nom ||
between(GROUP_ADD_USER - 1, GROUP_JOIN + 1, event.kind) ||
REACTION_KINDS.includes(event.kind)
) {
return undefined
}
if (topics.length !== 1) 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: [{}],
eventToItem: readGroupMessage,
eventToItem: readMessage,
itemToEvent: item => item.event,
})
// Group Chats
// Chats
export type GroupChat = {
nom: string
messages: GroupMessage[]
export type Chat = {
id: string
url: string
topic: string
messages: Message[]
}
export const groupChats = derived(groupMessages, $groupMessages => {
const groupMessagesByNom = groupBy($groupMessage => $groupMessage.nom, $groupMessages)
export const getChatId = (url: string, topic: string) => `${url}'${topic}`
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 {
indexStore: groupChatByNom,
deriveItem: deriveGroupChat,
loadItem: loadGroupChat,
indexStore: chatsById,
deriveItem: deriveChat,
loadItem: loadChat,
} = collection({
name: "groupChats",
store: groupChats,
getKey: groupChat => groupChat.nom,
load: (nom: string, hints = [], request: Partial<SubscribeRequest> = {}) => {
const relays = [...hints, ...(get(relayUrlsByNom).get(nom) || [])]
const chat = get(groupChats).find(c => c.nom === nom)
name: "chats",
store: chats,
getKey: chat => chat.id,
load: (id: string, request: Partial<SubscribeRequestWithHandlers> = {}) => {
const [url, topic] = splitChatId(id)
const chat = get(chatsById).get(id)
const timestamps = chat?.messages.map(m => m.event.created_at) || []
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)
})
export const userMembership = derived(
[pubkey, groupMembershipByPubkey],
([$pubkey, $groupMembershipByPubkey]) => {
export const userMembership = withGetter(
derived([pubkey, membershipByPubkey], ([$pubkey, $membershipByPubkey]) => {
if (!$pubkey) return null
loadGroupMembership($pubkey)
loadMembership($pubkey)
return $groupMembershipByPubkey.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
return $membershipByPubkey.get($pubkey)
}),
)
export const userRelayUrlsByNom = derived(userGroupsByNom, $userGroupsByNom => {
const $userRelayUrlsByNom = new Map()
// Other utils
for (const [nom, groups] of $userGroupsByNom.entries()) {
for (const group of groups) {
pushToMapKey($userRelayUrlsByNom, nom, group.relay.url)
}
}
export const decodeNEvent = (nevent: string) => nip19.decode(nevent).data as string
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)
}
}
},
})
+24 -19
View File
@@ -10,6 +10,7 @@
relays,
handles,
loadRelay,
db,
initStorage,
repository,
session,
@@ -27,7 +28,7 @@
import PrimaryNav from "@app/components/PrimaryNav.svelte"
import {modals, clearModal} from "@app/modal"
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 * as state from "@app/state"
@@ -60,23 +61,27 @@
onMount(() => {
Object.assign(window, {get, state})
ready = initStorage('flotilla', 3, {
events: {
keyPath: "id",
store: createEventStore(repository),
},
relays: {
keyPath: "url",
store: relays,
},
handles: {
keyPath: "nip05",
store: handles,
},
publishStatus: storageAdapters.fromObjectStore(publishStatusData),
freshness: storageAdapters.fromObjectStore(freshness),
plaintext: storageAdapters.fromObjectStore(plaintext),
}).then(() => sleep(300)) // Wait an extra few ms because of repository throttle
ready = db
? Promise.resolve()
: initStorage("flotilla", 2, {
events: {
keyPath: "id",
store: createEventStore(repository),
},
relays: {
keyPath: "url",
store: relays,
},
handles: {
keyPath: "nip05",
store: handles,
},
topicsByUrl: storageAdapters.fromMapStore(topicsByUrl),
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", () => {
if (modal) {
@@ -85,7 +90,7 @@
})
ready.then(() => {
for (const url of DEFAULT_RELAYS) {
for (const url of INDEXER_RELAYS) {
loadRelay(url)
}
+17 -29
View File
@@ -1,27 +1,20 @@
<script lang="ts">
import {onMount} from "svelte"
import Masonry from "svelte-bricks"
import {GROUP_META, displayRelayUrl} from "@welshman/util"
import {load, relays} from "@welshman/app"
import {displayRelayUrl} from "@welshman/util"
import {load, relaySearch} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import {makeSpacePath} from "@app/routes"
import {
displayGroup,
searchGroups,
relayUrlsByNom,
userMembership,
DEFAULT_RELAYS,
} from "@app/state"
import {userMembership, discoverRelays} from "@app/state"
let term = ""
$: groups = $searchGroups.searchOptions(term).filter(g => $relayUrlsByNom.get(g.nom)?.length > 0)
$: relays = $relaySearch.searchOptions(term)
onMount(() => {
load({
relays: [...DEFAULT_RELAYS, ...$relays.map(r => r.url)],
filters: [{kinds: [GROUP_META]}],
})
const sub = discoverRelays()
return () => sub.close()
})
</script>
@@ -34,26 +27,26 @@
</label>
<Masonry
animate={false}
items={groups}
items={relays}
minColWidth={250}
maxColWidth={800}
gap={16}
idKey="nom"
let:item={group}>
idKey="url"
let:item={relay}>
<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]">
<div class="center avatar mt-8">
<div
class="center relative !flex w-20 rounded-full border-2 border-solid border-base-300 bg-base-300">
{#if group.picture}
<img alt="" src={group.picture} />
{#if relay.profile?.icon}
<img alt="" src={relay.profile.icon} />
{:else}
<Icon icon="ghost" size={7} />
{/if}
</div>
</div>
{#if $userMembership?.noms.has(group.nom)}
{#if $userMembership?.topicsByUrl.has(relay.url)}
<div class="center absolute flex w-full">
<div
class="tooltip relative left-8 top-[38px] h-5 w-5 rounded-full bg-primary"
@@ -63,14 +56,9 @@
</div>
{/if}
<div class="card-body">
<h2 class="card-title justify-center">{displayGroup(group)}</h2>
<div class="text-center text-sm">
{#each $relayUrlsByNom.get(group.nom) || [] as url}
<div class="badge badge-neutral">{displayRelayUrl(url)}</div>
{/each}
</div>
{#if group.about}
<p class="py-4 text-sm">{group.about}</p>
<h2 class="card-title justify-center">{displayRelayUrl(relay.url)}</h2>
{#if relay.profile?.description}
<p class="py-4 text-sm text-center">{relay.profile.description}</p>
{/if}
</div>
</a>
+3 -14
View File
@@ -6,9 +6,9 @@
import {subscribe, loadRelay, relaySearch} from "@welshman/app"
import Button from "@lib/components/Button.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
@@ -17,18 +17,7 @@
let term = ""
onMount(() => {
const sub = subscribe({
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)
}
})
const sub = discoverRelays()
return () => sub.close()
})
@@ -1,5 +1,8 @@
<script lang="ts">
import {nip19} from 'nostr-tools'
import {page} from "$app/stores"
import {sort} from '@welshman/lib'
import {displayRelayUrl} from '@welshman/util'
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Page from "@lib/components/Page.svelte"
@@ -11,8 +14,10 @@
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
import SpaceExit from "@app/components/SpaceExit.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 {makeSpacePath} from "@app/routes"
const openMenu = () => {
showMenu = true
@@ -22,22 +27,24 @@
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
$: nom = $page.params.nom
$: group = deriveGroup(nom)
$: url = decodeNEvent($page.params.nrelay)
$: rooms = sort($userMembership?.topicsByUrl?.get(url) || [])
</script>
{#key nom}
{#key url}
<SecondaryNav>
<SecondaryNavSection>
<div>
<SecondaryNavItem class="w-full !justify-between" on:click={openMenu}>
<strong>{displayGroup($group)}</strong>
<strong>{displayRelayUrl(url)}</strong>
<Icon icon="alt-arrow-down" />
</SecondaryNavItem>
{#if showMenu}
@@ -45,7 +52,7 @@
<ul
transition:fly|local
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">
<Button on:click={leaveSpace}>
<Icon icon="exit" />
@@ -66,22 +73,22 @@
</div>
<div class="my-3 h-px bg-base-200" />
<div in:fly|local>
<SecondaryNavItem href="/spaces/{nom}">
<SecondaryNavItem href={makeSpacePath(url)}>
<Icon icon="chat-round" /> Chat
</SecondaryNavItem>
</div>
<div in:fly|local={{delay: 50}}>
<SecondaryNavItem href="/spaces/{nom}/threads">
<SecondaryNavItem href={makeSpacePath(url, "threads")}>
<Icon icon="notes-minimalistic" /> Threads
</SecondaryNavItem>
</div>
<div in:fly|local={{delay: 100}}>
<SecondaryNavItem href="/spaces/{nom}/events">
<SecondaryNavItem href={makeSpacePath(url, "events")}>
<Icon icon="calendar-minimalistic" /> Calendar
</SecondaryNavItem>
</div>
<div in:fly|local={{delay: 150}}>
<SecondaryNavItem href="/spaces/{nom}/listings">
<SecondaryNavItem href={makeSpacePath(url, "listings")}>
<Icon icon="shop-minimalistic" /> Market
</SecondaryNavItem>
</div>
@@ -89,11 +96,18 @@
<div class="h-2" />
<SecondaryNavHeader>
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" />
</Button>
</SecondaryNavHeader>
</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>
</SecondaryNav>
<Page>
@@ -11,16 +11,16 @@
import {onMount} from "svelte"
import {page} from "$app/stores"
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 Icon from "@lib/components/Icon.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import GroupNote from "@app/components/GroupNote.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 chat = deriveGroupChat(nom)
const {url, topic} = $page.params
const chat = deriveChat(url)
const assertEvent = (e: any) => e as TrustedEvent
@@ -60,10 +60,10 @@
}, 3000)
onMount(() => {
const sub = subscribe({
filters: [{"#h": [nom], since: now() - 30}],
relays: $userRelayUrlsByNom.get(nom) || [],
})
const since = now() - 30
const kinds = [MESSAGE, REPLY]
const filter = topic ? {kinds, since, "#t": [topic]} : {kinds, since} as Filter
const sub = subscribe({filters: [filter], relays: [url]})
return () => sub.close()
})
@@ -100,5 +100,5 @@
</Spinner>
</p>
</div>
<GroupCompose {nom} />
<GroupCompose {url} {topic} />
</div>