From 7eb8caf0b102e55a4e47684a9865c6b7767a201a Mon Sep 17 00:00:00 2001 From: Jonathan Staab Date: Thu, 15 Dec 2022 05:27:48 -0800 Subject: [PATCH] Add alerts page --- .env.local | 3 +- README.md | 1 + src/App.svelte | 41 +++++++------- src/partials/Card.svelte | 18 +++++++ src/partials/Note.svelte | 14 ++--- src/routes/Alerts.svelte | 101 ++++++++++++++++++++++++++++------- src/routes/UserDetail.svelte | 6 +-- src/state/app.js | 42 +++++++++++++-- 8 files changed, 167 insertions(+), 59 deletions(-) create mode 100644 src/partials/Card.svelte diff --git a/.env.local b/.env.local index 5e3e91b9..1703798e 100644 --- a/.env.local +++ b/.env.local @@ -1,2 +1 @@ -_VITE_DUFFLEPUD_URL=http://localhost:8000 -VITE_DUFFLEPUD_URL=https://dufflepud.onrender.com +VITE_DUFFLEPUD_URL=http://localhost:8000 diff --git a/README.md b/README.md index 5cff109c..46703e67 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/App.svelte b/src/App.svelte index 6f090daa..e00e8450 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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 @@
  • Alerts - {#if hasAlerts} + {#if mostRecentAlert > $alerts.since}
    {/if} @@ -202,7 +199,7 @@

    Coracle

    - {#if hasAlerts} + {#if mostRecentAlert > $alerts.since}
    {/if}
    diff --git a/src/partials/Card.svelte b/src/partials/Card.svelte new file mode 100644 index 00000000..8c777aca --- /dev/null +++ b/src/partials/Card.svelte @@ -0,0 +1,18 @@ + + +
  • + +
  • diff --git a/src/partials/Note.svelte b/src/partials/Note.svelte index da1b37d3..92359c43 100644 --- a/src/partials/Note.svelte +++ b/src/partials/Note.svelte @@ -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 @@ }} /> -
    +

    {formatTimestamp(note.created_at)}

    @@ -155,7 +149,7 @@
    {/if}
    - + {#if reply !== null}
    - 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) }) -{#each $alerts.items as e (e.id)} -{#if e.kind === 1 && notesById} - -{:else if e.kind === 7} -hi -{/if} -{:else} -
    + + +
      + {#each $events as e (e.id)} + {#if e.kind === 7} + + {#if e.parent?.pubkey === $user.pubkey} +
    • modal.set({note: e.parent})}> +
      + + liked your note. +
      +
      + {ellipsize(e.parent.content, 240)} +
      +
    • + {/if} + {:else} +
    • + {/if} + {/each} +
    + +{#if loading} +
    +{:else if $events.length === 0} +
    No recent activity found.
    -{/each} - +{/if} diff --git a/src/routes/UserDetail.svelte b/src/routes/UserDetail.svelte index 592f259a..8cf62931 100644 --- a/src/routes/UserDetail.svelte +++ b/src/routes/UserDetail.svelte @@ -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}}) } diff --git a/src/state/app.js b/src/state/app.js index bac3c74a..ee0f55bc 100644 --- a/src/state/app.js +++ b/src/state/app.js @@ -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))