mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-30 00:41:12 +00:00
Move some stuff around
This commit is contained in:
parent
7eb8caf0b1
commit
8e80a32b0d
11
README.md
11
README.md
@ -1,5 +1,6 @@
|
||||
Bugs
|
||||
|
||||
- [ ] Completely redo notes fetching, it's buggy as heck
|
||||
- [ ] Add alerts for replies to posts the user liked
|
||||
- [ ] Support bech32 keys/add guide on how to convert
|
||||
- [ ] Loading icon not showing at bottom
|
||||
@ -14,19 +15,13 @@ Features
|
||||
- [x] Threads/social
|
||||
- [x] Search
|
||||
- [ ] Mentions
|
||||
- [x] Notifications
|
||||
- [x] Link previews
|
||||
- [x] Add notes, follows, likes tab to profile
|
||||
- [ ] Notifications
|
||||
- [ ] Images
|
||||
- [ ] An actual readme
|
||||
- [ ] Server discovery and relay publishing - https://github.com/nostr-protocol/nips/pull/32/files
|
||||
- [ ] Favorite chat rooms
|
||||
- [ ] Optimistically load events the user publishes (e.g. to reduce reflow for reactions/replies).
|
||||
- Essentially, we can pretend to be our own in-memory relay.
|
||||
- This allows us to keep a copy of all user data, and possibly user likes/reply parents
|
||||
|
||||
Nostr implementation comments
|
||||
|
||||
- [ ] It's impossible to get deletes for an event's replies/mentions in one query, since deletes can't tag anything other than what is to be deleted.
|
||||
- [ ] Recursive queries are really painful, e.g. to get all notes for an account, you need to 1. get the account's notes, then get everything with those notes in their tags, then get deletions for those.
|
||||
- [ ] The limit of 3 channels makes things difficult. I want to show a modal without losing all the state in the background. I am reserving one channel for one-off recursive queries.
|
||||
- [ ] Why no spaces in names? Seems user hostile
|
||||
|
@ -4,8 +4,9 @@
|
||||
import {uniqBy, sortBy, prop, identity} from 'ramda'
|
||||
import Spinner from "src/partials/Spinner.svelte"
|
||||
import Note from "src/partials/Note.svelte"
|
||||
import {createScroller, notesListener, annotateNotes} from "src/util/notes"
|
||||
import {Cursor, epoch, findReply, channels} from 'src/state/nostr'
|
||||
import {createScroller, notesListener, annotateNotes, modal} from "src/state/app"
|
||||
import {modal} from "src/state/app"
|
||||
|
||||
export let author
|
||||
export let notes
|
||||
|
@ -2,8 +2,9 @@
|
||||
import {onMount} from 'svelte'
|
||||
import {writable} from 'svelte/store'
|
||||
import Spinner from 'src/partials/Spinner.svelte'
|
||||
import {notesListener, annotateNotes} from "src/util/notes"
|
||||
import {channels} from "src/state/nostr"
|
||||
import {notesListener, annotateNotes, modal} from "src/state/app"
|
||||
import {modal} from "src/state/app"
|
||||
import {user} from "src/state/user"
|
||||
import Note from 'src/partials/Note.svelte'
|
||||
|
||||
|
@ -2,11 +2,11 @@
|
||||
import {onMount, onDestroy} from 'svelte'
|
||||
import {fly} from 'svelte/transition'
|
||||
import {uniqBy, sortBy, reject, prop} from 'ramda'
|
||||
import {createScroller, getMuffleValue, threadify, notesListener} from "src/util/notes"
|
||||
import Spinner from "src/partials/Spinner.svelte"
|
||||
import Note from "src/partials/Note.svelte"
|
||||
import {Cursor, epoch, filterTags} from 'src/state/nostr'
|
||||
import {timedelta} from 'src/util/misc'
|
||||
import {createScroller, getMuffleValue, threadify, notesListener, modal} from "src/state/app"
|
||||
import {modal} from "src/state/app"
|
||||
|
||||
export let filter
|
||||
export let notes
|
||||
|
@ -40,7 +40,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class={cx("flex flex-col bg-dark w-full sm:w-56 h-full fixed py-8 border-r border-solid border-r-medium", className)}>
|
||||
<div class={cx("flex flex-col bg-dark w-full sm:w-56 h-full fixed z-10 py-8 border-r border-solid border-r-medium", className)}>
|
||||
<div class="my-4 mx-3">
|
||||
<Input bind:value={q} type="text" placeholder="Search rooms">
|
||||
<i slot="before" class="fa-solid fa-search" />
|
||||
|
@ -4,10 +4,11 @@
|
||||
import {writable} from 'svelte/store'
|
||||
import {sortBy, uniqBy, prop} from 'ramda'
|
||||
import {now} from 'src/util/misc'
|
||||
import {annotateAlerts, notesListener, createScroller} from 'src/util/notes'
|
||||
import {ellipsize} from 'hurdak/src/core'
|
||||
import {user} from 'src/state/user'
|
||||
import {Cursor, epoch} from 'src/state/nostr'
|
||||
import {alerts, annotateAlerts, notesListener, createScroller, modal} from 'src/state/app'
|
||||
import {alerts, modal} from 'src/state/app'
|
||||
import Spinner from "src/partials/Spinner.svelte"
|
||||
import UserBadge from "src/partials/UserBadge.svelte"
|
||||
import Note from 'src/partials/Note.svelte'
|
||||
|
@ -5,9 +5,10 @@
|
||||
import {prop, uniq, pluck, reverse, uniqBy, sortBy, last} from 'ramda'
|
||||
import {formatTimestamp} from 'src/util/misc'
|
||||
import {toHtml} from 'src/util/html'
|
||||
import {createScroller} from 'src/util/notes'
|
||||
import UserBadge from 'src/partials/UserBadge.svelte'
|
||||
import {Listener, Cursor, epoch} from 'src/state/nostr'
|
||||
import {accounts, createScroller, ensureAccounts} from 'src/state/app'
|
||||
import {accounts, ensureAccounts} from 'src/state/app'
|
||||
import {dispatch} from 'src/state/dispatch'
|
||||
import {user} from 'src/state/user'
|
||||
import RoomList from "src/partials/chat/RoomList.svelte"
|
||||
@ -149,7 +150,7 @@
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="fixed top-0 pt-20 w-full sm:-ml-56 sm:pl-60 p-4 border-b border-solid border-medium bg-dark flex gap-4">
|
||||
<div class="fixed z-10 top-0 pt-20 w-full sm:-ml-56 sm:pl-60 p-4 border-b border-solid border-medium bg-dark flex gap-4">
|
||||
<div
|
||||
class="overflow-hidden w-12 h-12 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
|
||||
style="background-image: url({roomData.picture})" />
|
||||
@ -165,7 +166,7 @@
|
||||
<div>{roomData.about || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed bottom-0 w-full sm:-ml-56 sm:pl-56 flex bg-medium border-medium border-t border-solid border-dark">
|
||||
<div class="fixed z-10 bottom-0 w-full sm:-ml-56 sm:pl-56 flex bg-medium border-medium border-t border-solid border-dark">
|
||||
<textarea
|
||||
rows="4"
|
||||
autofocus
|
||||
|
@ -4,13 +4,14 @@
|
||||
import {fly} from 'svelte/transition'
|
||||
import {uniqBy, pluck, prop} from 'ramda'
|
||||
import {fuzzy} from "src/util/misc"
|
||||
import {createScroller, annotateNotes} from "src/util/notes"
|
||||
import Anchor from "src/partials/Anchor.svelte"
|
||||
import Input from "src/partials/Input.svelte"
|
||||
import Spinner from "src/partials/Spinner.svelte"
|
||||
import Note from "src/partials/Note.svelte"
|
||||
import {relays, Cursor} from "src/state/nostr"
|
||||
import {user} from "src/state/user"
|
||||
import {createScroller, ensureAccounts, accounts, annotateNotes, modal} from "src/state/app"
|
||||
import {ensureAccounts, accounts, modal} from "src/state/app"
|
||||
|
||||
export let type
|
||||
|
||||
|
@ -4,9 +4,10 @@
|
||||
import {fly} from 'svelte/transition'
|
||||
import Button from "src/partials/Button.svelte"
|
||||
import SelectButton from "src/partials/SelectButton.svelte"
|
||||
import {getMuffleValue} from "src/util/notes"
|
||||
import {user} from 'src/state/user'
|
||||
import {dispatch, t} from 'src/state/dispatch'
|
||||
import {modal, getMuffleValue} from "src/state/app"
|
||||
import {modal} from "src/state/app"
|
||||
|
||||
const muffleOptions = ['Never', 'Sometimes', 'Often', 'Always']
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import {find} from 'ramda'
|
||||
import {writable} from 'svelte/store'
|
||||
import {fly} from 'svelte/transition'
|
||||
import {navigate} from 'svelte-routing'
|
||||
@ -8,7 +9,7 @@
|
||||
import Button from "src/partials/Button.svelte"
|
||||
import {user as currentUser} from 'src/state/user'
|
||||
import {t, dispatch} from 'src/state/dispatch'
|
||||
import {accounts, getFollow, modal} from "src/state/app"
|
||||
import {accounts, modal} from "src/state/app"
|
||||
|
||||
export let pubkey
|
||||
export let activeTab
|
||||
@ -19,7 +20,7 @@
|
||||
const network = writable([])
|
||||
const authors = $currentUser ? $currentUser.petnames.map(t => t[1]) : []
|
||||
|
||||
let following = getFollow(pubkey)
|
||||
let following = $currentUser && find(t => t[1] === pubkey, $currentUser.petnames)
|
||||
|
||||
const setActiveTab = tab => navigate(`/users/${pubkey}/${tab}`)
|
||||
|
||||
@ -81,7 +82,7 @@
|
||||
</div>
|
||||
<Tabs tabs={['notes', 'likes', 'network']} {activeTab} {setActiveTab} />
|
||||
{#if activeTab === 'notes'}
|
||||
<Notes notes={notes} filter={{kinds: [1], authors: [pubkey]}} />
|
||||
<Notes notes={notes} filter={console.log({kinds: [1], authors: [pubkey]}) || {kinds: [1], authors: [pubkey]}} />
|
||||
{:else if activeTab === 'likes'}
|
||||
<Likes notes={likes} author={pubkey} />
|
||||
{:else if activeTab === 'network'}
|
||||
|
311
src/state/app.js
311
src/state/app.js
@ -1,12 +1,11 @@
|
||||
import {identity, uniq, concat, propEq, uniqBy, prop, groupBy, find, last, pluck} from 'ramda'
|
||||
import {debounce} from 'throttle-debounce'
|
||||
import {uniq} from 'ramda'
|
||||
import {writable, get} from 'svelte/store'
|
||||
import {navigate} from "svelte-routing"
|
||||
import {globalHistory} from "svelte-routing/src/history"
|
||||
import {switcherFn, createMap} from 'hurdak/lib/hurdak'
|
||||
import {getLocalJson, setLocalJson, now, timedelta, sleep} from "src/util/misc"
|
||||
import {switcherFn} from 'hurdak/lib/hurdak'
|
||||
import {getLocalJson, setLocalJson, now, timedelta} from "src/util/misc"
|
||||
import {user} from 'src/state/user'
|
||||
import {epoch, filterMatches, Listener, channels, relays, findReply, findRoot} from 'src/state/nostr'
|
||||
import {channels, relays} from 'src/state/nostr'
|
||||
|
||||
export const modal = {
|
||||
subscribe: cb => {
|
||||
@ -113,305 +112,3 @@ export const ensureAccounts = async (pubkeys, {force = false} = {}) => {
|
||||
// Keep our user in sync
|
||||
user.update($user => $user ? {...$user, ...get(accounts)[$user.pubkey]} : null)
|
||||
}
|
||||
|
||||
export const getFollow = pubkey => {
|
||||
const $user = get(user)
|
||||
|
||||
return $user && find(t => t[1] === pubkey, $user.petnames)
|
||||
}
|
||||
|
||||
export const getMuffleValue = pubkey => {
|
||||
const $user = get(user)
|
||||
|
||||
if (!$user) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const tag = find(t => t[1] === pubkey, $user.muffle)
|
||||
|
||||
if (!tag) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return parseFloat(last(tag))
|
||||
}
|
||||
|
||||
// Notes
|
||||
|
||||
export const threadify = async notes => {
|
||||
if (notes.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const noteIds = pluck('id', notes)
|
||||
const rootIds = notes.map(findReply)
|
||||
const parentIds = notes.map(findRoot)
|
||||
const ancestorIds = concat(rootIds, parentIds).filter(identity)
|
||||
|
||||
// Find all direct parents and thread roots
|
||||
const filters = ancestorIds.length === 0
|
||||
? [{kinds: [1, 7], '#e': noteIds}]
|
||||
: [{kinds: [1], ids: ancestorIds},
|
||||
{kinds: [1, 7], '#e': noteIds.concat(ancestorIds)}]
|
||||
|
||||
const events = await channels.getter.all(filters)
|
||||
|
||||
await ensureAccounts(uniq(pluck('pubkey', notes.concat(events))))
|
||||
|
||||
const $accounts = get(accounts)
|
||||
const reactionsByParent = groupBy(findReply, events.filter(propEq('kind', 7)))
|
||||
const allNotes = uniqBy(prop('id'), notes.concat(events.filter(propEq('kind', 1))))
|
||||
const notesById = createMap('id', allNotes)
|
||||
const notesByRoot = groupBy(
|
||||
n => {
|
||||
const rootId = findRoot(n)
|
||||
const parentId = findReply(n)
|
||||
|
||||
// Actually dereference the notes in case we weren't able to retrieve them
|
||||
if (notesById[rootId]) {
|
||||
return rootId
|
||||
}
|
||||
|
||||
if (notesById[parentId]) {
|
||||
return parentId
|
||||
}
|
||||
|
||||
return n.id
|
||||
},
|
||||
allNotes
|
||||
)
|
||||
|
||||
const threads = []
|
||||
for (const [rootId, _notes] of Object.entries(notesByRoot)) {
|
||||
const annotate = note => {
|
||||
return {
|
||||
...note,
|
||||
user: $accounts[note.pubkey],
|
||||
reactions: reactionsByParent[note.id] || [],
|
||||
children: uniqBy(prop('id'), _notes.filter(n => findReply(n) === note.id)).map(annotate),
|
||||
}
|
||||
}
|
||||
|
||||
threads.push(annotate(notesById[rootId]))
|
||||
}
|
||||
|
||||
return threads
|
||||
}
|
||||
|
||||
export const annotateNotes = async (notes, {showParent = false} = {}) => {
|
||||
if (notes.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const noteIds = pluck('id', notes)
|
||||
const parentIds = notes.map(findReply).filter(identity)
|
||||
const filters = [{kinds: [1, 7], '#e': noteIds}]
|
||||
|
||||
if (showParent && parentIds.length > 0) {
|
||||
filters.push({kinds: [1], ids: parentIds})
|
||||
filters.push({kinds: [7], '#e': parentIds})
|
||||
}
|
||||
|
||||
const events = await channels.getter.all(filters)
|
||||
|
||||
await ensureAccounts(uniq(pluck('pubkey', notes.concat(events))))
|
||||
|
||||
const $accounts = get(accounts)
|
||||
const reactionsByParent = groupBy(findReply, events.filter(propEq('kind', 7)))
|
||||
const allNotes = uniqBy(prop('id'), notes.concat(events.filter(propEq('kind', 1))))
|
||||
const notesById = createMap('id', allNotes)
|
||||
|
||||
const annotate = note => ({
|
||||
...note,
|
||||
user: $accounts[note.pubkey],
|
||||
reactions: reactionsByParent[note.id] || [],
|
||||
children: uniqBy(prop('id'), allNotes.filter(n => findReply(n) === note.id)).map(annotate),
|
||||
})
|
||||
|
||||
return notes.map(note => {
|
||||
const parentId = findReply(note)
|
||||
|
||||
// If we have a parent, return that instead
|
||||
return annotate(
|
||||
showParent && notesById[parentId]
|
||||
? notesById[parentId]
|
||||
: note
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const annotateAlerts = async events => {
|
||||
if (events.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const eventIds = pluck('id', events)
|
||||
const parentIds = events.map(findReply).filter(identity)
|
||||
const filters = [
|
||||
{kinds: [1], ids: parentIds},
|
||||
{kinds: [7], '#e': parentIds},
|
||||
{kinds: [1, 7], '#e': eventIds},
|
||||
]
|
||||
|
||||
const relatedEvents = await channels.getter.all(filters)
|
||||
|
||||
await ensureAccounts(uniq(pluck('pubkey', events.concat(relatedEvents))))
|
||||
|
||||
const $accounts = get(accounts)
|
||||
const reactionsByParent = groupBy(findReply, relatedEvents.filter(e => e.kind === 7 && e.content === '+'))
|
||||
const allNotes = uniqBy(prop('id'), events.concat(relatedEvents).filter(propEq('kind', 1)))
|
||||
const notesById = createMap('id', allNotes)
|
||||
|
||||
const annotate = note => ({
|
||||
...note,
|
||||
user: $accounts[note.pubkey],
|
||||
reactions: reactionsByParent[note.id] || [],
|
||||
children: uniqBy(prop('id'), allNotes.filter(n => findReply(n) === note.id)).map(annotate),
|
||||
})
|
||||
|
||||
return uniqBy(e => e.parent?.id || e.id, events.map(event => {
|
||||
const parentId = findReply(event)
|
||||
|
||||
return {...annotate(event), parent: annotate(notesById[parentId])}
|
||||
}))
|
||||
}
|
||||
|
||||
export const annotateNewNote = async (note) => {
|
||||
await ensureAccounts([note.pubkey])
|
||||
|
||||
const $accounts = get(accounts)
|
||||
|
||||
return {
|
||||
...note,
|
||||
user: $accounts[note.pubkey],
|
||||
children: [],
|
||||
reactions: [],
|
||||
}
|
||||
}
|
||||
|
||||
export const notesListener = (notes, filter, {shouldMuffle = false, repliesOnly = false} = {}) => {
|
||||
const updateNote = (note, id, f) => {
|
||||
if (note.id === id) {
|
||||
return f(note)
|
||||
}
|
||||
|
||||
return {
|
||||
...note,
|
||||
parent: note.parent ? updateNote(note.parent, id, f) : null,
|
||||
children: note.children.map(n => updateNote(n, id, f)),
|
||||
}
|
||||
}
|
||||
|
||||
const updateNotes = (id, f) =>
|
||||
notes.update($notes => $notes.map(n => updateNote(n, id, f)))
|
||||
|
||||
const deleteNote = (note, ids, deleted_at) => {
|
||||
if (ids.includes(note.id)) {
|
||||
return {...note, deleted_at}
|
||||
}
|
||||
|
||||
return {
|
||||
...note,
|
||||
parent: note.parent ? deleteNote(note.parent, ids, deleted_at) : null,
|
||||
children: note.children.map(n => deleteNote(n, ids, deleted_at)),
|
||||
reactions: note.reactions.filter(e => !ids.includes(e.id)),
|
||||
}
|
||||
}
|
||||
|
||||
const deleteNotes = (ids, t) =>
|
||||
notes.update($notes => $notes.map(n => deleteNote(n, ids, t)))
|
||||
|
||||
return new Listener(filter, e => switcherFn(e.kind, {
|
||||
1: async () => {
|
||||
const id = findReply(e)
|
||||
const muffle = shouldMuffle && Math.random() > getMuffleValue(e.pubkey)
|
||||
|
||||
if (id) {
|
||||
const note = await annotateNewNote(e)
|
||||
|
||||
updateNotes(id, n => ({...n, children: n.children.concat(note)}))
|
||||
} else if (!repliesOnly && !muffle && filterMatches(filter, e)) {
|
||||
const [note] = await threadify([e])
|
||||
|
||||
notes.update($notes => [note].concat($notes))
|
||||
}
|
||||
},
|
||||
5: () => {
|
||||
deleteNotes(e.tags.map(t => t[1]), e.created_at)
|
||||
},
|
||||
7: () => {
|
||||
updateNotes(findReply(e), n => ({...n, reactions: n.reactions.concat(e)}))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// UI
|
||||
|
||||
export const createScroller = (
|
||||
cursor,
|
||||
onChunk,
|
||||
{since = epoch, reverse = false} = {}
|
||||
) => {
|
||||
const startingDelta = cursor.delta
|
||||
|
||||
let active = false
|
||||
|
||||
const start = debounce(1000, async () => {
|
||||
if (active) {
|
||||
return
|
||||
}
|
||||
|
||||
active = true
|
||||
|
||||
/* eslint no-constant-condition: 0 */
|
||||
while (true) {
|
||||
// While we have empty space, fill it
|
||||
const {scrollY, innerHeight} = window
|
||||
const {scrollHeight} = document.body
|
||||
|
||||
if (
|
||||
(reverse && scrollY > innerHeight * 2)
|
||||
|| (!reverse && scrollY + innerHeight * 2 < scrollHeight)
|
||||
) {
|
||||
break
|
||||
}
|
||||
|
||||
// Stop if we've gone back far enough
|
||||
if (cursor.since <= since) {
|
||||
break
|
||||
}
|
||||
|
||||
// Get our chunk
|
||||
const chunk = await cursor.chunk()
|
||||
|
||||
// Notify the caller
|
||||
if (chunk.length > 0) {
|
||||
await onChunk(chunk)
|
||||
}
|
||||
|
||||
// If we have an empty chunk, increase our step size so we can get back to where
|
||||
// we might have old events. Once we get a chunk, knock it down to the default again
|
||||
if (chunk.length === 0) {
|
||||
cursor.delta = Math.min(timedelta(30, 'days'), cursor.delta * 2)
|
||||
} else {
|
||||
cursor.delta = startingDelta
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
break
|
||||
}
|
||||
|
||||
// Wait a moment before proceeding to the next chunk for the caller
|
||||
// to load results into the dom
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
active = false
|
||||
})
|
||||
|
||||
return {
|
||||
start,
|
||||
stop: () => { active = false },
|
||||
isActive: () => Boolean(cursor.sub),
|
||||
}
|
||||
}
|
||||
|
302
src/util/notes.js
Normal file
302
src/util/notes.js
Normal file
@ -0,0 +1,302 @@
|
||||
import {identity, uniq, concat, propEq, uniqBy, prop, groupBy, find, last, pluck} from 'ramda'
|
||||
import {debounce} from 'throttle-debounce'
|
||||
import {get} from 'svelte/store'
|
||||
import {switcherFn, createMap} from 'hurdak/lib/hurdak'
|
||||
import {timedelta, sleep} from "src/util/misc"
|
||||
import {user} from 'src/state/user'
|
||||
import {epoch, filterMatches, Listener, channels, findReply, findRoot} from 'src/state/nostr'
|
||||
import {accounts, ensureAccounts} from 'src/state/app'
|
||||
|
||||
export const getMuffleValue = pubkey => {
|
||||
const $user = get(user)
|
||||
|
||||
if (!$user) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const tag = find(t => t[1] === pubkey, $user.muffle)
|
||||
|
||||
if (!tag) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return parseFloat(last(tag))
|
||||
}
|
||||
|
||||
export const threadify = async notes => {
|
||||
if (notes.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const noteIds = pluck('id', notes)
|
||||
const rootIds = notes.map(findReply)
|
||||
const parentIds = notes.map(findRoot)
|
||||
const ancestorIds = concat(rootIds, parentIds).filter(identity)
|
||||
|
||||
// Find all direct parents and thread roots
|
||||
const filters = ancestorIds.length === 0
|
||||
? [{kinds: [1, 7], '#e': noteIds}]
|
||||
: [{kinds: [1], ids: ancestorIds},
|
||||
{kinds: [1, 7], '#e': noteIds.concat(ancestorIds)}]
|
||||
|
||||
const events = await channels.getter.all(filters)
|
||||
|
||||
await ensureAccounts(uniq(pluck('pubkey', notes.concat(events))))
|
||||
|
||||
const $accounts = get(accounts)
|
||||
const reactionsByParent = groupBy(findReply, events.filter(propEq('kind', 7)))
|
||||
const allNotes = uniqBy(prop('id'), notes.concat(events.filter(propEq('kind', 1))))
|
||||
const notesById = createMap('id', allNotes)
|
||||
const notesByRoot = groupBy(
|
||||
n => {
|
||||
const rootId = findRoot(n)
|
||||
const parentId = findReply(n)
|
||||
|
||||
// Actually dereference the notes in case we weren't able to retrieve them
|
||||
if (notesById[rootId]) {
|
||||
return rootId
|
||||
}
|
||||
|
||||
if (notesById[parentId]) {
|
||||
return parentId
|
||||
}
|
||||
|
||||
return n.id
|
||||
},
|
||||
allNotes
|
||||
)
|
||||
|
||||
const threads = []
|
||||
for (const [rootId, _notes] of Object.entries(notesByRoot)) {
|
||||
const annotate = note => {
|
||||
return {
|
||||
...note,
|
||||
user: $accounts[note.pubkey],
|
||||
reactions: reactionsByParent[note.id] || [],
|
||||
children: uniqBy(prop('id'), _notes.filter(n => findReply(n) === note.id)).map(annotate),
|
||||
}
|
||||
}
|
||||
|
||||
threads.push(annotate(notesById[rootId]))
|
||||
}
|
||||
|
||||
return threads
|
||||
}
|
||||
|
||||
export const annotateNotes = async (notes, {showParent = false} = {}) => {
|
||||
if (notes.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const noteIds = pluck('id', notes)
|
||||
const parentIds = notes.map(findReply).filter(identity)
|
||||
const filters = [{kinds: [1, 7], '#e': noteIds}]
|
||||
|
||||
if (showParent && parentIds.length > 0) {
|
||||
filters.push({kinds: [1], ids: parentIds})
|
||||
filters.push({kinds: [7], '#e': parentIds})
|
||||
}
|
||||
|
||||
const events = await channels.getter.all(filters)
|
||||
|
||||
await ensureAccounts(uniq(pluck('pubkey', notes.concat(events))))
|
||||
|
||||
const $accounts = get(accounts)
|
||||
const reactionsByParent = groupBy(findReply, events.filter(propEq('kind', 7)))
|
||||
const allNotes = uniqBy(prop('id'), notes.concat(events.filter(propEq('kind', 1))))
|
||||
const notesById = createMap('id', allNotes)
|
||||
|
||||
const annotate = note => ({
|
||||
...note,
|
||||
user: $accounts[note.pubkey],
|
||||
reactions: reactionsByParent[note.id] || [],
|
||||
children: uniqBy(prop('id'), allNotes.filter(n => findReply(n) === note.id)).map(annotate),
|
||||
})
|
||||
|
||||
return notes.map(note => {
|
||||
const parentId = findReply(note)
|
||||
|
||||
// If we have a parent, return that instead
|
||||
return annotate(
|
||||
showParent && notesById[parentId]
|
||||
? notesById[parentId]
|
||||
: note
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const annotateAlerts = async events => {
|
||||
if (events.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const eventIds = pluck('id', events)
|
||||
const parentIds = events.map(findReply).filter(identity)
|
||||
const filters = [
|
||||
{kinds: [1], ids: parentIds},
|
||||
{kinds: [7], '#e': parentIds},
|
||||
{kinds: [1, 7], '#e': eventIds},
|
||||
]
|
||||
|
||||
const relatedEvents = await channels.getter.all(filters)
|
||||
|
||||
await ensureAccounts(uniq(pluck('pubkey', events.concat(relatedEvents))))
|
||||
|
||||
const $accounts = get(accounts)
|
||||
const reactionsByParent = groupBy(findReply, relatedEvents.filter(e => e.kind === 7 && e.content === '+'))
|
||||
const allNotes = uniqBy(prop('id'), events.concat(relatedEvents).filter(propEq('kind', 1)))
|
||||
const notesById = createMap('id', allNotes)
|
||||
|
||||
const annotate = note => ({
|
||||
...note,
|
||||
user: $accounts[note.pubkey],
|
||||
reactions: reactionsByParent[note.id] || [],
|
||||
children: uniqBy(prop('id'), allNotes.filter(n => findReply(n) === note.id)).map(annotate),
|
||||
})
|
||||
|
||||
return uniqBy(e => e.parent?.id || e.id, events.map(event => {
|
||||
const parentId = findReply(event)
|
||||
|
||||
return {...annotate(event), parent: annotate(notesById[parentId])}
|
||||
}))
|
||||
}
|
||||
|
||||
export const annotateNewNote = async (note) => {
|
||||
await ensureAccounts([note.pubkey])
|
||||
|
||||
const $accounts = get(accounts)
|
||||
|
||||
return {
|
||||
...note,
|
||||
user: $accounts[note.pubkey],
|
||||
children: [],
|
||||
reactions: [],
|
||||
}
|
||||
}
|
||||
|
||||
export const notesListener = (notes, filter, {shouldMuffle = false, repliesOnly = false} = {}) => {
|
||||
const updateNote = (note, id, f) => {
|
||||
if (note.id === id) {
|
||||
return f(note)
|
||||
}
|
||||
|
||||
return {
|
||||
...note,
|
||||
parent: note.parent ? updateNote(note.parent, id, f) : null,
|
||||
children: note.children.map(n => updateNote(n, id, f)),
|
||||
}
|
||||
}
|
||||
|
||||
const updateNotes = (id, f) =>
|
||||
notes.update($notes => $notes.map(n => updateNote(n, id, f)))
|
||||
|
||||
const deleteNote = (note, ids, deleted_at) => {
|
||||
if (ids.includes(note.id)) {
|
||||
return {...note, deleted_at}
|
||||
}
|
||||
|
||||
return {
|
||||
...note,
|
||||
parent: note.parent ? deleteNote(note.parent, ids, deleted_at) : null,
|
||||
children: note.children.map(n => deleteNote(n, ids, deleted_at)),
|
||||
reactions: note.reactions.filter(e => !ids.includes(e.id)),
|
||||
}
|
||||
}
|
||||
|
||||
const deleteNotes = (ids, t) =>
|
||||
notes.update($notes => $notes.map(n => deleteNote(n, ids, t)))
|
||||
|
||||
return new Listener(filter, e => switcherFn(e.kind, {
|
||||
1: async () => {
|
||||
const id = findReply(e)
|
||||
const muffle = shouldMuffle && Math.random() > getMuffleValue(e.pubkey)
|
||||
|
||||
if (id) {
|
||||
const note = await annotateNewNote(e)
|
||||
|
||||
updateNotes(id, n => ({...n, children: n.children.concat(note)}))
|
||||
} else if (!repliesOnly && !muffle && filterMatches(filter, e)) {
|
||||
const [note] = await threadify([e])
|
||||
|
||||
notes.update($notes => [note].concat($notes))
|
||||
}
|
||||
},
|
||||
5: () => {
|
||||
deleteNotes(e.tags.map(t => t[1]), e.created_at)
|
||||
},
|
||||
7: () => {
|
||||
updateNotes(findReply(e), n => ({...n, reactions: n.reactions.concat(e)}))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// UI
|
||||
|
||||
export const createScroller = (
|
||||
cursor,
|
||||
onChunk,
|
||||
{since = epoch, reverse = false} = {}
|
||||
) => {
|
||||
const startingDelta = cursor.delta
|
||||
|
||||
let active = false
|
||||
|
||||
const start = debounce(1000, async () => {
|
||||
if (active) {
|
||||
return
|
||||
}
|
||||
|
||||
active = true
|
||||
|
||||
/* eslint no-constant-condition: 0 */
|
||||
while (true) {
|
||||
// While we have empty space, fill it
|
||||
const {scrollY, innerHeight} = window
|
||||
const {scrollHeight} = document.body
|
||||
|
||||
if (
|
||||
(reverse && scrollY > innerHeight * 2)
|
||||
|| (!reverse && scrollY + innerHeight * 2 < scrollHeight)
|
||||
) {
|
||||
break
|
||||
}
|
||||
|
||||
// Stop if we've gone back far enough
|
||||
if (cursor.since <= since) {
|
||||
break
|
||||
}
|
||||
|
||||
// Get our chunk
|
||||
const chunk = await cursor.chunk()
|
||||
|
||||
// Notify the caller
|
||||
if (chunk.length > 0) {
|
||||
await onChunk(chunk)
|
||||
}
|
||||
|
||||
// If we have an empty chunk, increase our step size so we can get back to where
|
||||
// we might have old events. Once we get a chunk, knock it down to the default again
|
||||
if (chunk.length === 0) {
|
||||
cursor.delta = Math.min(timedelta(30, 'days'), cursor.delta * 2)
|
||||
} else {
|
||||
cursor.delta = startingDelta
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
break
|
||||
}
|
||||
|
||||
// Wait a moment before proceeding to the next chunk for the caller
|
||||
// to load results into the dom
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
active = false
|
||||
})
|
||||
|
||||
return {
|
||||
start,
|
||||
stop: () => { active = false },
|
||||
isActive: () => Boolean(cursor.sub),
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user