Use welshman/app

This commit is contained in:
Jon Staab
2024-08-30 16:25:41 -07:00
parent 05e35c9f26
commit 618049c4af
26 changed files with 127 additions and 904 deletions
+11
View File
@@ -23,6 +23,7 @@
"@tiptap/extension-text": "^2.6.6",
"@tiptap/suggestion": "^2.6.4",
"@types/throttle-debounce": "^5.0.2",
"@welshman/app": "^0.0.1",
"@welshman/lib": "^0.0.15",
"@welshman/net": "^0.0.20",
"@welshman/signer": "^0.0.4",
@@ -1652,6 +1653,16 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@welshman/app": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@welshman/app/-/app-0.0.1.tgz",
"integrity": "sha512-3vvbfOwMhY6iSyswhTlmL1TCkHVF7nlpxUVvbNpgS4KD2gIs4YFr9WqAlSL4x1Ebjdm0EE3KakCr3YodP5kIOg==",
"dependencies": {
"@welshman/lib": "0.0.15",
"@welshman/util": "0.0.28",
"svelte": "^4.2.18"
}
},
"node_modules/@welshman/lib": {
"version": "0.0.15",
"resolved": "https://registry.npmjs.org/@welshman/lib/-/lib-0.0.15.tgz",
+1
View File
@@ -48,6 +48,7 @@
"@tiptap/extension-text": "^2.6.6",
"@tiptap/suggestion": "^2.6.4",
"@types/throttle-debounce": "^5.0.2",
"@welshman/app": "^0.0.1",
"@welshman/lib": "^0.0.15",
"@welshman/net": "^0.0.20",
"@welshman/signer": "^0.0.4",
-87
View File
@@ -1,87 +0,0 @@
import {derived} from "svelte/store"
import {memoize, assoc} from "@welshman/lib"
import type {TrustedEvent, HashedEvent} from "@welshman/util"
import {Repository, createEvent, Relay, REACTION, ZAP_RESPONSE} from "@welshman/util"
import {withGetter} from "@welshman/store"
import {NetworkContext, Tracker} from "@welshman/net"
import {Nip46Broker, Nip46Signer, Nip07Signer, Nip01Signer} from "@welshman/signer"
import {synced} from "@lib/util"
import type {Session} from "@app/types"
export const DEFAULT_RELAYS = [
"wss://groups.fiatjaf.com/",
"wss://relay29.galaxoidlabs.com/",
"wss://devrelay.highlighter.com/",
"wss://relay.groups.nip29.com/",
]
export const INDEXER_RELAYS = ["wss://purplepag.es/", "wss://relay.damus.io/", "wss://nos.lol/"]
export const DUFFLEPUD_URL = "https://dufflepud.onrender.com"
export const REACTION_KINDS = [REACTION, ZAP_RESPONSE]
export const repository = new Repository<HashedEvent>()
export const relay = new Relay(repository)
export const tracker = new Tracker()
export const pk = withGetter(synced<string | null>("pk", null))
export const sessions = withGetter(synced<Record<string, Session>>("sessions", {}))
export const session = withGetter(
derived([pk, sessions], ([$pk, $sessions]) => ($pk ? $sessions[$pk] : null)),
)
export const getSession = (pubkey: string) => sessions.get()[pubkey]
export const addSession = (session: Session) => {
sessions.update(assoc(session.pubkey, session))
pk.set(session.pubkey)
}
export const nip46Perms = "sign_event:22242,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt"
export const getSigner = memoize((session: Session) => {
switch (session?.method) {
case "extension":
return new Nip07Signer()
case "privkey":
return new Nip01Signer(session.secret!)
case "nip46":
return new Nip46Signer(Nip46Broker.get(session.pubkey, session.secret!, session.handler!))
default:
return null
}
})
export const signer = withGetter(derived(session, getSigner))
const seenChallenges = new Set()
Object.assign(NetworkContext, {
onEvent: (url: string, event: TrustedEvent) => tracker.track(event.id, url),
isDeleted: (url: string, event: TrustedEvent) => repository.isDeleted(event),
onAuth: async (url: string, challenge: string) => {
if (seenChallenges.has(challenge) || !signer.get()) {
return
}
seenChallenges.add(challenge)
const event = await signer.get()!.sign(
createEvent(22242, {
tags: [
["relay", url],
["challenge", challenge],
],
}),
)
NetworkContext.pool.get(url).send(["AUTH", event])
return event
},
})
+17 -17
View File
@@ -8,26 +8,26 @@ import {
displayProfile,
} from "@welshman/util"
import {PublishStatus} from "@welshman/net"
import {pk, repository, INDEXER_RELAYS} from "@app/base"
import {
pubkey,
repository,
loadOne,
getWriteRelayUrls,
loadGroup,
loadGroupMembership,
loadProfile,
loadFollows,
loadMutes,
getRelaySelectionsByPubkey,
loadRelaySelections,
makeThunk,
publishThunk,
getProfilesByPubkey,
} from "@app/state"
loadProfile,
profilesByPubkey,
relaySelectionsByPubkey,
loadRelaySelections,
getWriteRelayUrls,
loadFollows,
loadMutes,
} from "@welshman/app"
import {loadGroup, loadGroupMembership, INDEXER_RELAYS} from "@app/state"
// Utils
export const getPubkeyHints = (pubkey: string) => {
const selections = getRelaySelectionsByPubkey().get(pubkey)
const selections = relaySelectionsByPubkey.get().get(pubkey)
const relays = selections ? getWriteRelayUrls(selections) : []
const hints = relays.length ? relays : INDEXER_RELAYS
@@ -35,7 +35,7 @@ export const getPubkeyHints = (pubkey: string) => {
}
export const getPubkeyPetname = (pubkey: string) => {
const profile = getProfilesByPubkey().get(pubkey)
const profile = profilesByPubkey.get().get(pubkey)
const display = displayProfile(profile)
return display
@@ -82,9 +82,9 @@ export const loadUserData = async (pubkey: string, hints: string[] = []) => {
export type ModifyTags = (tags: string[][]) => string[][]
export const updateList = async (kind: number, modifyTags: ModifyTags) => {
const $pk = pk.get()!
const [prev] = repository.query([{kinds: [kind], authors: [$pk]}])
const relays = getWriteRelayUrls(getRelaySelectionsByPubkey().get($pk))
const $pubkey = pubkey.get()!
const [prev] = repository.query([{kinds: [kind], authors: [$pubkey]}])
const relays = getWriteRelayUrls(relaySelectionsByPubkey.get().get($pubkey))
// Preserve content if we have it
const event = prev
@@ -102,7 +102,7 @@ export const removeGroupMemberships = (noms: string[]) =>
export const sendJoinRequest = async (nom: string, url: string): Promise<[boolean, string]> => {
const relays = [url]
const filters = [{kinds: [9000], "#h": [nom], "#p": [pk.get()!], since: now() - 30}]
const filters = [{kinds: [9000], "#h": [nom], "#p": [pubkey.get()!], since: now() - 30}]
const event = createEvent(GROUP_JOIN, {tags: [["h", nom]]})
const statusData = await publishThunk(makeThunk({event, relays}))
+2 -1
View File
@@ -5,10 +5,11 @@
import {createEditor, type Editor, EditorContent} from "svelte-tiptap"
import {NProfileExtension, TagExtension as TopicExtension, 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 {publishThunk, makeThunk, userRelayUrlsByNom} from "@app/state"
import {userRelayUrlsByNom} from "@app/state"
import {makeMention, makeIMeta} from "@app/commands"
import {getChatEditorOptions, addFile} from "@app/editor"
@@ -3,8 +3,8 @@
import type {NodeViewProps} from "@tiptap/core"
import {NodeViewWrapper} from "svelte-tiptap"
import {displayProfile} from "@welshman/util"
import {deriveProfile} from "@welshman/app"
import Link from "@lib/components/Link.svelte"
import {deriveProfile} from "@app/state"
export let node: NodeViewProps["node"]
export let selected: NodeViewProps["selected"]
@@ -1,5 +1,5 @@
<script lang="ts">
import {deriveProfileDisplay} from "@app/state"
import {deriveProfileDisplay} from "@welshman/app"
export let value
+11 -6
View File
@@ -8,6 +8,13 @@
import type {TrustedEvent} from "@welshman/util"
import {deriveEvents} from "@welshman/store"
import {PublishStatus} from "@welshman/net"
import {
publishStatusData,
deriveProfile,
deriveProfileDisplay,
formatTimestampAsTime,
} from "@welshman/app"
import type {PublishStatusData} from "@welshman/app"
import {
GROUP_REPLY,
REACTION,
@@ -15,14 +22,12 @@
displayRelayUrl,
getAncestorTags,
} from "@welshman/util"
import {repository} from "@welshman/app"
import {fly} from "@lib/transition"
import {formatTimestampAsTime} from "@lib/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import {repository} from "@app/base"
import type {PublishStatusData} from "@app/state"
import {deriveProfile, deriveProfileDisplay, deriveEvent, publishStatusData} from "@app/state"
import {deriveEvent} from "@app/state"
import {getChatViewOptions} from "@app/editor"
export let event: TrustedEvent
@@ -73,8 +78,8 @@
let editor: Readable<Editor>
$: parentPubkey = $parentEvent?.pubkey || replies[0]?.[4]
$: parentProfile = deriveProfile(parentPubkey)
$: parentProfileDisplay = deriveProfileDisplay(parentPubkey)
$: parentProfile = deriveProfile(parentPubkey || "")
$: parentProfileDisplay = deriveProfileDisplay(parentPubkey || "")
$: isPublished = findStatus($ps, [PublishStatus.Success])
$: isPending = findStatus($ps, [PublishStatus.Pending]) && event.created_at > now() - 30
$: failure =
+1 -1
View File
@@ -3,7 +3,7 @@
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/base"
import {DEFAULT_RELAYS} from "@app/state"
import {clip} from "@app/toast"
</script>
+1 -3
View File
@@ -1,16 +1,14 @@
<script lang="ts">
import {makeSecret, getNip07, Nip46Broker} from "@welshman/signer"
import {addSession, loadHandle, nip46Perms, type Session} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import SignUp from "@app/components/SignUp.svelte"
import InfoNostr from "@app/components/LogIn.svelte"
import type {Session} from "@app/types"
import {pushModal, clearModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {addSession, nip46Perms} from "@app/base"
import {loadHandle} from "@app/state"
import {loadUserData} from "@app/commands"
const signUp = () => pushModal(SignUp)
+1 -1
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import {clearStorage} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import {clearStorage} from "@app/storage"
const back = () => history.back()
+1 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {makeSecret, Nip46Broker} from "@welshman/signer"
import {addSession, nip46Perms, loadHandle} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
@@ -8,8 +9,6 @@
import InfoNostr from "@app/components/LogIn.svelte"
import {pushModal, clearModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {addSession, nip46Perms} from "@app/base"
import {loadHandle} from "@app/state"
const login = () => pushModal(LogIn)
+3 -2
View File
@@ -1,12 +1,13 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {loadRelay} from "@welshman/app"
import CardButton from "@lib/components/CardButton.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import {pushToast} from "@app/toast"
import {splitGroupId, loadRelay, loadGroup} from "@app/state"
import {splitGroupId, loadGroup} from "@app/state"
import {addGroupMemberships} from "@app/commands"
const back = () => history.back()
@@ -24,7 +25,7 @@
})
}
if (!relay.supported_nips?.includes(29)) {
if (!relay.profile?.supported_nips?.includes(29)) {
return pushToast({
theme: "error",
message: "Sorry, it looks like that relay doesn't support nostr spaces.",
+6 -8
View File
@@ -1,4 +1,3 @@
import cx from 'classnames'
import type {Writable} from "svelte/store"
import {nprofileEncode} from "nostr-tools/nip19"
import {SvelteNodeViewRenderer} from "svelte-tiptap"
@@ -23,6 +22,7 @@ import {
TagExtension as TopicExtension,
} from "nostr-editor"
import type {StampedEvent} from "@welshman/util"
import {signer, topicSearch, profileSearch} from "@welshman/app"
import {LinkExtension, asInline, createSuggestions} from "@lib/tiptap"
import GroupComposeMention from "@app/components/GroupComposeMention.svelte"
import GroupComposeTopic from "@app/components/GroupComposeTopic.svelte"
@@ -34,8 +34,6 @@ import GroupComposeLink from "@app/components/GroupComposeLink.svelte"
import GroupComposeSuggestions from "@app/components/GroupComposeSuggestions.svelte"
import GroupComposeTopicSuggestion from "@app/components/GroupComposeTopicSuggestion.svelte"
import GroupComposeProfileSuggestion from "@app/components/GroupComposeProfileSuggestion.svelte"
import {signer} from "@app/base"
import {searchProfiles, searchTopics} from "@app/state"
import {getPubkeyHints} from "@app/commands"
export const addFile = (editor: Editor) => editor.chain().selectFiles().run()
@@ -90,7 +88,7 @@ export const getChatEditorOptions = ({uploading, sendMessage}: ChatComposeEditor
char: "@",
name: "nprofile",
editor: this.editor,
search: searchProfiles,
search: profileSearch,
select: (pubkey: string, props: any) => {
const relays = getPubkeyHints(pubkey)
const nprofile = nprofileEncode({pubkey, relays})
@@ -119,13 +117,13 @@ export const getChatEditorOptions = ({uploading, sendMessage}: ChatComposeEditor
const attrs = {
...mark.attrs,
...HTMLAttributes,
target: '_blank',
rel: 'noopener noreferer',
target: "_blank",
rel: "noopener noreferer",
href: `https://coracle.social/topics/${mark.attrs.tag.toLowerCase()}`,
class: "underline",
}
return ['a', attrs, 0]
return ["a", attrs, 0]
},
addProseMirrorPlugins() {
return [
@@ -133,7 +131,7 @@ export const getChatEditorOptions = ({uploading, sendMessage}: ChatComposeEditor
char: "#",
name: "topic",
editor: this.editor,
search: searchTopics,
search: topicSearch,
select: (name: string, props: any) => props.command({name}),
allowCreate: true,
suggestionComponent: GroupComposeTopicSuggestion,
+41 -511
View File
@@ -1,123 +1,52 @@
import {throttle} from "throttle-debounce"
import type {Readable} from "svelte/store"
import type {FuseResult} from "fuse.js"
import {get, writable, readable, derived} from "svelte/store"
import {get, derived} from "svelte/store"
import type {Maybe} from "@welshman/lib"
import {
max,
first,
between,
uniqBy,
groupBy,
pushToMapKey,
nthEq,
batcher,
postJson,
stripProtocol,
assoc,
indexBy,
now,
Worker,
inc,
} from "@welshman/lib"
import {max, between, groupBy, pushToMapKey, nthEq, stripProtocol, indexBy} from "@welshman/lib"
import {
getIdFilters,
getIdentifier,
getRelayTags,
normalizeRelayUrl,
GROUP_META,
PROFILE,
RELAYS,
FOLLOWS,
MUTES,
GROUPS,
getGroupTags,
readProfile,
readList,
asDecryptedEvent,
displayProfile,
displayPubkey,
GROUP_JOIN,
GROUP_ADD_USER,
REACTION,
ZAP_RESPONSE,
} from "@welshman/util"
import type {
SignedEvent,
HashedEvent,
EventTemplate,
TrustedEvent,
PublishedProfile,
PublishedList,
} from "@welshman/util"
import type {SubscribeRequest} from "@welshman/net"
import {publish as basePublish, subscribe as baseSubscribe, PublishStatus} from "@welshman/net"
import {decrypt, stamp, own, hash} from "@welshman/signer"
import {custom, deriveEvents, deriveEventsMapped, getter, withGetter} from "@welshman/store"
import {createSearch} from "@lib/util"
import type {Handle, Relay} from "@app/types"
import type {TrustedEvent} from "@welshman/util"
import {
INDEXER_RELAYS,
DUFFLEPUD_URL,
env,
pubkey,
repository,
pk,
getSession,
getSigner,
REACTION_KINDS,
} from "@app/base"
// Utils
export const createCollection = <T>({
name,
store,
getKey,
createSearch,
load,
}: {
name: string
store: Readable<T[]>
getKey: (item: T) => string
load: (key: string, ...args: any) => Promise<any>
}) => {
const indexStore = derived(store, $items => indexBy(getKey, $items))
const getIndex = getter(indexStore)
const getItem = (key: string) => getIndex().get(key)
const pending = new Map<string, Promise<Maybe<T>>>()
collection,
loadRelay,
relaysByPubkey,
loadProfile,
profilesByPubkey,
loadRelaySelections,
getWriteRelayUrls,
} from "@welshman/app"
import type {Relay} from "@welshman/app"
import type {SubscribeRequest} from "@welshman/net"
import {deriveEvents, deriveEventsMapped, withGetter} from "@welshman/store"
const loadItem = async (key: string, ...args: any[]) => {
if (getFreshness(name, key) > now() - 3600) {
return getIndex().get(key)
}
export const DEFAULT_RELAYS = [
"wss://groups.fiatjaf.com/",
"wss://relay29.galaxoidlabs.com/",
"wss://devrelay.highlighter.com/",
"wss://relay.groups.nip29.com/",
]
if (pending.has(key)) {
await pending.get(key)
} else {
setFreshness(name, key, now())
export const INDEXER_RELAYS = ["wss://purplepag.es/", "wss://relay.damus.io/", "wss://nos.lol/"]
const promise = load(key, ...args)
export const DUFFLEPUD_URL = "https://dufflepud.onrender.com"
pending.set(key, promise)
export const REACTION_KINDS = [REACTION, ZAP_RESPONSE]
await promise
pending.delete(key)
}
return getIndex().get(key)
}
const deriveItem = (key: Maybe<string>, ...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
load(key, ...args)
return derived(indexStore, $index => $index.get(key))
}
return {indexStore, getIndex, deriveItem, loadItem, getItem}
}
Object.assign(env, {DUFFLEPUD_URL})
export const deriveEvent = (idOrAddress: string, hints: string[] = []) => {
let attempted = false
@@ -138,402 +67,6 @@ export const deriveEvent = (idOrAddress: string, hints: string[] = []) => {
)
}
// Publish
export type Thunk = {
event: HashedEvent
relays: string[]
}
export type ThunkWithResolve = Thunk & {
resolve: (data: PublishStatusDataByUrl) => void
}
export const thunkWorker = new Worker<ThunkWithResolve>()
thunkWorker.addGlobalHandler(async ({event, relays, resolve}: ThunkWithResolve) => {
const session = getSession(event.pubkey)
if (!session) {
return console.warn(`No session found for ${event.pubkey}`)
}
const signedEvent = await getSigner(session)!.sign(event)
const pub = basePublish({event: signedEvent, relays})
// Copy the signature over since we had deferred it
;(repository.getEvent(signedEvent.id) as SignedEvent).sig = signedEvent.sig
// Track publish success
const {id} = event
const statusByUrl: PublishStatusDataByUrl = {}
pub.emitter.on("*", (status: PublishStatus, url: string, message: string) => {
publishStatusData.update(
assoc(id, Object.assign(statusByUrl, {[url]: {id, url, status, message}})),
)
if (
Object.values(statusByUrl).filter(s => s.status !== PublishStatus.Pending).length ===
relays.length
) {
resolve(statusByUrl)
}
})
})
export type ThunkParams = {
event: EventTemplate
relays: string[]
}
export const makeThunk = ({event, relays}: ThunkParams) => {
const $pk = get(pk)
if (!$pk) {
throw new Error("Unable to make thunk if no user is logged in")
}
return {event: hash(own(stamp(event), $pk)), relays}
}
export const publishThunk = (thunk: Thunk) =>
new Promise<PublishStatusDataByUrl>(resolve => {
thunkWorker.push({...thunk, resolve})
repository.publish(thunk.event)
})
// Subscribe
export const subscribe = (request: SubscribeRequest) => {
const sub = baseSubscribe({delay: 50, authTimeout: 3000, ...request})
sub.emitter.on("event", (url: string, e: SignedEvent) => {
repository.publish(e)
})
return sub
}
export const load = (request: SubscribeRequest) =>
new Promise<TrustedEvent[]>(resolve => {
const sub = subscribe({closeOnEose: true, timeout: 3000, ...request})
const events: TrustedEvent[] = []
sub.emitter.on("event", (url: string, e: SignedEvent) => events.push(e))
sub.emitter.on("complete", () => resolve(events))
})
export const loadOne = async (request: SubscribeRequest) => first(await load(request))
// Publish status
export type PublishStatusData = {
id: string
url: string
message: string
status: PublishStatus
}
export type PublishStatusDataByUrl = Record<string, PublishStatusData>
export type PublishStatusDataByUrlById = Record<string, PublishStatusDataByUrl>
export const publishStatusData = writable<PublishStatusDataByUrlById>({})
// Freshness
export const freshness = withGetter(writable<Record<string, number>>({}))
export const getFreshnessKey = (ns: string, key: string) => `${ns}:${key}`
export const getFreshness = (ns: string, key: string) =>
freshness.get()[getFreshnessKey(ns, key)] || 0
export const setFreshness = (ns: string, key: string, ts: number) =>
freshness.update(assoc(getFreshnessKey(ns, key), ts))
export const setFreshnessBulk = (ns: string, updates: Record<string, number>) =>
freshness.update($freshness => {
for (const [key, ts] of Object.entries(updates)) {
$freshness[key] = ts
}
return $freshness
})
// Plaintext
export const plaintext = withGetter(writable<Record<string, string>>({}))
export const getPlaintext = (e: TrustedEvent) => plaintext.get()[e.id]
export const setPlaintext = (e: TrustedEvent, content: string) =>
plaintext.update(assoc(e.id, content))
export const ensurePlaintext = async (e: TrustedEvent) => {
if (e.content && !getPlaintext(e)) {
const $signer = getSigner(getSession(e.pubkey))
if ($signer) {
setPlaintext(e, await decrypt($signer, e.pubkey, e.content))
}
}
return getPlaintext(e)
}
// Topics
export type Topic = {
name: string
count: number
}
export const topics = custom<Topic[]>(setter => {
const getTopics = () => {
const topics = new Map<string, number>()
for (const tagString of repository.eventsByTag.keys()) {
if (tagString.startsWith("t:")) {
const topic = tagString.slice(2).toLowerCase()
topics.set(topic, inc(topics.get(topic)))
}
}
return Array.from(topics.entries()).map(([name, count]) => ({name, count}))
}
setter(getTopics())
const onUpdate = throttle(3000, () => setter(getTopics()))
repository.on("update", onUpdate)
return () => repository.off("update", onUpdate)
})
export const searchTopics = derived(topics, $topics =>
createSearch($topics, {
getValue: (topic: Topic) => topic.name,
fuseOptions: {keys: ["name"]},
}),
)
// Relay info
export const relays = writable<Relay[]>([])
export const relaysByPubkey = derived(relays, $relays =>
groupBy(
($relay: Relay) => $relay.pubkey,
$relays.filter(r => r.pubkey),
),
)
export const {
indexStore: relaysByUrl,
getIndex: getRelaysByUrl,
deriveItem: deriveRelay,
loadItem: loadRelay,
} = createCollection({
name: "relays",
store: relays,
getKey: (relay: Relay) => relay.url,
load: batcher(800, async (urls: string[]) => {
const urlSet = new Set(urls)
const res = await postJson(`${DUFFLEPUD_URL}/relay/info`, {urls: Array.from(urlSet)})
const index = indexBy((item: any) => item.url, res?.data || [])
const items: Relay[] = urls.map(url => {
const {info = {}} = index.get(url) || {}
return {...info, url}
})
relays.update($relays => uniqBy($relay => $relay.url, [...$relays, ...items]))
return items
}),
})
export const searchRelays = derived(relays, $relays =>
createSearch($relays, {
getValue: (relay: Relay) => relay.url,
fuseOptions: {
keys: ["url", "name", {name: "description", weight: 0.3}],
},
}),
)
// Handles
export const handles = writable<Handle[]>([])
export const {
indexStore: handlesByNip05,
getIndex: getHandlesByNip05,
deriveItem: deriveHandle,
loadItem: loadHandle,
} = createCollection({
name: "handles",
store: handles,
getKey: (handle: Handle) => handle.nip05,
load: batcher(800, async (nip05s: string[]) => {
const nip05Set = new Set(nip05s)
const res = await postJson(`${DUFFLEPUD_URL}/handle/info`, {handles: Array.from(nip05Set)})
const index = indexBy((item: any) => item.handle, res?.data || [])
const items: Handle[] = nip05s.map(nip05 => {
const {info = {}} = index.get(nip05) || {}
return {...info, nip05}
})
handles.update($handles => uniqBy($handle => $handle.nip05, [...$handles, ...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,
} = createCollection({
name: "profiles",
store: profiles,
getKey: profile => profile.event.pubkey,
load: async (pubkey: string, hints = [], request: Partial<SubscribeRequest> = {}) => {
const relays = getWriteRelayUrls(await loadRelaySelections(pubkey))
return load({
...request,
relays: [...relays, ...hints, ...INDEXER_RELAYS],
filters: [{kinds: [PROFILE], authors: [pubkey]}],
})
},
})
export const searchProfiles = derived(profiles, $profiles =>
createSearch($profiles, {
getValue: (profile: PublishedProfile) => profile.event.pubkey,
fuseOptions: {
keys: ["name", "display_name", {name: "about", weight: 0.3}],
},
}),
)
export const displayProfileByPubkey = (pubkey: string, profile?: PublishedProfile) =>
displayProfile(profile, pubkey ? displayPubkey(pubkey) : undefined)
export const deriveProfileDisplay = (pubkey: string) =>
derived(deriveProfile(pubkey), $profile => displayProfileByPubkey(pubkey, $profile))
// Relay selections
export const getReadRelayUrls = (event?: TrustedEvent): string[] =>
getRelayTags(event?.tags || [])
.filter((t: string[]) => !t[2] || t[2] === "read")
.map((t: string[]) => normalizeRelayUrl(t[1]))
export const getWriteRelayUrls = (event?: TrustedEvent): string[] =>
getRelayTags(event?.tags || [])
.filter((t: string[]) => !t[2] || t[2] === "write")
.map((t: string[]) => normalizeRelayUrl(t[1]))
export const relaySelections = deriveEvents(repository, {filters: [{kinds: [RELAYS]}]})
export const {
indexStore: relaySelectionsByPubkey,
getIndex: getRelaySelectionsByPubkey,
deriveItem: deriveRelaySelections,
loadItem: loadRelaySelections,
} = createCollection({
name: "relaySelections",
store: relaySelections,
getKey: relaySelections => relaySelections.pubkey,
load: (pubkey: string, hints = [], request: Partial<SubscribeRequest> = {}) =>
load({
...request,
relays: [...hints, ...INDEXER_RELAYS],
filters: [{kinds: [RELAYS], authors: [pubkey]}],
}),
})
// Follows
export const follows = deriveEventsMapped<PublishedList>(repository, {
filters: [{kinds: [FOLLOWS]}],
itemToEvent: item => item.event,
eventToItem: async (event: TrustedEvent) =>
readList(
asDecryptedEvent(event, {
content: await ensurePlaintext(event),
}),
),
})
export const {
indexStore: followsByPubkey,
getIndex: getFollowsByPubkey,
deriveItem: deriveFollows,
loadItem: loadFollows,
} = createCollection({
name: "follows",
store: follows,
getKey: follows => follows.event.pubkey,
load: async (pubkey: string, hints = [], request: Partial<SubscribeRequest> = {}) => {
const relays = getWriteRelayUrls(await loadRelaySelections(pubkey, hints))
return load({
...request,
relays: [...relays, ...hints, ...INDEXER_RELAYS],
filters: [{kinds: [FOLLOWS], authors: [pubkey]}],
})
},
})
// Mutes
export const mutes = deriveEventsMapped<PublishedList>(repository, {
filters: [{kinds: [MUTES]}],
itemToEvent: item => item.event,
eventToItem: async (event: TrustedEvent) =>
readList(
asDecryptedEvent(event, {
content: await ensurePlaintext(event),
}),
),
})
export const {
indexStore: mutesByPubkey,
getIndex: getMutesByPubkey,
deriveItem: deriveMutes,
loadItem: loadMutes,
} = createCollection({
name: "mutes",
store: mutes,
getKey: mute => mute.event.pubkey,
load: async (pubkey: string, hints = [], request: Partial<SubscribeRequest> = {}) => {
const relays = getWriteRelayUrls(await loadRelaySelections(pubkey, hints))
return load({
...request,
relays: [...relays, ...hints, ...INDEXER_RELAYS],
filters: [{kinds: [MUTES], authors: [pubkey]}],
})
},
})
// Groups
export const GROUP_DELIMITER = `'`
@@ -586,10 +119,9 @@ export const groups = deriveEventsMapped<PublishedGroup>(repository, {
export const {
indexStore: groupsByNom,
getIndex: getGroupsByNom,
deriveItem: deriveGroup,
loadItem: loadGroup,
} = createCollection({
} = collection({
name: "groups",
store: groups,
getKey: (group: PublishedGroup) => group.nom,
@@ -694,10 +226,9 @@ export const groupMemberships = deriveEventsMapped<PublishedGroupMembership>(rep
export const {
indexStore: groupMembershipByPubkey,
getIndex: getGroupMembershipsByPubkey,
deriveItem: deriveGroupMembership,
loadItem: loadGroupMembership,
} = createCollection({
} = collection({
name: "groupMemberships",
store: groupMemberships,
getKey: groupMembership => groupMembership.event.pubkey,
@@ -754,10 +285,9 @@ export const groupChats = derived(groupMessages, $groupMessages => {
export const {
indexStore: groupChatByNom,
getIndex: getGroupChatsByNom,
deriveItem: deriveGroupChat,
loadItem: loadGroupChat,
} = createCollection({
} = collection({
name: "groupChats",
store: groupChats,
getKey: groupChat => groupChat.nom,
@@ -773,22 +303,22 @@ export const {
// User stuff
export const userProfile = derived([pk, profilesByPubkey], ([$pk, $profilesByPubkey]) => {
if (!$pk) return null
export const userProfile = derived([pubkey, profilesByPubkey], ([$pubkey, $profilesByPubkey]) => {
if (!$pubkey) return null
loadProfile($pk)
loadProfile($pubkey)
return $profilesByPubkey.get($pk)
return $profilesByPubkey.get($pubkey)
})
export const userMembership = derived(
[pk, groupMembershipByPubkey],
([$pk, $groupMembershipByPubkey]) => {
if (!$pk) return null
[pubkey, groupMembershipByPubkey],
([$pubkey, $groupMembershipByPubkey]) => {
if (!$pubkey) return null
loadGroupMembership($pk)
loadGroupMembership($pubkey)
return $groupMembershipByPubkey.get($pk)
return $groupMembershipByPubkey.get($pubkey)
},
)
-121
View File
@@ -1,121 +0,0 @@
import {openDB, deleteDB} from "idb"
import type {IDBPDatabase} from "idb"
import {throttle} from "throttle-debounce"
import {writable} from "svelte/store"
import type {Unsubscriber, Writable} from "svelte/store"
import {randomInt} from "@welshman/lib"
import {withGetter} from "@welshman/store"
export type Item = Record<string, any>
export type IndexedDbAdapter = {
keyPath: string
store: Writable<Item[]>
}
export let db: IDBPDatabase
export const dead = withGetter(writable(false))
export const subs: Unsubscriber[] = []
export const DB_NAME = "flotilla"
export const getAll = async (name: string) => {
const tx = db.transaction(name, "readwrite")
const store = tx.objectStore(name)
const result = await store.getAll()
await tx.done
return result
}
export const bulkPut = async (name: string, data: any[]) => {
const tx = db.transaction(name, "readwrite")
const store = tx.objectStore(name)
await Promise.all(data.map(item => store.put(item)))
await tx.done
}
export const bulkDelete = async (name: string, ids: string[]) => {
const tx = db.transaction(name, "readwrite")
const store = tx.objectStore(name)
await Promise.all(ids.map(id => store.delete(id)))
await tx.done
}
export const initIndexedDbAdapter = async (name: string, adapter: IndexedDbAdapter) => {
let copy = await getAll(name)
adapter.store.set(copy)
adapter.store.subscribe(
throttle(randomInt(3000, 5000), async (data: Item[]) => {
if (dead.get()) {
return
}
const prevIds = new Set(copy.map(item => item[adapter.keyPath]))
const currentIds = new Set(data.map(item => item[adapter.keyPath]))
const newRecords = data.filter(r => !prevIds.has(r[adapter.keyPath]))
const removedRecords = copy.filter(r => !currentIds.has(r[adapter.keyPath]))
copy = data
if (newRecords.length > 0) {
await bulkPut(name, newRecords)
}
if (removedRecords.length > 0) {
await bulkDelete(
name,
removedRecords.map(item => item[adapter.keyPath]),
)
}
}),
)
}
export const initStorage = async (version: number, adapters: Record<string, IndexedDbAdapter>) => {
if (!window.indexedDB) return
window.addEventListener("beforeunload", () => closeStorage())
db = await openDB(DB_NAME, version, {
upgrade(db: IDBPDatabase) {
const names = Object.keys(adapters)
for (const name of db.objectStoreNames) {
if (!names.includes(name)) {
db.deleteObjectStore(name)
}
}
for (const [name, {keyPath}] of Object.entries(adapters)) {
try {
db.createObjectStore(name, {keyPath})
} catch (e) {
console.warn(e)
}
}
},
})
await Promise.all(
Object.entries(adapters).map(([name, config]) => initIndexedDbAdapter(name, config)),
)
}
export const closeStorage = async () => {
dead.set(true)
subs.forEach(unsub => unsub())
await db?.close()
}
export const clearStorage = async () => {
await closeStorage()
await deleteDB(DB_NAME)
}
+1 -1
View File
@@ -1,3 +1,3 @@
import {synced} from "@lib/util"
import {synced} from "@welshman/store"
export const theme = synced<string>("theme", "dark")
-18
View File
@@ -1,18 +0,0 @@
import type {Nip46Handler} from "@welshman/signer"
import type {RelayProfile} from "@welshman/util"
export type Session = {
method: string
pubkey: string
secret?: string
handler?: Nip46Handler
}
export type Relay = RelayProfile
export type Handle = {
pubkey: string
nip05: string
nip46: string[]
relays: string[]
}
+1 -1
View File
@@ -4,7 +4,7 @@ import tippy, {type Instance} from "tippy.js"
import type {Editor} from "@tiptap/core"
import {PluginKey} from "@tiptap/pm/state"
import Suggestion from "@tiptap/suggestion"
import type {Search} from "@lib/util"
import type {Search} from "@welshman/app"
export type SuggestionsOptions = {
char: string
-105
View File
@@ -1,105 +0,0 @@
import Fuse from "fuse.js"
import type {IFuseOptions, FuseResult} from "fuse.js"
import {throttle} from "throttle-debounce"
import {writable} from "svelte/store"
import {sortBy} from "@welshman/lib"
import {browser} from "$app/environment"
export const parseJson = (json: string) => {
if (!json) return null
try {
return JSON.parse(json)
} catch (e) {
return null
}
}
export const getJson = (k: string) => (browser ? parseJson(localStorage.getItem(k) || "") : null)
export const setJson = (k: string, v: any) => {
if (browser) {
localStorage.setItem(k, JSON.stringify(v))
}
}
export const synced = <T>(key: string, defaultValue: T, delay = 300) => {
const init = getJson(key)
const store = writable<T>(init === null ? defaultValue : init)
store.subscribe(throttle(delay, (value: T) => setJson(key, value)))
return store
}
export type SearchOptions<V, T> = {
getValue: (item: T) => V
fuseOptions?: IFuseOptions<T>
sortFn?: (items: FuseResult<T>) => any
}
export type Search<V, T> = {
getValue: (item: T) => V
getOption: (value: V) => T | undefined
searchOptions: (term: string) => T[]
searchValues: (term: string) => V[]
}
export const createSearch = <V, T>(data: T[], opts: SearchOptions<V, T>): Search<V, T> => {
const fuse = new Fuse(data, {...opts.fuseOptions, includeScore: true})
const map = new Map<V, T>(data.map(item => [opts.getValue(item), item]))
const search = (term: string) => {
let results = term ? fuse.search(term) : data.map(item => ({item, score: 1}) as FuseResult<T>)
if (opts.sortFn) {
results = sortBy(opts.sortFn, results)
}
return results.map(result => result.item)
}
return {
getValue: opts.getValue,
getOption: (value: V) => map.get(value),
searchOptions: (term: string) => search(term),
searchValues: (term: string) => search(term).map(opts.getValue),
}
}
export const secondsToDate = (ts: number) => new Date(ts * 1000)
export const dateToSeconds = (date: Date) => Math.round(date.valueOf() / 1000)
export const getTimeZone = () => new Date().toString().match(/GMT[^\s]+/)
export const createLocalDate = (dateString: any) => new Date(`${dateString} ${getTimeZone()}`)
export const getLocale = () => new Intl.DateTimeFormat().resolvedOptions().locale
export const formatTimestamp = (ts: number) => {
const formatter = new Intl.DateTimeFormat(getLocale(), {
dateStyle: "short",
timeStyle: "short",
})
return formatter.format(secondsToDate(ts))
}
export const formatTimestampAsDate = (ts: number) => {
const formatter = new Intl.DateTimeFormat(getLocale(), {
year: "numeric",
month: "long",
day: "numeric",
})
return formatter.format(secondsToDate(ts))
}
export const formatTimestampAsTime = (ts: number) => {
const formatter = new Intl.DateTimeFormat(getLocale(), {
timeStyle: "short",
})
return formatter.format(secondsToDate(ts))
}
+17 -8
View File
@@ -6,6 +6,19 @@
import {goto} from "$app/navigation"
import {browser} from "$app/environment"
import {sleep} from "@welshman/lib"
import {
relays,
handles,
loadRelay,
initStorage,
repository,
session,
pubkey,
publishStatusData,
plaintext,
freshness,
} from "@welshman/app"
import type {PublishStatusData, PublishStatusDataByUrlById} from "@welshman/app"
import {createEventStore, adapter} from "@welshman/store"
import ModalBox from "@lib/components/ModalBox.svelte"
import Toast from "@app/components/Toast.svelte"
@@ -13,12 +26,8 @@
import PrimaryNav from "@app/components/PrimaryNav.svelte"
import {modals, clearModal} from "@app/modal"
import {theme} from "@app/theme"
import {pk, session, repository, DEFAULT_RELAYS} from "@app/base"
import type {PublishStatusData, PublishStatusDataByUrlById} from "@app/state"
import {relays, freshness, plaintext, handles, loadRelay, publishStatusData} from "@app/state"
import {initStorage} from "@app/storage"
import {DEFAULT_RELAYS} from "@app/state"
import {loadUserData} from "@app/commands"
import * as base from "@app/base"
import * as state from "@app/state"
let ready: Promise<unknown>
@@ -48,7 +57,7 @@
}
onMount(() => {
Object.assign(window, {get, base, state})
Object.assign(window, {get, state})
ready = initStorage(3, {
events: {
@@ -137,8 +146,8 @@
loadRelay(url)
}
if ($pk) {
loadUserData($pk)
if ($pubkey) {
loadUserData($pubkey)
}
})
})
+2
View File
@@ -0,0 +1,2 @@
export const prerender = true
export const ssr = false
+2 -3
View File
@@ -2,16 +2,15 @@
import {onMount} from "svelte"
import Masonry from "svelte-bricks"
import {GROUP_META, displayRelayUrl} from "@welshman/util"
import {load, relays} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import {makeSpacePath} from "@app/routes"
import {DEFAULT_RELAYS} from "@app/base"
import {
load,
displayGroup,
relays,
searchGroups,
relayUrlsByNom,
userMembership,
DEFAULT_RELAYS,
} from "@app/state"
let term = ""
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import themes from "daisyui/src/theming/themes"
import {identity} from "@welshman/lib"
import {createSearch} from "@lib/util"
import {createSearch} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import {theme} from "@app/theme"
+3 -3
View File
@@ -3,10 +3,10 @@
import {readable} from "svelte/store"
import {displayRelayUrl, isShareableRelayUrl} from "@welshman/util"
import type {SignedEvent} from "@welshman/util"
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/base"
import {searchRelays, subscribe, loadRelay} from "@app/state"
import {DEFAULT_RELAYS, INDEXER_RELAYS} from "@app/state"
const relays = readable(DEFAULT_RELAYS)
@@ -52,7 +52,7 @@
<Icon icon="magnifer" />
<input bind:value={term} class="grow" type="text" placeholder="Search for relays..." />
</label>
{#each $searchRelays.searchValues(term).filter(url => !$relays.includes(url)) as url (url)}
{#each $relaySearch.searchValues(term).filter(url => !$relays.includes(url)) as url (url)}
<div class="card2 card2-sm flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon icon="remote-controller-minimalistic" />
@@ -12,12 +12,12 @@
import {page} from "$app/stores"
import {sortBy, now} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {formatTimestampAsDate} from "@lib/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 {subscribe, deriveGroupChat, userRelayUrlsByNom} from "@app/state"
import {deriveGroupChat, userRelayUrlsByNom} from "@app/state"
const {nom} = $page.params
const chat = deriveGroupChat(nom)