Re-work feeds using lib

This commit is contained in:
Jon Staab 2024-04-12 17:05:28 -07:00
parent 103e8ca1ab
commit e3e79cea04
10 changed files with 191 additions and 287 deletions

View File

@ -22,7 +22,7 @@
forcePlatformRelaySelections, forcePlatformRelaySelections,
} from "src/engine" } from "src/engine"
import {router} from "src/app/router" import {router} from "src/app/router"
import {feedCompiler} from "src/app/util" import {feedLoader} from "src/app/util"
export let feed export let feed
export let group = null export let group = null
@ -71,9 +71,10 @@
let subs = [] let subs = []
onMount(async () => { onMount(async () => {
const {filters} = await feedCompiler.compile(feed) const {filters} = await feedLoader.compiler.compile(feed)
const selections = getFilterSelections(filters) const selections = getFilterSelections(filters)
const subs = forcePlatformRelaySelections(selections).map(({relay, filters}) =>
subs = forcePlatformRelaySelections(selections).map(({relay, filters}) =>
subscribe({relays: [relay], filters, onEvent}), subscribe({relays: [relay], filters, onEvent}),
) )
}) })

View File

@ -11,7 +11,6 @@
import {FeedLoader} from "src/app/util" import {FeedLoader} from "src/app/util"
export let feed: Feed export let feed: Feed
export let relays = []
export let anchor = null export let anchor = null
export let eager = false export let eager = false
export let skipCache = false export let skipCache = false
@ -25,18 +24,22 @@
export let onEvent = null export let onEvent = null
let loader, element let loader, element
let limit = 0
let notes = readable([]) let notes = readable([])
const hideReplies = writable(Storage.getJson("hideReplies")) const hideReplies = writable(Storage.getJson("hideReplies"))
const loadMore = () => loader.load(20) const loadMore = () => {
limit += 5
if ($notes.length < limit) {
loader.load(20)
}
}
const start = () => { const start = () => {
loader?.stop()
loader = new FeedLoader({ loader = new FeedLoader({
feed, feed,
relays,
anchor, anchor,
skipCache, skipCache,
skipNetwork, skipNetwork,
@ -52,12 +55,6 @@
notes = loader.notes notes = loader.notes
} }
const updateFilter = newFilter => {
filter = newFilter
start()
}
const unsubHideReplies = hideReplies.subscribe($hideReplies => { const unsubHideReplies = hideReplies.subscribe($hideReplies => {
start() start()
Storage.setJson("hideReplies", $hideReplies) Storage.setJson("hideReplies", $hideReplies)
@ -69,27 +66,16 @@
return () => { return () => {
unsubHideReplies() unsubHideReplies()
scroller?.stop() scroller?.stop()
loader?.stop()
} }
}) })
</script> </script>
<FlexColumn xl bind:element> <FlexColumn xl bind:element>
{#await loader.config} {#each $notes.slice(0, limit) as note, i (note.id)}
<!-- pass --> <div in:fly={{y: 20}}>
{:then { filters }} <Note depth={$hideReplies ? 0 : 2} context={note.replies || []} {showGroup} {anchor} {note} />
{#each $notes as note, i (note.id)} </div>
<div in:fly={{y: 20}}> {/each}
<Note
depth={$hideReplies ? 0 : 2}
context={note.replies || []}
{filters}
{showGroup}
{anchor}
{note} />
</div>
{/each}
{/await}
</FlexColumn> </FlexColumn>
{#if !hideSpinner} {#if !hideSpinner}

View File

@ -1,19 +1,17 @@
import {partition, prop, uniqBy, without, assoc} from "ramda" import {partition, prop, uniqBy} from "ramda"
import {batch} from "hurdak" import {batch} from "hurdak"
import {now, writable} from "@coracle.social/lib" import {writable} from "@coracle.social/lib"
import type {Filter} from "@coracle.social/util"
import { import {
Tags, Tags,
getIdOrAddress, getIdOrAddress,
getIdAndAddress, getIdAndAddress,
getIdFilters, getIdFilters,
guessFilterDelta, isContextAddress,
decodeAddress,
} from "@coracle.social/util" } from "@coracle.social/util"
import type {Feed} from "@coracle.social/feeds" import type {Feed, Loader} from "@coracle.social/feeds"
import {FeedCompiler, Scope} from "@coracle.social/feeds" import {FeedLoader as CoreFeedLoader, FeedType, Scope} from "@coracle.social/feeds"
import {race} from "src/util/misc"
import {generatePrivateKey} from "src/util/nostr" import {generatePrivateKey} from "src/util/nostr"
import {info} from "src/util/logger"
import {LOCAL_RELAY_URL, reactionKinds, repostKinds} from "src/util/nostr" import {LOCAL_RELAY_URL, reactionKinds, repostKinds} from "src/util/nostr"
import type {DisplayEvent, Event} from "src/engine" import type {DisplayEvent, Event} from "src/engine"
import { import {
@ -21,15 +19,14 @@ import {
unwrapRepost, unwrapRepost,
isEventMuted, isEventMuted,
isDeleted, isDeleted,
primeWotCaches,
hints, hints,
forcePlatformRelays,
forcePlatformRelaySelections, forcePlatformRelaySelections,
forceRelaySelections,
addRepostFilters, addRepostFilters,
getFilterSelections, getFilterSelections,
tracker, tracker,
load, load,
subscribe,
MultiCursor,
dvmRequest, dvmRequest,
getFollowedPubkeys, getFollowedPubkeys,
getFollowers, getFollowers,
@ -39,65 +36,70 @@ import {
user, user,
} from "src/engine" } from "src/engine"
export const feedCompiler = new FeedCompiler({ const requestDvm = async ({kind, tags = [], onEvent}) => {
requestDvm: async ({request, onEvent}) => { const sk = generatePrivateKey()
const event = await dvmRequest({ const event = await dvmRequest({kind, tags, sk, timeout: 3000})
...request,
timeout: 3000,
sk: generatePrivateKey(),
})
if (event) { if (event) {
onEvent(event) onEvent(event)
}
}
const request = async ({relays, filters, onEvent}) => {
if (relays.length > 0) {
await load({filters, relays, onEvent})
} else {
await Promise.all(
getFilterSelections(filters).map(({relay, filters}) =>
load({filters, relays: [relay], onEvent}),
),
)
}
}
const getPubkeysForScope = (scope: string) => {
const $user = user.get()
switch (scope) {
case Scope.Self:
return $user ? [$user.pubkey] : []
case Scope.Follows:
return getFollowedPubkeys($user)
case Scope.Followers:
return Array.from(getFollowers($user.pubkey).map(p => p.pubkey))
default:
throw new Error(`Invalid scope ${scope}`)
}
}
const getPubkeysForWotRange = (min, max) => {
const pubkeys = []
const $user = user.get()
const thresholdMin = maxWot.get() * min
const thresholdMax = maxWot.get() * max
primeWotCaches($user.pubkey)
for (const person of people.get()) {
const score = getWotScore($user.pubkey, person.pubkey)
if (score >= thresholdMin && score <= thresholdMax) {
pubkeys.push(person.pubkey)
} }
}, }
request: async ({relays, filters, onEvent}) => {
if (relays.length > 0) {
await load({filters, relays, onEvent})
} else {
await Promise.all(
getFilterSelections(filters).map(({relay, filters}) =>
load({filters, relays: [relay], onEvent}),
),
)
}
},
getPubkeysForScope: (scope: string) => {
const $user = user.get()
switch (scope) { return pubkeys
case Scope.Self: }
return $user ? [$user.pubkey] : []
case Scope.Follows:
return getFollowedPubkeys($user)
case Scope.Followers:
return Array.from(getFollowers($user.pubkey).map(p => p.pubkey))
default:
throw new Error(`Invalid scope ${scope}`)
}
},
getPubkeysForWotRange: (min, max) => {
const pubkeys = []
const $user = user.get()
const thresholdMin = maxWot.get() * min
const thresholdMax = maxWot.get() * max
for (const person of people.get()) { export const feedLoader = new CoreFeedLoader({
const score = getWotScore($user.pubkey, person.pubkey) request,
requestDvm,
if (score >= thresholdMin && score <= thresholdMax) { getPubkeysForScope,
pubkeys.push(person.pubkey) getPubkeysForWotRange,
}
}
return pubkeys
},
}) })
export type FeedOpts = { export type FeedOpts = {
feed: Feed feed: Feed
relays: string[]
onEvent?: (e: Event) => void
anchor?: string anchor?: string
skipCache?: boolean skipCache?: boolean
skipNetwork?: boolean skipNetwork?: boolean
@ -108,108 +110,91 @@ export type FeedOpts = {
shouldHideReplies?: boolean shouldHideReplies?: boolean
shouldLoadParents?: boolean shouldLoadParents?: boolean
includeReposts?: boolean includeReposts?: boolean
onEvent?: (e: Event) => void
} }
export class FeedLoader { export class FeedLoader {
stopped = false done = false
config: Promise<{filters: Filter[]}> loader: Promise<Loader>
subs: Array<{close: () => void}> = []
buffer = writable<Event[]>([])
notes = writable<DisplayEvent[]>([]) notes = writable<DisplayEvent[]>([])
parents = new Map<string, DisplayEvent>() parents = new Map<string, DisplayEvent>()
reposts = new Map<string, Event[]>() reposts = new Map<string, Event[]>()
replies = new Map<string, Event[]>() replies = new Map<string, Event[]>()
cursor: MultiCursor
isEventMuted = isEventMuted.get() isEventMuted = isEventMuted.get()
isDeleted = isDeleted.get() isDeleted = isDeleted.get()
constructor(readonly opts: FeedOpts) { constructor(readonly opts: FeedOpts) {
this.config = this.start() // Use a custom feed loader so we can intercept the filters
} const feedLoader = new CoreFeedLoader({
requestDvm,
async start() { getPubkeysForScope,
const requestItem = await feedCompiler.compile(this.opts.feed) getPubkeysForWotRange,
const filters = request: async ({relays, filters, onEvent}) => {
this.opts.includeReposts && !requestItem.filters.some(f => f.authors?.length > 0) if (this.opts.includeReposts && !filters.some(f => f.authors?.length > 0)) {
? addRepostFilters(requestItem.filters) filters = addRepostFilters(filters)
: requestItem.filters
let relaySelections = []
if (requestItem.relays.length > 0) {
relaySelections = requestItem.relays.map(relay => ({relay, filters}))
} else if (!this.opts.skipNetwork) {
relaySelections = getFilterSelections(filters)
relaySelections = forceRelaySelections(relaySelections, this.opts.relays)
if (!this.opts.skipPlatform) {
relaySelections = forcePlatformRelaySelections(relaySelections)
}
}
if (!this.opts.skipCache && requestItem.relays.length === 0) {
relaySelections.push({relay: LOCAL_RELAY_URL, filters})
}
// No point in subscribing if we have an end date
if (this.opts.shouldListen && !filters.every(prop("until"))) {
this.addSubs(
relaySelections.map(({relay, filters}) =>
subscribe({
relays: [relay],
skipCache: true,
filters: filters.map(assoc("since", now())),
onEvent: batch(300, async (events: Event[]) => {
events = await this.discardEvents(events)
if (this.opts.shouldLoadParents) {
this.loadParents(events)
}
if (this.opts.shouldBuffer) {
this.buffer.update($buffer => $buffer.concat(events))
} else {
this.addToFeed(events, {prepend: true})
}
}),
}),
),
)
}
this.cursor = new MultiCursor({
relaySelections,
onEvent: batch(300, async events => {
if (this.opts.shouldLoadParents) {
this.loadParents(await this.discardEvents(events))
} }
}),
const promises = []
if (relays.length > 0) {
promises.push(load({filters, relays, onEvent}))
} else {
if (!this.opts.skipCache) {
promises.push(load({filters, relays: [LOCAL_RELAY_URL], onEvent}))
}
if (!this.opts.skipNetwork) {
let selections = getFilterSelections(filters)
if (!this.opts.skipPlatform) {
selections = forcePlatformRelaySelections(selections)
}
for (const {relay, filters} of selections) {
promises.push(load({filters, relays: [relay], onEvent}))
}
}
}
await Promise.all(promises)
},
}) })
const subs = this.addSubs(this.cursor.load(50)) this.loader = feedLoader.getLoader(opts.feed, {
onEvent: batch(300, async events => {
const keep = await this.discardEvents(events)
// Wait until at least one subscription has completed to reduce the chance of if (this.opts.shouldLoadParents) {
// out of order notes this.loadParents(keep)
if (subs.length > 1) { }
await race(
Math.min(2, subs.length),
subs.map(
s =>
new Promise(r => {
s.emitter.on("event", r)
s.emitter.on("complete", r)
}),
),
)
}
return {filters} const ok = this.deferOrphans(keep)
this.addToFeed(ok)
}),
onExhausted: () => {
this.done = true
},
})
} }
async discardEvents(events) { // Public api
subscribe = f => this.notes.subscribe(f)
load = (limit: number) => this.loader.then(loader => loader(limit))
// Event selection, deferral, and parent loading
discardEvents = async events => {
let strict = false
// Be more tolerant when looking at communities // Be more tolerant when looking at communities
const {filters} = await this.config feedLoader.compiler.walk(this.opts.feed, ([type, ...feed]) => {
const strict = filters.some(f => f["#a"]) if (type === FeedType.Filter) {
strict = feed.some(f => f["#a"]?.find(a => isContextAddress(decodeAddress(a))))
}
})
return events.filter(e => { return events.filter(e => {
if (this.isDeleted(e)) { if (this.isDeleted(e)) {
@ -263,15 +248,12 @@ export class FeedLoader {
return return
} }
const scenario = const selections = hints.merge(notesWithParent.map(hints.EventParents)).getSelections()
this.opts.relays.length > 0
? hints.product(Array.from(parentIds), this.opts.relays)
: hints.merge(notesWithParent.map(hints.EventParents))
for (const {relay, values} of scenario.getSelections()) { for (const {relay, values} of selections) {
load({ load({
relays: [relay],
filters: getIdFilters(values), filters: getIdFilters(values),
relays: this.opts.skipPlatform ? [relay] : forcePlatformRelays([relay]),
onEvent: batch(100, async events => { onEvent: batch(100, async events => {
for (const e of await this.discardEvents(events)) { for (const e of await this.discardEvents(events)) {
this.parents.set(e.id, e) this.parents.set(e.id, e)
@ -281,30 +263,34 @@ export class FeedLoader {
} }
} }
// Control deferOrphans = (notes: Event[]) => {
if (!this.opts.shouldLoadParents || this.opts.shouldDefer === false) {
addSubs(subs) { return notes
for (const sub of subs) {
this.subs.push(sub)
sub.emitter.on("complete", () => {
this.subs = without([sub], this.subs)
})
} }
return subs // If something has a parent id but we haven't found the parent yet, skip it until we have it.
} const [ok, defer] = partition(e => {
const parent = Tags.fromEvent(e).parent()
stop() { return !parent || this.parents.has(parent.value())
this.stopped = true }, notes)
for (const sub of this.subs) { setTimeout(() => this.addToFeed(defer), 3000)
sub.close()
} return ok
} }
// Feed building // Feed building
addToFeed = (notes: Event[], {prepend = false} = {}) => {
this.notes.update($notes => {
const chunk = this.buildFeedChunk(notes)
const combined = prepend ? [...chunk, ...$notes] : [...$notes, ...chunk]
return uniqBy(prop("id"), combined)
})
}
buildFeedChunk = (notes: Event[]) => { buildFeedChunk = (notes: Event[]) => {
const seen = new Set(this.notes.get().map(getIdOrAddress)) const seen = new Set(this.notes.get().map(getIdOrAddress))
const parents = [] const parents = []
@ -331,8 +317,8 @@ export class FeedLoader {
} }
} }
// Only replace parents for kind 1 replies // Only replace parents for kind 1 replies or reactions
if (e.kind !== 1) { if (!reactionKinds.concat(1).includes(e.kind)) {
return e return e
} }
@ -381,73 +367,4 @@ export class FeedLoader {
), ),
) )
} }
addToFeed = (notes: Event[], {prepend = false} = {}) => {
this.notes.update($notes => {
const chunk = this.buildFeedChunk(notes)
const combined = prepend ? [...chunk, ...$notes] : [...$notes, ...chunk]
return uniqBy(prop("id"), combined)
})
}
subscribe = f => this.notes.subscribe(f)
// Loading
async load(n) {
await this.config
if (this.cursor.done()) {
return
}
const [subs, events] = this.cursor.take(n)
this.addSubs(subs)
this.addToFeed(this.deferOrphans(await this.discardEvents(events)))
}
loadBuffer() {
this.buffer.update($buffer => {
this.addToFeed($buffer)
return []
})
}
deferOrphans = (notes: Event[]) => {
if (!this.opts.shouldLoadParents || this.opts.shouldDefer === false) {
return notes
}
// If something has a parent id but we haven't found the parent yet, skip it until we have it.
const [ok, defer] = partition(e => {
const parent = Tags.fromEvent(e).parent()
return !parent || this.parents.has(parent.value())
}, notes)
setTimeout(() => this.addToFeed(defer), 3000)
return ok
}
deferAncient = async (notes: Event[]) => {
const {filters} = await this.config
if (this.opts.shouldDefer === false) {
return notes
}
// Sometimes relays send very old data very quickly. Pop these off the queue and re-add
// them after we have more timely data. They still might be relevant, but order will still
// be maintained since everything before the cutoff will be deferred the same way.
const since = now() - guessFilterDelta(filters)
const [ok, defer] = partition(e => e.created_at > since, notes)
setTimeout(() => this.addToFeed(defer), 5000)
return ok
}
} }

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import {Tags} from "@coracle.social/util" import {Tags} from "@coracle.social/util"
import {Scope, filter} from "@coracle.social/feeds" import {Scope, filter, usingRelays} from "@coracle.social/feeds"
import {noteKinds} from "src/util/nostr" import {noteKinds} from "src/util/nostr"
import {theme} from "src/partials/state" import {theme} from "src/partials/state"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
@ -13,6 +13,10 @@
export let relays = [] export let relays = []
export let feed = filter({kinds: noteKinds, scopes: [Scope.Follows]}) export let feed = filter({kinds: noteKinds, scopes: [Scope.Follows]})
if (relays.length > 0) {
feed = usingRelays(relays, feed)
}
let key = Math.random() let key = Math.random()
const showLists = () => router.at("lists").open() const showLists = () => router.at("lists").open()
@ -26,10 +30,6 @@
const topics = tags.topics().valueOf() const topics = tags.topics().valueOf()
const urls = tags.values("r").valueOf() const urls = tags.values("r").valueOf()
if (urls.length > 0) {
relays = urls
}
if (authors.length > 0) { if (authors.length > 0) {
feed = filter({kinds: noteKinds, authors}) feed = filter({kinds: noteKinds, authors})
} else if (topics.length > 0) { } else if (topics.length > 0) {
@ -38,6 +38,10 @@
feed = filter({kinds: noteKinds, scopes: [Scope.Follows]}) feed = filter({kinds: noteKinds, scopes: [Scope.Follows]})
} }
if (urls.length > 0) {
feed = usingRelays(urls, feed)
}
key = Math.random() key = Math.random()
} }
@ -54,7 +58,7 @@
{/if} {/if}
{#key key} {#key key}
<Feed skipCache includeReposts showGroup {feed} {relays}> <Feed skipCache includeReposts showGroup {feed}>
<div slot="controls"> <div slot="controls">
{#if $canSign} {#if $canSign}
{#if $userLists.length > 0} {#if $userLists.length > 0}

View File

@ -34,7 +34,7 @@
const setActiveTab = tab => router.at("notifications").at(tab).push() const setActiveTab = tab => router.at("notifications").at(tab).push()
const loadMore = () => { const loadMore = async () => {
limit += 4 limit += 4
} }

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import {batch} from "hurdak" import {batch} from "hurdak"
import {Scope, filter} from "@coracle.social/feeds" import {filter, usingRelays} from "@coracle.social/feeds"
import {getAvgRating, noteKinds} from "src/util/nostr" import {getAvgRating, noteKinds} from "src/util/nostr"
import Feed from "src/app/shared/Feed.svelte" import Feed from "src/app/shared/Feed.svelte"
import Tabs from "src/partials/Tabs.svelte" import Tabs from "src/partials/Tabs.svelte"
@ -15,8 +15,8 @@
let reviews = [] let reviews = []
let activeTab = "notes" let activeTab = "notes"
url = normalizeRelayUrl(url) $: url = normalizeRelayUrl(url)
$: feed = usingRelays([url], feed)
$: rating = getAvgRating(reviews) $: rating = getAvgRating(reviews)
const relay = deriveRelay(url) const relay = deriveRelay(url)
@ -54,5 +54,5 @@
"#r": [$relay.url], "#r": [$relay.url],
})} /> })} />
{:else} {:else}
<Feed skipCache relays={[$relay.url]} {feed} /> <Feed skipCache {feed} />
{/if} {/if}

View File

@ -12,7 +12,7 @@
const sortedEvents = events.derived(sortEventsDesc) const sortedEvents = events.derived(sortEventsDesc)
const loadMore = () => { const loadMore = async () => {
limit += 50 limit += 50
} }

View File

@ -1,12 +1,8 @@
import {shuffle, splitAt} from "@coracle.social/lib" import {splitAt} from "@coracle.social/lib"
import type {Filter, RouterScenario, RouterScenarioOptions} from "@coracle.social/util" import type {Filter, RouterScenario, RouterScenarioOptions} from "@coracle.social/util"
import {isContextAddress, mergeFilters, getFilterId, decodeAddress} from "@coracle.social/util" import {isContextAddress, mergeFilters, getFilterId, decodeAddress} from "@coracle.social/util"
import {without, sortBy, prop} from "ramda" import {without, sortBy, prop} from "ramda"
import {switcherFn} from "hurdak"
import {env} from "src/engine/session/state"
import {user} from "src/engine/session/derived"
import {getSetting} from "src/engine/session/utils" import {getSetting} from "src/engine/session/utils"
import {getFollowedPubkeys, getNetwork} from "src/engine/people/utils"
import {hints} from "src/engine/relays/utils" import {hints} from "src/engine/relays/utils"
export const addRepostFilters = (filters: Filter[]) => export const addRepostFilters = (filters: Filter[]) =>

View File

@ -25,7 +25,7 @@
scroller = createScroller(loadMore, {element, reverse: true}) scroller = createScroller(loadMore, {element, reverse: true})
} }
const loadMore = () => { const loadMore = async () => {
limit += 10 limit += 10
} }

View File

@ -85,7 +85,7 @@ type ScrollerOpts = {
} }
export const createScroller = ( export const createScroller = (
loadMore: () => any, loadMore: () => Promise<void>,
{delay = 1000, threshold = 2000, reverse = false, element}: ScrollerOpts = {}, {delay = 1000, threshold = 2000, reverse = false, element}: ScrollerOpts = {},
) => { ) => {
let done = false let done = false