Add threads

This commit is contained in:
Jonathan Staab 2022-11-26 09:22:10 -08:00
parent acf7af1eff
commit b4edf0d5b2
10 changed files with 174 additions and 34 deletions

View File

@ -19,3 +19,4 @@ 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

@ -148,7 +148,9 @@
<div class="absolute inset-0 mt-20 sm:mt-32 modal-content" transition:fly={{y: 1000, opacity: 1}}>
<dialog open class="bg-dark border-t border-solid border-medium h-full w-full">
{#if $modal.note}
{#key $modal.note.id}
<NoteDetail note={$modal.note} />
{/key}
{/if}
</dialog>
</div>

View File

@ -1,30 +1,33 @@
<script>
import cx from 'classnames'
import {onMount} from 'svelte'
import {find, uniqBy, prop, whereEq} from 'ramda'
import {find, last, uniqBy, prop, whereEq} from 'ramda'
import {fly} from 'svelte/transition'
import {navigate} from 'svelte-routing'
import {ellipsize} from 'hurdak/src/core'
import {hasParent} from 'src/util/html'
import Anchor from 'src/partials/Anchor.svelte'
import {nostr} from "src/state/nostr"
import {dispatch} from "src/state/dispatch"
import {dispatch, t} from "src/state/dispatch"
import {accounts, 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 interactive = false
export let invertColors = false
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 = prop(1, find(t => last(t) === 'reply' ? t[1] : null, note.tags))
}
const onClick = e => {
@ -33,6 +36,10 @@
}
}
const showParent = () => {
modal.set({note: {id: parentId}})
}
const react = content => {
if ($user) {
dispatch('reaction/create', content, note)
@ -44,24 +51,68 @@
const deleteReaction = e => {
dispatch('event/delete', [e.id])
}
const startReply = () => {
if ($user) {
reply = reply || ''
} else {
navigate('/login')
}
}
const sendReply = () => {
if (reply) {
dispatch("reply/create", reply, note)
reply = null
}
}
const onReplyKeyPress = e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendReply()
}
}
</script>
<li
<svelte:body
on:click={e => {
if (!hasParent('.fa-reply', e.target) && !hasParent('.note-reply', e.target)) {
reply = null
}
}}
on:keydown={e => {
if (e.key === 'Escape') {
reply = null
}
}}
/>
<div
in:fly={{y: 20}}
on:click={onClick}
class={cx("py-2 px-3 flex flex-col gap-2 text-white", {
"hover:bg-dark transition-all cursor-pointer": interactive,
"border border-solid border-black hover:border-medium": interactive,
"cursor-pointer transition-all": interactive,
"border border-solid border-black hover:border-medium hover:bg-dark": interactive && !invertColors,
"border border-solid border-dark hover:border-medium hover:bg-medium": interactive && invertColors,
})}>
<div class="flex gap-4 items-center justify-between">
<UserBadge user={$accounts[note.pubkey]} />
<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}
<p>{ellipsize(note.content, 240)}</p>
<div class="flex gap-6 text-light">
<div>
<i class="fa-solid fa-reply cursor-pointer" />
<i
class="fa-solid fa-reply cursor-pointer"
on:click={startReply} />
{note.replies.length}
</div>
<div class={cx({'text-accent': like})}>
@ -78,4 +129,25 @@
</div>
</div>
</div>
</li>
</div>
{#if reply !== null}
<div
class="note-reply flex bg-medium border-medium border border-solid"
transition:fly={{y: 20}}>
<textarea
rows="4"
autofocus
placeholder="Type something..."
bind:value={reply}
on:keypress={onReplyKeyPress}
class="w-full p-2 text-white bg-medium
placeholder:text-light outline-0 resize-none" />
<div
on:click={sendReply}
class="flex flex-col py-8 p-4 justify-center gap-2 border-l border-solid border-dark
hover:bg-accent transition-all cursor-pointer text-white ">
<i class="fa-solid fa-paper-plane fa-xl" />
</div>
</div>
{/if}

View File

@ -1,5 +1,6 @@
<script>
import {onMount} from 'svelte'
import {find, propEq} from 'ramda'
import {findNotes} from "src/state/app"
import {channels} from "src/state/nostr"
import {user} from "src/state/user"
@ -12,14 +13,31 @@
channels.modal,
[{ids: [note.id]},
{'#e': [note.id]},
// We can't target deletes by e tag, so get them all so we can
// support toggling like/flags
// We can't target reaction deletes by e tag, so get them
// all so we can support toggling like/flags for our user
{kinds: [5], authors: [$user.pubkey]}],
$notes => {
note = $notes[0] || note
note = find(propEq('id', note.id), $notes) || note
}
)
})
</script>
{#if note.pubkey}
<Note 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>
{/each}
</div>
{/each}
{/if}

View File

@ -27,9 +27,16 @@
})
</script>
<ul class="py-8 flex flex-col gap-4 max-w-xl m-auto">
<ul class="py-8 flex flex-col gap-2 max-w-xl m-auto">
{#each reverse(notes || []) as n (n.id)}
<Note interactive note={n} />
<li class="border-l border-solid border-medium">
<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>
{/each}
</ul>

View File

@ -26,10 +26,6 @@
notes = $notes
})
})
$: {
console.log(notes)
}
</script>
{#if user}
@ -53,10 +49,17 @@
</div>
</div>
<div class="h-px bg-medium" in:fly={{y: 20, delay: 200}} />
<div class="flex flex-col gap-4" in:fly={{y: 20, delay: 400}}>
<ul class="flex flex-col -mt-4" in:fly={{y: 20, delay: 400}}>
{#each reverse(notes || []) as n (n.id)}
<Note interactive note={n} />
<li class="border-l border-solid border-medium pb-2">
<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>
{/each}
</div>
</ul>
</div>
{/if}

View File

@ -1,4 +1,4 @@
import {prop, uniqBy, find, last, groupBy} from 'ramda'
import {prop, sortBy, uniqBy, find, last, groupBy} from 'ramda'
import {writable, derived, get} from 'svelte/store'
import {switcherFn, ensurePlural} from 'hurdak/lib/hurdak'
import {getLocalJson, setLocalJson, now, timedelta} from "src/util/misc"
@ -97,7 +97,7 @@ export const findNotes = (channel, queries, cb) => {
reactions: (reactionsById[n.id] || []).map(reaction => annotate(reaction)),
})
return $notes.map(annotate)
return sortBy(prop('created'), $notes.map(annotate))
}
)

View File

@ -1,4 +1,4 @@
import {identity, without} from 'ramda'
import {identity, last, without} from 'ramda'
import {getPublicKey} from 'nostr-tools'
import {get} from 'svelte/store'
import {first, defmulti} from "hurdak/lib/hurdak"
@ -67,8 +67,8 @@ dispatch.addMethod("message/create", async (topic, roomId, content) => {
return event
})
dispatch.addMethod("note/create", async (topic, content) => {
const event = nostr.event(1, content)
dispatch.addMethod("note/create", async (topic, content, tags=[]) => {
const event = nostr.event(1, content, tags)
await nostr.publish(event)
@ -76,8 +76,17 @@ dispatch.addMethod("note/create", async (topic, content) => {
})
dispatch.addMethod("reaction/create", async (topic, content, e) => {
const tags = e.tags.filter(tag => tag[0].includes(["e", "p"])).map(t => t.slice(0, 2))
const event = nostr.event(7, content, tags.concat([t("p", e.pubkey), t("e", e.id, 'reply')]))
const tags = copyTags(e).concat([t("p", e.pubkey), t("e", e.id, 'reply')])
const event = nostr.event(7, content, tags)
await nostr.publish(event)
return event
})
dispatch.addMethod("reply/create", async (topic, content, e) => {
const tags = copyTags(e).concat([t("p", e.pubkey), t("e", e.id, 'reply')])
const event = nostr.event(1, content, tags)
await nostr.publish(event)
@ -94,7 +103,12 @@ dispatch.addMethod("event/delete", async (topic, ids) => {
// utils
const t = (type, content, marker) => {
export const copyTags = e => {
// Remove reply type from e tags
return e.tags.map(t => last(t) === 'reply' ? t.slice(0, -1) : t)
}
export const t = (type, content, marker) => {
const tag = [type, content, first(get(relays))]
if (marker) {

View File

@ -25,6 +25,25 @@ nostr.event = (kind, content = '', tags = []) => {
return {kind, content, tags, pubkey, created_at: createdAt}
}
nostr.find = (filter, timeout = 300) => {
return new Promise(resolve => {
const sub = channels.getter.sub({
filter,
cb: e => {
resolve(e)
sub.unsub()
},
})
setTimeout(() => {
resolve(null)
sub.unsub()
}, timeout)
})
}
nostr.findLast = (filter, timeout = 300) => {
return new Promise(resolve => {
let result = null

View File

@ -45,13 +45,17 @@ export const stripExifData = async file => {
})
}
export const hasParent = (tag, e) => {
while (e) {
if (e.tagName === tag.toUpperCase()) {
export const hasParent = (tagOrClass, $el) => {
while ($el) {
if (tagOrClass.startsWith('.')) {
if ($el.classList?.contains(tagOrClass.slice(1))) {
return true
}
} else if ($el.tagName === tagOrClass.toUpperCase()) {
return true
}
e = e.parentNode
$el = $el.parentNode
}
return false