Use feed for note detail

This commit is contained in:
Jonathan Staab 2023-07-17 14:19:32 -07:00
parent 3d6730793b
commit 34fc5972f6
17 changed files with 198 additions and 169 deletions

View File

@ -1,9 +1,7 @@
# Current
- [ ] Refactor
- [ ] Remove load, use subscribe with autoClose. Add subscription class and put handlers on that.
- [ ] Cancel load requests as well as subscribes when navigating to save bandwidth. Get rid of load?
- [ ] Abort context load requests when the next page is requested?
- [ ] Add thread view
- [ ] Use limit 1 to find most recent notification, then load when visiting the notifications page.
- [ ] Remove external dependencies from engine, open source it?
- [ ] If connections fail, re-open and re-send active subs
@ -43,7 +41,6 @@
# Core
- [ ] Private groups
- [ ] Preload quotes
- [ ] Add custom emoji support
- [ ] Reminders for max time spent on coracle
- [ ] Proxy handle requests for CORS

View File

@ -38,6 +38,24 @@ const engine = createDefaultEngine({
})
export default engine
export const Alerts = engine.Alerts
export const Builder = engine.Builder
export const Content = engine.Content
export const Directory = engine.Directory
export const Events = engine.Events
export const Keys = engine.Keys
export const Meta = engine.Meta
export const Network = engine.Network
export const Nip02 = engine.Nip02
export const Nip04 = engine.Nip04
export const Nip05 = engine.Nip05
export const Nip28 = engine.Nip28
export const Nip57 = engine.Nip57
export const Nip65 = engine.Nip65
export const Outbox = engine.Outbox
export const PubkeyLoader = engine.PubkeyLoader
export const Storage = engine.Storage
export const User = engine.User
export const alerts = engine.Alerts
export const builder = engine.Builder
export const content = engine.Content

View File

@ -85,6 +85,7 @@
depth: 2,
relays: getRelays(),
filter: compileFilter(filter),
shouldLoadParents: true,
})
feed.start()

View File

@ -1,7 +1,6 @@
<script lang="ts">
import type {Event} from "src/engine/types"
import {onDestroy} from "svelte"
import {fly} from "src/util/transition"
import {warn} from "src/util/logger"
import {modal} from "src/partials/state"
import Anchor from "src/partials/Anchor.svelte"
import Card from "src/partials/Card.svelte"
@ -12,31 +11,24 @@
export let note
export let value
let quote = null
let muted = false
let loading = true
const openPerson = pubkey => modal.push({type: "person/feed", pubkey})
const loadQuote = () => {
const {id, relays = []} = value
const {id, relays = []} = value
return new Promise(async (resolve, reject) => {
try {
await network.load({
relays: nip65.mergeHints(3, [relays, nip65.getEventHints(3, note)]),
filter: [{ids: [id]}],
onEvent: event => {
muted = user.applyMutes([event]).length === 0
resolve(event)
},
})
} catch (e) {
warn(e)
}
reject()
}) as Promise<Event>
}
const sub = network.subscribe({
timeout: 5000,
relays: nip65.mergeHints(3, [relays, nip65.getEventHints(3, note)]),
filter: [{ids: [id]}],
onEvent: event => {
loading = false
muted = user.applyMutes([event]).length === 0
quote = event
},
})
const openQuote = e => {
// stopPropagation wasn't working for some reason
@ -48,15 +40,19 @@
const unmute = e => {
muted = false
}
onDestroy(() => {
sub.close()
})
</script>
<div class="py-2">
<Card interactive invertColors class="my-2" on:click={openQuote}>
{#await loadQuote()}
{#if loading}
<div class="px-20">
<Spinner />
</div>
{:then quote}
{:else if quote}
{#if muted}
<p class="mb-1 py-24 text-center text-gray-5" in:fly={{y: 20}}>
You have muted this note.
@ -75,10 +71,10 @@
</div>
<slot name="note-content" {quote} />
{/if}
{:catch}
{:else}
<p class="mb-1 py-24 text-center text-gray-5" in:fly={{y: 20}}>
Unable to load a preview for quoted event
</p>
{/await}
{/if}
</Card>
</div>

View File

@ -12,12 +12,11 @@
let pubkeys = []
onMount(async () => {
onMount(() => {
if (type === "follows") {
pubkeys = nip02.getFollows(pubkey)
} else {
await network.load({
shouldProcess: false,
const sub = network.subscribe({
relays: nip65.getPubkeyHints(user.getSetting("relay_limit"), pubkey, "read"),
filter: {kinds: [3], "#p": [pubkey]},
onEvent: batch(500, events => {
@ -28,6 +27,8 @@
pubkeys = uniq(pubkeys.concat(newPubkeys))
}),
})
return sub.close
}
})
</script>

View File

@ -1,5 +1,5 @@
<script lang="ts">
import {onMount} from "svelte"
import {onDestroy} from "svelte"
import {fly} from "src/util/transition"
import {tweened} from "svelte/motion"
import {numberFmt, batch} from "src/util/misc"
@ -11,17 +11,17 @@
const followsCount = nip02.graph.key(pubkey).derived(() => nip02.getFollowsSet(pubkey).size)
const interpolate = (a, b) => t => a + Math.round((b - a) * t)
let sub
let canLoadFollowers = true
let followersCount = tweened(0, {interpolate, duration: 1000})
const showFollows = () => {
modal.push({type: "person/follows", pubkey})
}
const showFollows = () => modal.push({type: "person/follows", pubkey})
const showFollowers = () => {
modal.push({type: "person/followers", pubkey})
}
const showFollowers = () => modal.push({type: "person/followers", pubkey})
const loadFollowers = async () => {
canLoadFollowers = false
onMount(async () => {
// Get our followers count
const count = await network.count({kinds: [3], "#p": [pubkey]})
@ -30,9 +30,10 @@
} else {
const followers = new Set()
await network.load({
relays: nip65.getPubkeyHints(3, user.getPubkey(), "read"),
sub = network.subscribe({
timeout: 30_000,
shouldProcess: false,
relays: nip65.getPubkeyHints(3, user.getPubkey(), "read"),
filter: [{kinds: [3], "#p": [pubkey]}],
onEvent: batch(300, events => {
for (const e of events) {
@ -43,6 +44,10 @@
}),
})
}
}
onDestroy(() => {
sub?.close()
})
</script>
@ -51,6 +56,15 @@
<strong>{$followsCount}</strong> following
</button>
<button on:click={showFollowers}>
<strong>{numberFmt.format($followersCount)}</strong> followers
<strong>
{#if canLoadFollowers}
<i class="fa fa-download mr-1" on:click|stopPropagation={loadFollowers} />
{:else if $followersCount === 0}
<i class="fa fa-circle-notch fa-spin mr-1" />
{:else}
{numberFmt.format($followersCount)}
{/if}
</strong>
followers
</button>
</div>

View File

@ -32,7 +32,7 @@
}
onMount(() => {
network.load({
const sub = network.subscribe({
relays: nip65.getPubkeyHints(3, user.getPubkey(), "read"),
filter: {
limit: 1000,
@ -44,6 +44,8 @@
reviews = reviews.concat(event)
},
})
return sub.close
})
</script>

View File

@ -114,9 +114,9 @@ export const listen = async () => {
;(listen as any)._listener = await network.subscribe({
relays: user.getRelayUrls("read"),
filter: [
{kinds: noteKinds.concat(4), authors: [pubkey], since},
{kinds, "#p": [pubkey], since},
{kinds, "#e": eventIds, since},
{kinds: noteKinds.concat(4), authors: [pubkey], since, limit: 1},
{kinds, "#p": [pubkey], since, limit: 1},
{kinds, "#e": eventIds, since, limit: 1},
{kinds: [42], "#e": channelIds, since},
],
onEvent: batch(3000, events => {

View File

@ -5,7 +5,6 @@
import Content from "src/partials/Content.svelte"
import Anchor from "src/partials/Anchor.svelte"
import NoteContent from "src/app/shared/NoteContent.svelte"
import Spinner from "src/partials/Spinner.svelte"
import {directory, nip65, network} from "src/app/engine"
export let identifier
@ -14,12 +13,12 @@
export let relays = []
let note
let loading = true
const display = directory.displayPubkey(pubkey)
onMount(async () => {
await network.load({
const sub = network.subscribe({
timeout: 30_000,
relays: nip65.selectHints(3, relays),
filter: {kinds: [kind], pubkey, "#d": [identifier]},
onEvent: event => {
@ -27,7 +26,7 @@
},
})
loading = false
return sub.close
})
</script>
@ -56,7 +55,5 @@
{/if}
{/each}
</ul>
{:else if loading}
<Spinner />
{/if}
</Content>

View File

@ -1,5 +1,5 @@
<script>
import {propEq} from "ramda"
import {propEq, find} from "ramda"
import {onMount, onDestroy} from "svelte"
import {fly} from "src/util/transition"
import {isMobile} from "src/util/html"
@ -15,8 +15,8 @@
export let relays = []
export let invertColors = false
console.log(nip65.selectHints(3, relays))
const feed = network.feed({
limit: 1,
depth: 6,
relays: nip65.selectHints(3, relays),
filter: {ids: [note.id]},
@ -31,17 +31,21 @@
let loading = true
let feedRelay = null
let displayNote = feed.feed.derived($feed => {
console.log($feed)
const found = find(propEq("id", note.id), $feed)
//if (found) {
loading = false
//}
if (found) {
loading = false
}
return asDisplayEvent(found || note)
})
onMount(() => {
// If our note came from a feed, we can load faster
if (note.replies) {
feed.hydrate([note])
}
feed.start()
feed.loadAll()
})

View File

@ -14,7 +14,7 @@
import Tabs from "src/partials/Tabs.svelte"
import Content from "src/partials/Content.svelte"
import Notification from "src/app/views/Notification.svelte"
import engine, {user, alerts} from "src/app/engine"
import {Events, user, alerts} from "src/app/engine"
const {lastChecked} = alerts
const tabs = ["Mentions & Replies", "Reactions"]
@ -39,7 +39,7 @@
({notifications}) => -notifications.reduce((a, b) => Math.max(a, b.created_at), 0),
$notifications
.slice(0, limit)
.map(e => [e, engine.events.cache.key(findReplyId(e)).get()])
.map(e => [e, Events.cache.key(findReplyId(e)).get()])
.filter(([e, ref]) => {
if (ref && !noteKinds.includes(ref.kind)) {
return false

View File

@ -24,14 +24,16 @@
// If we have a query, search using nostr.band. If not, ask for random profiles.
// This allows us to populate results even if search isn't supported by forced urls
if (q.length > 2) {
network.load({
network.subscribe({
relays: nip65.getSearchRelays(),
filter: [{kinds: [0], search, limit: 10}],
timeout: 3000,
})
} else if (directory.profiles.get().length < 50) {
network.load({
network.subscribe({
relays: user.getRelayUrls("read"),
filter: [{kinds: [0], limit: 50}],
timeout: 3000,
})
}
})

View File

@ -7,6 +7,7 @@ import {warn, error, log} from "src/util/logger"
import {normalizeRelayUrl} from "src/util/nostr"
import type {Event, Filter} from "src/engine/types"
import {Cursor, MultiCursor} from "src/engine/util/Cursor"
import {Subscription} from "src/engine/util/Subscription"
import {Feed} from "src/engine/util/Feed"
export type SubscribeOpts = {
@ -14,7 +15,7 @@ export type SubscribeOpts = {
filter: Filter[] | Filter
onEvent?: (event: Event) => void
onEose?: (url: string) => void
autoClose?: boolean
timeout?: number
shouldProcess?: boolean
}
@ -183,14 +184,15 @@ export class Network {
const subscribe = ({
relays,
filter,
onEvent,
onEose,
autoClose,
onEvent,
timeout,
shouldProcess = true,
}: SubscribeOpts) => {
const urls = getUrls(relays)
const executor = getExecutor(urls)
const filters = ensurePlural(filter)
const subscription = new Subscription()
const now = Date.now()
const seen = new Map()
const eose = new Set()
@ -199,26 +201,14 @@ export class Network {
Network.emitter.emit("sub:open", urls)
let closed = false
const closeListeners = []
subscription.on("close", () => {
sub.unsubscribe()
executor.target.cleanup()
Network.emitter.emit("sub:close", urls)
})
const close = () => {
if (!closed) {
sub.unsubscribe()
executor.target.cleanup()
Network.emitter.emit("sub:close", urls)
for (const f of closeListeners) {
f()
}
}
closed = true
}
// If autoClose is set but we don't get an eose, make sure we clean up after ourselves
if (autoClose) {
setTimeout(close, 30_000)
if (timeout) {
setTimeout(subscription.close, timeout)
}
const sub = executor.subscribe(filters, {
@ -260,7 +250,7 @@ export class Network {
Events.queue.push(event)
}
onEvent(event)
onEvent?.(event)
},
onEose: url => {
onEose?.(url)
@ -272,77 +262,13 @@ export class Network {
eose.add(url)
if (autoClose && eose.size === relays.length) {
close()
if (timeout && eose.size === relays.length) {
subscription.close()
}
},
})
return {
close,
isClosed: () => closed,
onClose: f => closeListeners.push(f),
}
}
const load = ({
relays,
filter,
onEvent = null,
shouldProcess = true,
timeout = 5000,
}: {
relays: string[]
filter: Filter | Filter[]
onEvent?: (event: Event) => void
shouldProcess?: boolean
timeout?: number
}) => {
return new Promise(resolve => {
let completed = false
const eose = new Set()
const allEvents = []
const attemptToComplete = force => {
// If we've already unsubscribed we're good
if (completed) {
return
}
const isDone = eose.size === relays.length
if (force) {
relays.forEach(url => {
if (!eose.has(url)) {
Network.pool.emit("timeout", url)
}
})
}
if (isDone || force) {
sub.close()
resolve(allEvents)
completed = true
}
}
// If a relay takes too long, give up
setTimeout(() => attemptToComplete(true), timeout)
const sub = subscribe({
relays,
filter,
shouldProcess,
onEvent: event => {
onEvent?.(event)
allEvents.push(event)
},
onEose: url => {
eose.add(url)
attemptToComplete(false)
},
})
}) as Promise<Event[]>
return subscription
}
const count = async filter => {
@ -362,13 +288,13 @@ export class Network {
})
}
const cursor = opts => new Cursor({...opts, load})
const cursor = opts => new Cursor({...opts, subscribe})
const multiCursor = ({relays, ...opts}) =>
new MultiCursor(relays.map(relay => cursor({relay, ...opts})))
const feed = opts => new Feed({engine, ...opts})
return {subscribe, publish, load, count, cursor, multiCursor, feed}
return {subscribe, publish, count, cursor, multiCursor, feed}
}
}

View File

@ -68,9 +68,10 @@ export class PubkeyLoader {
await Promise.all(
chunk(256, pubkeys).map(async chunk => {
await Network.load({
await Network.subscribe({
relays: getChunkRelays(chunk),
filter: getChunkFilter(chunk),
timeout: 3000,
})
})
)

View File

@ -1,4 +1,4 @@
import {mergeLeft, identity, sortBy} from "ramda"
import {all, prop, mergeLeft, identity, sortBy} from "ramda"
import {ensurePlural, first} from "hurdak/lib/hurdak"
import {now} from "src/util/misc"
import type {Filter, Event} from "../types"
@ -11,11 +11,13 @@ export type CursorOpts = {
}
export class Cursor {
done: boolean
until: number
buffer: Event[]
loading: boolean
constructor(readonly opts: CursorOpts) {
this.done = false
this.until = now()
this.buffer = []
this.loading = false
@ -25,7 +27,7 @@ export class Cursor {
const limit = n - this.buffer.length
// If we're already loading, or we have enough buffered, do nothing
if (this.loading || limit <= 0) {
if (this.done || this.loading || limit <= 0) {
return null
}
@ -34,6 +36,8 @@ export class Cursor {
this.loading = true
let count = 0
return this.opts.subscribe({
autoClose: true,
relays: [relay],
@ -42,10 +46,13 @@ export class Cursor {
this.until = Math.min(until, event.created_at)
this.buffer.push(event)
count += 1
onEvent?.(event)
},
onEose: () => {
this.loading = false
this.done = count < limit
},
})
}
@ -80,6 +87,10 @@ export class MultiCursor {
return this.#cursors.map(c => c.load(limit)).filter(identity)
}
done() {
return all(prop("done"), this.#cursors)
}
count() {
return this.#cursors.reduce((n, c) => n + c.buffer.length, 0)
}

View File

@ -1,6 +1,7 @@
import {matchFilters} from "nostr-tools"
import {throttle} from "throttle-debounce"
import {
omit,
pluck,
partition,
identity,
@ -22,7 +23,10 @@ import type {Collection} from "./store"
import {Cursor, MultiCursor} from "./Cursor"
import type {Event, DisplayEvent, Filter} from "../types"
const fromDisplayEvent = omit(["zaps", "likes", "replies", "matchesFilter"])
export type FeedOpts = {
limit?: number
depth: number
relays: string[]
filter: Filter | Filter[]
@ -37,11 +41,11 @@ export class Feed {
context: Collection<Event>
feed: Collection<DisplayEvent>
seen: Set<string>
subs: Record<string, Array<() => void>>
subs: Record<string, Array<{close: () => void}>>
cursor: MultiCursor
constructor(readonly opts: FeedOpts) {
this.limit = 20
this.limit = opts.limit || 20
this.since = now()
this.stopped = false
this.deferred = []
@ -62,7 +66,7 @@ export class Feed {
for (const sub of ensurePlural(subs)) {
this.subs[key].push(sub)
sub.onClose(() => {
sub.on("close", () => {
this.subs[key] = without([sub], this.subs[key])
})
}
@ -81,6 +85,10 @@ export class Feed {
}
isMissingParent = e => {
if (!this.opts.shouldLoadParents) {
return false
}
const parentId = findReplyId(e)
return parentId && this.matchFilters(e) && !this.context.key(parentId).exists()
@ -220,7 +228,7 @@ export class Feed {
return
}
this.subs.listeners.forEach(unsubscribe => unsubscribe())
this.subs.listeners.forEach(sub => sub.close())
const contextByParentId = groupBy(findReplyId, this.context.get())
@ -251,7 +259,7 @@ export class Feed {
this.loadPubkeys(events)
if (shouldLoadParents) {
if (this.opts.shouldLoadParents && shouldLoadParents) {
this.loadParents(events)
}
@ -264,7 +272,7 @@ export class Feed {
start() {
const {since} = this
const {relays, filter, engine} = this.opts
const {relays, filter, engine, depth} = this.opts
// No point in subscribing if we have an end date
if (!all(prop("until"), ensurePlural(filter))) {
@ -273,7 +281,7 @@ export class Feed {
relays,
filter: ensurePlural(filter).map(assoc("since", since)),
onEvent: batch(1000, context =>
this.addContext(context, {shouldLoadParents: true, depth: 6})
this.addContext(context, {shouldLoadParents: true, depth})
),
}),
])
@ -287,7 +295,7 @@ export class Feed {
filter,
subscribe: engine.Network.subscribe,
onEvent: batch(100, context =>
this.addContext(context, {shouldLoadParents: true, depth: 6})
this.addContext(context, {shouldLoadParents: true, depth})
),
})
)
@ -305,6 +313,32 @@ export class Feed {
}
}
hydrate(feed) {
const {depth} = this.opts
const notes = []
const context = []
const addContext = ({zaps, replies, reactions, ...note}) => {
context.push(fromDisplayEvent(note))
zaps.map(zap => context.push(zap))
reactions.map(reaction => context.push(reaction))
replies.map(addContext)
}
feed.forEach(note => {
addContext(note)
notes.push(fromDisplayEvent(note))
})
this.feed.set(notes)
this.addContext(context, {depth})
}
// Loading
async load() {
// If we don't have a decent number of notes yet, try to get enough
// to avoid out of order notes
@ -328,6 +362,12 @@ export class Feed {
this.addToFeed(ok)
}
async loadAll() {
while (!this.cursor.done()) {
await this.load()
}
}
deferReactions = notes => {
const [defer, ok] = partition(e => !this.isTextNote(e) && this.isMissingParent(e), notes)
@ -337,7 +377,7 @@ export class Feed {
this.addToFeed(ready)
this.deferred = this.deferred.concat(orphans)
}, 3000)
}, 1500)
return ok
}
@ -346,7 +386,7 @@ export class Feed {
// If something has a parent id but we haven't found the parent yet, skip it until we have it.
const [defer, ok] = partition(e => this.isTextNote(e) && this.isMissingParent(e), notes)
setTimeout(() => this.addToFeed(defer), 3000)
setTimeout(() => this.addToFeed(defer), 1500)
return ok
}

View File

@ -0,0 +1,19 @@
import EventEmitter from "events"
export class Subscription extends EventEmitter {
closed: boolean
constructor() {
super()
this.closed = false
}
close = () => {
if (!this.closed) {
this.closed = true
this.emit("close")
this.removeAllListeners()
}
}
}