mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-30 00:41:12 +00:00
Add alerts page
This commit is contained in:
parent
06ebe73f88
commit
7eb8caf0b1
@ -1,2 +1 @@
|
|||||||
_VITE_DUFFLEPUD_URL=http://localhost:8000
|
VITE_DUFFLEPUD_URL=http://localhost:8000
|
||||||
VITE_DUFFLEPUD_URL=https://dufflepud.onrender.com
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
Bugs
|
Bugs
|
||||||
|
|
||||||
|
- [ ] Add alerts for replies to posts the user liked
|
||||||
- [ ] Support bech32 keys/add guide on how to convert
|
- [ ] Support bech32 keys/add guide on how to convert
|
||||||
- [ ] Loading icon not showing at bottom
|
- [ ] Loading icon not showing at bottom
|
||||||
- [ ] uniq and sortBy are sprinkled all over the place, figure out a better solution
|
- [ ] uniq and sortBy are sprinkled all over the place, figure out a better solution
|
||||||
|
@ -10,8 +10,9 @@
|
|||||||
import {Router, Route, links, navigate} from "svelte-routing"
|
import {Router, Route, links, navigate} from "svelte-routing"
|
||||||
import {globalHistory} from "svelte-routing/src/history"
|
import {globalHistory} from "svelte-routing/src/history"
|
||||||
import {hasParent} from 'src/util/html'
|
import {hasParent} from 'src/util/html'
|
||||||
|
import {timedelta} from 'src/util/misc'
|
||||||
import {store as toast} from "src/state/toast"
|
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 {modal, logout, alerts} from "src/state/app"
|
||||||
import {user} from 'src/state/user'
|
import {user} from 'src/state/user'
|
||||||
import Anchor from 'src/partials/Anchor.svelte'
|
import Anchor from 'src/partials/Anchor.svelte'
|
||||||
@ -42,36 +43,32 @@
|
|||||||
let menuIcon
|
let menuIcon
|
||||||
let scrollY
|
let scrollY
|
||||||
let suspendedSubs = []
|
let suspendedSubs = []
|
||||||
let hasAlerts = false
|
let mostRecentAlert = epoch
|
||||||
|
|
||||||
export let url = ""
|
export let url = ""
|
||||||
|
|
||||||
onMount(() => {
|
// Close menu on click outside
|
||||||
// Close menu on click outside
|
document.querySelector("html").addEventListener("click", e => {
|
||||||
document.querySelector("html").addEventListener("click", e => {
|
if (e.target !== menuIcon) {
|
||||||
if (e.target !== menuIcon) {
|
menuIsOpen.set(false)
|
||||||
menuIsOpen.set(false)
|
}
|
||||||
}
|
})
|
||||||
})
|
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
// Poll for new notifications
|
// Poll for new notifications
|
||||||
setInterval(throttle(10_000, async () => {
|
(async function pollForNotifications() {
|
||||||
if ($user) {
|
if ($user) {
|
||||||
const events = await channels.getter.all({
|
const events = await channels.getter.all({
|
||||||
limit: 1000,
|
|
||||||
kinds: [1, 7],
|
kinds: [1, 7],
|
||||||
'#p': $user.id,
|
'#p': $user.pubkey,
|
||||||
|
since: $alerts.since,
|
||||||
})
|
})
|
||||||
|
|
||||||
const items = events//.filter(e => e.pubkey !== $user.pubkey)
|
mostRecentAlert = events.reduce((t, e) => Math.max(t, e.created_at), $alerts.since)
|
||||||
|
|
||||||
alerts.set({...$alerts, items})
|
|
||||||
|
|
||||||
hasAlerts = items.filter(e => e.created_at > $alerts.since).length > 0
|
|
||||||
} else {
|
|
||||||
hasAlerts = false
|
|
||||||
}
|
}
|
||||||
}), 100)
|
|
||||||
|
setTimeout(pollForNotifications, 60_000)
|
||||||
|
})()
|
||||||
|
|
||||||
return modal.subscribe($modal => {
|
return modal.subscribe($modal => {
|
||||||
// Keep scroll position on body, but don't allow scrolling
|
// Keep scroll position on body, but don't allow scrolling
|
||||||
@ -142,7 +139,7 @@
|
|||||||
<li class="cursor-pointer relative">
|
<li class="cursor-pointer relative">
|
||||||
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/alerts">
|
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/alerts">
|
||||||
<i class="fa-solid fa-bell mr-2" /> 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" />
|
<div class="w-2 h-2 rounded bg-accent absolute top-3 left-6" />
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
@ -202,7 +199,7 @@
|
|||||||
<Anchor external type="unstyled" href="https://github.com/staab/coracle">
|
<Anchor external type="unstyled" href="https://github.com/staab/coracle">
|
||||||
<h1 class="staatliches text-3xl">Coracle</h1>
|
<h1 class="staatliches text-3xl">Coracle</h1>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
{#if hasAlerts}
|
{#if mostRecentAlert > $alerts.since}
|
||||||
<div class="w-2 h-2 rounded bg-accent absolute top-4 left-12" />
|
<div class="w-2 h-2 rounded bg-accent absolute top-4 left-12" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
18
src/partials/Card.svelte
Normal file
18
src/partials/Card.svelte
Normal 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>
|
@ -2,7 +2,7 @@
|
|||||||
import cx from 'classnames'
|
import cx from 'classnames'
|
||||||
import {find, uniqBy, prop, whereEq} from 'ramda'
|
import {find, uniqBy, prop, whereEq} from 'ramda'
|
||||||
import {onMount} from 'svelte'
|
import {onMount} from 'svelte'
|
||||||
import {fly, slide} from 'svelte/transition'
|
import {slide} from 'svelte/transition'
|
||||||
import {navigate} from 'svelte-routing'
|
import {navigate} from 'svelte-routing'
|
||||||
import {ellipsize} from 'hurdak/src/core'
|
import {ellipsize} from 'hurdak/src/core'
|
||||||
import {hasParent, toHtml, findLink} from 'src/util/html'
|
import {hasParent, toHtml, findLink} from 'src/util/html'
|
||||||
@ -14,6 +14,7 @@
|
|||||||
import {user} from "src/state/user"
|
import {user} from "src/state/user"
|
||||||
import {formatTimestamp} from 'src/util/misc'
|
import {formatTimestamp} from 'src/util/misc'
|
||||||
import UserBadge from "src/partials/UserBadge.svelte"
|
import UserBadge from "src/partials/UserBadge.svelte"
|
||||||
|
import Card from "src/partials/Card.svelte"
|
||||||
|
|
||||||
export let note
|
export let note
|
||||||
export let depth = 0
|
export let depth = 0
|
||||||
@ -99,14 +100,7 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<Card on:click={onClick} {interactive} {invertColors}>
|
||||||
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,
|
|
||||||
})}>
|
|
||||||
<div class="flex gap-4 items-center justify-between">
|
<div class="flex gap-4 items-center justify-between">
|
||||||
<UserBadge user={{...$accounts[note.pubkey], pubkey: note.pubkey}} />
|
<UserBadge user={{...$accounts[note.pubkey], pubkey: note.pubkey}} />
|
||||||
<p class="text-sm text-light">{formatTimestamp(note.created_at)}</p>
|
<p class="text-sm text-light">{formatTimestamp(note.created_at)}</p>
|
||||||
@ -155,7 +149,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{#if reply !== null}
|
{#if reply !== null}
|
||||||
<div
|
<div
|
||||||
|
@ -1,34 +1,99 @@
|
|||||||
<script>
|
<script>
|
||||||
import {onMount} from 'svelte'
|
import {onMount, onDestroy} from 'svelte'
|
||||||
import {propEq} from 'ramda'
|
import {fly} from 'svelte/transition'
|
||||||
import {createMap} from 'hurdak/lib/hurdak'
|
import {writable} from 'svelte/store'
|
||||||
|
import {sortBy, uniqBy, prop} from 'ramda'
|
||||||
import {now} from 'src/util/misc'
|
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'
|
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
|
// Clear notification badge
|
||||||
alerts.set({...$alerts, since: now()})
|
alerts.set({...$alerts, since: now()})
|
||||||
|
|
||||||
annotateNotes($alerts.items.filter(propEq('kind', 1))).then($notes => {
|
cursor = new Cursor({kinds: [1, 7], '#p': [$user.pubkey]})
|
||||||
notesById = createMap('id', $notes)
|
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>
|
</script>
|
||||||
|
|
||||||
{#each $alerts.items as e (e.id)}
|
<svelte:window on:scroll={scroller?.start} />
|
||||||
{#if e.kind === 1 && notesById}
|
|
||||||
<Note interactive note={notesById[e.id]} />
|
<ul class="py-4 flex flex-col gap-2 max-w-xl m-auto">
|
||||||
{:else if e.kind === 7}
|
{#each $events as e (e.id)}
|
||||||
hi
|
{#if e.kind === 7}
|
||||||
{/if}
|
<!-- don't show alerts for likes of replies to this user's notes -->
|
||||||
{:else}
|
{#if e.parent?.pubkey === $user.pubkey}
|
||||||
<div class="flex w-full justify-center items-center py-16">
|
<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">
|
<div class="text-center max-w-md">
|
||||||
No recent activity found.
|
No recent activity found.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/if}
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
const follow = () => {
|
const follow = () => {
|
||||||
const petnames = $currentUser.petnames
|
const petnames = $currentUser.petnames
|
||||||
.concat([t("p", user.pubkey, user.name)])
|
.concat([t("p", pubkey, user?.name)])
|
||||||
|
|
||||||
dispatch('account/petnames', petnames)
|
dispatch('account/petnames', petnames)
|
||||||
|
|
||||||
@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
const unfollow = () => {
|
const unfollow = () => {
|
||||||
const petnames = $currentUser.petnames
|
const petnames = $currentUser.petnames
|
||||||
.filter(([_, pubkey]) => pubkey !== user.pubkey)
|
.filter(([_, pubkey]) => pubkey !== pubkey)
|
||||||
|
|
||||||
dispatch('account/petnames', petnames)
|
dispatch('account/petnames', petnames)
|
||||||
|
|
||||||
@ -42,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openAdvanced = () => {
|
const openAdvanced = () => {
|
||||||
modal.set({form: 'user/advanced', user})
|
modal.set({form: 'user/advanced', user: user || {pubkey}})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -189,7 +189,6 @@ export const threadify = async notes => {
|
|||||||
user: $accounts[note.pubkey],
|
user: $accounts[note.pubkey],
|
||||||
reactions: reactionsByParent[note.id] || [],
|
reactions: reactionsByParent[note.id] || [],
|
||||||
children: uniqBy(prop('id'), _notes.filter(n => findReply(n) === note.id)).map(annotate),
|
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) => {
|
export const annotateNewNote = async (note) => {
|
||||||
await ensureAccounts([note.pubkey])
|
await ensureAccounts([note.pubkey])
|
||||||
|
|
||||||
@ -249,13 +284,12 @@ export const annotateNewNote = async (note) => {
|
|||||||
return {
|
return {
|
||||||
...note,
|
...note,
|
||||||
user: $accounts[note.pubkey],
|
user: $accounts[note.pubkey],
|
||||||
numberOfAncestors: note.tags.filter(([x]) => x === 'e').length,
|
|
||||||
children: [],
|
children: [],
|
||||||
reactions: [],
|
reactions: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const notesListener = (notes, filter, {shouldMuffle = false} = {}) => {
|
export const notesListener = (notes, filter, {shouldMuffle = false, repliesOnly = false} = {}) => {
|
||||||
const updateNote = (note, id, f) => {
|
const updateNote = (note, id, f) => {
|
||||||
if (note.id === id) {
|
if (note.id === id) {
|
||||||
return f(note)
|
return f(note)
|
||||||
@ -296,7 +330,7 @@ export const notesListener = (notes, filter, {shouldMuffle = false} = {}) => {
|
|||||||
const note = await annotateNewNote(e)
|
const note = await annotateNewNote(e)
|
||||||
|
|
||||||
updateNotes(id, n => ({...n, children: n.children.concat(note)}))
|
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])
|
const [note] = await threadify([e])
|
||||||
|
|
||||||
notes.update($notes => [note].concat($notes))
|
notes.update($notes => [note].concat($notes))
|
||||||
|
Loading…
Reference in New Issue
Block a user