Improve chat

This commit is contained in:
Jonathan Staab 2022-11-30 12:39:11 -08:00
parent 995904a2b2
commit e5a4f86474
7 changed files with 120 additions and 93 deletions

View File

@ -1,6 +1,6 @@
Bugs
- [ ] Use cursor for chat rooms
- [ ] Pin joined relays at the top
- [ ] Load/publish user preferred relays
- [ ] 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.

View File

@ -15,19 +15,15 @@
onMount(() => {
cursor = new Cursor({ids: [note.id]}, note.created_at)
// Can't use async/await since we need to return unsubscribe functions
notesListener(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] : []}
]).then(_listener => {
listener = _listener
])
// Populate our initial empty space
listener.start()
})
// Populate our initial empty space
listener.start()
const unsubNotes = notes.subscribe($notes => {
note = find(propEq('id', note.id), $notes)

View File

@ -6,7 +6,6 @@
import Input from "src/partials/Input.svelte"
import Textarea from "src/partials/Textarea.svelte"
import Button from "src/partials/Button.svelte"
import RoomList from "src/partials/chat/RoomList.svelte"
import {dispatch} from "src/state/dispatch"
import {channels} from "src/state/nostr"
import toast from "src/state/toast"
@ -51,44 +50,37 @@
}
</script>
<div class="flex gap-4 h-full">
<div class="sm:ml-56 w-full">
<form on:submit={submit} class="flex justify-center py-8 px-4" in:fly={{y: 20}}>
<div class="flex flex-col gap-4 max-w-2xl">
<div class="flex justify-center items-center flex-col mb-4">
<h1 class="staatliches text-6xl">Name your room</h1>
</div>
<div class="flex flex-col gap-8 w-full">
<div class="flex flex-col gap-1">
<strong>Room name</strong>
<Input type="text" name="name" wrapperClass="flex-grow" bind:value={values.name}>
<i slot="before" class="fa-solid fa-tag" />
</Input>
<p class="text-sm text-light">
The room's name can be changed anytime.
</p>
</div>
<div class="flex flex-col gap-1">
<strong>Room information</strong>
<Textarea name="about" bind:value={values.about} />
<p class="text-sm text-light">
Give people an idea of what kind of conversations will be happening here.
</p>
</div>
<div class="flex flex-col gap-1">
<strong>Picture</strong>
<input type="file" name="picture" />
<p class="text-sm text-light">
A picture to help people remember your room.
</p>
</div>
<Button type="submit" class="text-center">Done</Button>
</div>
<form on:submit={submit} class="flex justify-center py-12" in:fly={{y: 20}}>
<div class="flex flex-col gap-4 max-w-2xl">
<div class="flex justify-center items-center flex-col mb-4">
<h1 class="staatliches text-6xl">Name your room</h1>
</div>
<div class="flex flex-col gap-8 w-full">
<div class="flex flex-col gap-1">
<strong>Room name</strong>
<Input type="text" name="name" wrapperClass="flex-grow" bind:value={values.name}>
<i slot="before" class="fa-solid fa-tag" />
</Input>
<p class="text-sm text-light">
The room's name can be changed anytime.
</p>
</div>
</form>
<div class="flex flex-col gap-1">
<strong>Room information</strong>
<Textarea name="about" bind:value={values.about} />
<p class="text-sm text-light">
Give people an idea of what kind of conversations will be happening here.
</p>
</div>
<div class="flex flex-col gap-1">
<strong>Picture</strong>
<input type="file" name="picture" />
<p class="text-sm text-light">
A picture to help people remember your room.
</p>
</div>
<Button type="submit" class="text-center">Done</Button>
</div>
</div>
<div class="hidden sm:block">
<RoomList />
</div>
</div>
</form>

View File

@ -2,20 +2,21 @@
import {onMount, onDestroy} from 'svelte'
import {fly} from 'svelte/transition'
import {navigate} from 'svelte-routing'
import {prop, uniqBy, sortBy, last} from 'ramda'
import {switcherFn} from 'hurdak/src/core'
import {prop, uniq, pluck, reverse, uniqBy, sortBy, last} from 'ramda'
import {formatTimestamp} from 'src/util/misc'
import {toHtml} from 'src/util/html'
import UserBadge from 'src/partials/UserBadge.svelte'
import {Listener, epoch} from 'src/state/nostr'
import {accounts, ensureAccounts} from 'src/state/app'
import {Listener, Cursor, epoch} from 'src/state/nostr'
import {accounts, scroller, 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"
export let room
let cursor
let listener
let scroll
let textarea
let messages = []
let annotatedMessages = []
@ -23,7 +24,7 @@
$: {
// Group messages so we're only showing the account once per chunk
annotatedMessages = sortBy(prop('created_at'), uniqBy(prop('id'), messages)).reduce(
annotatedMessages = reverse(sortBy(prop('created_at'), uniqBy(prop('id'), messages)).reduce(
(mx, m) => {
const account = $accounts[m.pubkey]
@ -39,7 +40,7 @@
})
},
[]
)
))
}
onMount(async () => {
@ -47,52 +48,65 @@
return navigate('/login')
}
// flex-col means the first is the last
const getLastListItem = () => document.querySelector('ul[name=messages] li')
const stickToBottom = async (behavior, cb) => {
const shouldStick = window.scrollY + window.innerHeight > document.body.scrollHeight - 200
const $li = getLastListItem()
await cb()
if ($li && shouldStick) {
$li.scrollIntoView({behavior})
}
}
cursor = new Cursor({kinds: [42], '#e': [room]})
scroll = scroller(
cursor,
chunk => {
stickToBottom('auto', async () => {
for (const e of chunk) {
messages = messages.concat(e)
}
if (chunk.length > 0) {
await ensureAccounts(uniq(pluck('pubkey', chunk)))
}
})
},
{reverse: true}
)
listener = new Listener(
[{kinds: [40, 41], ids: [room], since: epoch},
{kinds: [42, 43, 44], '#e': [room], since: epoch}],
{kinds: [42], '#e': [room]}],
e => {
const {pubkey, kind, content} = e
if ([40, 41].includes(kind)) {
roomData = {pubkey, ...roomData, ...JSON.parse(content)}
} else {
switcherFn(kind, {
42: () => {
messages = messages.concat(e)
stickToBottom('smooth', async () => {
messages = messages.concat(e)
ensureAccounts([pubkey])
const $prevListItem = last(document.querySelectorAll('.chat-message'))
if ($prevListItem && isVisible($prevListItem)) {
setTimeout(() => {
const $li = last(document.querySelectorAll('.chat-message'))
$li.scrollIntoView({behavior: "smooth"})
}, 100)
}
},
43: () => null,
44: () => null,
await ensureAccounts([e.pubkey])
})
}
}
)
scroll()
listener.start()
})
onDestroy(() => {
cursor?.stop()
listener?.stop()
})
const isVisible = $el => {
const bodyRect = document.body.getBoundingClientRect()
const {top, height} = $el.getBoundingClientRect()
return top + height < bodyRect.height
}
const edit = () => {
navigate(`/chat/${room}/edit`)
}
@ -115,14 +129,15 @@
}
</script>
<svelte:window on:scroll={scroll} />
<div class="flex gap-4 h-full">
<div class="sm:ml-56 w-full">
<div class="relative">
<div class="flex flex-col pt-20 pb-32">
<ul class="p-4 max-h-full flex-grow">
{#each annotatedMessages as m}
<li in:fly={{y: 20}} class="py-1 chat-message">
<ul class="p-4 max-h-full flex-grow flex flex-col-reverse" name="messages">
{#each annotatedMessages as m (m.id)}
<li in:fly={{y: 20}} class="py-1">
{#if m.showAccount}
<div class="flex gap-4 items-center justify-between">
<UserBadge user={m.account} />

View File

@ -1,6 +1,7 @@
<script>
import {onMount, onDestroy} from 'svelte'
import {writable} from 'svelte/store'
import {fly} from 'svelte/transition'
import {navigate} from "svelte-routing"
import {uniqBy, prop} from 'ramda'
import Anchor from "src/partials/Anchor.svelte"
@ -60,8 +61,12 @@
{/each}
</li>
{/each}
<li class="flex justify-center py-12" in:fly={{y: 20}}>
<p>Loading notes...</p>
</li>
</ul>
{#if $relays.length > 0}
<div class="fixed bottom-0 right-0 p-8">
<div

View File

@ -116,7 +116,7 @@ export const annotateNotes = async (chunk, {showParents = false} = {}) => {
return reverse(sortBy(prop('created'), chunk.map(annotate)))
}
export const notesListener = async (notes, filter) => {
export const notesListener = (notes, filter) => {
const updateNote = (id, f) =>
notes.update($notes =>
$notes
@ -170,21 +170,29 @@ export const notesListener = async (notes, filter) => {
// UI
export const scroller = (cursor, cb, {isInModal = false, since = epoch} = {}) => {
export const scroller = (cursor, cb, {isInModal = false, since = epoch, reverse = false} = {}) => {
const startingDelta = cursor.delta
return debounce(1000, async () => {
/* eslint no-constant-condition: 0 */
while (true) {
// If a modal opened up, wait for them to close it
// If a modal opened up, wait for them to close it. Otherwise, throttle a tad
if (!isInModal && get(modal)) {
await sleep(1000)
continue
} else {
await sleep(100)
}
// While we have empty space, fill it
if (window.scrollY + window.innerHeight * 3 < document.body.scrollHeight) {
const {scrollY, innerHeight} = window
const {scrollHeight} = document.body
if (
(reverse && scrollY > innerHeight * 3)
|| (!reverse && scrollY + innerHeight * 3 < scrollHeight)
) {
break
}
@ -197,7 +205,9 @@ export const scroller = (cursor, cb, {isInModal = false, since = epoch} = {}) =>
const chunk = await cursor.chunk()
// Notify the caller
await cb(chunk)
if (chunk.length > 0) {
await cb(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

View File

@ -67,7 +67,14 @@ export class Channel {
}
let resolve
const sub = nostr.sub({filter, cb}, this.name, onEose)
const eoseRelays = []
const sub = nostr.sub({filter, cb}, this.name, r => {
eoseRelays.push(r)
if (eoseRelays.length === get(relays).length) {
onEose()
}
})
this.p = new Promise(r => {
resolve = r
@ -132,13 +139,15 @@ export class Cursor {
this.sub = null
}
}
restart() {
async restart() {
this.stop()
this.start()
await this.start()
}
step() {
async step() {
this.since -= this.delta
this.restart()
await this.restart()
}
onEvent(e) {
this.until = e.created_at - 1
@ -148,7 +157,7 @@ export class Cursor {
this.stop()
}
async chunk() {
this.step()
await this.step()
/* eslint no-constant-condition: 0 */
while (true) {