diff --git a/src/app/views/Zap.svelte b/src/app/views/Zap.svelte
index f4f2516b9..e15b08471 100644
--- a/src/app/views/Zap.svelte
+++ b/src/app/views/Zap.svelte
@@ -5,7 +5,7 @@
import {ctx, now, tryCatch, fetchJson} from "@welshman/lib"
import {createEvent} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
- import {signer, profilesByPubkey, zappersByLnurl} from "@welshman/app"
+ import {signer, profilesByPubkey, displayProfileByPubkey, zappersByLnurl} from "@welshman/app"
import Anchor from "src/partials/Anchor.svelte"
import FieldInline from "src/partials/FieldInline.svelte"
import Toggle from "src/partials/Toggle.svelte"
@@ -17,7 +17,6 @@
export let splits
export let id = null
export let anonymous = false
- export let callback = null
export let amount = getSetting("default_zap")
let zaps = []
@@ -64,10 +63,7 @@
const profile = $profilesByPubkey.get(zap.pubkey)
const zapper = $zappersByLnurl.get(profile?.lnurl)
const relays = ctx.app.router
- .merge([
- ctx.app.router.PublishMessage(zap.pubkey),
- ctx.app.router.fromRelays([zap.relay]),
- ])
+ .merge([ctx.app.router.ForPubkey(zap.pubkey), ctx.app.router.FromRelays([zap.relay])])
.getUrls()
return {...zap, zapper, relays, content}
@@ -103,7 +99,7 @@
const qs = `?amount=${msats}&nostr=${zapString}&lnurl=${zapper.lnurl}`
const res = await tryCatch(() => fetchJson(zapper.callback + qs))
- return {...zap, invoice: res?.pr}
+ return {...zap, invoice: res?.pr, error: res?.reason}
}),
)
@@ -117,8 +113,13 @@
// Close the router once we can show the next modal
router.pop()
- for (const {invoice, relays, zapper, pubkey} of preppedZaps) {
+ for (const {invoice, error, relays, zapper, pubkey} of preppedZaps) {
if (!invoice) {
+ const profileDisplay = displayProfileByPubkey(pubkey)
+ const message = error || "no error given"
+
+ alert(`Failed to get an invoice for ${profileDisplay}: ${message}`)
+
continue
}
@@ -133,7 +134,6 @@
load({
relays,
- onEvent: callback,
filters: [{kinds: [9735], authors: [zapper.nostrPubkey], "#p": [pubkey], since}],
})
}
diff --git a/src/domain/connection.ts b/src/domain/connection.ts
new file mode 100644
index 000000000..059f37af5
--- /dev/null
+++ b/src/domain/connection.ts
@@ -0,0 +1,90 @@
+import {getRelayQuality, type ThunkStatus} from "@welshman/app"
+import {AuthStatus, Connection, PublishStatus, SocketStatus} from "@welshman/net"
+import {derived, writable} from "svelte/store"
+
+export type PublishNotice = {
+ eventId: string
+ created_at: number
+ eventKind: string
+ message: string
+ status: ThunkStatus
+ url: string
+}
+
+export type SubscriptionNotice = {created_at: number; url: string; notice: string[]}
+
+export const subscriptionNotices = writable>(new Map())
+
+export const subscriptionNoticesByRelay = derived(subscriptionNotices, $notices => {
+ return $notices.values()
+})
+
+const pendingStatuses = [
+ AuthStatus.Requested,
+ AuthStatus.PendingSignature,
+ AuthStatus.PendingResponse,
+]
+
+const failureStatuses = [AuthStatus.DeniedSignature, AuthStatus.Forbidden]
+
+export const getConnectionStatus = (cxn: Connection): ConnectionType => {
+ if (pendingStatuses.includes(cxn.auth.status)) {
+ return ConnectionType.Logging
+ } else if (failureStatuses.includes(cxn.auth.status)) {
+ return ConnectionType.LoginFailed
+ } else if (cxn.socket.status === SocketStatus.Error) {
+ return ConnectionType.ConnectFailed
+ } else if (cxn.socket.status === SocketStatus.Closed) {
+ return ConnectionType.WaitReconnect
+ } else if (cxn.socket.status === SocketStatus.New) {
+ return ConnectionType.NotConnected
+ } else if (getRelayQuality(cxn.url) < 0.5) {
+ return ConnectionType.UnstableConnection
+ } else {
+ return ConnectionType.Connected
+ }
+}
+
+export function messageAndColorFromStatus(status: ThunkStatus) {
+ switch (status.status) {
+ case PublishStatus.Success:
+ return {message: status.message || "Published", color: "text-success"}
+ case PublishStatus.Pending:
+ return {message: status.message || "Pending", color: "text-warning"}
+ case PublishStatus.Failure:
+ return {message: status.message || "Failed", color: "text-danger"}
+ case PublishStatus.Timeout:
+ return {message: status.message || "Timed out", color: "text-accent"}
+ case PublishStatus.Aborted:
+ return {message: status.message || "Aborted", color: "text-accent"}
+ }
+}
+
+export enum ConnectionType {
+ Connected,
+ Logging,
+ LoginFailed,
+ ConnectFailed,
+ WaitReconnect,
+ NotConnected,
+ UnstableConnection,
+}
+
+export const displayConnectionType = (type: ConnectionType) => {
+ switch (type) {
+ case ConnectionType.Connected:
+ return "Connected"
+ case ConnectionType.Logging:
+ return "Logging in"
+ case ConnectionType.LoginFailed:
+ return "Failed to log in"
+ case ConnectionType.ConnectFailed:
+ return "Failed to connect"
+ case ConnectionType.WaitReconnect:
+ return "Wainting to reconnect"
+ case ConnectionType.NotConnected:
+ return "Not connected"
+ case ConnectionType.UnstableConnection:
+ return "Unstable connection"
+ }
+}
diff --git a/src/domain/group.ts b/src/domain/group.ts
deleted file mode 100644
index be1aff5b3..000000000
--- a/src/domain/group.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import {fromPairs, nthEq} from "@welshman/lib"
-import type {TrustedEvent} from "@welshman/util"
-import {isSignedEvent} from "@welshman/util"
-
-export type GroupMeta = {
- kind: number
- feeds: string[][]
- relays: string[][]
- moderators: string[][]
- identifier: string
- name: string
- about: string
- banner: string
- image: string
- listing_is_public: boolean
- event?: TrustedEvent
-}
-
-export type PublishedGroupMeta = Omit & {
- event: TrustedEvent
-}
-
-export const readGroupMeta = (event: TrustedEvent) => {
- const meta = fromPairs(event.tags)
-
- return {
- event,
- kind: event.kind,
- feeds: event.tags.filter(nthEq(0, "feed")),
- relays: event.tags.filter(nthEq(0, "relay")),
- moderators: event.tags.filter(nthEq(0, "p")),
- identifier: meta.d,
- name: meta.name || "",
- banner: meta.banner || "",
- about: meta.about || meta.description || "",
- image: meta.image || meta.picture || "",
- listing_is_public: isSignedEvent(event),
- } as PublishedGroupMeta
-}
-
-export const displayGroupMeta = (meta: GroupMeta) => meta?.name || meta?.identifier || "[no name]"
diff --git a/src/domain/index.ts b/src/domain/index.ts
index 77b293184..3cd8739d2 100644
--- a/src/domain/index.ts
+++ b/src/domain/index.ts
@@ -1,6 +1,5 @@
export * from "./collection"
export * from "./feed"
-export * from "./group"
export * from "./handler"
export * from "./kind"
export * from "./list"
diff --git a/src/engine/commands.ts b/src/engine/commands.ts
index 71296cc1a..8fff2df6a 100644
--- a/src/engine/commands.ts
+++ b/src/engine/commands.ts
@@ -1,98 +1,82 @@
-import crypto from "crypto"
-import {get} from "svelte/store"
+import type {Session} from "@welshman/app"
import {
- ctx,
+ follow as baseFollow,
+ unfollow as baseUnfollow,
+ ensurePlaintext,
+ getRelayUrls,
+ inboxRelaySelectionsByPubkey,
+ nip46Perms,
+ pubkey,
+ repository,
+ session,
+ sessions,
+ signer,
+ tagPubkey,
+ userInboxRelaySelections,
+ userRelaySelections,
+} from "@welshman/app"
+import {
+ append,
cached,
- indexBy,
- nthNe,
+ ctx,
equals,
- splitAt,
first,
+ groupBy,
+ indexBy,
last,
- append,
+ now,
nthEq,
- groupBy,
+ nthNe,
remove,
- now,
+ splitAt,
} from "@welshman/lib"
-import type {TrustedEvent, Profile} from "@welshman/util"
+import {Nip01Signer, Nip46Broker, Nip59, makeSecret} from "@welshman/signer"
+import type {Profile, TrustedEvent} from "@welshman/util"
import {
- getAddress,
- Tags,
- createEvent,
Address,
- isSignedEvent,
- normalizeRelayUrl,
- GROUP,
- COMMUNITY,
+ DIRECT_MESSAGE,
FEEDS,
FOLLOWS,
- RELAYS,
- PROFILE,
INBOX_RELAYS,
- DIRECT_MESSAGE,
- SEEN_CONVERSATION,
LOCAL_RELAY_URL,
- makeList,
- editProfile,
+ PROFILE,
+ RELAYS,
+ SEEN_CONVERSATION,
+ Tags,
+ addToListPublicly,
+ createEvent,
createProfile,
+ editProfile,
+ getAddress,
isPublishedProfile,
+ isSignedEvent,
+ makeList,
+ normalizeRelayUrl,
removeFromList,
- addToListPublicly,
} from "@welshman/util"
-import type {Nip46Handler} from "@welshman/signer"
-import {Nip59, Nip01Signer, getPubkey, makeSecret, Nip46Broker} from "@welshman/signer"
-import {
- updateSession,
- repository,
- pubkey,
- nip46Perms,
- signer,
- sessions,
- session,
- loadHandle,
- getRelayUrls,
- displayProfileByPubkey,
- inboxRelaySelectionsByPubkey,
- addNoFallbacks,
- ensurePlaintext,
- tagPubkey,
- userRelaySelections,
- userInboxRelaySelections,
- follow as baseFollow,
- unfollow as baseUnfollow,
-} from "@welshman/app"
-import type {Session} from "@welshman/app"
-import {Fetch, randomId, seconds, sleep, tryFunc} from "hurdak"
-import {assoc, flatten, identity, omit, partition, prop, reject, uniq, without} from "ramda"
-import {stripExifData, blobToFile} from "src/util/html"
-import {joinPath, parseJson} from "src/util/misc"
-import {appDataKeys} from "src/util/nostr"
-import {GroupAccess} from "src/engine/model"
-import {sortEventsDesc} from "src/engine/utils"
+import crypto from "crypto"
+import {Fetch, seconds, sleep, tryFunc} from "hurdak"
+import {assoc, flatten, identity, omit, prop, reject, uniq} from "ramda"
import {
+ addClientTags,
+ anonymous,
channels,
- getChannelSeenKey,
createAndPublish,
- deriveAdminKeyForGroup,
- userIsGroupMember,
- deriveSharedKeyForGroup,
- env,
- addClientTags,
+ getChannelIdFromEvent,
+ getChannelSeenKey,
getClientTags,
- groupAdminKeys,
- groupSharedKeys,
- groups,
+ hasNip44,
publish,
sign,
- hasNip44,
- withIndexers,
- anonymous,
- mentionGroup,
- userSeenStatusEvents,
- getChannelIdFromEvent,
userFeedFavorites,
+ userSeenStatusEvents,
+ withIndexers,
} from "src/engine/state"
+import {sortEventsDesc} from "src/engine/utils"
+import {blobToFile, stripExifData} from "src/util/html"
+import {joinPath, parseJson} from "src/util/misc"
+import {appDataKeys} from "src/util/nostr"
+import {get} from "svelte/store"
// Helpers
@@ -216,360 +200,15 @@ export const uploadFiles = async (urls, files, compressorOpts = {}) => {
return eventsToMeta(nip94Events)
}
-// Groups
-
// Key state management
-export const initSharedKey = (address: string, relays: string[]) => {
- const privkey = makeSecret()
- const pubkey = getPubkey(privkey)
- const key = {
- group: address,
- pubkey: pubkey,
- privkey: privkey,
- created_at: now(),
- hints: relays,
- }
-
- groupSharedKeys.key(pubkey).set(key)
-
- return key
-}
-
-export const initGroup = (kind, relays) => {
- const identifier = randomId()
- const privkey = makeSecret()
- const pubkey = getPubkey(privkey)
- const address = `${kind}:${pubkey}:${identifier}`
- const sharedKey = kind === 35834 ? initSharedKey(address, relays) : null
- const adminKey = {
- group: address,
- pubkey: pubkey,
- privkey: privkey,
- created_at: now(),
- hints: relays,
- }
-
- groupAdminKeys.key(pubkey).set(adminKey)
-
- groups.key(address).set({id: identifier, pubkey, address})
-
- return {identifier, address, adminKey, sharedKey}
-}
-
-const addGroupATags = (template, addresses) => ({
- ...template,
- tags: [...template.tags, ...addresses.map(mentionGroup)],
-})
-
-// Utils for publishing group-related messages
-// Relay selections for groups should ignore platform relays, since groups provide their own
-// relays, and can straddle public/private contexts.
-
-export const publishToGroupAdmin = async (address, template) => {
- const relays = ctx.app.router.WithinContext(address).getUrls()
- const pubkeys = [Address.from(address).pubkey, session.get().pubkey]
- const expireTag = [["expiration", String(now() + seconds(30, "day"))]]
- const helper = Nip59.fromSigner(signer.get())
-
- for (const pubkey of pubkeys) {
- const rumor = await helper.wrap(pubkey, template, expireTag)
-
- await publish({event: rumor.wrap, relays, forcePlatform: false})
- }
-}
-
-export const publishAsGroupAdminPublicly = async (address, template, relays = []) => {
- const _relays = ctx.app.router
- .merge([ctx.app.router.fromRelays(relays), ctx.app.router.WithinContext(address)])
- .getUrls()
- const adminKey = deriveAdminKeyForGroup(address).get()
- const event = await sign(template, {sk: adminKey.privkey})
-
- return publish({event, relays: _relays, forcePlatform: false})
-}
-
-export const publishAsGroupAdminPrivately = async (address, template, relays = []) => {
- const _relays = ctx.app.router
- .merge([ctx.app.router.fromRelays(relays), ctx.app.router.WithinContext(address)])
- .getUrls()
- const adminKey = deriveAdminKeyForGroup(address).get()
- const sharedKey = deriveSharedKeyForGroup(address).get()
- const adminSigner = new Nip01Signer(adminKey.privkey)
- const sharedSigner = new Nip01Signer(sharedKey.privkey)
- const helper = Nip59.fromSigner(adminSigner).withWrapper(sharedSigner)
- const rumor = await helper.wrap(sharedKey.pubkey, template)
-
- return publish({event: rumor.wrap, relays: _relays, forcePlatform: false})
-}
-
-export const publishToGroupsPublicly = async (addresses, template, {anonymous = false} = {}) => {
- for (const address of addresses) {
- if (!address.startsWith("34550:")) {
- throw new Error(`Attempted to publish publicly to an invalid address: ${address}`)
- }
- }
-
- const event = await sign(addGroupATags(template, addresses), {anonymous})
+export const signAndPublish = async (template, {anonymous = false} = {}) => {
+ const event = await sign(template, {anonymous})
const relays = ctx.app.router.PublishEvent(event).getUrls()
- return publish({event, relays, forcePlatform: false})
-}
-
-export const publishToGroupsPrivately = async (addresses, template, {anonymous = false} = {}) => {
- const $userIsGroupMember = userIsGroupMember.get()
-
- const events = []
- const pubs = []
- for (const address of addresses) {
- const relays = ctx.app.router.WithinContext(address).getUrls()
- const thisTemplate = addGroupATags(template, [address])
- const sharedKey = deriveSharedKeyForGroup(address).get()
-
- if (!address.startsWith("35834:")) {
- throw new Error(`Attempted to publish privately to an invalid address: ${address}`)
- }
-
- if (!$userIsGroupMember(address)) {
- throw new Error("Attempted to publish privately to a group the user is not a member of")
- }
-
- const userSigner = anonymous ? signer.get() : Nip01Signer.ephemeral()
- const wrapSigner = new Nip01Signer(sharedKey.privkey)
- const helper = Nip59.fromSigner(userSigner).withWrapper(wrapSigner)
- const rumor = await helper.wrap(sharedKey.pubkey, thisTemplate)
-
- events.push(rumor)
- pubs.push(await publish({event: rumor.wrap, relays, forcePlatform: false}))
- }
-
- return {events, pubs}
-}
-
-export const publishToZeroOrMoreGroups = async (addresses, template, {anonymous = false} = {}) => {
- const pubs = []
- const events = []
-
- if (addresses.length === 0) {
- const event = await sign(template, {anonymous})
- const relays = ctx.app.router.PublishEvent(event).getUrls()
-
- events.push(event)
- pubs.push(await publish({event, relays}))
- } else {
- const [wrap, nowrap] = partition((address: string) => address.startsWith("35834:"), addresses)
-
- if (wrap.length > 0) {
- const result = await publishToGroupsPrivately(wrap, template, {anonymous})
-
- for (const event of result.events) {
- events.push(event)
- }
-
- for (const pub of result.pubs) {
- pubs.push(pub)
- }
- }
-
- if (nowrap.length > 0) {
- const pub = await publishToGroupsPublicly(nowrap, template, {anonymous})
-
- events.push(pub.request.event)
- pubs.push(pub)
- }
- }
-
- return {events, pubs}
+ return await publish({event, relays})
}
-// Admin functions
-
-export const publishKeyShares = async (address, pubkeys, template) => {
- const adminKey = deriveAdminKeyForGroup(address).get()
-
- const pubs = []
-
- for (const pubkey of pubkeys) {
- const relays = ctx.app.router
- .merge([
- ctx.app.router.ForPubkeys([pubkey]),
- ctx.app.router.WithinContext(address),
- ctx.app.router.fromRelays(env.PLATFORM_RELAYS),
- ])
- .policy(addNoFallbacks)
- .getUrls()
-
- const adminSigner = new Nip01Signer(adminKey.privkey)
- const helper = Nip59.fromSigner(adminSigner)
- const rumor = await helper.wrap(pubkey, template)
-
- pubs.push(await publish({event: rumor.wrap, relays, forcePlatform: false}))
- }
-
- return pubs
-}
-
-export const publishAdminKeyShares = async (address, pubkeys) => {
- const relays = ctx.app.router.WithinContext(address).getUrls()
- const {privkey} = deriveAdminKeyForGroup(address).get()
- const template = createEvent(24, {
- tags: [
- mentionGroup(address),
- ["role", "admin"],
- ["privkey", privkey],
- ...getClientTags(),
- ...relays.map(url => ["relay", url]),
- ],
- })
-
- return publishKeyShares(address, pubkeys, template)
-}
-
-export const publishGroupInvites = async (address, pubkeys, gracePeriod = 0) => {
- const relays = ctx.app.router.WithinContext(address).getUrls()
- const adminKey = deriveAdminKeyForGroup(address).get()
- const {privkey} = deriveSharedKeyForGroup(address).get()
- const template = createEvent(24, {
- tags: [
- mentionGroup(address),
- ["role", "member"],
- ["privkey", privkey],
- ["grace_period", String(gracePeriod)],
- ...getClientTags(),
- ...relays.map(url => ["relay", url]),
- ],
- })
-
- return publishKeyShares(address, [...pubkeys, adminKey.pubkey], template)
-}
-
-export const publishGroupEvictions = async (address, pubkeys) =>
- publishKeyShares(
- address,
- pubkeys,
- createEvent(24, {
- tags: [mentionGroup(address), ...getClientTags()],
- }),
- )
-
-export const publishGroupMembers = async (address, op, pubkeys) => {
- const template = createEvent(27, {
- tags: [["op", op], mentionGroup(address), ...getClientTags(), ...pubkeys.map(tagPubkey)],
- })
-
- return publishAsGroupAdminPrivately(address, template)
-}
-
-export const publishCommunityMeta = (address, identifier, meta) => {
- const template = createEvent(COMMUNITY, {
- tags: [
- ["d", identifier],
- ["name", meta.name],
- ["about", meta.about],
- ["description", meta.about],
- ["banner", meta.banner],
- ["picture", meta.image],
- ["image", meta.image],
- ...meta.feeds,
- ...meta.relays,
- ...meta.moderators,
- ...getClientTags(),
- ],
- })
-
- return publishAsGroupAdminPublicly(address, template, meta.relays)
-}
-
-export const publishGroupMeta = (address, identifier, meta, listPublicly) => {
- const template = createEvent(GROUP, {
- tags: [
- ["d", identifier],
- ["name", meta.name],
- ["about", meta.about],
- ["description", meta.about],
- ["banner", meta.banner],
- ["picture", meta.image],
- ["image", meta.image],
- ...meta.feeds,
- ...meta.relays,
- ...meta.moderators,
- ...getClientTags(),
- ],
- })
-
- return listPublicly
- ? publishAsGroupAdminPublicly(address, template, meta.relays)
- : publishAsGroupAdminPrivately(address, template, meta.relays)
-}
-
-export const deleteGroupMeta = address =>
- publishAsGroupAdminPublicly(address, createEvent(5, {tags: [mentionGroup(address)]}))
-
-// Member functions
-
-export const modifyGroupStatus = (session, address, timestamp, updates) => {
- if (!session.groups) {
- session.groups = {}
- }
-
- const newGroupStatus = updateRecord(session.groups[address], timestamp, updates)
-
- if (!equals(session.groups[address], newGroupStatus)) {
- session.groups[address] = newGroupStatus
- }
-
- return session
-}
-
-export const setGroupStatus = (pubkey, address, timestamp, updates) =>
- updateSession(pubkey, s => modifyGroupStatus(s, address, timestamp, updates))
-
-export const resetGroupAccess = address =>
- setGroupStatus(pubkey.get(), address, now(), {access: GroupAccess.None})
-
-export const publishGroupEntryRequest = (address, claim = null) => {
- if (deriveAdminKeyForGroup(address).get()) {
- publishGroupInvites(address, [session.get().pubkey])
- } else {
- setGroupStatus(pubkey.get(), address, now(), {access: GroupAccess.Requested})
-
- const tags = [...getClientTags(), mentionGroup(address)]
-
- if (claim) {
- tags.push(["claim", claim])
- }
-
- publishToGroupAdmin(
- address,
- createEvent(25, {
- content: `${displayProfileByPubkey(pubkey.get())} would like to join the group`,
- tags,
- }),
- )
- }
-}
-
-export const publishGroupExitRequest = address => {
- setGroupStatus(pubkey.get(), address, now(), {access: GroupAccess.None})
-
- if (!deriveAdminKeyForGroup(address).get()) {
- publishToGroupAdmin(
- address,
- createEvent(26, {
- content: `${displayProfileByPubkey(pubkey.get())} is leaving the group`,
- tags: [...getClientTags(), mentionGroup(address)],
- }),
- )
- }
-}
-
-export const publishCommunitiesList = addresses =>
- createAndPublish({
- kind: 10004,
- tags: [...addresses.map(mentionGroup), ...getClientTags()],
- relays: ctx.app.router.WriteRelays().getUrls(),
- })
-
// Deletes
export const publishDeletion = ({kind, address = null, id = null}) => {
@@ -586,7 +225,7 @@ export const publishDeletion = ({kind, address = null, id = null}) => {
return createAndPublish({
tags,
kind: 5,
- relays: ctx.app.router.WriteRelays().getUrls(),
+ relays: ctx.app.router.FromUser().getUrls(),
forcePlatform: false,
})
}
@@ -600,7 +239,7 @@ export const deleteEventByAddress = (address: string) =>
// Profile
export const publishProfile = (profile: Profile, {forcePlatform = false} = {}) => {
- const relays = withIndexers(ctx.app.router.WriteRelays().getUrls())
+ const relays = withIndexers(ctx.app.router.FromUser().getUrls())
const template = isPublishedProfile(profile) ? editProfile(profile) : createProfile(profile)
return createAndPublish({...addClientTags(template), relays, forcePlatform})
@@ -624,25 +263,24 @@ export const removeFeedFavorite = async (address: string) => {
const list = get(userFeedFavorites) || makeList({kind: FEEDS})
const template = await removeFromList(list, address).reconcile(nip44EncryptToSelf)
- return createAndPublish({...template, relays: ctx.app.router.WriteRelays().getUrls()})
+ return createAndPublish({...template, relays: ctx.app.router.FromUser().getUrls()})
}
export const addFeedFavorite = async (address: string) => {
const list = get(userFeedFavorites) || makeList({kind: FEEDS})
const template = await addToListPublicly(list, ["a", address]).reconcile(nip44EncryptToSelf)
- return createAndPublish({...template, relays: ctx.app.router.WriteRelays().getUrls()})
+ return createAndPublish({...template, relays: ctx.app.router.FromUser().getUrls()})
}
// Relays
-export const requestRelayAccess = async (url: string, claim: string, sk?: string) =>
+export const requestRelayAccess = async (url: string, claim: string) =>
createAndPublish({
kind: 28934,
forcePlatform: false,
tags: [["claim", claim]],
relays: [url],
- sk,
})
export const setOutboxPolicies = async (modifyTags: (tags: string[][]) => string[][]) => {
@@ -653,7 +291,7 @@ export const setOutboxPolicies = async (modifyTags: (tags: string[][]) => string
kind: list.kind,
content: list.event?.content || "",
tags: modifyTags(list.publicTags),
- relays: withIndexers(ctx.app.router.WriteRelays().getUrls()),
+ relays: withIndexers(ctx.app.router.FromUser().getUrls()),
})
} else {
anonymous.update($a => ({...$a, relays: modifyTags($a.relays)}))
@@ -667,7 +305,7 @@ export const setInboxPolicies = async (modifyTags: (tags: string[][]) => string[
kind: list.kind,
content: list.event?.content || "",
tags: modifyTags(list.publicTags),
- relays: withIndexers(ctx.app.router.WriteRelays().getUrls()),
+ relays: withIndexers(ctx.app.router.FromUser().getUrls()),
})
}
@@ -677,7 +315,7 @@ export const setInboxPolicy = (url: string, enabled: boolean) => {
// Only update inbox policies if they already exist or we're adding them
if (enabled || urls.includes(url)) {
setInboxPolicies($tags => {
- $tags = $tags.filter(t => t[1] !== url)
+ $tags = $tags.filter(t => normalizeRelayUrl(t[1]) !== url)
if (enabled) {
$tags.push(["relay", url])
@@ -690,7 +328,7 @@ export const setInboxPolicy = (url: string, enabled: boolean) => {
export const setOutboxPolicy = (url: string, read: boolean, write: boolean) =>
setOutboxPolicies($tags => {
- $tags = $tags.filter(t => t[1] !== url)
+ $tags = $tags.filter(t => normalizeRelayUrl(t[1]) !== url)
if (read && write) {
$tags.push(["r", url])
@@ -762,7 +400,7 @@ export const markAsSeen = async (kind: number, eventsByKey: Record {
- const $pubkey = pubkey.get()
- const recipients = without([$pubkey], channelId.split(","))
-
- if (recipients.length > 1) {
- throw new Error("Attempted to send legacy message to more than 1 recipient")
- }
-
- const recipient = recipients[0] || $pubkey
-
- return createAndPublish({
- kind: 4,
- tags: [tagPubkey(recipient), ...getClientTags()],
- content: await signer.get().nip04.encrypt(recipient, content),
- relays: ctx.app.router.PublishMessage(recipient).getUrls(),
- forcePlatform: false,
- })
-}
-
-export const sendMessage = async (channelId: string, content: string) => {
+export const sendMessage = async (channelId: string, content: string, delay: number) => {
const recipients = channelId.split(",")
const template = {
content,
@@ -802,10 +421,15 @@ export const sendMessage = async (channelId: string, content: string) => {
const helper = Nip59.fromSigner(signer.get())
const rumor = await helper.wrap(recipient, template)
- await publish({
+ // Publish immediately to the repository so messages show up right away
+ repository.publish(rumor)
+
+ // Publish via thunk
+ publish({
event: rumor.wrap,
- relays: ctx.app.router.PublishMessage(recipient).getUrls(),
+ relays: ctx.app.router.PubkeyInbox(recipient).getUrls(),
forcePlatform: false,
+ delay,
})
}
}
@@ -845,52 +469,39 @@ const addSession = (s: Session) => {
pubkey.set(s.pubkey)
}
-export const loginWithPrivateKey = (secret, extra = {}) =>
- addSession({method: "nip01", pubkey: getPubkey(secret), secret, ...extra})
-
export const loginWithPublicKey = pubkey => addSession({method: "pubkey", pubkey})
-export const loginWithExtension = pubkey => addSession({method: "nip07", pubkey})
+export const loginWithNip07 = pubkey => addSession({method: "nip07", pubkey})
-export const loginWithSigner = (pubkey, pkg) =>
+export const loginWithNip55 = (pubkey, pkg) =>
addSession({method: "nip55", pubkey: pubkey, signer: pkg})
-export const loginWithNsecBunker = async (pubkey, token, connectRelay) => {
- const secret = makeSecret()
- const handler = {relays: [connectRelay]}
- const broker = Nip46Broker.get(pubkey, secret, handler)
- const result = await broker.connect(token, nip46Perms)
-
- if (result) {
- addSession({method: "nip46", pubkey, secret, token, handler})
- }
-
- return result
-}
+export const loginWithNip46 = async ({
+ relays,
+ signerPubkey,
+ clientSecret = makeSecret(),
+ connectSecret = "",
+}: {
+ relays: string[]
+ signerPubkey: string
+ clientSecret?: string
+ connectSecret?: string
+}) => {
+ const broker = Nip46Broker.get({relays, clientSecret, signerPubkey})
+ const result = await broker.connect("", connectSecret, nip46Perms)
-export const loginWithNostrConnect = async (username, handler: Nip46Handler) => {
- const secret = makeSecret()
- const {pubkey} = (await loadHandle(`${username}@${handler.domain}`)) || {}
+ // TODO: remove ack result
+ if (!["ack", connectSecret].includes(result)) return false
- let broker = Nip46Broker.get(pubkey, secret, handler)
+ const pubkey = await broker.getPublicKey()
- if (!pubkey) {
- const pubkey = await broker.createAccount(username, nip46Perms)
+ if (!pubkey) return false
- if (!pubkey) {
- return null
- }
+ const handler = {relays, pubkey: signerPubkey}
- broker = Nip46Broker.get(pubkey, secret, handler)
- }
-
- const result = await broker.connect("", nip46Perms)
-
- if (result) {
- addSession({method: "nip46", pubkey: broker.pubkey, secret, handler})
- }
+ addSession({method: "nip46", pubkey, secret: clientSecret, handler})
- return result
+ return true
}
export const logoutPubkey = pubkey => {
@@ -914,7 +525,7 @@ export const setAppData = async (d: string, data: any) => {
kind: 30078,
tags: [["d", d]],
content: await signer.get().nip04.encrypt(pubkey, JSON.stringify(data)),
- relays: ctx.app.router.WriteRelays().getUrls(),
+ relays: ctx.app.router.FromUser().getUrls(),
forcePlatform: false,
})
}
diff --git a/src/engine/index.ts b/src/engine/index.ts
index 6efc96342..fa942f6c8 100644
--- a/src/engine/index.ts
+++ b/src/engine/index.ts
@@ -3,4 +3,3 @@ export * from "src/engine/utils"
export * from "src/engine/state"
export * from "src/engine/requests"
export * from "src/engine/commands"
-export * from "src/engine/projections"
diff --git a/src/engine/model.ts b/src/engine/model.ts
index 6dcb7ebbf..5a19ec3bf 100644
--- a/src/engine/model.ts
+++ b/src/engine/model.ts
@@ -1,51 +1,6 @@
+import type {Session} from "@welshman/app"
import type {Publish} from "@welshman/net"
import type {TrustedEvent, Zapper as WelshmanZapper} from "@welshman/util"
-import type {Session} from "@welshman/app"
-import {isTrustedEvent} from "@welshman/util"
-
-export enum GroupAccess {
- None = null,
- Requested = "requested",
- Granted = "granted",
- Revoked = "revoked",
-}
-
-export type Group = {
- id: string
- pubkey: string
- address: string
- members?: string[]
- recent_member_updates?: TrustedEvent[]
-}
-
-export type GroupKey = {
- group: string
- pubkey: string
- privkey: string
- created_at: number
- hints?: string[]
-}
-
-export type GroupRequest = TrustedEvent & {
- group: string
- resolved: boolean
-}
-
-export const isGroupRequest = (e: any): e is GroupRequest =>
- typeof e.group === "string" && typeof e.resolved === "boolean" && isTrustedEvent(e)
-
-export type GroupAlert = TrustedEvent & {
- group: string
- type: "exit" | "invite"
-}
-
-export const isGroupAlert = (e: any): e is GroupAlert =>
- typeof e.group === "string" && typeof e.type === "string" && isTrustedEvent(e)
-
-export type DisplayEvent = TrustedEvent & {
- replies?: DisplayEvent[]
- reposts?: TrustedEvent[]
-}
export type Zapper = WelshmanZapper & {
lnurl: string
@@ -80,15 +35,7 @@ export type Channel = {
messages: TrustedEvent[]
}
-export type GroupStatus = {
- joined: boolean
- joined_updated_at: number
- access: GroupAccess
- access_updated_at: number
-}
-
export type SessionWithMeta = Session & {
- groups?: Record
onboarding_tasks_completed?: string[]
}
diff --git a/src/engine/projections.ts b/src/engine/projections.ts
deleted file mode 100644
index 4ce18bef1..000000000
--- a/src/engine/projections.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import {mergeRight, prop, sortBy, uniq, whereEq, without} from "ramda"
-import {switcherFn} from "hurdak"
-import {ctx} from "@welshman/lib"
-import type {TrustedEvent} from "@welshman/util"
-import {
- Tags,
- getIdFilters,
- MUTES,
- APP_DATA,
- SEEN_CONVERSATION,
- SEEN_GENERAL,
- SEEN_CONTEXT,
- FOLLOWS,
- COMMUNITIES,
- WRAP,
-} from "@welshman/util"
-import {getPubkey} from "@welshman/signer"
-import {repository, putSession, getSession, ensurePlaintext} from "@welshman/app"
-import {GroupAccess, type SessionWithMeta} from "src/engine/model"
-import {
- deriveAdminKeyForGroup,
- getGroupStatus,
- groupAdminKeys,
- groupAlerts,
- groupRequests,
- groupSharedKeys,
- groups,
- load,
- projections,
-} from "src/engine/state"
-import {modifyGroupStatus, setGroupStatus} from "src/engine/commands"
-
-// Synchronize repository with projections. All events should be published to the
-// repository, and when accepted, be propagated to projections. This avoids processing
-// the same event multiple times, since repository deduplicates
-
-repository.on("update", ({added}: {added: TrustedEvent[]}) => {
- for (const event of added) {
- projections.push(event)
- }
-})
-
-// Key sharing
-
-projections.addHandler(24, (e: TrustedEvent) => {
- const tags = Tags.fromEvent(e)
- const privkey = tags.get("privkey")?.value()
- const address = tags.get("a")?.value()
- const recipient = Tags.fromEvent(e.wrap).get("p")?.value()
- const relays = tags.values("relay").valueOf()
-
- if (!address) {
- return
- }
-
- const status = getGroupStatus(getSession(recipient), address)
-
- if (privkey) {
- const pubkey = getPubkey(privkey)
- const role = tags.get("role")?.value()
- const keys = role === "admin" ? groupAdminKeys : groupSharedKeys
-
- keys.key(pubkey).update($key => ({
- pubkey,
- privkey,
- group: address,
- created_at: e.created_at,
- hints: relays,
- ...$key,
- }))
-
- // Notify the user if this isn't just a key rotation
- if (status?.access !== GroupAccess.Granted) {
- groupAlerts.key(e.id).set({...e, group: address, type: "invite"})
- }
-
- // Load the group's metadata and posts
- load({
- delay: 5000,
- skipCache: true,
- relays: ctx.app.router.fromRelays(relays).getUrls(),
- filters: [
- ...getIdFilters([address]),
- {kinds: [WRAP], "#p": [pubkey]},
- {kinds: [WRAP], authors: [pubkey]},
- ],
- })
- } else if ([GroupAccess.Granted, GroupAccess.Requested].includes(status?.access)) {
- groupAlerts.key(e.id).set({...e, group: address, type: "exit"})
- }
-
- if (getSession(recipient)) {
- setGroupStatus(recipient, address, e.created_at, {
- access: privkey ? GroupAccess.Granted : GroupAccess.Revoked,
- })
- }
-})
-
-projections.addHandler(27, (e: TrustedEvent) => {
- const address = Tags.fromEvent(e).groups().values().first()
-
- if (!address) {
- return
- }
-
- let {members = [], recent_member_updates = []} = groups.key(address).get() || {}
-
- // Only replay updates if we have something new
- if (!recent_member_updates.find(whereEq({id: e.id}))) {
- recent_member_updates = sortBy(prop("created_at"), recent_member_updates.concat(e)).slice(-100)
-
- for (const event of recent_member_updates) {
- const tags = Tags.fromEvent(event)
- const op = tags.get("op")?.value()
- const pubkeys = tags.values("p").valueOf()
-
- members = switcherFn(op, {
- add: () => uniq(pubkeys.concat(members)),
- remove: () => without(pubkeys, members),
- set: () => pubkeys,
- default: () => members,
- })
- }
-
- groups.key(address).merge({members, recent_member_updates})
- }
-})
-
-// Membership
-
-projections.addHandler(COMMUNITIES, (e: TrustedEvent) => {
- let session = getSession(e.pubkey) as SessionWithMeta
-
- if (!session) {
- return
- }
-
- const addresses = Tags.fromEvent(e).communities().values().valueOf()
-
- for (const address of uniq(Object.keys(session.groups || {}).concat(addresses))) {
- session = modifyGroupStatus(session, address, e.created_at, {
- joined: addresses.includes(address),
- })
- }
-
- putSession(session)
-})
-
-const handleGroupRequest = access => (e: TrustedEvent) => {
- const address = Tags.fromEvent(e).get("a")?.value()
- const adminKey = deriveAdminKeyForGroup(address)
-
- // Don't bother the admin with old requests
- if (adminKey.get() && e.created_at) {
- groupRequests.key(e.id).update(
- mergeRight({
- ...e,
- group: address,
- resolved: false,
- }),
- )
- }
-
- if (getSession(e.pubkey)) {
- setGroupStatus(e.pubkey, address, e.created_at, {access})
- }
-}
-
-projections.addHandler(25, handleGroupRequest(GroupAccess.Requested))
-
-projections.addHandler(26, handleGroupRequest(GroupAccess.None))
-
-// Decrypt encrypted events eagerly
-
-projections.addHandler(SEEN_GENERAL, ensurePlaintext)
-projections.addHandler(SEEN_CONTEXT, ensurePlaintext)
-projections.addHandler(SEEN_CONVERSATION, ensurePlaintext)
-projections.addHandler(APP_DATA, ensurePlaintext)
-projections.addHandler(FOLLOWS, ensurePlaintext)
-projections.addHandler(MUTES, ensurePlaintext)
diff --git a/src/engine/requests.ts b/src/engine/requests.ts
index 45a472589..78d7ec325 100644
--- a/src/engine/requests.ts
+++ b/src/engine/requests.ts
@@ -1,16 +1,16 @@
import {debounce} from "throttle-debounce"
import {get, writable, derived} from "svelte/store"
-import {noop, sleep, switcherFn} from "hurdak"
-import type {LoadOpts} from "@welshman/feeds"
-import {FeedLoader, Scope} from "@welshman/feeds"
+import {noop, sleep} from "hurdak"
+import type {RequestOpts, Feed} from "@welshman/feeds"
+import {FeedController} from "@welshman/feeds"
import {
ctx,
+ uniq,
+ without,
+ partition,
assoc,
always,
chunk,
- nthEq,
- nth,
- now,
max,
first,
int,
@@ -20,10 +20,7 @@ import {
} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {
- Address,
getIdFilters,
- isGroupAddress,
- createEvent,
WRAP,
EPOCH,
LABEL,
@@ -37,39 +34,29 @@ import {
HANDLER_RECOMMENDATION,
DEPRECATED_DIRECT_MESSAGE,
FEEDS,
+ Address,
} from "@welshman/util"
+import {Tracker} from "@welshman/net"
import {deriveEvents} from "@welshman/store"
-import {makeDvmRequest} from "@welshman/dvm"
import {
pubkey,
repository,
- signer,
loadProfile,
loadFollows,
loadMutes,
getFilterSelections,
- getFollowers,
getFollows,
pull,
hasNegentropy,
- wotGraph,
- maxWot,
- getNetwork,
+ requestDVM,
+ getPubkeysForScope,
+ getPubkeysForWOTRange,
} from "@welshman/app"
import type {AppSyncOpts} from "@welshman/app"
-import {noteKinds, reactionKinds, repostKinds} from "src/util/nostr"
-import {partition, uniq, without} from "ramda"
+import {noteKinds, reactionKinds} from "src/util/nostr"
+import {race} from "src/util/misc"
import {CUSTOM_LIST_KINDS} from "src/domain"
-import {
- env,
- getUserCircles,
- load,
- subscribePersistent,
- sessionWithMeta,
- groupAdminKeys,
- groupSharedKeys,
- type MySubscribeRequest,
-} from "src/engine/state"
+import {env, load, subscribePersistent, type MySubscribeRequest} from "src/engine/state"
// Utils
@@ -105,98 +92,22 @@ export const pullConservatively = ({relays, filters}: AppSyncOpts) => {
return Promise.all(promises)
}
-export const loadAll = (feed, opts: LoadOpts = {}) => {
+export const loadAll = (feed, {onEvent}: {onEvent: (e: TrustedEvent) => void}) => {
const loading = writable(true)
- const stop = () => loading.set(false)
+ const onExhausted = () => loading.set(false)
const promise = new Promise(async resolve => {
- const load = await feedLoader.getLoader(feed, {
- onEvent: opts.onEvent,
- onExhausted: () => {
- opts.onExhausted?.()
- stop()
- },
- })
+ const ctrl = createFeedController({feed, onEvent, onExhausted})
while (get(loading)) {
- await load(100)
+ await ctrl.load(100)
}
resolve()
})
- return {promise, loading, stop}
-}
-
-// Groups
-
-export const attemptedAddrs = new Map()
-
-export const getStaleAddrs = (addrs: string[]) => {
- const stale = new Set()
-
- for (const addr of addrs) {
- const attempts = attemptedAddrs.get(addr) | 0
-
- if (attempts > 0) {
- continue
- }
-
- stale.add(addr)
-
- attemptedAddrs.set(addr, attempts + 1)
- }
-
- return Array.from(stale)
-}
-
-export const loadGroups = async (rawAddrs: string[], explicitRelays: string[] = []) => {
- const addrs = getStaleAddrs(rawAddrs)
- const authors = addrs.map(a => Address.from(a).pubkey)
- const identifiers = addrs.map(a => Address.from(a).identifier)
-
- if (addrs.length > 0) {
- const filters = [{kinds: [34550, 35834], authors, "#d": identifiers}]
- const relays = ctx.app.router
- .merge([
- ctx.app.router.product(addrs, explicitRelays),
- ctx.app.router.WithinMultipleContexts(addrs),
- ])
- .getUrls()
-
- return load({relays, filters, skipCache: true, forcePlatform: false})
- }
-}
-
-export const loadGroupMessages = async (addrs: string[]) => {
- for (const address of addrs) {
- const keys = [...groupAdminKeys.get(), ...groupSharedKeys.get()]
- const pubkeys = keys.filter(k => k.group === address).map(k => k.pubkey)
-
- await pullConservatively({
- relays: ctx.app.router.WithinContext(address).getUrls(),
- filters: [{kinds: [WRAP], "#p": pubkeys}],
- })
- }
-}
-
-export const loadCommunityMessages = async (addrs: string[]) => {
- await pullConservatively({
- relays: ctx.app.router.WithinMultipleContexts(addrs).getUrls(),
- filters: [{kinds: [...noteKinds, ...repostKinds], "#a": addrs}],
- })
-}
-
-export const loadCircleMessages = async (addrs?: string[]) => {
- if (!addrs) {
- addrs = getUserCircles(sessionWithMeta.get())
- }
-
- const [groups, communities] = partition(isGroupAddress, addrs)
-
- loadGroupMessages(groups)
- loadCommunityMessages(communities)
+ return {promise, loading, stop: onExhausted}
}
export const loadEvent = async (idOrAddress: string, request: Partial = {}) =>
@@ -218,6 +129,10 @@ export const deriveEvent = (idOrAddress: string, request: Partial {
if (!attempted && events.length === 0) {
+ if (Address.isAddress(idOrAddress) && !request.relays) {
+ const {pubkey, relays} = Address.from(idOrAddress)
+ request.relays = uniq([...relays, ...ctx.app.router.ForPubkey(pubkey).getUrls()])
+ }
loadEvent(idOrAddress, request)
attempted = true
}
@@ -279,61 +194,60 @@ export const loadPubkeys = async (pubkeys: string[]) => {
// Feeds
-export const feedLoader = new FeedLoader({
- request: async ({relays, filters, onEvent}) => {
+export type FeedRequestHandlerOptions = {forcePlatform: boolean}
+
+export const makeFeedRequestHandler =
+ ({forcePlatform}: FeedRequestHandlerOptions) =>
+ async ({relays, filters, onEvent}: RequestOpts) => {
+ const tracker = new Tracker()
+ const loadOptions = {
+ onEvent,
+ tracker,
+ forcePlatform,
+ skipCache: true,
+ delay: 0,
+ }
+
if (relays?.length > 0) {
- await load({filters, relays, onEvent, skipCache: true, forcePlatform: false})
+ await load({...loadOptions, filters, relays, authTimeout: 3000})
} else {
- await Promise.all(
- getFilterSelections(filters).map(({relays, filters}) => load({filters, relays, onEvent})),
+ // Break out selections by relay so we can complete early after a certain number
+ // of requests complete for faster load times
+ await race(
+ filters.every(f => f.search) ? 0.1 : 0.8,
+ getFilterSelections(filters).flatMap(({relays, filters}) =>
+ relays.map(relay => load({...loadOptions, relays: [relay], filters})),
+ ),
)
- }
- },
- requestDVM: async ({kind, onEvent, tags = [], ...request}) => {
- tags = [...tags, ["expiration", String(now() + 5)]]
-
- const req = makeDvmRequest({
- event: await signer.get().sign(createEvent(kind, {tags})),
- relays:
- request.relays?.length > 0
- ? ctx.app.router.fromRelays(request.relays).getUrls()
- : ctx.app.router.Messages(tags.filter(nthEq(0, "p")).map(nth(1))).getUrls(),
- })
-
- await new Promise(resolve => {
- req.emitter.on("result", (url, event) => {
+
+ // Wait until after we've queried the network to access our local cache. This results in less
+ // snappy response times, but is necessary to prevent stale stuff that the user has already seen
+ // from showing up at the top of the feed
+ for (const event of repository.query(filters)) {
onEvent(event)
- resolve()
- })
- })
- },
- getPubkeysForScope: (scope: string) => {
- const $pubkey = pubkey.get()
-
- const pubkeys = switcherFn(scope, {
- [Scope.Self]: () => ($pubkey ? [$pubkey] : []),
- [Scope.Follows]: () => getFollows($pubkey),
- [Scope.Network]: () => getNetwork($pubkey),
- [Scope.Followers]: () => getFollowers($pubkey),
- default: always([]),
- })
-
- return pubkeys.length === 0 ? env.DEFAULT_FOLLOWS : pubkeys
- },
- getPubkeysForWOTRange: (min, max) => {
- const pubkeys = []
- const thresholdMin = maxWot.get() * min
- const thresholdMax = maxWot.get() * max
-
- for (const [tpk, score] of wotGraph.get().entries()) {
- if (score >= thresholdMin && score <= thresholdMax) {
- pubkeys.push(tpk)
}
}
+ }
+
+export type FeedControllerOptions = {
+ feed: Feed
+ onEvent: (event: TrustedEvent) => void
+ onExhausted: () => void
+ forcePlatform?: boolean
+ useWindowing?: boolean
+}
- return pubkeys
- },
-})
+export const createFeedController = ({forcePlatform = true, ...options}: FeedControllerOptions) => {
+ const request = makeFeedRequestHandler({forcePlatform})
+
+ return new FeedController({
+ request,
+ requestDVM,
+ getPubkeysForScope,
+ getPubkeysForWOTRange,
+ ...options,
+ })
+}
// Notifications
@@ -344,7 +258,7 @@ export const loadNotifications = () => {
const filter = {kinds: getNotificationKinds(), "#p": [pubkey.get()]}
return pullConservatively({
- relays: ctx.app.router.ReadRelays().getUrls(),
+ relays: ctx.app.router.ForUser().getUrls(),
filters: [addSinceToFilter(filter, int(WEEK))],
})
}
@@ -355,7 +269,7 @@ export const listenForNotifications = () => {
subscribePersistent({
timeout: 30_000,
skipCache: true,
- relays: ctx.app.router.User().getUrls(),
+ relays: ctx.app.router.ForUser().getUrls(),
filters: [addSinceToFilter(filter)],
})
}
@@ -401,7 +315,10 @@ export const loadFeedsAndLists = () =>
export const loadMessages = () =>
pullConservatively({
- relays: ctx.app.router.User().getUrls(),
+ // TODO, stop using non-inbox relays
+ relays: ctx.app.router
+ .merge([ctx.app.router.ForUser(), ctx.app.router.FromUser(), ctx.app.router.UserInbox()])
+ .getUrls(),
filters: [
{kinds: [DEPRECATED_DIRECT_MESSAGE], authors: [pubkey.get()]},
{kinds: [DEPRECATED_DIRECT_MESSAGE, WRAP], "#p": [pubkey.get()]},
@@ -414,7 +331,14 @@ export const listenForMessages = (pubkeys: string[]) => {
return subscribePersistent({
skipCache: true,
forcePlatform: false,
- relays: ctx.app.router.Messages(pubkeys).getUrls(),
+ // TODO, stop using non-inbox relays
+ relays: ctx.app.router
+ .merge([
+ ctx.app.router.ForPubkeys(pubkeys),
+ ctx.app.router.FromPubkeys(pubkeys),
+ ctx.app.router.PubkeyInboxes(pubkeys),
+ ])
+ .getUrls(),
filters: [
{kinds: [DEPRECATED_DIRECT_MESSAGE], authors: allPubkeys, "#p": allPubkeys},
{kinds: [WRAP], "#p": [pubkey.get()]},
@@ -426,7 +350,7 @@ export const loadHandlers = () =>
load({
skipCache: true,
forcePlatform: false,
- relays: ctx.app.router.ReadRelays().getUrls().concat("wss://relay.nostr.band/"),
+ relays: ctx.app.router.ForUser().getUrls().concat("wss://relay.nostr.band/"),
filters: [
addSinceToFilter({
kinds: [HANDLER_RECOMMENDATION],
diff --git a/src/engine/state.ts b/src/engine/state.ts
index 2cf99d238..607590feb 100644
--- a/src/engine/state.ts
+++ b/src/engine/state.ts
@@ -1,157 +1,128 @@
-import Fuse from "fuse.js"
-import crypto from "crypto"
-import {get, derived, writable} from "svelte/store"
-import {doPipe, batch, seconds, sleep} from "hurdak"
-import {defaultTo, equals, assoc, sortBy, omit, partition, prop, whereEq, without} from "ramda"
+import type {PartialSubscribeRequest} from "@welshman/app"
+import {
+ subscribe as baseSubscribe,
+ db,
+ displayProfileByPubkey,
+ ensurePlaintext,
+ followsByPubkey,
+ freshness,
+ getDefaultAppContext,
+ getDefaultNetContext,
+ getNetwork,
+ getPlaintext,
+ getSession,
+ getSigner,
+ getUserWotScore,
+ handles,
+ initStorage,
+ loadRelay,
+ makeRouter,
+ makeTrackerStore,
+ maxWot,
+ mutesByPubkey,
+ plaintext,
+ pubkey,
+ publishThunk,
+ relay,
+ relays,
+ repository,
+ session,
+ sessions,
+ setPlaintext,
+ signer,
+ storageAdapters,
+ tagPubkey,
+ tracker,
+ zappers,
+} from "@welshman/app"
+import * as Content from "@welshman/content"
import {
- ctx,
- setContext,
Worker,
- simpleCache,
+ ctx,
+ groupBy,
identity,
- last,
- nth,
- uniq,
- uniqBy,
now,
- intersection,
- sort,
- groupBy,
- indexBy,
+ ago,
pushToMapKey,
- tryCatch,
+ setContext,
+ simpleCache,
+ sort,
take,
+ tryCatch,
+ uniq,
+ uniqBy,
} from "@welshman/lib"
+import type {Connection, PublishRequest, Target} from "@welshman/net"
+import {
+ Executor,
+ AuthMode,
+ Local,
+ Multi,
+ Relays,
+ SubscriptionEvent,
+ ConnectionEvent,
+} from "@welshman/net"
+import {Nip01Signer, Nip59} from "@welshman/signer"
+import {deriveEvents, deriveEventsMapped, throttled, withGetter} from "@welshman/store"
+import type {EventTemplate, PublishedList, SignedEvent, TrustedEvent} from "@welshman/util"
import {
- CLIENT_AUTH,
APP_DATA,
- COMMUNITIES,
- COMMUNITY,
+ DIRECT_MESSAGE,
FEED,
FEEDS,
FOLLOWS,
- GROUP,
HANDLER_INFORMATION,
HANDLER_RECOMMENDATION,
LABEL,
+ LOCAL_RELAY_URL,
+ MUTES,
+ NAMED_BOOKMARKS,
SEEN_CONTEXT,
SEEN_CONVERSATION,
SEEN_GENERAL,
- DIRECT_MESSAGE,
- NAMED_BOOKMARKS,
- WRAP,
- Address,
Tags,
+ WRAP,
+ asDecryptedEvent,
createEvent,
getAddress,
- getIdentifier,
+ getAddressTagValues,
+ getAncestorTagValues,
getIdAndAddress,
- getIdOrAddress,
getIdFilters,
- LOCAL_RELAY_URL,
- isGroupAddress,
- isCommunityAddress,
- isHashedEvent,
- getPubkeyTagValues,
+ getIdOrAddress,
+ getIdentifier,
getListTags,
+ getPubkeyTagValues,
getTagValues,
- normalizeRelayUrl,
- isContextAddress,
- getContextTagValues,
- getAddressTagValues,
- getAncestorTagValues,
- getAddressTags,
+ isHashedEvent,
makeList,
+ normalizeRelayUrl,
readList,
- asDecryptedEvent,
-} from "@welshman/util"
-import type {
- Filter,
- TrustedEvent,
- SignedEvent,
- EventTemplate,
- PublishedList,
- StampedEvent,
} from "@welshman/util"
-import {Nip59, Nip01Signer} from "@welshman/signer"
-import {Executor, Multi, Plex, Local, Relays, publish as basePublish} from "@welshman/net"
-import type {PartialSubscribeRequest} from "@welshman/app"
-import type {PublishRequest} from "@welshman/net"
-import * as Content from "@welshman/content"
-import {withGetter, deriveEvents, deriveEventsMapped, throttled} from "@welshman/store"
-import {
- session,
- getSession,
- getSigner,
- signer,
- repository,
- relay,
- tracker,
- pubkey,
- handles,
- displayProfileByPubkey,
- mutesByPubkey,
- followsByPubkey,
- makeRouter,
- subscribe as baseSubscribe,
- storageAdapters,
- freshness,
- zappers,
- relays,
- initStorage,
- db,
- plaintext,
- getPlaintext,
- setPlaintext,
- ensurePlaintext,
- getDefaultNetContext,
- getDefaultAppContext,
- loadRelay,
- tagPubkey,
- getNetwork,
- getUserWotScore,
- sessions,
- maxWot,
- repositoryStore,
-} from "@welshman/app"
-import {parseJson, fromCsv, SearchHelper} from "src/util/misc"
-import {Collection as CollectionStore} from "src/util/store"
-import {isLike, repostKinds, noteKinds, reactionKinds, metaKinds, appDataKeys} from "src/util/nostr"
-import logger from "src/util/logger"
-import type {
- GroupMeta,
- PublishedFeed,
- PublishedListFeed,
- PublishedUserList,
- PublishedGroupMeta,
-} from "src/domain"
+import crypto from "crypto"
+import Fuse from "fuse.js"
+import {batch, doPipe, seconds, sleep} from "hurdak"
+import {equals, partition, prop, sortBy, without} from "ramda"
+import type {PublishedFeed, PublishedListFeed, PublishedUserList} from "src/domain"
import {
- displayFeed,
+ CollectionSearch,
EDITABLE_LIST_KINDS,
UserListSearch,
- readFeed,
- readUserList,
+ displayFeed,
+ getHandlerAddress,
+ mapListToFeed,
readCollections,
- CollectionSearch,
+ readFeed,
readHandlers,
- mapListToFeed,
- getHandlerAddress,
- readGroupMeta,
- displayGroupMeta,
+ readUserList,
} from "src/domain"
-import type {
- Channel,
- Group,
- GroupAlert,
- GroupKey,
- GroupRequest,
- GroupStatus,
- PublishInfo,
- SessionWithMeta,
- AnonymousUserState,
-} from "src/engine/model"
-import {sortEventsAsc, unwrapRepost} from "src/engine/utils"
-import {GroupAccess, OnboardingTask} from "src/engine/model"
+import type {AnonymousUserState, Channel, SessionWithMeta} from "src/engine/model"
+import {OnboardingTask} from "src/engine/model"
+import {sortEventsAsc} from "src/engine/utils"
+import logger from "src/util/logger"
+import {SearchHelper, fromCsv, parseJson} from "src/util/misc"
+import {appDataKeys, isLike, metaKinds, noteKinds, reactionKinds, repostKinds} from "src/util/nostr"
+import {derived, get, writable} from "svelte/store"
export const env = {
CLIENT_ID: import.meta.env.VITE_CLIENT_ID as string,
@@ -164,36 +135,25 @@ export const env = {
ENABLE_MARKET: JSON.parse(import.meta.env.VITE_ENABLE_MARKET) as boolean,
ENABLE_ZAPS: JSON.parse(import.meta.env.VITE_ENABLE_ZAPS) as boolean,
BLUR_CONTENT: JSON.parse(import.meta.env.VITE_BLUR_CONTENT) as boolean,
- FORCE_GROUP: import.meta.env.VITE_FORCE_GROUP as string,
IMGPROXY_URL: import.meta.env.VITE_IMGPROXY_URL as string,
- MULTIPLEXTR_URL: import.meta.env.VITE_MULTIPLEXTR_URL as string,
NIP96_URLS: fromCsv(import.meta.env.VITE_NIP96_URLS) as string[],
+ BLOSSOM_URLS: fromCsv(import.meta.env.VITE_BLOSSOM_URLS) as string[],
ONBOARDING_LISTS: fromCsv(import.meta.env.VITE_ONBOARDING_LISTS) as string[],
PLATFORM_PUBKEY: import.meta.env.VITE_PLATFORM_PUBKEY as string,
PLATFORM_RELAYS: fromCsv(import.meta.env.VITE_PLATFORM_RELAYS).map(normalizeRelayUrl) as string[],
PLATFORM_ZAP_SPLIT: parseFloat(import.meta.env.VITE_PLATFORM_ZAP_SPLIT) as number,
SEARCH_RELAYS: fromCsv(import.meta.env.VITE_SEARCH_RELAYS).map(normalizeRelayUrl) as string[],
+ SIGNER_RELAYS: fromCsv(import.meta.env.VITE_SIGNER_RELAYS).map(normalizeRelayUrl) as string[],
+ APP_URL: import.meta.env.VITE_APP_URL,
+ APP_NAME: import.meta.env.VITE_APP_NAME,
+ APP_LOGO: import.meta.env.VITE_APP_LOGO,
}
export const sessionWithMeta = withGetter(derived(session, $s => $s as SessionWithMeta))
export const hasNip44 = derived(signer, $signer => Boolean($signer?.nip44))
-// Base state
-
export const anonymous = withGetter(writable({follows: [], relays: []}))
-export const groupHints = withGetter(writable>({}))
-export const publishes = withGetter(writable>({}))
-
-export const groups = new CollectionStore("address")
-export const groupAdminKeys = new CollectionStore("pubkey")
-export const groupSharedKeys = new CollectionStore("pubkey")
-export const groupRequests = new CollectionStore("id")
-export const groupAlerts = new CollectionStore("id")
-
-export const projections = new Worker({
- getKey: prop("kind"),
-})
// Plaintext
@@ -217,9 +177,7 @@ export const ensureMessagePlaintext = async (e: TrustedEvent) => {
return getPlaintext(e)
}
-export const canUnwrap = (event: TrustedEvent) =>
- event.kind === WRAP &&
- (getSession(Tags.fromEvent(event).get("p")?.value()) || getRecipientKey(event))
+const pendingUnwraps = new Map>()
export const ensureUnwrapped = async (event: TrustedEvent) => {
if (event.kind !== WRAP) {
@@ -232,30 +190,29 @@ export const ensureUnwrapped = async (event: TrustedEvent) => {
return rumor
}
+ const pending = pendingUnwraps.get(event.id)
+
+ if (pending) {
+ return pending
+ }
+
// Decrypt by session
const session = getSession(Tags.fromEvent(event).get("p")?.value())
const signer = getSigner(session)
if (signer) {
try {
- rumor = await Nip59.fromSigner(signer).unwrap(event as SignedEvent)
- } catch (e) {
- // pass
- }
- }
-
- // Decrypt by group key
- const secret = getRecipientKey(event)
+ const pending = Nip59.fromSigner(signer).unwrap(event as SignedEvent)
- if (secret) {
- try {
- rumor = await Nip59.fromSecret(secret).unwrap(event as SignedEvent)
+ pendingUnwraps.set(event.id, pending)
+ rumor = await pending
} catch (e) {
// pass
}
}
if (rumor && isHashedEvent(rumor)) {
+ pendingUnwraps.delete(event.id)
tracker.copy(event.id, rumor.id)
relay.send("EVENT", rumor)
}
@@ -263,24 +220,55 @@ export const ensureUnwrapped = async (event: TrustedEvent) => {
return rumor
}
+// Unwrap/decrypt stuff as it comes in
+
+const unwrapper = new Worker({chunkSize: 10})
+
+unwrapper.addGlobalHandler(async (event: TrustedEvent) => {
+ if (event.kind === WRAP) {
+ await ensureUnwrapped(event)
+ } else {
+ await ensurePlaintext(event)
+ }
+})
+
+const decryptKinds = [SEEN_GENERAL, SEEN_CONTEXT, SEEN_CONVERSATION, APP_DATA, FOLLOWS, MUTES]
+
+repository.on("update", ({added}: {added: TrustedEvent[]}) => {
+ for (const event of added) {
+ if (decryptKinds.includes(event.kind) && event.content && !getPlaintext(event)) {
+ unwrapper.push(event)
+ }
+
+ if (event.kind === WRAP) {
+ unwrapper.push(event)
+ }
+ }
+})
+
+// Tracker
+
+export const trackerStore = makeTrackerStore({throttle: 1000})
+
// Settings
export const defaultSettings = {
relay_limit: 5,
- relay_redundancy: 2,
default_zap: 21,
show_media: true,
+ send_delay: 0, // undo send delay in ms
muted_words: [],
hide_sensitive: true,
report_analytics: true,
min_wot_score: 0,
enable_client_tag: false,
- auto_authenticate: true,
+ auto_authenticate: false,
note_actions: ["zaps", "replies", "reactions", "recommended_apps"],
+ upload_type: "nip96",
nip96_urls: env.NIP96_URLS.slice(0, 1),
+ blossom_urls: env.BLOSSOM_URLS.slice(0, 1),
imgproxy_url: env.IMGPROXY_URL,
dufflepud_url: env.DUFFLEPUD_URL,
- multiplextr_url: env.MULTIPLEXTR_URL,
platform_zap_split: env.PLATFORM_ZAP_SPLIT,
}
@@ -295,7 +283,7 @@ export const userSettingsPlaintext = derived(
([$plaintext, $userSettingsEvent]) => $plaintext[$userSettingsEvent?.id],
)
-export const userSettings = withGetter(
+export const userSettings = withGetter(
derived(userSettingsPlaintext, $userSettingsPlaintext => {
const overrides = parseJson($userSettingsPlaintext) || {}
@@ -350,201 +338,10 @@ export const userMutes = derived(
l => new Set(getTagValues(["p", "e"], getListTags(l))),
)
-// Communities
-
-export const communityListEvents = deriveEvents(repository, {filters: [{kinds: [COMMUNITIES]}]})
-
-export const communityLists = derived(
- [plaintext, communityListEvents],
- ([$plaintext, $communityListEvents]) =>
- $communityListEvents.map(event =>
- readList(
- asDecryptedEvent(event, {
- content: $plaintext[event.id],
- }),
- ),
- ),
-)
-
-export const communityListsByPubkey = withGetter(
- derived(communityLists, $ls => indexBy($l => $l.event.pubkey, $ls)),
-)
-
-export const communityListsByAddress = derived(communityLists, $communityLists => {
- const m = new Map()
-
- for (const list of $communityLists) {
- for (const a of getAddressTagValues(getListTags(list))) {
- pushToMapKey(m, a, list)
- }
- }
-
- return m
-})
-
-export const getCommunityList = (pk: string) =>
- communityListsByPubkey.get().get(pk) as PublishedList | undefined
-
-export const deriveCommunityList = (pk: string) =>
- derived(communityListsByPubkey, m => m.get(pk) as PublishedList | undefined)
-
-export const getCommunities = (pk: string) =>
- new Set(getAddressTagValues(getListTags(getCommunityList(pk))))
-
-export const deriveCommunities = (pk: string) =>
- derived(communityListsByPubkey, m => new Set(getAddressTagValues(getListTags(m.get(pk)))))
-
-// Groups
-
-export const groupMeta = deriveEventsMapped(repository, {
- filters: [{kinds: [GROUP, COMMUNITY]}],
- itemToEvent: prop("event"),
- eventToItem: readGroupMeta,
-})
-
-export const groupMetaByAddress = withGetter(
- derived(groupMeta, $metas => indexBy($meta => getAddress($meta.event), $metas)),
-)
-
-export const deriveGroupMeta = (address: string) =>
- derived(groupMetaByAddress, $m => $m.get(address))
-
-export const displayGroupByAddress = a => displayGroupMeta(groupMetaByAddress.get().get(a))
-
-export class GroupSearch extends SearchHelper {
- config = {
- keys: [{name: "identifier", weight: 0.2}, "name", {name: "about", weight: 0.5}],
- threshold: 0.3,
- shouldSort: false,
- includeScore: true,
- }
-
- getSearch = () => {
- const fuse = new Fuse(this.options, this.config)
- const sortFn = (r: any) => r.score - Math.pow(Math.max(0, r.item.score), 1 / 100)
-
- return (term: string) =>
- term
- ? sortBy(sortFn, fuse.search(term)).map((r: any) => r.item)
- : sortBy(meta => -meta.score, this.options)
- }
-
- getValue = (option: GroupMeta) => getAddress(option.event)
-
- displayValue = displayGroupByAddress
-}
-
-export const groupMetaSearch = derived(
- [groupMeta, communityListsByAddress, userFollows],
- ([$groupMeta, $communityListsByAddress, $userFollows]) => {
- const options = $groupMeta.map(meta => {
- const lists = $communityListsByAddress.get(getAddress(meta.event)) || []
- const members = lists.map(l => l.event.pubkey)
- const followedMembers = intersection(members, Array.from($userFollows))
-
- return {...meta, score: followedMembers.length}
- })
-
- return new GroupSearch(options)
- },
-)
-
-// Legacy
-export const deriveGroup = address => {
- const {pubkey, identifier: id} = Address.from(address)
-
- return groups.key(address).derived(defaultTo({id, pubkey, address}))
-}
-
-export const getRecipientKey = wrap => {
- const pubkey = Tags.fromEvent(wrap).values("p").first()
- const sharedKey = groupSharedKeys.key(pubkey).get()
-
- if (sharedKey) {
- return sharedKey.privkey
- }
-
- const adminKey = groupAdminKeys.key(pubkey).get()
-
- if (adminKey) {
- return adminKey.privkey
- }
-
- return null
-}
-
-export const deriveSharedKeyForGroup = (address: string) =>
- groupSharedKeys.derived($keys =>
- last(sortBy(prop("created_at"), $keys.filter(whereEq({group: address})))),
- )
-
-export const deriveAdminKeyForGroup = (address: string) => groupAdminKeys.key(address.split(":")[1])
-
-export const getGroupStatus = (sessionWithMeta: SessionWithMeta, address: string) =>
- (sessionWithMeta?.groups?.[address] || {}) as GroupStatus
-
-export const deriveGroupStatus = address =>
- derived(sessionWithMeta, $sessionWithMeta => getGroupStatus($sessionWithMeta, address))
-
-export const userIsGroupMember = withGetter(
- derived(sessionWithMeta, $sessionWithMeta => (address, includeRequests = false) => {
- const status = getGroupStatus($sessionWithMeta, address)
-
- if (isCommunityAddress(address)) {
- return status.joined
- }
-
- if (isGroupAddress(address)) {
- if (includeRequests && status.access === GroupAccess.Requested) {
- return true
- }
-
- return status.access === GroupAccess.Granted
- }
-
- return false
- }),
-)
-
-export const deriveGroupOptions = (defaultGroups = []) =>
- derived([sessionWithMeta, userIsGroupMember], ([$sessionWithMeta, $userIsGroupMember]) => {
- const options = []
-
- for (const address of Object.keys($sessionWithMeta?.groups || {})) {
- const group = groups.key(address).get()
-
- if (group && $userIsGroupMember(address)) {
- options.push(group)
- }
- }
-
- for (const address of defaultGroups) {
- options.push({address})
- }
-
- return uniqBy(prop("address"), options)
- })
-
-export const getUserCircles = (sessionWithMeta: SessionWithMeta) => {
- const $userIsGroupMember = userIsGroupMember.get()
-
- return Object.entries(sessionWithMeta?.groups || {})
- .filter(([a, s]) => !repository.deletes.has(a) && $userIsGroupMember(a))
- .map(([a, s]) => a)
-}
-
-export const getUserGroups = (sessionWithMeta: SessionWithMeta) =>
- getUserCircles(sessionWithMeta).filter(isGroupAddress)
-
-export const getUserCommunities = (sessionWithMeta: SessionWithMeta) =>
- getUserCircles(sessionWithMeta).filter(isCommunityAddress)
-
-// Events
-
export const isEventMuted = withGetter(
derived(
- [userMutes, userFollows, userSettings, pubkey, userIsGroupMember],
- ([$userMutes, $userFollows, $userSettings, $pubkey, $userIsGroupMember]) => {
+ [userMutes, userFollows, userSettings, pubkey],
+ ([$userMutes, $userFollows, $userSettings, $pubkey]) => {
const words = $userSettings.muted_words
const minWot = $userSettings.min_wot_score
const regex =
@@ -566,11 +363,9 @@ export const isEventMuted = withGetter(
if (strict || $userFollows.has(e.pubkey)) return false
- const addresses = getAddressTagValues(e.tags || []).filter(isContextAddress)
- const wotAdjustment = addresses.some(a => $userIsGroupMember(a)) ? 1 : 0
const wotScore = getUserWotScore(e.pubkey)
- return wotScore < minWot - wotAdjustment
+ return wotScore < minWot
}
},
),
@@ -693,64 +488,6 @@ export const unreadReactionNotifications = derived(
([$isSeen, events]) => events.filter(e => !$isSeen("reactions", e) && !$isSeen("zaps", e)),
)
-// -- Group Notifications
-
-export const groupNotifications = derived(
- [
- sessionWithMeta,
- isEventMuted,
- groupRequests,
- groupAlerts,
- groupAdminKeys,
- throttled(3000, repositoryStore),
- ],
- ([$session, $isMuted, $requests, $alerts, $adminKeys, $repository]) => {
- const admins = new Set($adminKeys.map(k => k.pubkey))
- const addresses = getUserCircles($session)
- const kinds = [...noteKinds, ...repostKinds]
- const events = $repository.query([{"#a": addresses, kinds}])
-
- return sortBy(
- e => -e.created_at,
- [
- ...$requests.filter(r => !r.resolved && !$repository.deletes.has(r.group)),
- ...$alerts.filter(a => !admins.has(a.pubkey) && !$repository.deletes.has(a.group)),
- ...events
- .map(e => {
- // Unwrap reposts, add community tags so we know where stuff was posted to
- if (repostKinds.includes(e.kind)) {
- const contextTags = getAddressTags(e.tags)
-
- e = unwrapRepost(e)
-
- for (const tag of contextTags) {
- if (isContextAddress(tag[1])) {
- e?.tags.push(tag)
- }
- }
- }
-
- return e
- })
- .filter(
- e =>
- e &&
- e.pubkey !== $session.pubkey &&
- // Skip mentions since they're covered in normal notifications
- !e.tags.some(t => t[0] === "p" && t[1] === $session.pubkey) &&
- !$isMuted(e),
- ),
- ],
- ) as (TrustedEvent | GroupRequest | GroupAlert)[]
- },
-)
-
-export const unreadGroupNotifications = derived(
- [isSeen, groupNotifications],
- ([$isSeen, $groupNotifications]) =>
- $groupNotifications.filter(e => !getContextTagValues(e.tags).every(a => $isSeen(a, e))),
-)
-
// Channels
export const getChannelId = (pubkeys: string[]) => sort(uniq(pubkeys)).join(",")
@@ -761,7 +498,10 @@ export const getChannelIdFromEvent = (event: TrustedEvent) =>
export const getChannelSeenKey = (id: string) =>
crypto.createHash("sha256").update(id.replace(",", "")).digest("hex")
-export const messages = deriveEvents(repository, {filters: [{kinds: [4, DIRECT_MESSAGE]}]})
+export const messages = deriveEvents(repository, {
+ throttle: 300,
+ filters: [{kinds: [4, DIRECT_MESSAGE]}],
+})
export const channels = derived(
[pubkey, messages, getSeenAt],
@@ -805,26 +545,6 @@ export const channelHasNewMessages = (channel: Channel) =>
export const hasNewMessages = derived(channels, $channels => $channels.some(channelHasNewMessages))
-// Relay selection
-
-export const getGroupRelayUrls = address => {
- const meta = groupMetaByAddress.get().get(address)
-
- if (meta?.relays) {
- return meta.relays.map(nth(1))
- }
-
- const latestKey = last(
- sortBy(prop("created_at"), get(groupSharedKeys).filter(whereEq({group: address}))),
- )
-
- if (latestKey?.hints) {
- return latestKey.hints
- }
-
- return get(groupHints)[address] || []
-}
-
export const forceRelays = (relays: string[], forceRelays: string[]) =>
forceRelays.length > 0 ? forceRelays : relays
@@ -1005,48 +725,10 @@ export const collectionSearch = derived(
// Network
-export const addRepostFilters = (filters: Filter[]) =>
- filters.flatMap(original => {
- const filterChunk = [original]
-
- if (!original.kinds) {
- filterChunk.push({...original, kinds: [6, 16]})
- } else {
- if (original.kinds.includes(1)) {
- filterChunk.push({...original, kinds: [6]})
- }
-
- const otherKinds = without([1], original.kinds)
-
- if (otherKinds.length > 0) {
- filterChunk.push({...original, kinds: [16], "#k": otherKinds.map(String)})
- }
- }
-
- return filterChunk
- })
-
export const getExecutor = (urls: string[]) => {
- const muxUrl = getSetting("multiplextr_url")
const [localUrls, remoteUrls] = partition(equals(LOCAL_RELAY_URL), urls)
- // Try to use our multiplexer, but if it fails to connect fall back to relays. If
- // we're only connecting to a single relay, just do it directly, unless we already
- // have a connection to the multiplexer open, in which case we're probably doing
- // AUTH with a single relay.
- let target
-
- if (muxUrl && remoteUrls.length > 0) {
- const connection = ctx.net.pool.get(muxUrl)
-
- if (connection.socket.isOpen()) {
- target = new Plex(remoteUrls, connection)
- }
- }
-
- if (!target) {
- target = new Relays(remoteUrls.map(url => ctx.net.pool.get(url)))
- }
+ let target: Target = new Relays(remoteUrls.map(url => ctx.net.pool.get(url)))
if (localUrls.length > 0) {
target = new Multi([target, new Local(relay)])
@@ -1070,13 +752,7 @@ export const subscribe = ({forcePlatform, skipCache, ...request}: MySubscribeReq
request.relays = [...request.relays, LOCAL_RELAY_URL]
}
- const sub = baseSubscribe(request)
-
- sub.emitter.on("event", async (url: string, event: TrustedEvent) => {
- projections.push(await ensureUnwrapped(event))
- })
-
- return sub
+ return baseSubscribe(request)
}
export const subscribePersistent = (request: MySubscribeRequest) => {
@@ -1106,21 +782,16 @@ export const load = (request: MySubscribeRequest) =>
const events: TrustedEvent[] = []
const sub = subscribe({...request, closeOnEose: true})
- sub.emitter.on("event", (url: string, event: TrustedEvent) => events.push(event))
- sub.emitter.on("complete", (url: string) => resolve(events))
+ sub.emitter.on(SubscriptionEvent.Event, (url: string, e: TrustedEvent) => events.push(e))
+ sub.emitter.on(SubscriptionEvent.Complete, (url: string) => resolve(events))
})
export type MyPublishRequest = PublishRequest & {
forcePlatform?: boolean
+ delay?: number
}
-const shouldTrackPublish = (event: TrustedEvent) => {
- if ([SEEN_CONTEXT, SEEN_CONVERSATION, SEEN_GENERAL].includes(event.kind)) return false
-
- return event.pubkey === pubkey.get() || canUnwrap(event)
-}
-
-export const publish = async ({forcePlatform = true, ...request}: MyPublishRequest) => {
+export const publish = ({forcePlatform = true, ...request}: MyPublishRequest) => {
request.relays = forcePlatform
? forcePlatformRelays(request.relays)
: withPlatformRelays(request.relays)
@@ -1131,27 +802,13 @@ export const publish = async ({forcePlatform = true, ...request}: MyPublishReque
logger.info(`Publishing event`, request)
- // Make sure the event is decrypted before updating stores
- if (canUnwrap(request.event)) {
- await ensureUnwrapped(request.event)
- } else if (projections.handlers.get(request.event.kind)?.includes(ensurePlaintext)) {
- await ensurePlaintext(request.event)
- }
-
- // Publish to local and remote relays
- const pub = basePublish(request)
-
- // Listen to updates and update our publish queue
- if (shouldTrackPublish(request.event)) {
- const pubInfo = omit(["emitter", "result"], pub)
-
- pub.emitter.on("*", t => publishes.update(assoc(pubInfo.id, pubInfo)))
- }
-
- return pub
+ return publishThunk(request)
}
-export const sign = (template, opts: {anonymous?: boolean; sk?: string} = {}) => {
+export const sign = (
+ template,
+ opts: {anonymous?: boolean; sk?: string} = {},
+): Promise => {
if (opts.anonymous) {
return Nip01Signer.ephemeral().sign(template)
}
@@ -1194,14 +851,6 @@ export const createAndPublish = async ({
return publish({event, relays, verb, timeout, forcePlatform})
}
-// Publish
-
-export const mentionGroup = (address: string, ...args: unknown[]) => [
- "a",
- address,
- ctx.app.router.WithinContext(address).getUrl(),
-]
-
export const tagsFromContent = (content: string) => {
const tags = []
@@ -1278,7 +927,7 @@ export class ThreadLoader {
if (filteredIds.length > 0) {
load({
filters: getIdFilters(filteredIds),
- relays: ctx.app.router.fromRelays(this.relays).getUrls(),
+ relays: ctx.app.router.FromRelays(this.relays).getUrls(),
onEvent: batch(300, (events: TrustedEvent[]) => {
this.addToThread(events)
this.loadNotes(events.flatMap(getAncestorIds))
@@ -1323,6 +972,7 @@ export class ThreadLoader {
// Remove the old database. TODO remove this
import {deleteDB} from "idb"
+import {subscriptionNotices} from "src/domain/connection"
deleteDB("nostr-engine/Storage")
let ready: Promise = Promise.resolve()
@@ -1330,7 +980,7 @@ let ready: Promise = Promise.resolve()
const migrateFreshness = (data: {key: string; value: number}[]) => {
const cutoff = now() - seconds(1, "hour")
- return data.filter(({value}) => value < cutoff)
+ return data.filter(({value}) => value > cutoff)
}
const getScoreEvent = () => {
@@ -1373,11 +1023,19 @@ const getScoreEvent = () => {
}
}
+let lastMigrate = 0
+
const migrateEvents = (events: TrustedEvent[]) => {
- if (events.length < 50_000) {
+ if (events.length < 50_000 || ago(lastMigrate) < 60) {
return events
}
+ // filter out all event posted to encrypted group
+ events = events.filter(e => !e.wrap?.tags.some(t => t[1].startsWith("35834:")))
+
+ // Keep track of the last time we migrated the events, since it's expensive
+ lastMigrate = now()
+
const scoreEvent = getScoreEvent()
return take(
@@ -1388,6 +1046,7 @@ const migrateEvents = (events: TrustedEvent[]) => {
// Avoid initializing multiple times on hot reload
if (!db) {
+ const noticeVerbs = ["NOTICE", "CLOSED", "OK", "NEG-MSG"]
const initialRelays = [
...env.DEFAULT_RELAYS,
...env.DVM_RELAYS,
@@ -1397,36 +1056,38 @@ if (!db) {
]
setContext({
- net: getDefaultNetContext({
- getExecutor,
- signEvent: (event: StampedEvent) => {
- if (
- event.kind === CLIENT_AUTH &&
- !env.FORCE_GROUP &&
- env.PLATFORM_RELAYS.length === 0 &&
- !getSetting("auto_authenticate")
- ) {
- return
- }
-
- return signer.get()?.sign(event)
- },
- }),
+ net: getDefaultNetContext({getExecutor}),
app: getDefaultAppContext({
dufflepudUrl: env.DUFFLEPUD_URL,
indexerRelays: env.INDEXER_RELAYS,
requestTimeout: 10000,
router: makeRouter({
- getRedundancy: () => getSetting("relay_redundancy"),
getLimit: () => getSetting("relay_limit"),
}),
}),
})
userSettings.subscribe($settings => {
+ const autoAuthenticate = $settings.auto_authenticate || env.PLATFORM_RELAYS.length > 0
+
+ ctx.net.authMode = autoAuthenticate ? AuthMode.Implicit : AuthMode.Explicit
ctx.app.dufflepudUrl = getSetting("dufflepud_url")
})
+ ctx.net.pool.on("init", (connection: Connection) => {
+ connection.on(ConnectionEvent.Receive, function (cxn, [verb, ...args]) {
+ if (!noticeVerbs.includes(verb)) return
+ subscriptionNotices.update($notices => {
+ pushToMapKey($notices, connection.url, {
+ created_at: now(),
+ url: cxn.url,
+ notice: [verb, ...args],
+ })
+ return $notices
+ })
+ })
+ })
+
ready = initStorage("coracle", 2, {
relays: {keyPath: "url", store: throttled(1000, relays)},
handles: {keyPath: "nip05", store: throttled(1000, handles)},
@@ -1437,11 +1098,6 @@ if (!db) {
}),
plaintext: storageAdapters.fromObjectStore(plaintext, {throttle: 1000}),
repository: storageAdapters.fromRepository(repository, {throttle: 300, migrate: migrateEvents}),
- groups: {keyPath: "address", store: groups},
- groupAlerts: {keyPath: "id", store: groupAlerts},
- groupRequests: {keyPath: "id", store: groupRequests},
- groupSharedKeys: {keyPath: "pubkey", store: groupSharedKeys},
- groupAdminKeys: {keyPath: "pubkey", store: groupAdminKeys},
}).then(() => Promise.all(initialRelays.map(loadRelay)))
}
diff --git a/src/engine/utils/events.ts b/src/engine/utils/events.ts
index b5e7e57df..ae8b72fc3 100644
--- a/src/engine/utils/events.ts
+++ b/src/engine/utils/events.ts
@@ -2,7 +2,7 @@ import {nip19} from "nostr-tools"
import {tryFunc, switcherFn} from "hurdak"
import {sortBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
-import {fromNostrURI, Address, hasValidSignature, Tags} from "@welshman/util"
+import {fromNostrURI, Address, hasValidSignature} from "@welshman/util"
import {parseJson} from "src/util/misc"
export const sortEventsAsc = events => sortBy((e: TrustedEvent) => e.created_at, events)
@@ -45,13 +45,5 @@ export const unwrapRepost = repost => {
return null
}
- const originalGroup = Tags.fromEvent(event).context().values().first()
- const repostGroup = Tags.fromEvent(repost).context().values().first()
-
- // Only show cross-posts, not reposts from global to global
- if (originalGroup === repostGroup) {
- return null
- }
-
return event
}
diff --git a/src/main.js b/src/main.js
index b6a3a81f6..7d1027f23 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,5 +1,5 @@
import "src/app.css"
-import * as Sentry from '@sentry/browser'
+import * as Sentry from "@sentry/browser"
import {App as CapacitorApp} from "@capacitor/app"
import App from "src/app/App.svelte"
import {installPrompt} from "src/partials/state"
@@ -9,7 +9,7 @@ if (import.meta.env.VITE_GLITCHTIP_API_KEY) {
dsn: import.meta.env.VITE_GLITCHTIP_API_KEY,
tracesSampleRate: 0.01,
integrations(integrations) {
- return integrations.filter(integration => integration.name !== 'Breadcrumbs')
+ return integrations.filter(integration => integration.name !== "Breadcrumbs")
},
})
}
diff --git a/src/partials/Anchor.svelte b/src/partials/Anchor.svelte
index 1b102d878..ed173628b 100644
--- a/src/partials/Anchor.svelte
+++ b/src/partials/Anchor.svelte
@@ -35,8 +35,7 @@
underline,
"opacity-50 pointer-events-none": loading || disabled,
"bg-white text-black hover:bg-white-l": button && !accent && !low,
- "text-base bg-tinted-700 text-tinted-200 hover:bg-tinted-600 border border-solid border-tinted-600":
- button && low,
+ "text-base bg-tinted-700 text-tinted-200 hover:bg-tinted-600": button && low,
"bg-accent text-white hover:bg-accent": button && accent,
"text-danger border border-solid !border-danger": button && danger,
"text-xl staatliches rounded whitespace-nowrap flex justify-center items-center gap-2 px-6":
diff --git a/src/partials/Channel.svelte b/src/partials/Channel.svelte
deleted file mode 100644
index 3debd70a5..000000000
--- a/src/partials/Channel.svelte
+++ /dev/null
@@ -1,353 +0,0 @@
-
-
- {
- showNewMessages = false
- }} />
-
-
-
-
-
-
-
- {#if sending && $signer instanceof Nip46Signer}
-
-
- Sending your message...
-
- {/if}
-
- {#each groupedMessages as message (message.id)}
-
-
- {#if message.showProfile && message.pubkey !== $session.pubkey}
-
- {/if}
-
- {#await getContent(message)}
-
- {:then content}
-
- {/await}
-
-
- {formatTimestamp(message.created_at)}
- {#if message.kind === 4}
-
-
-
- This message was sent using nostr's legacy DMs, which have a number of
- shortcomings. Read more here .
-
-
- {:else}
-
-
-
-
- This message was sent using nostr's new group chat specification, which solves
- several problems with legacy DMs. Read more here .
-
- {#if message.pubkey === $session.pubkey}
-
- Note that these messages are not yet universally supported. Make sure the
- person you're chatting with is using a compatible nostr client.
-
- {/if}
-
-
- {/if}
-
-
-
- {/each}
- {#await loading}
-
Looking for messages...
- {:then}
-
End of message history
- {/await}
-
- {#if $hasNip44 || !isGroupMessage}
-
-
-
- addImage(e.detail)}>
-
-
-
-
-
-
-
-
- {#if $hasNip44 && hasSingleRecipientWithInbox}
-
- {#if userHasInbox}
-
- {:else}
-
-
-
-
-
-
- You must have at least one inbox relay to send messages using nip-17. Click here to set
- up your inbox relays.
-
-
- {/if}
-
- Send messages using
-
- NIP 17
-
-
- When enabled, Coracle will use nostr's new group chat specification, which solves
- several problems with legacy DMs. Read more here .
-
-
- Note that these messages are not yet universally supported. Make sure the person
- you're chatting with is using a compatible nostr client.
-
-
-
-
-
- {/if}
-
- {:else}
-
-
-
- You are using a login method that doesn't yet support group chats. Please consider upgrading
- your signer to access this feature.
-
-
- {/if}
- {#if showNewMessages}
-
- {/if}
-
-
-{#if confirmIsOpen}
-
- Missing Inbox Relays
- {#if $pubkeysWithoutInbox.length > 0}
-
- {displayList($pubkeysWithoutInbox.map(displayProfileByPubkey))}
- {pluralize($pubkeysWithoutInbox.length, "does not have", "do not have")}
- inbox relays, which means they likely either don't want to receive DMs, or are using a client
- that does not support nostr group chats.
-
- {:else if !userHasInbox}
-
- You don't have any inbox relays set up yet, which will make it difficult for you to receive
- replies to this conversation. Click here to
- set up your inbox relays.
-
- {/if}
-
-
-{/if}
diff --git a/src/partials/Chip.svelte b/src/partials/Chip.svelte
index 68471f1a6..02214aebe 100644
--- a/src/partials/Chip.svelte
+++ b/src/partials/Chip.svelte
@@ -4,6 +4,7 @@
export let pad = false
export let dark = true
export let light = false
+ export let accent = false
export let danger = false
export let warning = false
export let small = false
@@ -13,6 +14,7 @@
"border-neutral-800": light,
"border-neutral-100": dark,
"!border-danger": danger,
+ "!bg-accent": accent,
"!border-warning": warning,
"py-1 px-2": !small,
"py-0.5 px-2 text-sm": small,
diff --git a/src/partials/FieldInline.svelte b/src/partials/FieldInline.svelte
index 36744a1ca..21072d653 100644
--- a/src/partials/FieldInline.svelte
+++ b/src/partials/FieldInline.svelte
@@ -5,7 +5,7 @@
-
+
{#if icon}
diff --git a/src/partials/Modal.svelte b/src/partials/Modal.svelte
index 01a73dde8..cf8ce3c40 100644
--- a/src/partials/Modal.svelte
+++ b/src/partials/Modal.svelte
@@ -1,4 +1,5 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
+
diff --git a/src/partials/SearchSelect.svelte b/src/partials/SearchSelect.svelte
index a98d3a2b8..138cd71db 100644
--- a/src/partials/SearchSelect.svelte
+++ b/src/partials/SearchSelect.svelte
@@ -17,7 +17,7 @@
export let termIsValid = null
export let getKey: (x: any) => any = identity
export let displayItem = getKey
- export let autofocus = false
+ export let autofocus = undefined
export let multiple = false
export let loading = false
export let defaultOptions = []
diff --git a/src/partials/Select.svelte b/src/partials/Select.svelte
index 375bc742f..5570725eb 100644
--- a/src/partials/Select.svelte
+++ b/src/partials/Select.svelte
@@ -8,8 +8,8 @@
onChange(value)}>
+ on:change={() => onChange && onChange(value)}>
diff --git a/src/partials/ThunkNotice.svelte b/src/partials/ThunkNotice.svelte
new file mode 100644
index 000000000..29e72c1a1
--- /dev/null
+++ b/src/partials/ThunkNotice.svelte
@@ -0,0 +1,15 @@
+
+
+
+ {formatTimestamp(notice.created_at)}
+ to {notice.url}:
+ [Kind {notice.eventKind}]
+ {notice.message}
+
diff --git a/src/partials/ThunkStatus.svelte b/src/partials/ThunkStatus.svelte
new file mode 100644
index 000000000..b0720d7ca
--- /dev/null
+++ b/src/partials/ThunkStatus.svelte
@@ -0,0 +1,21 @@
+
+
+
+ Published to {total - pending}/{total} relays.
+
View details
+
diff --git a/src/partials/Toast.svelte b/src/partials/Toast.svelte
index ed8daefb9..aa8aa930f 100644
--- a/src/partials/Toast.svelte
+++ b/src/partials/Toast.svelte
@@ -11,7 +11,7 @@
timeout = 5,
...opts
}) => {
- toast.set({id, type, theme, ...opts})
+ toast.set({id, type, theme, timeout, ...opts})
if (timeout) {
setTimeout(() => {
@@ -26,7 +26,9 @@
export const showWarning = (message, opts = {}) => showToast({message, theme: "warning", ...opts})
- export const showPublishInfo = (pub, opts = {}) => showToast({pub, type: "publish", ...opts})
+ export const showPublishInfo = (thunk: Thunk, opts = {}) => {
+ showToast({thunk, type: "publish", ...opts})
+ }
window.addEventListener("online", () => {
if (get(toast)?.id === "offline") {
@@ -40,9 +42,12 @@
{#if $toast}
- {#key "key"}
+ {#key $toast.id}
{#if $toast.type === "text"}
{$toast.message}
+ {:else if $toast.type === "delay"}
+ Sending in {timeLeft} seconds...
+
{
+ $toast.onCancel()
+ toast.set(null)
+ }}>Cancel
{:else if $toast.type === "publish"}
- {@const {status, request} = $toast.pub}
- {@const total = request.relays.length}
- {@const pending = Array.from(status.values()).filter(s => s === "pending").length}
- Published to {total - pending}/{total} relays.
-
View details
+
{/if}
toast.set(null)}>
diff --git a/src/partials/state.ts b/src/partials/state.ts
index c2440cc1a..9de9077b8 100644
--- a/src/partials/state.ts
+++ b/src/partials/state.ts
@@ -33,9 +33,7 @@ const DARK_THEME = parseTheme(import.meta.env.VITE_DARK_THEME)
const LIGHT_THEME = parseTheme(import.meta.env.VITE_LIGHT_THEME)
-const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches
-
-export const theme = synced("ui/theme", prefersDark ? "dark" : "light")
+export const theme = synced("ui/theme", "dark")
theme.subscribe(value => {
if (value === "dark") {
diff --git a/src/types.d.ts b/src/types.d.ts
index 99f03a1e6..1572542ae 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -1 +1,11 @@
+import type {NetContext} from "@welshman/net"
+import type {AppContext} from "@welshman/app"
+
declare module "fuse.js/dist/fuse.min.js"
+
+declare module "@welshman/lib" {
+ interface Context {
+ net: NetContext
+ app: AppContext
+ }
+}
diff --git a/src/util/misc.ts b/src/util/misc.ts
index 4d002d574..ab2672058 100644
--- a/src/util/misc.ts
+++ b/src/util/misc.ts
@@ -1,11 +1,17 @@
-import {throttle} from "throttle-debounce"
-import {writable} from "svelte/store"
-import {now, stripProtocol, isPojo, first, sleep} from "@welshman/lib"
+import {derived, writable, type Readable} from "svelte/store"
+import {now, throttle, stripProtocol, isPojo, first, sleep} from "@welshman/lib"
import {pluck, fromPairs, last, identity, sum, is} from "ramda"
import {Storage, ensurePlural, seconds, tryFunc, round} from "hurdak"
import Fuse from "fuse.js"
import logger from "src/util/logger"
+export const timestamp1: Readable
= derived([], (_, set) => {
+ const interval = setInterval(() => {
+ set(Math.floor(Date.now() / 1000))
+ }, 1000)
+ return () => clearInterval(interval)
+})
+
export const secondsToDate = ts => new Date(parseInt(ts) * 1000)
export const dateToSeconds = date => Math.round(date.valueOf() / 1000)
@@ -166,7 +172,7 @@ export const race = (threshold, promises) => {
p.then(() => {
count++
- if (count >= threshold) {
+ if (count >= threshold * promises.length) {
resolve()
}
}).catch(reject)
diff --git a/src/util/router.ts b/src/util/router.ts
index 511c36adc..3c0e04b37 100644
--- a/src/util/router.ts
+++ b/src/util/router.ts
@@ -240,6 +240,8 @@ class RouterExtension {
open = (config = {}) => this.go({...config, modal: true})
+ pushModal = (config = {}) => this.go({...config, modal: true})
+
replace = (config = {}) => this.go({...config, replace: true})
replaceModal = (config = {}) => this.go({...config, replace: true, modal: true})
diff --git a/src/util/store.ts b/src/util/store.ts
deleted file mode 100644
index 5f13e616f..000000000
--- a/src/util/store.ts
+++ /dev/null
@@ -1,306 +0,0 @@
-import {throttle} from "throttle-debounce"
-import {ensurePlural, identity} from "@welshman/lib"
-
-// Deprecated: use svelte's stores instead. I did all this to add a `get` convenience
-// method that was more perfomant than subscribing/unsubscribing. That turned out to be
-// a mistake, since that makes it much harder to make custom stores that don't run when
-// there are no subscribers.
-
-export type Invalidator = (value?: T) => void
-export type Subscriber = (value: T) => void
-
-type Derivable = IDerivable | IDerivable[]
-type Unsubscriber = () => void
-type R = Record
-type M = Map
-
-export interface ISubscribable {
- subscribe(this: void, run: Subscriber, invalidate?: Invalidator): Unsubscriber
-}
-
-export interface ISettable {
- set: (xs: T) => void
-}
-
-export interface IDerivable extends ISubscribable {
- get: () => T
-}
-
-export interface IReadable extends IDerivable {
- derived: (f: (v: T) => U) => IReadable
- throttle(t: number): IReadable
-}
-
-export interface IWritable extends IReadable {
- set: (xs: T) => void
-}
-
-export class Writable implements IWritable {
- value: T
- subs: Subscriber[] = []
-
- constructor(defaultValue: T, t?: number) {
- this.value = defaultValue
-
- if (t) {
- this.notify = throttle(t, this.notify)
- }
- }
-
- notify = () => {
- for (const sub of this.subs) {
- sub(this.value)
- }
- }
-
- get() {
- return this.value
- }
-
- set(newValue: T) {
- this.value = newValue
- this.notify()
- }
-
- update(f: (v: T) => T) {
- this.set(f(this.value))
- }
-
- subscribe(f: Subscriber) {
- this.subs.push(f)
-
- f(this.value)
-
- return () => {
- this.subs.splice(
- this.subs.findIndex(x => x === f),
- 1,
- )
- }
- }
-
- derived(f: (v: T) => U): Derived {
- return new Derived(this, f)
- }
-
- throttle = (t: number): Derived => {
- return new Derived(this, identity, t)
- }
-}
-
-export class Derived implements IReadable {
- callerSubs: Subscriber[] = []
- mySubs: Unsubscriber[] = []
- stores: Derivable
- getValue: (values: any) => T
- latestValue: T | undefined
-
- constructor(stores: Derivable, getValue: (values: any) => T, t = 0) {
- this.stores = stores
- this.getValue = getValue
-
- if (t) {
- this.notify = throttle(t, this.notify)
- }
- }
-
- notify = () => {
- this.latestValue = undefined
- this.callerSubs.forEach(f => f(this.get()))
- }
-
- getInput() {
- if (Array.isArray(this.stores)) {
- return this.stores.map(s => s.get())
- } else {
- return this.stores.get()
- }
- }
-
- get = (): T => {
- // Recalculate if we're not subscribed, because we won't get notified when deps change
- if (this.latestValue === undefined || this.mySubs.length === 0) {
- this.latestValue = this.getValue(this.getInput())
- }
-
- return this.latestValue
- }
-
- subscribe(f: Subscriber) {
- if (this.callerSubs.length === 0) {
- for (const s of ensurePlural(this.stores)) {
- this.mySubs.push(s.subscribe(this.notify))
- }
- }
-
- this.callerSubs.push(f)
-
- f(this.get())
-
- return () => {
- this.callerSubs.splice(
- this.callerSubs.findIndex(x => x === f),
- 1,
- )
-
- if (this.callerSubs.length === 0) {
- for (const unsub of this.mySubs.splice(0)) {
- unsub()
- }
- }
- }
- }
-
- derived(f: (v: T) => U): IReadable {
- return new Derived(this, f) as IReadable
- }
-
- throttle = (t: number): IReadable => {
- return new Derived(this, identity, t)
- }
-}
-
-export class Key implements IReadable {
- readonly pk: string
- readonly key: string
- base: Writable>
- store: IReadable
-
- constructor(base: Writable>, pk: string, key: string) {
- if (!(base.get() instanceof Map)) {
- throw new Error("`key` can only be used on map collections")
- }
-
- this.pk = pk
- this.key = key
- this.base = base
- this.store = base.derived(m => m.get(key) as T)
- }
-
- get = () => this.base.get().get(this.key) as T
-
- subscribe = (f: Subscriber) => this.store.subscribe(f)
-
- derived = (f: (v: T) => U) => this.store.derived(f)
-
- throttle = (t: number) => this.store.throttle(t)
-
- exists = () => this.base.get().has(this.key)
-
- update = (f: (v: T) => T) =>
- this.base.update((m: M) => {
- if (!this.key) {
- throw new Error(`Cannot set key: "${this.key}"`)
- }
-
- // Make sure the pk always get set on the record
- const {pk, key} = this
- const oldValue = {...m.get(key), [pk]: key} as T
- const newValue = {...f(oldValue), [pk]: key}
-
- m.set(this.key, newValue)
-
- return m
- })
-
- set = (v: T) => this.update(() => v)
-
- merge = (d: Partial) => this.update(v => ({...v, ...d}))
-
- remove = () =>
- this.base.update(m => {
- m.delete(this.key)
-
- return m
- })
-
- pop = () => {
- const v = this.get()
-
- this.remove()
-
- return v
- }
-}
-
-export class DerivedKey implements IReadable {
- readonly pk: string
- readonly key: string
- base: IReadable>
- store: IReadable
-
- constructor(base: IReadable>, pk: string, key: string) {
- if (!(base.get() instanceof Map)) {
- throw new Error("`key` can only be used on map collections")
- }
-
- this.pk = pk
- this.key = key
- this.base = base
- this.store = base.derived(m => m.get(key) as T)
- }
-
- get = () => this.base.get().get(this.key) as T
-
- subscribe = (f: Subscriber) => this.store.subscribe(f)
-
- derived = (f: (v: T) => U) => this.store.derived(f)
-
- throttle = (t: number) => this.store.throttle(t)
-
- exists = () => this.base.get().has(this.key)
-}
-
-export class Collection implements IReadable {
- readonly pk: string
- readonly mapStore: Writable>
- readonly listStore: IReadable
-
- constructor(pk: string, t?: number) {
- this.pk = pk
- this.mapStore = new Writable(new Map())
- this.listStore = this.mapStore.derived((m: M) => Array.from(m.values()))
-
- if (t) {
- this.mapStore.notify = throttle(t, this.mapStore.notify)
- }
- }
-
- get = () => this.listStore.get()
-
- getMap = () => this.mapStore.get()
-
- subscribe = (f: Subscriber) => this.listStore.subscribe(f)
-
- derived = (f: (v: T[]) => U) => this.listStore.derived(f)
-
- throttle = (t: number) => this.listStore.throttle(t)
-
- key = (k: string) => new Key(this.mapStore, this.pk, k)
-
- set = (xs: T[]) => {
- const m = new Map()
-
- for (const x of xs) {
- if (!x) {
- console.error("Empty value passed to collection store")
- } else if (!x[this.pk]) {
- console.error(`Value with empty ${this.pk} passed to collection store`, x)
- } else {
- m.set(x[this.pk], x)
- }
- }
-
- this.mapStore.set(m)
- }
-
- update = (f: (v: T[]) => T[]) => this.set(f(this.get()))
-
- updateAsync = async (f: (v: T[]) => Promise) => this.set(await f(this.get()))
-
- reject = (f: (v: T) => boolean) => this.update((xs: T[]) => xs.filter(x => !f(x)))
-
- filter = (f: (v: T) => boolean) => this.update((xs: T[]) => xs.filter(f))
-
- map = (f: (v: T) => T) => this.update((xs: T[]) => xs.map(f))
-}
diff --git a/src/util/transition.ts b/src/util/transition.ts
index 41517a86c..d47ce5fa6 100644
--- a/src/util/transition.ts
+++ b/src/util/transition.ts
@@ -1,3 +1,4 @@
+import {cubicOut} from "svelte/easing"
import * as t from "svelte/transition"
// Fly animation kills safari for some reason, use a modified fade instead
@@ -7,3 +8,42 @@ export const fly = window.safari
: t.fly
export const fade = t.fade
export const slide = t.slide
+
+// Copy-pasted and tweaked from slide source code
+export function slideAndFade(
+ node: any,
+ {delay = 0, duration = 400, easing = cubicOut, axis = "y"} = {},
+) {
+ const style = getComputedStyle(node)
+ const primary_property = axis === "y" ? "height" : "width"
+ const primary_property_value = parseFloat(style[primary_property])
+ const secondary_properties = axis === "y" ? ["top", "bottom"] : ["left", "right"]
+ const capitalized_secondary_properties = secondary_properties.map(
+ (e: string) => `${e[0].toUpperCase()}${e.slice(1)}`,
+ )
+ const padding_start_value = parseFloat(style[`padding${capitalized_secondary_properties[0]}`])
+ const padding_end_value = parseFloat(style[`padding${capitalized_secondary_properties[1]}`])
+ const margin_start_value = parseFloat(style[`margin${capitalized_secondary_properties[0]}`])
+ const margin_end_value = parseFloat(style[`margin${capitalized_secondary_properties[1]}`])
+ const border_width_start_value = parseFloat(
+ style[`border${capitalized_secondary_properties[0]}Width`],
+ )
+ const border_width_end_value = parseFloat(
+ style[`border${capitalized_secondary_properties[1]}Width`],
+ )
+ return {
+ delay,
+ duration,
+ easing,
+ css: (t: number) =>
+ "overflow: hidden;" +
+ `opacity: ${t};` +
+ `${primary_property}: ${t * primary_property_value}px;` +
+ `padding-${secondary_properties[0]}: ${t * padding_start_value}px;` +
+ `padding-${secondary_properties[1]}: ${t * padding_end_value}px;` +
+ `margin-${secondary_properties[0]}: ${t * margin_start_value}px;` +
+ `margin-${secondary_properties[1]}: ${t * margin_end_value}px;` +
+ `border-${secondary_properties[0]}-width: ${t * border_width_start_value}px;` +
+ `border-${secondary_properties[1]}-width: ${t * border_width_end_value}px;`,
+ }
+}