mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-30 00:41:12 +00:00
Add threads
This commit is contained in:
parent
acf7af1eff
commit
b4edf0d5b2
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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))
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user