Move some stuff around

This commit is contained in:
Jonathan Staab 2022-12-15 05:55:12 -08:00
parent 7eb8caf0b1
commit 8e80a32b0d
12 changed files with 330 additions and 329 deletions

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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" />

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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']

View File

@ -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'}

View File

@ -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
View 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),
}
}