Add alerts page

This commit is contained in:
Jonathan Staab 2022-12-15 05:27:48 -08:00
parent 06ebe73f88
commit 7eb8caf0b1
8 changed files with 167 additions and 59 deletions

View File

@ -1,2 +1 @@
_VITE_DUFFLEPUD_URL=http://localhost:8000
VITE_DUFFLEPUD_URL=https://dufflepud.onrender.com
VITE_DUFFLEPUD_URL=http://localhost:8000

View File

@ -1,5 +1,6 @@
Bugs
- [ ] Add alerts for replies to posts the user liked
- [ ] Support bech32 keys/add guide on how to convert
- [ ] Loading icon not showing at bottom
- [ ] uniq and sortBy are sprinkled all over the place, figure out a better solution

View File

@ -10,8 +10,9 @@
import {Router, Route, links, navigate} from "svelte-routing"
import {globalHistory} from "svelte-routing/src/history"
import {hasParent} from 'src/util/html'
import {timedelta} from 'src/util/misc'
import {store as toast} from "src/state/toast"
import {channels} from "src/state/nostr"
import {channels, epoch} from "src/state/nostr"
import {modal, logout, alerts} from "src/state/app"
import {user} from 'src/state/user'
import Anchor from 'src/partials/Anchor.svelte'
@ -42,36 +43,32 @@
let menuIcon
let scrollY
let suspendedSubs = []
let hasAlerts = false
let mostRecentAlert = epoch
export let url = ""
onMount(() => {
// Close menu on click outside
document.querySelector("html").addEventListener("click", e => {
if (e.target !== menuIcon) {
menuIsOpen.set(false)
}
})
// Close menu on click outside
document.querySelector("html").addEventListener("click", e => {
if (e.target !== menuIcon) {
menuIsOpen.set(false)
}
})
onMount(() => {
// Poll for new notifications
setInterval(throttle(10_000, async () => {
(async function pollForNotifications() {
if ($user) {
const events = await channels.getter.all({
limit: 1000,
kinds: [1, 7],
'#p': $user.id,
'#p': $user.pubkey,
since: $alerts.since,
})
const items = events//.filter(e => e.pubkey !== $user.pubkey)
alerts.set({...$alerts, items})
hasAlerts = items.filter(e => e.created_at > $alerts.since).length > 0
} else {
hasAlerts = false
mostRecentAlert = events.reduce((t, e) => Math.max(t, e.created_at), $alerts.since)
}
}), 100)
setTimeout(pollForNotifications, 60_000)
})()
return modal.subscribe($modal => {
// Keep scroll position on body, but don't allow scrolling
@ -142,7 +139,7 @@
<li class="cursor-pointer relative">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/alerts">
<i class="fa-solid fa-bell mr-2" /> Alerts
{#if hasAlerts}
{#if mostRecentAlert > $alerts.since}
<div class="w-2 h-2 rounded bg-accent absolute top-3 left-6" />
{/if}
</a>
@ -202,7 +199,7 @@
<Anchor external type="unstyled" href="https://github.com/staab/coracle">
<h1 class="staatliches text-3xl">Coracle</h1>
</Anchor>
{#if hasAlerts}
{#if mostRecentAlert > $alerts.since}
<div class="w-2 h-2 rounded bg-accent absolute top-4 left-12" />
{/if}
</div>

18
src/partials/Card.svelte Normal file
View File

@ -0,0 +1,18 @@
<script>
import cx from 'classnames'
import {fly} from 'svelte/transition'
export let interactive = false
export let invertColors = false
</script>
<li
on:click
in:fly={{y: 20}}
class={cx("py-2 px-3 flex flex-col gap-2 text-white", {
"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,
})}>
<slot />
</li>

View File

@ -2,7 +2,7 @@
import cx from 'classnames'
import {find, uniqBy, prop, whereEq} from 'ramda'
import {onMount} from 'svelte'
import {fly, slide} from 'svelte/transition'
import {slide} from 'svelte/transition'
import {navigate} from 'svelte-routing'
import {ellipsize} from 'hurdak/src/core'
import {hasParent, toHtml, findLink} from 'src/util/html'
@ -14,6 +14,7 @@
import {user} from "src/state/user"
import {formatTimestamp} from 'src/util/misc'
import UserBadge from "src/partials/UserBadge.svelte"
import Card from "src/partials/Card.svelte"
export let note
export let depth = 0
@ -99,14 +100,7 @@
}}
/>
<div
in:fly={{y: 20}}
on:click={onClick}
class={cx("py-2 px-3 flex flex-col gap-2 text-white", {
"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,
})}>
<Card on:click={onClick} {interactive} {invertColors}>
<div class="flex gap-4 items-center justify-between">
<UserBadge user={{...$accounts[note.pubkey], pubkey: note.pubkey}} />
<p class="text-sm text-light">{formatTimestamp(note.created_at)}</p>
@ -155,7 +149,7 @@
</div>
{/if}
</div>
</div>
</Card>
{#if reply !== null}
<div

View File

@ -1,34 +1,99 @@
<script>
import {onMount} from 'svelte'
import {propEq} from 'ramda'
import {createMap} from 'hurdak/lib/hurdak'
import {onMount, onDestroy} from 'svelte'
import {fly} from 'svelte/transition'
import {writable} from 'svelte/store'
import {sortBy, uniqBy, prop} from 'ramda'
import {now} from 'src/util/misc'
import {alerts, annotateNotes} from 'src/state/app'
import {ellipsize} from 'hurdak/src/core'
import {user} from 'src/state/user'
import {Cursor, epoch} from 'src/state/nostr'
import {alerts, annotateAlerts, notesListener, createScroller, modal} from 'src/state/app'
import Spinner from "src/partials/Spinner.svelte"
import UserBadge from "src/partials/UserBadge.svelte"
import Note from 'src/partials/Note.svelte'
let notesById
let cursor
let listener
let scroller
let interval
let modalUnsub
let loading = true
let events = writable([])
onMount(() => {
onMount(async () => {
// Clear notification badge
alerts.set({...$alerts, since: now()})
annotateNotes($alerts.items.filter(propEq('kind', 1))).then($notes => {
notesById = createMap('id', $notes)
cursor = new Cursor({kinds: [1, 7], '#p': [$user.pubkey]})
listener = await notesListener(events, [{kinds: [1, 5, 7]}], {repliesOnly: true})
scroller = createScroller(cursor, async chunk => {
// Add chunk context
chunk = await annotateAlerts(chunk)
// Sort and deduplicate
events.set(sortBy(n => -n.created_at, uniqBy(prop('id'), $events.concat(chunk))))
})
// Track loading based on cursor cutoff date
interval = setInterval(() => {
loading = cursor.since > epoch
}, 1000)
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>
{#each $alerts.items as e (e.id)}
{#if e.kind === 1 && notesById}
<Note interactive note={notesById[e.id]} />
{:else if e.kind === 7}
hi
{/if}
{:else}
<div class="flex w-full justify-center items-center py-16">
<svelte:window on:scroll={scroller?.start} />
<ul class="py-4 flex flex-col gap-2 max-w-xl m-auto">
{#each $events as e (e.id)}
{#if e.kind === 7}
<!-- don't show alerts for likes of replies to this user's notes -->
{#if e.parent?.pubkey === $user.pubkey}
<li
in:fly={{y: 20}}
class="py-2 px-3 flex flex-col gap-2 text-white cursor-pointer transition-all
border border-solid border-black hover:border-medium hover:bg-dark"
on:click={() => modal.set({note: e.parent})}>
<div class="flex gap-2 items-center">
<UserBadge user={e.user} />
<span>liked your note.</span>
</div>
<div class="ml-6 text-light">
{ellipsize(e.parent.content, 240)}
</div>
</li>
{/if}
{:else}
<li in:fly={{y: 20}}><Note showParent note={e} /></li>
{/if}
{/each}
</ul>
{#if loading}
<div in:fly={{y: 20}}><Spinner /></div>
{:else if $events.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 recent activity found.
</div>
</div>
{/each}
{/if}

View File

@ -25,7 +25,7 @@
const follow = () => {
const petnames = $currentUser.petnames
.concat([t("p", user.pubkey, user.name)])
.concat([t("p", pubkey, user?.name)])
dispatch('account/petnames', petnames)
@ -34,7 +34,7 @@
const unfollow = () => {
const petnames = $currentUser.petnames
.filter(([_, pubkey]) => pubkey !== user.pubkey)
.filter(([_, pubkey]) => pubkey !== pubkey)
dispatch('account/petnames', petnames)
@ -42,7 +42,7 @@
}
const openAdvanced = () => {
modal.set({form: 'user/advanced', user})
modal.set({form: 'user/advanced', user: user || {pubkey}})
}
</script>

View File

@ -189,7 +189,6 @@ export const threadify = async notes => {
user: $accounts[note.pubkey],
reactions: reactionsByParent[note.id] || [],
children: uniqBy(prop('id'), _notes.filter(n => findReply(n) === note.id)).map(annotate),
numberOfAncestors: note.tags.filter(([x]) => x === 'e').length,
}
}
@ -241,6 +240,42 @@ export const annotateNotes = async (notes, {showParent = false} = {}) => {
})
}
export const annotateAlerts = async events => {
if (events.length === 0) {
return []
}
const eventIds = pluck('id', events)
const parentIds = events.map(findReply).filter(identity)
const filters = [
{kinds: [1], ids: parentIds},
{kinds: [7], '#e': parentIds},
{kinds: [1, 7], '#e': eventIds},
]
const relatedEvents = await channels.getter.all(filters)
await ensureAccounts(uniq(pluck('pubkey', events.concat(relatedEvents))))
const $accounts = get(accounts)
const reactionsByParent = groupBy(findReply, relatedEvents.filter(e => e.kind === 7 && e.content === '+'))
const allNotes = uniqBy(prop('id'), events.concat(relatedEvents).filter(propEq('kind', 1)))
const notesById = createMap('id', allNotes)
const annotate = note => ({
...note,
user: $accounts[note.pubkey],
reactions: reactionsByParent[note.id] || [],
children: uniqBy(prop('id'), allNotes.filter(n => findReply(n) === note.id)).map(annotate),
})
return uniqBy(e => e.parent?.id || e.id, events.map(event => {
const parentId = findReply(event)
return {...annotate(event), parent: annotate(notesById[parentId])}
}))
}
export const annotateNewNote = async (note) => {
await ensureAccounts([note.pubkey])
@ -249,13 +284,12 @@ export const annotateNewNote = async (note) => {
return {
...note,
user: $accounts[note.pubkey],
numberOfAncestors: note.tags.filter(([x]) => x === 'e').length,
children: [],
reactions: [],
}
}
export const notesListener = (notes, filter, {shouldMuffle = false} = {}) => {
export const notesListener = (notes, filter, {shouldMuffle = false, repliesOnly = false} = {}) => {
const updateNote = (note, id, f) => {
if (note.id === id) {
return f(note)
@ -296,7 +330,7 @@ export const notesListener = (notes, filter, {shouldMuffle = false} = {}) => {
const note = await annotateNewNote(e)
updateNotes(id, n => ({...n, children: n.children.concat(note)}))
} else if (!muffle && filterMatches(filter, e)) {
} else if (!repliesOnly && !muffle && filterMatches(filter, e)) {
const [note] = await threadify([e])
notes.update($notes => [note].concat($notes))