Add new thread view

This commit is contained in:
Jonathan Staab 2022-12-11 11:26:04 -08:00
parent fe25170bcf
commit 8648db3da3
11 changed files with 202 additions and 146 deletions

View File

@ -1,5 +1,6 @@
Bugs
- [ ] Some threads aren't navigable, click handlers aren't firing. Like isn't working, but un-like is.
- [ ] Permalink note detail (share/permalink button?). Permalinks don't work
- [ ] uniq and sortBy are sprinkled all over the place, figure out a better solution
- [ ] With link/image previews, remove the url from the note body if it's on a separate last line

View File

@ -103,7 +103,7 @@
<ul
class="py-20 w-56 bg-dark fixed top-0 bottom-0 left-0 transition-all shadow-xl
border-r border-medium text-white overflow-hidden"
border-r border-medium text-white overflow-hidden z-10"
class:-ml-56={!$menuIsOpen}
>
{#if $user}
@ -164,7 +164,7 @@
<div
class="fixed top-0 bg-dark flex justify-between items-center text-white w-full p-4
border-b border-medium"
border-b border-medium z-10"
>
<i class="fa-solid fa-bars fa-2xl cursor-pointer" bind:this={menuIcon} on:click={toggleMenu} />
<Anchor external type="unstyled" href="https://github.com/staab/coracle">
@ -173,7 +173,7 @@
</div>
{#if $modal}
<div class="fixed inset-0">
<div class="fixed inset-0 z-10">
<div
class="absolute inset-0 opacity-75 bg-black cursor-pointer"
transition:fade

View File

@ -5,8 +5,8 @@
import {switcherFn} from 'hurdak/lib/hurdak'
import Spinner from "src/partials/Spinner.svelte"
import Note from "src/partials/Note.svelte"
import {Cursor, Listener, epoch, findReplyTo, channels} from 'src/state/nostr'
import {createScroller, annotateNotes, modal} from "src/state/app"
import {Cursor, Listener, epoch, findReply, channels} from 'src/state/nostr'
import {createScroller, threadify, modal} from "src/state/app"
export let filter
export let notes
@ -19,14 +19,14 @@
let loading = true
const addLikes = async likes => {
const noteIds = likes.filter(e => e.content === '+').map(findReplyTo).filter(identity)
const noteIds = likes.filter(e => e.content === '+').map(findReply).filter(identity)
if (noteIds.length === 0) {
return
}
const chunk = await channels.getter.all({kinds: [1], ids: noteIds})
const annotated = await annotateNotes(chunk, {showParents: true})
const annotated = await threadify(chunk)
notes.update($notes => sortBy(n => -n.created_at, uniqBy(prop('id'), $notes.concat(annotated))))
}
@ -68,14 +68,7 @@
<ul class="py-4 flex flex-col gap-2 max-w-xl m-auto">
{#each $notes as n (n.id)}
<li>
<Note interactive note={n} />
{#each n.replies as r (r.id)}
<div class="ml-6 border-l border-solid border-medium">
<Note interactive isReply note={r} />
</div>
{/each}
</li>
<li><Note interactive note={n} /></li>
{:else}
{#if loading}
<li><Spinner /></li>

View File

@ -9,14 +9,13 @@
import Preview from 'src/partials/Preview.svelte'
import Anchor from 'src/partials/Anchor.svelte'
import {dispatch} from "src/state/dispatch"
import {findReplyTo} from "src/state/nostr"
import {accounts, settings, modal} from "src/state/app"
import {user} from "src/state/user"
import {formatTimestamp} from 'src/util/misc'
import UserBadge from "src/partials/UserBadge.svelte"
export let note
export let isReply = false
export let depth = 0
export let showEntire = false
export let interactive = false
export let invertColors = false
@ -25,12 +24,10 @@
let like = null
let flag = null
let reply = null
let parentId
$: {
like = find(e => e.pubkey === $user?.pubkey && e.content === "+", note.reactions)
flag = find(e => e.pubkey === $user?.pubkey && e.content === "-", note.reactions)
parentId = findReplyTo(note)
}
onMount(async () => {
@ -43,11 +40,6 @@
}
}
const showParent = async () => {
modal.set({note: {id: parentId}})
}
const react = content => {
if ($user) {
dispatch('reaction/create', content, note)
@ -110,11 +102,6 @@
<p class="text-sm text-light">{formatTimestamp(note.created_at)}</p>
</div>
<div class="ml-6 flex flex-col gap-2">
{#if parentId && !isReply}
<small class="text-light">
Reply to <Anchor on:click={showParent}>{parentId.slice(0, 8)}</Anchor>
</small>
{/if}
{#if flag}
<p class="text-light border-l-2 border-solid border-medium pl-4">
You have flagged this content as offensive.
@ -138,7 +125,7 @@
<i
class="fa-solid fa-reply cursor-pointer"
on:click={startReply} />
{note.replies.length}
{note.children.length}
</div>
<div class={cx({'text-accent': like})}>
<i
@ -175,3 +162,11 @@
</div>
</div>
{/if}
{#if depth > 0}
{#each note.children as child (child.id)}
<div class="ml-5 border-l border-solid border-medium">
<svelte:self note={child} interactive depth={depth - 1} {invertColors} />
</div>
{/each}
{/if}

View File

@ -1,22 +1,34 @@
<script>
import {onMount} from 'svelte'
import {writable} from 'svelte/store'
import {reverse} from 'ramda'
import Spinner from 'src/partials/Spinner.svelte'
import {channels} from "src/state/nostr"
import {notesListener, annotateNotes, modal} from "src/state/app"
import {notesListener, threadify, modal} from "src/state/app"
import {user} from "src/state/user"
import Note from 'src/partials/Note.svelte'
export let note
const notes = writable([])
let notes = writable([])
let cursor
let listener
const getAncestors = n => {
const parents = []
while (n.parent) {
parents.push(n.parent)
n = n.parent
}
return reverse(parents)
}
onMount(() => {
channels.getter
.all({kinds: [1, 5, 7], ids: [note.id]})
.then(annotateNotes)
.all({kinds: [1], ids: [note.id]})
.then(threadify)
.then($notes => {
notes.set($notes)
})
@ -45,22 +57,15 @@
{#each $notes as note (note.id)}
<div n:fly={{y: 20}}>
<Note showEntire note={note} />
{#each note.replies as r (r.id)}
<div class="ml-4 border-l border-solid border-medium">
<Note interactive invertColors isReply note={r} />
{#each r.replies as r2 (r2.id)}
<div class="ml-4 border-l border-solid border-medium">
<Note interactive invertColors isReply note={r2} />
{#each r2.replies as r3 (r3.id)}
<div class="ml-4 border-l border-solid border-medium">
<Note interactive invertColors isReply note={r3} />
</div>
{/each}
</div>
<div class="relative">
{#if note.parent}
<div class="w-px bg-medium absolute h-full ml-5 -mr-5 mt-5" />
{/if}
{#each getAncestors(note) as ancestor (ancestor.id)}
<Note interactive invertColors note={ancestor} />
{/each}
</div>
{/each}
</div>
<Note showEntire invertColors depth={5} note={note} />
</div>
{:else}
<Spinner />

View File

@ -2,10 +2,11 @@
import {onMount, onDestroy} from 'svelte'
import {fly} from 'svelte/transition'
import {uniqBy, sortBy, reject, prop} from 'ramda'
import {pluralize} from 'hurdak/lib/hurdak'
import Spinner from "src/partials/Spinner.svelte"
import Note from "src/partials/Note.svelte"
import {Cursor, epoch} from 'src/state/nostr'
import {createScroller, getMuffleValue, annotateNotes, notesListener, modal} from "src/state/app"
import {Cursor, epoch, filterTags} from 'src/state/nostr'
import {createScroller, getMuffleValue, threadify, combineThreads, notesListener, modal} from "src/state/app"
export let filter
export let notes
@ -18,6 +19,18 @@
let interval
let loading = true
const getRoot = n => {
if (!n.parent) {
return null
}
while (n.parent) {
n = n.parent
}
return n
}
onMount(async () => {
cursor = new Cursor(filter)
listener = await notesListener(notes, [filter, {kinds: [5, 7]}], {shouldMuffle})
@ -27,9 +40,14 @@
chunk = reject(n => Math.random() > getMuffleValue(n.pubkey), chunk)
}
const annotated = await annotateNotes(chunk, {showParents: true})
// Remove anything that's an ancestor of something we've already seen
const seen = $notes.flatMap(n => filterTags({tag: "e"}, n))
notes.update($notes => sortBy(n => -n.created_at, uniqBy(prop('id'), $notes.concat(annotated))))
// Add chunk context and combine threads
chunk = combineThreads(await threadify(reject(n => seen.includes(n.id), chunk)))
// Sort and deduplicate
notes.set(sortBy(n => -n.created_at, uniqBy(prop('id'), $notes.concat(chunk))))
})
// Track loading based on cursor cutoff date
@ -64,13 +82,29 @@
<ul class="py-4 flex flex-col gap-2 max-w-xl m-auto">
{#each $notes as n (n.id)}
{@const root = getRoot(n)}
<li>
<Note interactive note={n} />
{#each n.replies as r (r.id)}
<div class="ml-6 border-l border-solid border-medium">
<Note interactive isReply note={r} />
<div class="relative">
{#if n.parent}
<div class="w-px bg-medium absolute h-full ml-5 -mr-5 mt-5" />
{/if}
{#if root && root.id !== n.parent?.id}
<Note interactive note={root} />
{/if}
{#if n.numberOfAncestors > 2}
<div class="z-10 text-medium bg-black relative py-1 px-2" style="left: 10px" in:fly={{y: 20}}>
<i class="fa-solid fa-ellipsis-v" />
<span class="pl-2 text-light opacity-75">
{n.numberOfAncestors - 2} other {pluralize(n.numberOfAncestors - 2, 'note')}
in this conversation
</span>
</div>
{/if}
{#if n.parent}
<Note interactive depth={0} note={n.parent} />
{/if}
</div>
{/each}
<Note interactive depth={1} note={n} />
</li>
{:else}
{#if loading}

View File

@ -26,7 +26,7 @@
</script>
{#if preview}
<div in:slide>
<div in:slide class="max-w-sm">
<Anchor
external
href={url}

View File

@ -2,7 +2,7 @@
export let user
</script>
<a href={`/users/${user.pubkey}/notes`} class="flex gap-2 items-center">
<a href={`/users/${user.pubkey}/notes`} class="flex gap-2 items-center relative z-10">
<div
class="overflow-hidden w-4 h-4 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
style="background-image: url({user.picture})" />

View File

@ -10,7 +10,7 @@
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 {createScroller, ensureAccounts, accounts, threadify, modal} from "src/state/app"
export let type
@ -37,7 +37,7 @@
data.set(Object.values($accounts))
} else {
const annotated = await annotateNotes(chunk)
const annotated = await threadify(chunk)
data.update($data => uniqBy(prop('id'), $data.concat(annotated)))
}
@ -108,11 +108,6 @@
{#each (results || []) as e (e.id)}
<li in:fly={{y: 20}}>
<Note interactive note={e} />
{#each e.replies as r (r.id)}
<div class="ml-6 border-l border-solid border-medium">
<Note interactive isReply note={r} />
</div>
{/each}
</li>
{/each}
</ul>

View File

@ -1,14 +1,12 @@
import {when, prop, identity, whereEq, reverse, uniq, sortBy, uniqBy, find, last, pluck, groupBy} from 'ramda'
import {identity, uniq, uniqBy, prop, reject, groupBy, find, last, pluck} from 'ramda'
import {debounce} from 'throttle-debounce'
import {writable, get} from 'svelte/store'
import {navigate} from "svelte-routing"
import {globalHistory} from "svelte-routing/src/history"
import {switcherFn} from 'hurdak/lib/hurdak'
import {switcherFn, createMap} from 'hurdak/lib/hurdak'
import {getLocalJson, setLocalJson, now, timedelta, sleep} from "src/util/misc"
import {user} from 'src/state/user'
import {epoch, filterMatches, Listener, channels, relays, findReplyTo} from 'src/state/nostr'
// ws://localhost:7000
import {epoch, filterTags, filterMatches, Listener, channels, relays, findReply} from 'src/state/nostr'
export const modal = {
subscribe: cb => {
@ -131,103 +129,132 @@ export const getMuffleValue = pubkey => {
// Notes
export const annotateNotes = async (chunk, {showParents = false} = {}) => {
const parentIds = chunk.map(findReplyTo).filter(identity)
if (showParents && parentIds.length) {
// Find parents of replies to provide context
const parents = await channels.getter.all({
kinds: [1],
ids: parentIds,
})
// Remove replies, show parents instead
chunk = parents
.concat(chunk.filter(e => !find(whereEq({id: findReplyTo(e)}), parents)))
export const threadify = async notes => {
if (notes.length === 0) {
return []
}
chunk = uniqBy(prop('id'), chunk)
const ancestorIds = filterTags({tag: 'e'}, notes)
const filters = [{kinds: [1, 7], '#e': pluck('id', notes)}]
if (chunk.length === 0) {
return chunk
if (ancestorIds.length > 0) {
filters.push({kinds: [1], ids: ancestorIds})
filters.push({kinds: [7], '#e': ancestorIds})
}
const replies = await channels.getter.all({
kinds: [1],
'#e': pluck('id', chunk),
})
const events = uniqBy(prop('id'), await channels.getter.all(filters))
const reactions = await channels.getter.all({
kinds: [7],
'#e': pluck('id', chunk.concat(replies)),
})
await ensureAccounts(uniq(pluck('pubkey', notes.concat(events))))
const repliesById = groupBy(findReplyTo, replies)
const reactionsById = groupBy(findReplyTo, reactions)
const $accounts = get(accounts)
const eventsById = createMap('id', events)
const getChildren = (kind, n) =>
events.filter(e => e.kind === kind && findReply(e) === n.id)
await ensureAccounts(uniq(pluck('pubkey', chunk.concat(replies).concat(reactions))))
const annotate = (n, includeParent) => {
if (!n) {
return null
}
const annotated = {
...n,
numberOfAncestors: n.tags.filter(([x]) => x === 'e').length,
children: getChildren(1, n).map(child => annotate(child, false)),
reactions: getChildren(7, n),
user: $accounts[n.pubkey],
}
if (includeParent) {
annotated.parent = annotate(eventsById[findReply(n)], true)
}
return annotated
}
// Add parent/children/reactions
return notes.map(n => annotate(n, true))
}
export const combineThreads = notes => {
const notesById = createMap('id', notes.concat(pluck('parent', notes)).filter(identity))
const parentIds = notes.map(n => n.parent?.id).filter(identity)
// Group by parent, but filter out notes alredy represented in the parents list to
// avoid showing the same note twice. This privileges leaf nodes over root nodes.
const notesByParent = groupBy(
n => n.parent?.id || n.id,
reject(n => parentIds.includes(n.id), notes)
)
return Object.keys(notesByParent).map(id => notesById[id])
}
export const annotateNewNote = async note => {
await ensureAccounts([note.pubkey])
const $accounts = get(accounts)
const annotate = e => ({
...e,
user: $accounts[e.pubkey],
replies: uniqBy(prop('id'), (repliesById[e.id] || []).map(reply => annotate(reply))),
reactions: uniqBy(prop('id'), (reactionsById[e.id] || []).map(reaction => annotate(reaction))),
})
return reverse(sortBy(prop('created'), chunk.map(annotate)))
return {
...note,
user: $accounts[note.pubkey],
numberOfAncestors: note.tags.filter(([x]) => x === 'e').length,
children: [],
reactions: [],
}
}
export const notesListener = (notes, filter, {shouldMuffle = false} = {}) => {
const updateNote = (id, f) =>
notes.update($notes =>
$notes
.map(n => {
if (n.id === id) {
return f(n)
}
const updateNote = (note, id, f) => {
if (note.id === id) {
return f(note)
}
return {...n, replies: n.replies.map(when(whereEq({id}), f))}
})
)
return {
...note,
parent: note.parent ? updateNote(note.parent, id, f) : null,
children: note.children.map(n => updateNote(n, id, f)),
}
}
const deleteNotes = ($notes, ids) =>
$notes
.filter(e => !ids.includes(e.id))
.map(n => ({
...n,
replies: deleteNotes(n.replies, ids),
reactions: n.reactions.filter(e => !ids.includes(e.id)),
}))
const updateNotes = (id, f) =>
notes.update($notes => $notes.map(n => updateNote(n, id, f)))
return new Listener(filter, e => switcherFn(e.kind, {
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 => console.log(e) || switcherFn(e.kind, {
1: async () => {
const id = findReplyTo(e)
if (shouldMuffle && Math.random() > getMuffleValue(e.pubkey)) {
return
}
const id = findReply(e)
const muffle = shouldMuffle && Math.random() > getMuffleValue(e.pubkey)
if (id) {
const [reply] = await annotateNotes([e])
const note = await annotateNewNote(e)
updateNote(id, n => ({...n, replies: n.replies.concat(reply)}))
} else if (filterMatches(filter, e)) {
const [note] = await annotateNotes([e])
updateNotes(id, n => ({...n, children: n.children.concat(note)}))
} else if (!muffle && filterMatches(filter, e)) {
const [note] = await threadify([e])
notes.update($notes => uniqBy(prop('id'), [note].concat($notes)))
notes.update($notes => [note].concat($notes))
}
},
5: () => {
const ids = e.tags.map(t => t[1])
notes.update($notes => deleteNotes($notes, ids))
deleteNotes(e.tags.map(t => t[1]), e.created_at)
},
7: () => {
const id = findReplyTo(e)
updateNote(id, n => ({...n, reactions: n.reactions.concat(e)}))
updateNotes(findReply(e), n => ({...n, reactions: n.reactions.concat(e)}))
}
}))
}

View File

@ -27,9 +27,12 @@ export const filterTags = (where, events) =>
export const findTag = (where, events) => first(filterTags(where, events))
// Support the deprecated version where tags are marked as replies
export const findReplyTo = e =>
export const findReply = e =>
findTag({tag: "e", type: "reply"}, e) || findTag({tag: "e"}, e)
export const findRoot = e =>
findTag({tag: "e", type: "root"}, e) || findTag({tag: "e"}, e)
export const filterMatches = (filter, e) => {
return Boolean(find(
f => {
@ -99,7 +102,7 @@ export class Channel {
r => {
sub.unsub()
resolve(result)
resolve(uniqBy(prop('id'), result))
},
)
})
@ -198,8 +201,11 @@ export class Listener {
this.sub = await channels.listener.sub(
filter.map(f => ({since, ...f})),
e => {
this.since = e.created_at
this.onEvent(e)
// Not sure why since filter isn't working here, it's just slightly off
if (e.created_at >= since) {
this.since = e.created_at + 1
this.onEvent(e)
}
}
)
}