Use new collection pattern

This commit is contained in:
Jon Staab
2024-08-13 12:41:25 -07:00
parent 71d819edc7
commit 85fb09fc5f
12 changed files with 456 additions and 337 deletions

68
package-lock.json generated
View File

@@ -12,11 +12,11 @@
"@noble/hashes": "^1.4.0",
"@poppanator/sveltekit-svg": "^4.2.1",
"@types/throttle-debounce": "^5.0.2",
"@welshman/lib": "^0.0.13",
"@welshman/net": "^0.0.17",
"@welshman/lib": "^0.0.14",
"@welshman/net": "^0.0.18",
"@welshman/signer": "^0.0.2",
"@welshman/store": "^0.0.1",
"@welshman/util": "^0.0.24",
"@welshman/store": "^0.0.2",
"@welshman/util": "^0.0.25",
"daisyui": "^4.12.10",
"nostr-tools": "^2.7.2",
"prettier-plugin-tailwindcss": "^0.6.5",
@@ -1372,9 +1372,9 @@
}
},
"node_modules/@welshman/lib": {
"version": "0.0.13",
"resolved": "https://registry.npmjs.org/@welshman/lib/-/lib-0.0.13.tgz",
"integrity": "sha512-tp5+KAiUwoid04Pap47uqkz3MWDqA1iy+JklX7qu5WppClehMgkYfePtCbKOQ8LS9psl88xnZM1oR8NtDhVqnA==",
"version": "0.0.14",
"resolved": "https://registry.npmjs.org/@welshman/lib/-/lib-0.0.14.tgz",
"integrity": "sha512-q5sWp3psLcouajdP97PZs2D44WoMZ0cwwT7EWUdShIPgisRJq9hjuMLSkUFqdgfJ3qBn72SSjcMMfDnkcQja4A==",
"dependencies": {
"@scure/base": "^1.1.6",
"@types/events": "^3.0.3",
@@ -1384,12 +1384,12 @@
}
},
"node_modules/@welshman/net": {
"version": "0.0.17",
"resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.17.tgz",
"integrity": "sha512-m2hvpb3AdHPmmhtfc16oy03523TG+6ggM9bt7vOLfKZl67qg9ki5VHFRxd7VPRuKosvNabeIErWBsXIxtKWdJg==",
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.18.tgz",
"integrity": "sha512-5a6iKetUSDQisiG7/Cr61x9oSLhLNifqN/qzdOKW8Mu8ZqkOFYMIFPonLKT3UdixByvXn+RH5ka3b/nB77bBIA==",
"dependencies": {
"@welshman/lib": "0.0.13",
"@welshman/util": "0.0.24",
"@welshman/lib": "0.0.14",
"@welshman/util": "0.0.25",
"isomorphic-ws": "^5.0.0",
"ws": "^8.16.0"
}
@@ -1404,15 +1404,30 @@
"@welshman/util": "^0.0.24"
}
},
"node_modules/@welshman/store": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@welshman/store/-/store-0.0.1.tgz",
"integrity": "sha512-vpnbJOF8zoneTcLOr5iZuRETVwb67mUyLiWmGsfs8yoMI7lpxJkg0QKieVkp2FpVYoDYbb98eQEuYOiHdsf7RQ==",
"node_modules/@welshman/signer/node_modules/@welshman/lib": {
"version": "0.0.13",
"resolved": "https://registry.npmjs.org/@welshman/lib/-/lib-0.0.13.tgz",
"integrity": "sha512-tp5+KAiUwoid04Pap47uqkz3MWDqA1iy+JklX7qu5WppClehMgkYfePtCbKOQ8LS9psl88xnZM1oR8NtDhVqnA==",
"dependencies": {
"svelte": "^4.2.18"
"@scure/base": "^1.1.6",
"@types/events": "^3.0.3",
"@types/throttle-debounce": "^5.0.2",
"events": "^3.3.0",
"throttle-debounce": "^5.0.0"
}
},
"node_modules/@welshman/util": {
"node_modules/@welshman/signer/node_modules/@welshman/net": {
"version": "0.0.17",
"resolved": "https://registry.npmjs.org/@welshman/net/-/net-0.0.17.tgz",
"integrity": "sha512-m2hvpb3AdHPmmhtfc16oy03523TG+6ggM9bt7vOLfKZl67qg9ki5VHFRxd7VPRuKosvNabeIErWBsXIxtKWdJg==",
"dependencies": {
"@welshman/lib": "0.0.13",
"@welshman/util": "0.0.24",
"isomorphic-ws": "^5.0.0",
"ws": "^8.16.0"
}
},
"node_modules/@welshman/signer/node_modules/@welshman/util": {
"version": "0.0.24",
"resolved": "https://registry.npmjs.org/@welshman/util/-/util-0.0.24.tgz",
"integrity": "sha512-Qpe0J5VCYpqhpGB4f966d4FAw65L3JE2xYSqhBGbbfdMXnteqW6nHClP0YVbX9pMSYGRPn3ZXWkWf33yqImbGQ==",
@@ -1421,6 +1436,23 @@
"nostr-tools": "^2.3.2"
}
},
"node_modules/@welshman/store": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@welshman/store/-/store-0.0.2.tgz",
"integrity": "sha512-VjyXFP6KILCldtHykTAwdCHhAQVNjpDy2lZrXho1YNDMBxuuskXtmj3AzS6iJuq6zZOKWN+iEDOzjqxbI5raFA==",
"dependencies": {
"svelte": "^4.2.18"
}
},
"node_modules/@welshman/util": {
"version": "0.0.25",
"resolved": "https://registry.npmjs.org/@welshman/util/-/util-0.0.25.tgz",
"integrity": "sha512-Y0n7YKIyJmhqhUvi9iOTg21PnMPFgEJpoAfaQ1IypKfjUjLMfBe3blKQ/elO/W+nquZLHIFuSY9hNMLpXSnZ+Q==",
"dependencies": {
"@welshman/lib": "0.0.14",
"nostr-tools": "^2.3.2"
}
},
"node_modules/acorn": {
"version": "8.12.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",

View File

@@ -38,11 +38,12 @@
"@noble/hashes": "^1.4.0",
"@poppanator/sveltekit-svg": "^4.2.1",
"@types/throttle-debounce": "^5.0.2",
"@welshman/lib": "^0.0.13",
"@welshman/net": "^0.0.17",
"@welshman/lib": "^0.0.14",
"@welshman/net": "^0.0.18",
"@welshman/signer": "^0.0.2",
"@welshman/store": "^0.0.1",
"@welshman/util": "^0.0.24",
"@welshman/store": "^0.0.2",
"@welshman/util": "^0.0.25",
"@welshman/domain": "^0.0.1",
"daisyui": "^4.12.10",
"nostr-tools": "^2.7.2",
"prettier-plugin-tailwindcss": "^0.6.5",

View File

@@ -1,6 +1,6 @@
import {derived} from "svelte/store"
import {memoize} from '@welshman/lib'
import type {SignedEvent} from "@welshman/util"
import {memoize, assoc} from '@welshman/lib'
import type {CustomEvent} from '@welshman/util'
import {Repository, createEvent, Relay} from "@welshman/util"
import {getter} from "@welshman/store"
import {NetworkContext, Tracker} from "@welshman/net"
@@ -23,17 +23,24 @@ export const pk = synced<string | null>('pk', null)
export const sessions = synced<Record<string, Session>>('sessions', {})
export const getSessions = getter(sessions)
export const session = derived([pk, sessions], ([$pk, $sessions]) => $pk ? $sessions[$pk] : null)
export const getSession = getter(session)
export const addSession = (session: Session) => {
sessions.update(assoc(session.pubkey, session))
pk.set(session.pubkey)
}
export const makeSigner = memoize((session: Session) => {
switch (session?.method) {
case "extension":
return new Nip07Signer()
case "privkey":
return new Nip01Signer(session.secret!)
case "connect":
case "nip46":
return new Nip46Signer(Nip46Broker.get(session.pubkey, session.secret!, session.handler!))
default:
return null
@@ -47,8 +54,8 @@ export const getSigner = getter(signer)
const seenChallenges = new Set()
Object.assign(NetworkContext, {
onEvent: (url: string, event: SignedEvent) => tracker.track(event.id, url),
isDeleted: (url: string, event: SignedEvent) => repository.isDeleted(event),
onEvent: (url: string, event: CustomEvent) => tracker.track(event.id, url),
isDeleted: (url: string, event: CustomEvent) => repository.isDeleted(event),
onAuth: async (url: string, challenge: string) => {
if (seenChallenges.has(challenge)) {
return

View File

@@ -1,175 +0,0 @@
import {get} from 'svelte/store'
import type {SignedEvent} from '@welshman/util'
import {batcher, uniq, now, postJson, assoc} from "@welshman/lib"
import {normalizeRelayUrl, PROFILE, FOLLOWS, MUTES, GROUP_META} from "@welshman/util"
import {subscribe} from "@welshman/net"
import type {RelayInfo, HandleInfo, Session} from "@app/types"
import {splitGroupId} from "@app/domain"
import {DUFFLEPUD_URL, INDEXER_RELAYS, repository, pk, sessions} from "@app/base"
import {relayInfo, handleInfo, groupsById, profilesByPubkey, mutesByPubkey} from "@app/state"
// Session
export const addSession = (session: Session) => {
sessions.update(assoc(session.pubkey, session))
pk.set(session.pubkey)
}
// Handle info
export const loadHandleInfo = batcher(800, async (handles: string[]) => {
const res = await postJson(`${DUFFLEPUD_URL}/handle/info`, {handles: uniq(handles)})
const data: {handle: string, info: HandleInfo}[] = res?.data || []
handleInfo.update($handleInfo => {
for (const {handle, info} of data) {
$handleInfo.set(handle, {...info, fetched_at: now()})
}
return $handleInfo
})
return data.map(item => item.info)
})
export const getHandleInfo = (handle: string) => {
const info = get(handleInfo).get(handle)
if (info?.fetched_at > now() - 3600) {
return info
}
return loadHandleInfo(handle)
}
// Relay info
export const loadRelayInfo = batcher(800, async (urls: string[]) => {
const res = await postJson(`${DUFFLEPUD_URL}/relay/info`, {urls: uniq(urls)})
const data: {url: string, info: RelayInfo}[] = res?.data || []
relayInfo.update($relayInfo => {
for (const {url, info} of data) {
$relayInfo.set(normalizeRelayUrl(url), {...info, fetched_at: now()})
}
return $relayInfo
})
return data.map(item => item.info)
})
export const getRelayInfo = (url: string) => {
const info = get(relayInfo).get(url)
if (info?.fetched_at > now() - 3600) {
return info
}
return loadRelayInfo(url)
}
// Group meta
export const getGroup = (groupId: string) => {
const group = get(groupsById).get(groupId)
if (group?.event.fetched_at > now() - 3600) {
return group
}
const [url, nom] = splitGroupId(groupId)
const sub = subscribe({
relays: [url],
filters: [{kinds: [GROUP_META], '#d': [groupId]}],
closeOnEose: true,
})
sub.emitter.on('event', (url: string, e: SignedEvent) => {
e.fetched_at = now()
repository.publish(e)
console.log(e)
})
}
// Profile
export const getProfile = (pubkey: string, relays = []) => {
const profile = get(profilesByPubkey).get(pubkey)
if (profile?.event.fetched_at > now() - 3600) {
return profile
}
return new Promise(resolve => {
const sub = subscribe({
relays: [...relays, ...INDEXER_RELAYS],
filters: [{kinds: [PROFILE], authors: [pubkey]}],
closeOnEose: true,
})
sub.emitter.on('event', (url: string, e: SignedEvent) => {
e.fetched_at = now()
repository.publish(e)
console.log(e)
resolve(e)
})
sub.emitter.on('close', () => resolve(null))
})
}
// Follows
export const getFollows = (pubkey: string, relays = []) => {
const follows = get(followsByPubkey).get(pubkey)
if (follows?.event.fetched_at > now() - 3600) {
return follows
}
return new Promise(resolve => {
const sub = subscribe({
relays: [...relays, ...INDEXER_RELAYS],
filters: [{kinds: [FOLLOWS], authors: [pubkey]}],
closeOnEose: true,
})
sub.emitter.on('event', (url: string, e: SignedEvent) => {
e.fetched_at = now()
repository.publish(e)
console.log(e)
resolve(e)
})
sub.emitter.on('close', () => resolve(null))
})
}
// Mutes
export const getMutes = (pubkey: string, relays = []) => {
const mutes = get(mutesByPubkey).get(pubkey)
if (mutes?.event.fetched_at > now() - 3600) {
return mutes
}
return new Promise(resolve => {
const sub = subscribe({
relays: [...relays, ...INDEXER_RELAYS],
filters: [{kinds: [MUTES], authors: [pubkey]}],
closeOnEose: true,
})
sub.emitter.on('event', (url: string, e: SignedEvent) => {
e.fetched_at = now()
repository.publish(e)
console.log(e)
resolve(e)
})
sub.emitter.on('close', () => resolve(null))
})
}

View File

@@ -8,13 +8,14 @@
import InfoNostr from '@app/components/LogIn.svelte'
import {pushModal, clearModal} from '@app/modal'
import {pushToast} from '@app/toast'
import {getProfile, getFollows, getMutes, getHandleInfo, addSession} from '@app/commands'
import {addSession} from '@app/base'
import {loadProfile, loadFollows, loadMutes, loadHandle} from '@app/state'
const back = () => history.back()
const tryLogin = async () => {
const secret = makeSecret()
const handle = await getHandleInfo(`${username}@${handler.domain}`)
const handle = await loadHandle(`${username}@${handler.domain}`)
if (!handle?.pubkey) {
return pushToast({
@@ -26,9 +27,9 @@
const {pubkey, relays = []} = handle
const broker = Nip46Broker.get(pubkey, secret, handler)
const [profile, success] = await Promise.all([
getProfile(pubkey, relays),
getFollows(pubkey, relays),
getMutes(pubkey, relays),
loadProfile(pubkey, relays),
loadFollows(pubkey, relays),
loadMutes(pubkey, relays),
broker.connect(),
])

View File

@@ -10,16 +10,16 @@
import Icon from "@lib/components/Icon.svelte"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import SpaceAdd from '@app/components/SpaceAdd.svelte'
import {makeGroupId} from "@app/domain"
import {session} from "@app/base"
import {userGroupRelaysByNom, groupsById, deriveProfile} from "@app/state"
import {deriveGroupMembership, makeGroupId, getGroup, deriveProfile} from "@app/state"
import {pushModal} from "@app/modal"
export const addSpace = () => pushModal(SpaceAdd)
export const browseSpaces = () => goto("/browse")
const profile = deriveProfile($session?.pubkey)
$: profile = deriveProfile($session?.pubkey)
$: membership = deriveGroupMembership($session?.pubkey)
</script>
<div class="relative w-14 bg-base-100">
@@ -27,17 +27,24 @@
<div class="flex h-full flex-col justify-between">
<div>
<PrimaryNavItem title={$profile?.name}>
<div class="w-10 rounded-full border border-solid border-base-300">
<img alt="" src={$profile?.picture} />
<div class="!flex w-10 items-center justify-center rounded-full border border-solid border-base-300">
{#if $profile?.picture}
<img alt="" src={$profile.picture} />
{:else}
<Icon icon="user-rounded" size={7} />
{/if}
</div>
</PrimaryNavItem>
{#each $userGroupRelaysByNom.entries() as [nom, relays] (nom)}
{@const group = $groupsById.get(makeGroupId(relays[0], nom))}
<PrimaryNavItem title={group.name}>
{#each Array.from($membership?.ids || []) as groupId (groupId)}
{#await getGroup(groupId)}
<!-- pass -->
{:then group}
<PrimaryNavItem title={group?.name}>
<div class="w-10 rounded-full border border-solid border-base-300">
<img alt={group.name} src={group.picture} />
<img alt={group?.name} src={group?.picture} />
</div>
</PrimaryNavItem>
{/await}
{/each}
<PrimaryNavItem title="Add Space" on:click={addSpace}>
<div class="!flex w-10 items-center justify-center">

View File

@@ -5,11 +5,9 @@
import Field from '@lib/components/Field.svelte'
import Icon from '@lib/components/Icon.svelte'
import SpaceCreateFinish from '@app/components/SpaceCreateFinish.svelte'
import {splitGroupId, GROUP_DELIMITER} from '@app/domain'
import {getRelayInfo, getGroup} from '@app/commands'
import {pushModal} from '@app/modal'
import {pushToast} from '@app/toast'
import {relayInfo} from '@app/state'
import {GROUP_DELIMITER, splitGroupId, loadRelay, loadGroup} from '@app/state'
const back = () => history.back()
@@ -18,7 +16,7 @@
const tryJoin = async () => {
const [url, nom] = splitGroupId(id)
const info = await getRelayInfo(url)
const info = await loadRelay(url)
if (!info) {
return pushToast({
@@ -34,8 +32,7 @@
})
}
const group = await getGroup(id)
console.log(info, group)
const group = await loadGroup(id)
}
const join = async () => {

View File

@@ -1,28 +0,0 @@
import {stripProtocol} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getIdentifier, normalizeRelayUrl} from "@welshman/util"
export const GROUP_DELIMITER = `'`
export const makeGroupId = (url: string, nom: string) =>
[stripProtocol(url), nom].join(GROUP_DELIMITER)
export const splitGroupId = (groupId: string) => {
const [url, nom] = groupId.split(GROUP_DELIMITER)
return [normalizeRelayUrl(url), nom]
}
export const getGroupNom = (e: TrustedEvent) => getIdentifier(e)?.split(GROUP_DELIMITER)[1]
export const getGroupUrl = (e: TrustedEvent) => {
const id = getIdentifier(e)
const url = id?.split(GROUP_DELIMITER)[0]
return url ? normalizeRelayUrl(url) : null
}
export const getGroupName = (e: TrustedEvent | undefined) => e?.tags.find(t => t[0] === "name")?.[1]
export const getGroupPicture = (e: TrustedEvent | undefined) =>
e?.tags.find(t => t[0] === "picture")?.[1]

View File

@@ -1,83 +1,352 @@
import {writable, derived} from "svelte/store"
import {pushToMapKey, indexBy} from "@welshman/lib"
import {getIdentifier, getPubkeyTagValues, GROUP_META, PROFILE, FOLLOWS, MUTES, GROUPS, getGroupTagValues} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import type {Readable} from "svelte/store"
import {writable, readable, derived} from "svelte/store"
import {uniq, pushToMapKey, nthEq, batcher, postJson, stripProtocol, assoc, indexBy, now} from "@welshman/lib"
import {getIdentifier, normalizeRelayUrl, getPubkeyTagValues, GROUP_META, PROFILE, FOLLOWS, MUTES, GROUPS, getGroupTagValues} from "@welshman/util"
import type {Filter, SignedEvent, CustomEvent} from '@welshman/util'
import {subscribe} from '@welshman/net'
import {readProfile, readList, asDecryptedEvent} from '@welshman/domain'
import type {PublishedProfile, PublishedList} from '@welshman/domain'
import {decrypt} from '@welshman/signer'
import {deriveEvents, deriveEventsMapped, getter, withGetter} from "@welshman/store"
import {synced, parseJson} from '@lib/util'
import type {Session} from '@app/types'
import {repository, pk} from "@app/base"
import {getGroupNom, getGroupUrl, getGroupName, getGroupPicture, GROUP_DELIMITER} from "@app/domain"
import type {Session, Handle, Relay} from '@app/types'
import {INDEXER_RELAYS, DUFFLEPUD_URL, repository, pk, getSessions, makeSigner} from "@app/base"
export const relayInfo = writable(new Map())
// Utils
export const handleInfo = writable(new Map())
export const createCollection = <T>({
store,
getKey,
isStale,
loadItem,
}: {
store: Readable<T[]>,
getKey: (item: T) => string,
isStale: (item: T) => boolean,
loadItem: (key: string, ...args: any) => Promise<any>
}) => {
const indexStore = derived(store, $items => indexBy(getKey, $items))
const getIndex = getter(indexStore)
export const profileEvents = deriveEvents(repository, {
filters: [{kinds: [PROFILE]}],
})
const getItem = async (key: string, ...args: any[]) => {
const item = getIndex().get(key)
export const profiles = derived(profileEvents, $profileEvents =>
$profileEvents.map(event => ({...parseJson(event.content), event}))
)
export const profilesByPubkey = derived(profiles, $profiles => indexBy(profile => profile.event.pubkey, $profiles))
export const deriveProfile = (pubkey: string) => derived(profilesByPubkey, $m => $m.get(pubkey))
export const followEvents = deriveEvents(repository, {
filters: [{kinds: [FOLLOWS]}],
})
export const follows = derived(followEvents, $followEvents =>
$followEvents.map(event => ({pubkeys: new Set(getPubkeyTagValues(event.tags)), event}))
)
export const followsByPubkey = derived(follows, $follows => indexBy(follow => follow.event.pubkey, $follows))
export const muteEvents = deriveEvents(repository, {
filters: [{kinds: [MUTES]}],
})
export const mutes = derived(muteEvents, $muteEvents =>
$muteEvents.map(event => ({pubkeys: new Set(getPubkeyTagValues(event.tags)), event}))
)
export const mutesByPubkey = derived(mutes, $mutes => indexBy(mute => mute.event.pubkey, $mutes))
export const groupEvents = deriveEvents(repository, {
filters: [{kinds: [GROUP_META]}],
})
export const groups = derived([relayInfo, groupEvents], ([$relayInfo, $groupEvents]) =>
$groupEvents
.map(event => ({
event,
id: getIdentifier(event),
nom: getGroupNom(event),
url: getGroupUrl(event),
name: getGroupName(event),
picture: getGroupPicture(event),
}))
.filter(group => $relayInfo.get(group.url)?.pubkey === group.event.pubkey)
)
export const groupsById = derived(groups, $groups => indexBy(group => group.id, $groups))
export const groupsEvents = deriveEvents(repository, {
filters: [{kinds: [GROUPS]}],
})
export const userGroupsEvent = derived([pk, groupsEvents], ([$pk, $groupsEvents]) =>
$groupsEvents.find(e => e.pubkey === $pk),
)
export const userGroupRelaysByNom = derived(userGroupsEvent, $userGroupsEvent => {
const relaysByNom = new Map()
for (const id of getGroupTagValues($userGroupsEvent?.tags || [])) {
const [relay, nom] = id.split(GROUP_DELIMITER)
pushToMapKey(relaysByNom, nom, relay)
if (item && isStale(item)) {
return item
}
return relaysByNom
await loadItem(key, ...args)
return getIndex().get(key)
}
const deriveItem = (key: string | undefined, ...args: any[]) => {
if (!key) {
return readable(undefined)
}
// If we don't yet have the item, or it's stale, trigger a request for it. The derived
// store will update when it arrives
loadItem(key, ...args)
return derived(indexStore, $index => $index.get(key))
}
return {indexStore, getIndex, deriveItem, loadItem, getItem}
}
export const load = ({relays, filters}: {relays: string[], filters: Filter[]}) =>
new Promise<CustomEvent | undefined>(resolve => {
const sub = subscribe({relays, filters, closeOnEose: true})
sub.emitter.on('event', (url: string, event: SignedEvent) => {
const e: CustomEvent = {...event, fetched_at: now()}
repository.publish(e)
resolve(e)
})
sub.emitter.on('close', () => resolve(undefined))
})
// Plaintext
export const plaintext = withGetter(writable<Record<string, string>>({}))
export const getPlaintext = (e: CustomEvent) => plaintext.get()[e.id]
export const setPlaintext = (e: CustomEvent, content: string) =>
plaintext.update(assoc(e.id, content))
export const ensurePlaintext = async (e: CustomEvent) => {
if (!getPlaintext(e)) {
const sessions = getSessions()
const session = sessions[e.pubkey]
const signer = makeSigner(session)
if (signer) {
setPlaintext(e, await decrypt(signer, e.pubkey, e.content))
}
}
return getPlaintext(e)
}
// Relay info
export const relays = writable<Relay[]>([])
export const {
indexStore: relaysByUrl,
getIndex: getRelaysByUrl,
deriveItem: deriveRelay,
loadItem: loadRelay,
getItem: getRelay,
} = createCollection({
store: relays,
getKey: (relay: Relay) => relay.url,
isStale: (relay: Relay) => relay.fetched_at < now() - 3600,
loadItem: batcher(800, async (urls: string[]) => {
const urlSet = new Set(urls)
const res = await postJson(`${DUFFLEPUD_URL}/relay/info`, {urls: Array.from(urlSet)})
const items: Relay[] = (res?.data || []).map(({url, info}: any) => ({...info, url, fetched_at: now()}))
relays.update($relays => [
...$relays.filter($relay => !urlSet.has($relay.url)),
...items,
])
return items
}),
})
// Handles
export const handles = writable<Handle[]>([])
export const {
indexStore: handlesByPubkey,
getIndex: getHandlesByPubkey,
deriveItem: deriveHandle,
loadItem: loadHandle,
getItem: getHandle,
} = createCollection({
store: handles,
getKey: (handle: Handle) => handle.pubkey,
isStale: (handle: Handle) => handle.fetched_at < now() - 3600,
loadItem: batcher(800, async (nip05s: string[]) => {
const nip05Set = new Set(nip05s)
const res = await postJson(`${DUFFLEPUD_URL}/handle/info`, {handles: Array.from(nip05Set)})
const items: Handle[] = (res?.data || []).map(({handle: nip05, info}: any) => ({...info, nip05, fetched_at: now()}))
handles.update($handles => [
...$handles.filter($handle => !nip05Set.has($handle.nip05)),
...items,
])
return items
}),
})
// Profiles
export const profiles = deriveEventsMapped<PublishedProfile>({
repository,
filters: [{kinds: [PROFILE]}],
eventToItem: readProfile,
itemToEvent: item => item.event,
})
export const {
indexStore: profilesByPubkey,
getIndex: getProfilesByPubkey,
deriveItem: deriveProfile,
loadItem: loadProfile,
getItem: getProfile,
} = createCollection({
store: profiles,
getKey: profile => profile.event.pubkey,
isStale: (profile: PublishedProfile) => profile.event.fetched_at < now() - 3600,
loadItem: (pubkey: string, relays = []) =>
load({
relays: [...relays, ...INDEXER_RELAYS],
filters: [{kinds: [PROFILE], authors: [pubkey]}],
}),
})
// Follows
export const follows = deriveEventsMapped<PublishedList>({
repository,
filters: [{kinds: [FOLLOWS]}],
itemToEvent: item => item.event,
eventToItem: async (event: CustomEvent) =>
readList(
asDecryptedEvent(event, {
content: await ensurePlaintext(event),
}),
),
})
export const {
indexStore: followsByPubkey,
getIndex: getFollowsByPubkey,
deriveItem: deriveFollows,
loadItem: loadFollows,
getItem: getFollows,
} = createCollection({
store: follows,
getKey: follows => follows.event.pubkey,
isStale: (follows: PublishedList) => follows.event.fetched_at < now() - 3600,
loadItem: (pubkey: string, relays = []) =>
load({
relays: [...relays, ...INDEXER_RELAYS],
filters: [{kinds: [FOLLOWS], authors: [pubkey]}],
})
})
// Mutes
export const mutes = deriveEventsMapped<PublishedList>({
repository,
filters: [{kinds: [MUTES]}],
itemToEvent: item => item.event,
eventToItem: async (event: CustomEvent) =>
readList(
asDecryptedEvent(event, {
content: await ensurePlaintext(event),
}),
),
})
export const {
indexStore: mutesByPubkey,
getIndex: getMutesByPubkey,
deriveItem: deriveMutes,
loadItem: loadMutes,
getItem: getMutes,
} = createCollection({
store: mutes,
getKey: mute => mute.event.pubkey,
isStale: (mutes: PublishedList) => mutes.event.fetched_at < now() - 3600,
loadItem: (pubkey: string, relays = []) =>
load({
relays: [...relays, ...INDEXER_RELAYS],
filters: [{kinds: [MUTES], authors: [pubkey]}],
})
})
// Groups
export const GROUP_DELIMITER = `'`
export const makeGroupId = (url: string, nom: string) =>
[stripProtocol(url), nom].join(GROUP_DELIMITER)
export const splitGroupId = (groupId: string) => {
const [url, nom] = groupId.split(GROUP_DELIMITER)
return [normalizeRelayUrl(url), nom]
}
export const getGroupUrl = (groupId: string) => splitGroupId(groupId)[0]
export const getGroupNom = (groupId: string) => splitGroupId(groupId)[1]
export const getGroupName = (e?: CustomEvent) => e?.tags.find(nthEq(0, "name"))?.[1]
export const getGroupPicture = (e?: CustomEvent) => e?.tags.find(nthEq(0, "picture"))?.[1]
export type Group = {
id: string,
nom: string,
url: string,
name?: string,
picture?: string,
event?: CustomEvent
}
export type PublishedGroup = Omit<Group, "event"> & {
event: CustomEvent
}
export const readGroup = (event: CustomEvent) => {
const id = getIdentifier(event)!
const [url, nom] = id.split(GROUP_DELIMITER)
const name = event?.tags.find(nthEq(0, "name"))?.[1]
const picture = event?.tags.find(nthEq(0, "picture"))?.[1]
return {id, nom, url, name, picture, event}
}
export const groups = deriveEventsMapped<PublishedGroup>({
repository,
filters: [{kinds: [GROUP_META]}],
eventToItem: readGroup,
itemToEvent: item => item.event,
})
export const validGroups = derived([relaysByUrl, groups], ([$relaysByUrl, $groups]) =>
$groups.filter(group => $relaysByUrl.get(group.url)?.pubkey === group.event.pubkey)
)
export const {
indexStore: groupsById,
getIndex: getGroupsById,
deriveItem: deriveGroup,
loadItem: loadGroup,
getItem: getGroup,
} = createCollection({
store: validGroups,
getKey: (group: PublishedGroup) => group.id,
isStale: (group: PublishedGroup) => group.event.fetched_at < now() - 3600,
loadItem: (id: string) => {
const url = getGroupUrl(id)
return Promise.all([
loadRelay(url),
load({
relays: [url],
filters: [{kinds: [GROUP_META], '#d': [id]}],
}),
])
}
})
// Group membership
export type GroupMembership = {
ids: Set<string>
event?: CustomEvent
}
export type PublishedGroupMembership = Omit<GroupMembership, "event"> & {
event: CustomEvent
}
export const readGroupMembership = (event: CustomEvent) =>
({event, ids: new Set(getGroupTagValues(event.tags))})
export const groupMemberships = deriveEventsMapped<PublishedGroupMembership>({
repository,
filters: [{kinds: [GROUPS]}],
eventToItem: readGroupMembership,
itemToEvent: item => item.event,
})
export const {
indexStore: groupMembershipsByPubkey,
getIndex: getGroupMembersipsByPubkey,
deriveItem: deriveGroupMembership,
loadItem: loadGroupMembership,
getItem: getGroupMembership,
} = createCollection({
store: groupMemberships,
getKey: groupMembership => groupMembership.event.pubkey,
isStale: (groupMembership: PublishedGroupMembership) => groupMembership.event.fetched_at < now() - 3600,
loadItem: (pubkey: string, relays = []) =>
load({
relays: [...relays, ...INDEXER_RELAYS],
filters: [{kinds: [GROUPS], authors: [pubkey]}],
})
})

View File

@@ -1,4 +1,7 @@
import {verifiedSymbol} from 'nostr-tools'
import type {Nip46Handler} from "@welshman/signer"
import type {SignedEvent, TrustedEvent} from "@welshman/util"
import type {RelayProfile, Handle as HandleInfo} from "@welshman/domain"
export type Session = {
method: string
@@ -7,14 +10,16 @@ export type Session = {
handler?: Nip46Handler
}
export type RelayInfo = {
fetched_at: number
export type Relay = RelayProfile & {
fetched_at: number,
}
export type HandleInfo = {
pubkey: string
nip05: string
nip46: string[]
relays: string[]
fetched_at: number
export type Handle = HandleInfo & {
fetched_at: number,
}
declare module '@welshman/util' {
interface CustomEvent extends TrustedEvent {
fetched_at: number
}
}

View File

@@ -4,14 +4,14 @@
<div class="hero min-h-screen bg-base-200">
<div class="hero-content">
<div class="flex max-w-xl flex-col gap-4">
<div class="flex max-w-2xl flex-col gap-4">
<h1 class="text-stark-content text-center text-5xl">Welcome to</h1>
<h1 class="text-stark-content mb-4 text-center text-5xl font-bold uppercase">Flotilla</h1>
<div class="grid lg:grid-cols-2 gap-3">
<CardButton icon="add-circle" title="Create a group" class="h-24">
<CardButton icon="add-circle" title="Create a space" class="h-24">
Invite all your friends, do life together.
</CardButton>
<CardButton icon="compass" title="Discover groups" class="h-24">
<CardButton icon="compass" title="Discover spaces" class="h-24">
Find a community based on your hobbies or interests.
</CardButton>
<CardButton icon="plain" title="Leave feedback" class="h-24">

View File

@@ -3,6 +3,9 @@ import svg from "@poppanator/sveltekit-svg"
import {defineConfig} from "vite"
export default defineConfig({
server: {
port: 1847,
},
plugins: [
sveltekit(),
svg({