Move stuff to views

This commit is contained in:
Jonathan Staab 2022-12-17 09:02:33 -08:00
parent f4a706e12b
commit 03ce671814
20 changed files with 405 additions and 422 deletions

View File

@ -39,6 +39,7 @@ Coracle is currently in _alpha_ - expect bugs, slow loading times, and rough edg
- [ ] Check firefox - in dev it won't work, but it should in production
- [ ] Re-implement muffle
- [ ] Rename users/accounts to people
- https://vitejs.dev/guide/features.html#web-workers
- https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers
- https://web.dev/module-workers/

View File

@ -16,9 +16,9 @@
import {modal, logout, alerts} from "src/state/app"
import {user} from 'src/state/user'
import Anchor from 'src/partials/Anchor.svelte'
import NoteDetail from "src/partials/NoteDetail.svelte"
import NoteDetail from "src/views/NoteDetail.svelte"
import NotFound from "src/routes/NotFound.svelte"
// import Search from "src/routes/Search.svelte"
import Search from "src/routes/Search.svelte"
import Alerts from "src/routes/Alerts.svelte"
import Notes from "src/routes/Notes.svelte"
import Login from "src/routes/Login.svelte"
@ -27,7 +27,7 @@
import Keys from "src/routes/Keys.svelte"
import RelayList from "src/routes/RelayList.svelte"
import AddRelay from "src/routes/AddRelay.svelte"
// import UserDetail from "src/routes/UserDetail.svelte"
import UserDetail from "src/routes/UserDetail.svelte"
// import UserAdvanced from "src/routes/UserAdvanced.svelte"
import NoteCreate from "src/routes/NoteCreate.svelte"
// import Chat from "src/routes/Chat.svelte"
@ -54,8 +54,6 @@
}
})
window.addEventListener('unhandledrejection', e => console.error(e))
onMount(() => {
// Poll for new notifications
(async function pollForNotifications() {
@ -96,13 +94,7 @@
<div use:links class="h-full">
<div class="pt-16 text-white h-full">
<Route path="/alerts" component={Alerts} />
<!--
<Route path="/search/:type" let:params>
{#key params.type}
<Search {...params} />
{/key}
</Route>
-->
<Route path="/search/:type" component={Search} />
<Route path="/notes/:activeTab" component={Notes} />
<Route path="/notes/new" component={NoteCreate} />
<!--
@ -114,12 +106,12 @@
{/key}
</Route>
<Route path="/chat/:room/edit" component={ChatEdit} />
-->
<Route path="/users/:pubkey/:activeTab" let:params>
{#key params.pubkey + params.activeTab}
{#key params.pubkey}
<UserDetail {...params} />
{/key}
</Route>
-->
<Route path="/keys" component={Keys} />
<Route path="/relays" component={RelayList} />
<Route path="/profile" component={Profile} />

View File

@ -1,79 +0,0 @@
<script>
import {onMount, onDestroy} from 'svelte'
import {fly} from 'svelte/transition'
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 {modal} from "src/state/app"
export let author
export let notes
let cursor
let listener
let scroller
let modalUnsub
let interval
let loading = true
const addLikes = async likes => {
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, {showParent: false})
notes.update($notes => sortBy(n => -n.created_at, uniqBy(prop('id'), $notes.concat(annotated))))
}
onMount(async () => {
cursor = new Cursor({kinds: [7], authors: [author]})
listener = notesListener(notes, {kinds: [1, 5, 7], authors: [author]})
scroller = createScroller(cursor, addLikes)
// Track loading based on cursor cutoff date
interval = setInterval(() => {
loading = cursor.since > epoch
}, 1000)
// When a modal opens, suspend our subscriptions
modalUnsub = modal.subscribe(async $modal => {
if ($modal) {
cursor.stop()
listener.stop()
scroller.stop()
} else {
cursor.start()
listener.start()
scroller.start()
}
})
})
onDestroy(() => {
cursor?.stop()
listener?.stop()
scroller?.stop()
modalUnsub?.()
clearInterval(interval)
})
</script>
<svelte:window on:scroll={scroller?.start} />
<ul class="py-4 flex flex-col gap-2 max-w-xl m-auto">
{#each $notes as n (n.id)}
<li><Note interactive noReply note={n} depth={1} /></li>
{:else}
{#if loading}
<li><Spinner /></li>
{:else}
<li class="p-20 text-center" in:fly={{y: 20}}>No notes found.</li>
{/if}
{/each}
</ul>

View File

@ -1,52 +0,0 @@
<script>
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 {modal} from "src/state/app"
import {user} from "src/state/user"
import Note from 'src/partials/Note.svelte'
export let note
let notes = writable([])
let cursor
let listener
onMount(() => {
channels.getter
.all({kinds: [1], ids: [note.id]})
.then(async $notes => {
notes.set(await annotateNotes($notes))
})
listener = notesListener(notes, [
{kinds: [1, 5, 7], '#e': [note.id]},
// 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 ? [$user.pubkey] : []}
])
// Populate our initial empty space
listener.start()
// Unsubscribe when modal closes so that others can re-subscribe sooner
const unsubModal = modal.subscribe($modal => {
cursor?.stop()
listener?.stop()
})
return () => {
unsubModal()
}
})
</script>
{#each $notes as n (n.id)}
<div n:fly={{y: 20}}>
<Note showEntire showParent invertColors anchorId={note.id} note={n} depth={2} />
</div>
{:else}
<Spinner />
{/each}

View File

@ -1,59 +0,0 @@
<script>
import {prop, identity, concat, uniqBy, groupBy} from 'ramda'
import {createMap} from 'hurdak/lib/hurdak'
import {findReply, findRoot} from 'src/util/nostr'
import {fly} from 'svelte/transition'
import Note from "src/partials/Note.svelte"
import relay from 'src/relay'
export let filter
export let shouldMuffle = false
const toThread = async notes => {
const ancestorIds = concat(notes.map(findRoot), notes.map(findReply)).filter(identity)
const ancestors = await relay.filterEvents({kinds: [1], ids: ancestorIds}).toArray()
const allNotes = uniqBy(prop('id'), notes.concat(ancestors))
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
)
return Object.keys(notesByRoot).map(id => notesById[id])
}
const notes = relay.lq(async () => {
return await toThread(
await relay.filterEvents(filter).limit(10).reverse().sortBy('created_at')
)
})
</script>
<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} depth={2} /></li>
{/each}
</ul>
{#if $notes?.length === 0}
<div in:fly={{y: 20}} class="flex w-full justify-center items-center py-16">
<div class="text-center max-w-md">
No notes found.
</div>
</div>
{/if}

View File

@ -3,8 +3,8 @@ import {filterTags} from 'src/util/nostr'
export const db = new Dexie('coracle/relay')
db.version(2).stores({
events: '++id, pubkey, created_at, kind, content',
db.version(3).stores({
events: '++id, pubkey, created_at, kind, content, reply, root',
users: '++pubkey, name, about',
tags: '++key, event, value',
})

View File

@ -18,13 +18,21 @@ const lq = f => liveQuery(async () => {
const ensureContext = async e => {
// We can't return a promise, so use setTimeout instead
const user = await db.users.where('pubkey').equals(e.pubkey).first()
const user = await db.users.where('pubkey').equals(e.pubkey).first() || {
muffle: [],
petnames: [],
updated_at: 0,
pubkey: e.pubkey,
}
// Throttle updates for users
if (!user || user.updated_at < now() - timedelta(1, 'hours')) {
await pool.updateUser(user || {pubkey: e.pubkey, updated_at: 0})
if (user.updated_at < now() - timedelta(1, 'hours')) {
Object.assign(user, await pool.getUserInfo({pubkey: e.pubkey, ...user}))
}
// Even if we didn't find a match, save it so we don't keep trying to refresh
db.users.put({...user, updated_at: now()})
// TODO optimize this like user above so we're not double-fetching
await pool.fetchContext(e)
}
@ -79,6 +87,25 @@ const findReaction = async (id, filter) =>
const countReactions = async (id, filter) =>
(await filterReactions(id, filter)).length
const findNote = async id => {
const [note, children] = await Promise.all([
db.events.get(id),
db.events.where('reply').equals(id),
])
const [replies, reactions, user, html] = await Promise.all([
children.clone().filter(e => e.kind === 1).toArray(),
children.clone().filter(e => e.kind === 7).toArray(),
db.users.get(note.pubkey),
renderNote(note, {showEntire: false}),
])
return {
...note, reactions, user, html,
replies: await Promise.all(replies.map(r => findNote(r.id))),
}
}
const renderNote = async (note, {showEntire = false}) => {
const shouldEllipsize = note.content.length > 500 && !showEntire
const content = shouldEllipsize ? ellipsize(note.content, 500) : note.content
@ -105,5 +132,5 @@ const renderNote = async (note, {showEntire = false}) => {
export default {
db, pool, lq, ensureContext, filterEvents, filterReactions, countReactions,
findReaction, filterReplies, renderNote,
findReaction, filterReplies, findNote, renderNote,
}

View File

@ -1,7 +1,8 @@
import {uniqBy, prop} from 'ramda'
import {relayPool, getPublicKey} from 'nostr-tools'
import {noop, switcherFn, uuid} from 'hurdak/lib/hurdak'
import {now, timedelta} from "src/util/misc"
import {filterTags} from "src/util/nostr"
import {now, randomChoice, timedelta} from "src/util/misc"
import {filterTags, findReply, findRoot} from "src/util/nostr"
import {db} from 'src/relay/db'
// ============================================================================
@ -9,43 +10,78 @@ import {db} from 'src/relay/db'
const pool = relayPool()
const post = (topic, payload) => postMessage({topic, payload})
const req = ({filter, onEvent, onEose = noop}) => {
// If we don't have any relays, we'll wait forever for an eose, but
// we already know we're done. Use a timeout since callers are
// expecting this to be async and we run into errors otherwise.
if (pool.relays.length === 0) {
onEose()
return {unsub: noop}
class Channel {
constructor(name) {
this.name = name
this.p = Promise.resolve()
}
async sub(filter, onEvent, onEose = noop) {
// If we don't have any relays, we'll wait forever for an eose, but
// we already know we're done. Use a timeout since callers are
// expecting this to be async and we run into errors otherwise.
if (Object.keys(pool.relays).length === 0) {
setTimeout(onEose)
const eoseRelays = []
return pool.sub({filter, cb: onEvent}, uuid(), r => {
eoseRelays.push(r)
if (eoseRelays.length === pool.relays.length) {
onEose()
return {unsub: noop}
}
})
// Grab our spot in the queue, save resolve for later
let resolve
let p = this.p
this.p = new Promise(r => {
resolve = r
})
// Make sure callers have to wait for the previous sub to be done
// before they can get a new one.
await p
// Start our subscription, wait for all relays to eose before
// calling it done
const eoseRelays = []
const sub = pool.sub({filter, cb: onEvent}, this.name, r => {
eoseRelays.push(r)
if (eoseRelays.length === Object.keys(pool.relays).length) {
onEose()
}
})
return {
unsub: () => {
sub.unsub()
resolve()
}
}
}
all(filter) {
/* eslint no-async-promise-executor: 0 */
return new Promise(async resolve => {
const result = []
const sub = await this.sub(
filter,
e => result.push(e),
r => {
sub.unsub()
resolve(uniqBy(prop('id'), result))
},
)
})
}
}
// ============================================================================
// Start up a subscription to get recent data and listen for new stuff
export const channels = [
new Channel('a'),
new Channel('b'),
new Channel('c'),
]
const lastSync = now() - timedelta(1, 'days')
const req = filter => randomChoice(channels).all(filter)
req({
filter: {
kinds: [1],
since: lastSync,
limit: 10,
},
onEvent: e => {
post('events/put', e)
},
})
const prepEvent = e => ({...e, root: findRoot(e), reply: findReply(e)})
// ============================================================================
// Listen to messages posted from the main application
@ -81,47 +117,37 @@ const setPublicKey = pubkey => {
const publishEvent = event => {
pool.publish(event)
db.events.put(event)
db.events.put(prepEvent(event))
}
const updateUser = async user => {
if (!user.pubkey) throw new Error("Invalid user")
const loadEvents = async filter => {
const events = await req(filter)
user = {muffle: [], petnames: [], ...user}
db.events.bulkPut(events.map(prepEvent))
}
const sub = req({
filter: {
kinds: [0, 3, 12165],
authors: [user.pubkey],
since: user.updated_at,
},
onEvent: e => {
switcherFn(e.kind, {
0: () => Object.assign(user, JSON.parse(e.content)),
3: () => Object.assign(user, {petnames: e.tags}),
12165: () => Object.assign(user, {muffle: e.tags}),
})
},
onEose: () => {
sub.unsub()
const getUserInfo = async user => {
for (const e of await req({kinds: [0, 3, 12165], authors: [user.pubkey]})) {
switcherFn(e.kind, {
0: () => Object.assign(user, JSON.parse(e.content)),
3: () => Object.assign(user, {petnames: e.tags}),
12165: () => Object.assign(user, {muffle: e.tags}),
})
}
db.users.put({...user, updated_at: now()})
},
})
return user
}
const fetchContext = async event => {
const sub = req({
filter: [
{kinds: [5, 7], '#e': [event.id]},
{kinds: [5], 'ids': filterTags({tag: "e"}, event)},
],
onEvent: e => post('events/put', e),
onEose: () => sub.unsub(),
})
const events = await req([
{kinds: [5, 7], '#e': [event.id]},
{kinds: [5], 'ids': filterTags({tag: "e"}, event)},
])
db.events.bulkPut(events.map(prepEvent))
}
export default {
getPubkey, addRelay, removeRelay, setPrivateKey, setPublicKey,
publishEvent, updateUser, fetchContext,
publishEvent, loadEvents, getUserInfo, fetchContext,
}

View File

@ -7,7 +7,7 @@
import {user} from 'src/state/user'
import {alerts, modal} from 'src/state/app'
import UserBadge from "src/partials/UserBadge.svelte"
import Note from 'src/partials/Note.svelte'
import Note from 'src/views/Note.svelte'
const events = relay.lq(async () => {
const events = await relay

View File

@ -3,7 +3,7 @@
import {timedelta} from 'src/util/misc'
import Anchor from "src/partials/Anchor.svelte"
import Tabs from "src/partials/Tabs.svelte"
import Notes from "src/partials/Notes.svelte"
import Notes from "src/views/Notes.svelte"
import {relays} from "src/state/nostr"
import {user} from "src/state/user"

View File

@ -1,70 +1,14 @@
<script>
import {onMount, onDestroy} from 'svelte'
import {writable} from 'svelte/store'
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 {ensureAccounts, accounts, modal} from "src/state/app"
import SearchPeople from 'src/views/SearchPeople.svelte'
import SearchNotes from 'src/views/SearchNotes.svelte'
export let type
const data = writable([])
let q = ''
let search
let results
let cursor
let scroller
let modalUnsub
$: search = fuzzy($data, {keys: type === 'people' ? ["name", "about", "pubkey"] : ["content"]})
$: {
scroller?.start()
results = search(q)
}
onMount(async () => {
cursor = new Cursor({kinds: type === 'people' ? [0] : [1]})
scroller = createScroller(cursor, async chunk => {
if (type === 'people') {
await ensureAccounts(pluck('pubkey', chunk))
data.set(Object.values($accounts))
} else {
const annotated = await annotateNotes(chunk, {showParent: false})
data.update($data => uniqBy(prop('id'), $data.concat(annotated)))
}
})
// When a modal opens, suspend our subscriptions
modalUnsub = modal.subscribe(async $modal => {
if ($modal) {
cursor.stop()
scroller.stop()
} else {
cursor.start()
scroller.start()
}
})
})
onDestroy(() => {
cursor?.stop()
scroller?.stop()
modalUnsub?.()
})
</script>
<svelte:window on:scroll={scroller?.start} />
<ul class="border-b border-solid border-dark flex max-w-xl m-auto pt-2" in:fly={{y: 20}}>
<li
class="cursor-pointer hover:border-b border-solid border-medium"
@ -79,51 +23,13 @@
</ul>
<div class="max-w-xl m-auto mt-4" in:fly={{y: 20}}>
<Input bind:value={q} placeholder="Search for {type}">
<Input bind:value={q} placeholder="Search for people">
<i slot="before" class="fa-solid fa-search" />
</Input>
</div>
{#if type === 'people'}
<ul class="py-8 flex flex-col gap-2 max-w-xl m-auto">
{#each (results || []) as e (e.pubkey)}
{#if e.pubkey !== $user.pubkey}
<li in:fly={{y: 20}}>
<a href="/users/{e.pubkey}/notes" class="flex gap-4 my-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({e.picture})" />
<div class="flex-grow">
<h1 class="text-2xl">{e.name || e.pubkey.slice(0, 8)}</h1>
<p>{e.about || ''}</p>
</div>
</a>
<li>
{/if}
{/each}
</ul>
<SearchPeople {q} />
{:else if type === 'notes'}
<SearchNotes {q} />
{/if}
{#if type === 'notes'}
<ul class="py-8 flex flex-col gap-2 max-w-xl m-auto">
{#each (results || []) as e (e.id)}
<li in:fly={{y: 20}}>
<Note interactive showParent note={e} />
</li>
{/each}
</ul>
{/if}
<!-- This will always be sitting at the bottom in case infinite scrolling can't keep up -->
<Spinner />
{#if $relays.length === 0}
<div class="flex w-full justify-center items-center py-16">
<div class="text-center max-w-md">
You aren't yet connected to any relays. Please click <Anchor href="/relays"
>here</Anchor
> to get started.
</div>
</div>
{/if}

View File

@ -1,24 +1,20 @@
<script>
import {find} from 'ramda'
import {writable} from 'svelte/store'
import {fly} from 'svelte/transition'
import {navigate} from 'svelte-routing'
import Notes from "src/partials/Notes.svelte"
import Likes from "src/partials/Likes.svelte"
import Tabs from "src/partials/Tabs.svelte"
import Button from "src/partials/Button.svelte"
import Notes from "src/views/Notes.svelte"
import Likes from "src/views/Likes.svelte"
import {user as currentUser} from 'src/state/user'
import {t, dispatch} from 'src/state/dispatch'
import {accounts, modal} from "src/state/app"
import {modal} from "src/state/app"
import relay from 'src/relay'
export let pubkey
export let activeTab
const user = $accounts[pubkey]
const notes = writable([])
const likes = writable([])
const network = writable([])
const authors = $currentUser ? $currentUser.petnames.map(t => t[1]) : []
const user = relay.lq(() => relay.db.users.get(pubkey))
let following = $currentUser && find(t => t[1] === pubkey, $currentUser.petnames)
@ -26,7 +22,7 @@
const follow = () => {
const petnames = $currentUser.petnames
.concat([t("p", pubkey, user?.name)])
.concat([t("p", pubkey, $user?.name)])
dispatch('account/petnames', petnames)
@ -43,7 +39,7 @@
}
const openAdvanced = () => {
modal.set({form: 'user/advanced', user: user || {pubkey}})
modal.set({form: 'user/advanced', user: $user || {pubkey}})
}
</script>
@ -52,15 +48,15 @@
<div class="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({user?.picture})" />
style="background-image: url({$user?.picture})" />
<div class="flex-grow">
<div class="flex items-center gap-2">
<h1 class="text-2xl">{user?.name || pubkey.slice(0, 8)}</h1>
<h1 class="text-2xl">{$user?.name || pubkey.slice(0, 8)}</h1>
{#if $currentUser && $currentUser.pubkey !== pubkey}
<i class="fa-solid fa-sliders cursor-pointer" on:click={openAdvanced} />
{/if}
</div>
<p>{user?.about || ''}</p>
<p>{$user?.about || ''}</p>
</div>
<div class="whitespace-nowrap">
{#if $currentUser?.pubkey === pubkey}
@ -80,11 +76,18 @@
</div>
</div>
</div>
<Tabs tabs={['notes', 'likes', 'network']} {activeTab} {setActiveTab} />
{#if activeTab === 'notes'}
<Notes notes={notes} filter={{kinds: [1], authors: [pubkey]}} />
<Notes filter={{kinds: [1], authors: [pubkey]}} />
{:else if activeTab === 'likes'}
<Likes notes={likes} author={pubkey} />
<Likes author={pubkey} />
{:else if activeTab === 'network'}
<Notes notes={network} filter={{kinds: [1], authors}} shouldMuffle />
{#if $user}
<Notes shouldMuffle filter={{kinds: [1], authors: $user.petnames.map(t => t[1])}} />
{:else}
<div class="py-16 max-w-xl m-auto flex justify-center">
Unable to show network for this user.
</div>
{/if}
{/if}

View File

@ -23,10 +23,10 @@ dispatch.addMethod("account/init", async (topic, { privkey, pubkey }) => {
})
// Make sure we have data for this user
await ensureAccounts([pubkey], {force: true})
const {name} = await relay.pool.updateUser({pubkey})
// Tell the caller whether this user was found
return {found: Boolean(get(user).name)}
return {found: Boolean(name)}
})
dispatch.addMethod("account/update", async (topic, updates) => {
@ -58,8 +58,6 @@ dispatch.addMethod("account/muffle", async (topic, muffle) => {
})
dispatch.addMethod("relay/join", async (topic, url) => {
const $user = get(user)
relays.update(r => r.concat(url))
})

View File

@ -1,4 +1,5 @@
import pluck from "ramda/src/pluck"
import {pluck} from "ramda"
import {debounce} from 'throttle-debounce'
import Fuse from "fuse.js/dist/fuse.min.js"
export const fuzzy = (data, opts = {}) => {
@ -50,3 +51,28 @@ export const formatTimestamp = ts => {
export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
export const createScroller = loadMore => {
const onScroll = debounce(1000, async () => {
/* eslint no-constant-condition: 0 */
while (true) {
// While we have empty space, fill it
const {scrollY, innerHeight} = window
const {scrollHeight} = document.body
if (scrollY + innerHeight + 600 < scrollHeight) {
break
}
loadMore()
await sleep(1000)
}
})
onScroll()
return onScroll
}
export const randomChoice = xs => xs[Math.floor(Math.random() * xs.length)]

26
src/views/Likes.svelte Normal file
View File

@ -0,0 +1,26 @@
<script>
import {fly} from 'svelte/transition'
import {propEq} from 'ramda'
import Note from "src/views/Note.svelte"
import {findReply} from 'src/util/nostr'
import relay from 'src/relay'
export let author
const notes = relay.lq(async () => {
const reactions = await relay.db.events
.where('pubkey').equals(author).filter(propEq('kind', 7)).toArray()
return Promise.all(reactions.map(r => relay.findNote(findReply(r))))
})
</script>
{#if $notes}
<ul class="py-4 flex flex-col gap-2 max-w-xl m-auto">
{#each $notes as n (n.id)}
<li><Note interactive noReply note={n} depth={1} /></li>
{:else}
<li class="p-20 text-center" in:fly={{y: 20}}>No notes found.</li>
{/each}
</ul>
{/if}

View File

@ -1,6 +1,6 @@
<script>
import cx from 'classnames'
import {liveQuery} from 'dexie'
import {whereEq, find} from 'ramda'
import {slide} from 'svelte/transition'
import {navigate} from 'svelte-routing'
import {hasParent, findLink} from 'src/util/html'
@ -19,19 +19,21 @@
export let depth = 0
export let anchorId = null
export let showParent = false
export let showEntire = false
export let invertColors = false
let reply = null
const link = $settings.showLinkPreviews ? findLink(note.content) : null
const interactive = !anchorId || anchorId !== note.id
const like = liveQuery(() => relay.findReaction(note.id, {content: "+", pubkey: $user?.pubkey}))
const flag = liveQuery(() => relay.findReaction(note.id, {content: "-", pubkey: $user?.pubkey}))
const likes = liveQuery(() => relay.countReactions(note.id, {content: "+"}))
const flags = liveQuery(() => relay.countReactions(note.id, {content: "-"}))
const replies = liveQuery(() => relay.filterReplies(note.id))
const account = liveQuery(() => relay.db.users.get(note.pubkey))
let likes, flags, like, flag
$: {
likes = note.reactions.filter(whereEq({content: '+'}))
flags = note.reactions.filter(whereEq({content: '-'}))
like = find(whereEq({pubkey: $user?.pubkey}), likes)
flag = find(whereEq({pubkey: $user?.pubkey}), flags)
}
relay.ensureContext(note)
@ -41,10 +43,8 @@
}
}
const goToParent = () => {
const parentId = findReply(note)
modal.set({note: {id: parentId}})
const goToParent = async () => {
modal.set({note: {id: findReply(note)}})
}
const react = content => {
@ -98,7 +98,7 @@
<Card on:click={onClick} {interactive} {invertColors}>
<div class="flex gap-4 items-center justify-between">
<UserBadge user={{...$account, pubkey: note.pubkey}} />
<UserBadge user={{...note.user, pubkey: note.pubkey}} />
<p class="text-sm text-light">{formatTimestamp(note.created_at)}</p>
</div>
<div class="ml-6 flex flex-col gap-2">
@ -107,17 +107,14 @@
Reply to <Anchor on:click={goToParent}>{findReply(note).slice(0, 8)}</Anchor>
</small>
{/if}
{#if $flag}
{#if flag}
<p class="text-light border-l-2 border-solid border-medium pl-4">
You have flagged this content as offensive.
<Anchor on:click={() => deleteReaction($flag)}>Unflag</Anchor>
<Anchor on:click={() => deleteReaction(flag)}>Unflag</Anchor>
</p>
{:else}
<p class="text-ellipsis overflow-hidden">
{#await relay.renderNote(note, {showEntire})}
{:then content}
{@html content}
{/await}
{@html note.html}
{#if link}
<div class="mt-2" on:click={e => e.stopPropagation()}>
<Preview endpoint={`${$settings.dufflepudUrl}/link/preview`} url={link} />
@ -129,17 +126,17 @@
<i
class="fa-solid fa-reply cursor-pointer"
on:click={startReply} />
{$replies?.length}
{note.replies.length}
</div>
<div class={cx({'text-accent': $like})}>
<div class={cx({'text-accent': like})}>
<i
class="fa-solid fa-heart cursor-pointer"
on:click={() => $like ? deleteReaction($like) : react("+")} />
{$likes}
on:click={() => like ? deleteReaction(like) : react("+")} />
{likes.length}
</div>
<div>
<i class="fa-solid fa-flag cursor-pointer" on:click={() => react("-")} />
{$flags}
{flags.length}
</div>
</div>
{/if}
@ -168,7 +165,7 @@
{/if}
{#if depth > 0}
{#each ($replies || []) as r (r.id)}
{#each note.replies as r (r.id)}
<div class="ml-5 border-l border-solid border-medium">
<svelte:self note={r} depth={depth - 1} {invertColors} {anchorId} />
</div>

View File

@ -0,0 +1,14 @@
<script>
import relay from 'src/relay'
import Note from 'src/views/Note.svelte'
export let note
const observable = relay.lq(() => relay.findNote(note.id, {showEntire: true}))
</script>
{#if $observable}
<div n:fly={{y: 20}}>
<Note showParent invertColors anchorId={note.id} note={$observable} depth={2} />
</div>
{/if}

80
src/views/Notes.svelte Normal file
View File

@ -0,0 +1,80 @@
<script>
import {prop, identity, concat, uniqBy, groupBy} from 'ramda'
import {createMap} from 'hurdak/lib/hurdak'
import {now, timedelta} from 'src/util/misc'
import {findReply, findRoot} from 'src/util/nostr'
import {fly} from 'svelte/transition'
import {createScroller} from 'src/util/misc'
import Spinner from 'src/partials/Spinner.svelte'
import Note from "src/views/Note.svelte"
import relay from 'src/relay'
export let filter
export let shouldMuffle = false
let limit = 10, init = now(), offset = 0, notes
const onScroll = createScroller(async () => {
limit += 10
offset += 1
const delta = timedelta(1, 'minutes')
const since = init - delta * offset
const until = init - delta * (offset - 1)
await relay.pool.loadEvents({...filter, since, until})
createNotesObservable()
})
const createNotesObservable = () => {
notes = relay.lq(async () => {
const notes = await relay.filterEvents(filter).limit(limit).reverse().sortBy('created_at')
const ancestorIds = concat(notes.map(findRoot), notes.map(findReply)).filter(identity)
const ancestors = await relay.filterEvents({kinds: [1], ids: ancestorIds}).toArray()
const allNotes = uniqBy(prop('id'), notes.concat(ancestors))
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
)
return await Promise.all(Object.keys(notesByRoot).map(relay.findNote))
})
}
createNotesObservable()
</script>
<svelte:window on:scroll={onScroll} />
<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} depth={2} /></li>
{/each}
</ul>
{#if $notes?.length === 0}
<div in:fly={{y: 20}} class="flex w-full justify-center items-center py-16">
<div class="text-center max-w-md">
No notes found.
</div>
</div>
{:else}
<Spinner />
{/if}

View File

@ -0,0 +1,35 @@
<script>
import {fly} from 'svelte/transition'
import {fuzzy} from "src/util/misc"
import Note from "src/views/Note.svelte"
import relay from 'src/relay'
export let q
let results = []
const search = relay.lq(async () => {
const notes = await relay.filterEvents({kinds: [1]})
.limit(5000).reverse().sortBy('created_at')
return fuzzy(notes, {keys: ["content"]})
})
$: {
if ($search) {
Promise.all(
$search(q).map(n => relay.findNote(n.id))
).then(notes => {
results = notes
})
}
}
</script>
<ul class="py-8 flex flex-col gap-2 max-w-xl m-auto">
{#each results as e (e.id)}
<li in:fly={{y: 20}}>
<Note interactive note={e} />
</li>
{/each}
</ul>

View File

@ -0,0 +1,42 @@
<script>
import {fly} from 'svelte/transition'
import {fuzzy} from "src/util/misc"
import {user} from "src/state/user"
import relay from 'src/relay'
export let q
let results = []
const search = relay.lq(async () => {
return fuzzy(await relay.db.users.toArray(), {keys: ["name", "about", "pubkey"]})
})
$: {
if ($search) {
Promise.all(
$search(q).map(n => relay.findNote(n.id))
).then(notes => {
results = notes
})
}
}
</script>
<ul class="py-8 flex flex-col gap-2 max-w-xl m-auto">
{#each results as e (e.pubkey)}
{#if e.pubkey !== $user.pubkey}
<li in:fly={{y: 20}}>
<a href="/users/{e.pubkey}/notes" class="flex gap-4 my-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({e.picture})" />
<div class="flex-grow">
<h1 class="text-2xl">{e.name || e.pubkey.slice(0, 8)}</h1>
<p>{e.about || ''}</p>
</div>
</a>
<li>
{/if}
{/each}
</ul>