mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-19 11:43:35 +00:00
Rename alerts to notifications
This commit is contained in:
parent
5138f14e00
commit
fa2693d09e
@ -1,5 +1,6 @@
|
||||
# Current
|
||||
|
||||
- [ ] Update license
|
||||
- [ ] Test migration
|
||||
- [ ] Fix notifications
|
||||
- [ ] Add quotes to notifications
|
||||
|
@ -11,7 +11,7 @@
|
||||
import {createMap, first} from "hurdak/lib/hurdak"
|
||||
import {find, is, identity, nthArg, pluck} from "ramda"
|
||||
import {log, warn} from "src/util/logger"
|
||||
import {timedelta, shuffle, now, sleep} from "src/util/misc"
|
||||
import {timedelta, hexToBech32, bech32ToHex, shuffle, now, sleep} from "src/util/misc"
|
||||
import {displayPerson, isLike} from "src/util/nostr"
|
||||
import cmd from "src/agent/cmd"
|
||||
import {onReady, relays, people} from "src/agent/tables"
|
||||
@ -23,13 +23,12 @@
|
||||
import * as tables from "src/agent/tables"
|
||||
import user from "src/agent/user"
|
||||
import {loadAppData} from "src/app"
|
||||
import alerts from "src/app/alerts"
|
||||
import {modal, routes, menuIsOpen, logUsage} from "src/app/ui"
|
||||
import Anchor from "src/partials/Anchor.svelte"
|
||||
import Content from "src/partials/Content.svelte"
|
||||
import Modal from "src/partials/Modal.svelte"
|
||||
import Spinner from "src/partials/Spinner.svelte"
|
||||
import Alerts from "src/routes/Alerts.svelte"
|
||||
import Notifications from "src/routes/Notifications.svelte"
|
||||
import Bech32Entity from "src/routes/Bech32Entity.svelte"
|
||||
import ChatDetail from "src/routes/ChatDetail.svelte"
|
||||
import ChatList from "src/routes/ChatList.svelte"
|
||||
@ -65,7 +64,7 @@
|
||||
import AddRelay from "src/views/relays/AddRelay.svelte"
|
||||
import RelayCard from "src/views/relays/RelayCard.svelte"
|
||||
|
||||
Object.assign(window, {cmd, user, keys, network, pool, sync, tables})
|
||||
Object.assign(window, {cmd, user, keys, network, pool, sync, tables, bech32ToHex, hexToBech32})
|
||||
|
||||
export let url = ""
|
||||
|
||||
@ -185,7 +184,7 @@
|
||||
<div use:links class="h-full">
|
||||
{#if ready}
|
||||
<div class="h-full pt-16 text-white lg:ml-56">
|
||||
<Route path="/alerts" component={Alerts} />
|
||||
<Route path="/notifications" component={Notifications} />
|
||||
<Route path="/search">
|
||||
<EnsureData enforcePeople={false}>
|
||||
<Search />
|
||||
|
@ -125,6 +125,8 @@ class PublishableEvent {
|
||||
const pubkey = get(keys.pubkey)
|
||||
const createdAt = Math.round(new Date().valueOf() / 1000)
|
||||
|
||||
tags.push(["client", "coracle"])
|
||||
|
||||
this.event = {kind, content, tags, pubkey, created_at: createdAt}
|
||||
}
|
||||
async publish(relays, onProgress = null) {
|
||||
|
@ -261,18 +261,27 @@ const subscribe = async ({relays, filter, onEvent, onEose, onError}: SubscribeOp
|
||||
|
||||
const sub = conn.nostr.sub(filter, {
|
||||
id,
|
||||
alreadyHaveEvent: id => {
|
||||
conn.stats.eventsCount += 1
|
||||
let has = false
|
||||
if (seen.has(id)) has = true
|
||||
seen.add(id)
|
||||
return has
|
||||
},
|
||||
// This isn't currently working for some reason
|
||||
// alreadyHaveEvent: (id, url) => {
|
||||
// conn.stats.eventsCount += 1
|
||||
|
||||
// if (seen.has(id)) {
|
||||
// return true
|
||||
// }
|
||||
|
||||
// seen.add(id)
|
||||
|
||||
// return false
|
||||
// },
|
||||
})
|
||||
|
||||
sub.on("event", e => {
|
||||
// Normalize events here, annotate with relay url
|
||||
onEvent({...e, seen_on: relay.url, content: e.content || ""})
|
||||
if (!seen.has(e.id)) {
|
||||
seen.add(e.id)
|
||||
|
||||
// Normalize events here, annotate with relay url
|
||||
onEvent({...e, seen_on: relay.url, content: e.content || ""})
|
||||
}
|
||||
})
|
||||
|
||||
sub.on("eose", () => {
|
||||
|
@ -2,9 +2,9 @@ import {uniq, pick, identity} from "ramda"
|
||||
import {nip05} from "nostr-tools"
|
||||
import {noop, ensurePlural, chunk} from "hurdak/lib/hurdak"
|
||||
import {
|
||||
lnurlEncode,
|
||||
hexToBech32,
|
||||
tryFunc,
|
||||
lnurlDecode,
|
||||
bech32ToHex,
|
||||
tryFetch,
|
||||
now,
|
||||
sleep,
|
||||
@ -13,7 +13,7 @@ import {
|
||||
hash,
|
||||
} from "src/util/misc"
|
||||
import {Tags, roomAttrs, isRelay, isShareableRelay, normalizeRelayUrl} from "src/util/nostr"
|
||||
import {people, relays, rooms, routes} from "src/agent/tables"
|
||||
import {people, userEvents, relays, rooms, routes} from "src/agent/tables"
|
||||
import {uniqByUrl} from "src/agent/relays"
|
||||
import user from "src/agent/user"
|
||||
|
||||
@ -25,10 +25,15 @@ const addHandler = (kind, f) => {
|
||||
}
|
||||
|
||||
const processEvents = async events => {
|
||||
const userPubkey = user.getPubkey()
|
||||
const chunks = chunk(100, ensurePlural(events).filter(identity))
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
for (const event of chunks[i]) {
|
||||
if (event.pubkey === userPubkey) {
|
||||
userEvents.put(event)
|
||||
}
|
||||
|
||||
for (const handler of handlers[event.kind] || []) {
|
||||
handler(event)
|
||||
}
|
||||
@ -75,7 +80,7 @@ const verifyZapper = async (pubkey, address) => {
|
||||
|
||||
// Try to parse it as a lud06 LNURL or as a lud16 address
|
||||
if (address.startsWith("lnurl1")) {
|
||||
url = tryFunc(() => lnurlDecode(address))
|
||||
url = tryFunc(() => bech32ToHex(address))
|
||||
} else if (address.includes("@")) {
|
||||
const [name, domain] = address.split("@")
|
||||
|
||||
@ -90,7 +95,7 @@ const verifyZapper = async (pubkey, address) => {
|
||||
|
||||
const res = await tryFetch(() => fetch(url))
|
||||
const zapper = await tryJson(() => res?.json())
|
||||
const lnurl = lnurlEncode("lnurl", url)
|
||||
const lnurl = hexToBech32("lnurl", url)
|
||||
|
||||
if (zapper?.allowsNostr && zapper?.nostrPubkey) {
|
||||
updatePerson(pubkey, {
|
||||
|
@ -4,9 +4,10 @@ import {Table, listener, registry} from "src/agent/storage"
|
||||
|
||||
// Temporarily put no upper bound on people for 0.2.18 migration
|
||||
export const people = new Table("people", "pubkey", {maxEntries: 100000})
|
||||
export const userEvents = new Table("userEvents", "id", {maxEntries: 100000})
|
||||
export const contacts = new Table("contacts", "pubkey")
|
||||
export const rooms = new Table("rooms", "id")
|
||||
export const alerts = new Table("alerts", "id")
|
||||
export const notifications = new Table("notifications", "id", {maxEntries: 100000})
|
||||
export const relays = new Table("relays", "url")
|
||||
export const routes = new Table("routes", "id")
|
||||
|
||||
|
@ -1,189 +0,0 @@
|
||||
import type {DisplayEvent} from "src/util/types"
|
||||
import {max, find, pluck, propEq, partition, uniq} from "ramda"
|
||||
import {derived} from "svelte/store"
|
||||
import {createMap} from "hurdak/lib/hurdak"
|
||||
import {synced, tryJson, now, timedelta} from "src/util/misc"
|
||||
import {Tags, isAlert, asDisplayEvent, findReplyId} from "src/util/nostr"
|
||||
import {getUserReadRelays} from "src/agent/relays"
|
||||
import {alerts, contacts, rooms} from "src/agent/tables"
|
||||
import {watch} from "src/agent/storage"
|
||||
import network from "src/agent/network"
|
||||
|
||||
let listener
|
||||
|
||||
type AlertEvent = DisplayEvent & {
|
||||
zappedBy?: Array<string>
|
||||
likedBy: Array<string>
|
||||
repliesFrom: Array<string>
|
||||
isMention: boolean
|
||||
}
|
||||
|
||||
// State
|
||||
|
||||
const seenAlertIds = synced("app/alerts/seenAlertIds", [])
|
||||
|
||||
export const lastChecked = synced("app/alerts/lastChecked", {})
|
||||
|
||||
export const newAlerts = derived(
|
||||
[watch("alerts", t => pluck("created_at", t.all()).reduce(max, 0)), lastChecked],
|
||||
([$lastAlert, $lastChecked]) => $lastAlert > ($lastChecked.alerts || 0)
|
||||
)
|
||||
|
||||
export const newDirectMessages = derived(
|
||||
[watch("contacts", t => t.all()), lastChecked],
|
||||
([contacts, $lastChecked]) => Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], contacts))
|
||||
)
|
||||
|
||||
export const newChatMessages = derived(
|
||||
[watch("rooms", t => t.all()), lastChecked],
|
||||
([rooms, $lastChecked]) => Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], rooms))
|
||||
)
|
||||
|
||||
// Synchronization from events to state
|
||||
|
||||
const processAlerts = async (pubkey, events) => {
|
||||
// Keep track of alerts we've seen so we don't keep fetching parents repeatedly
|
||||
seenAlertIds.update($seenAlertIds => {
|
||||
const seen = new Set($seenAlertIds)
|
||||
|
||||
events = events.filter(e => isAlert(e, pubkey) && !seen.has(e.id))
|
||||
|
||||
events.forEach(e => $seenAlertIds.push(e.id))
|
||||
|
||||
return $seenAlertIds
|
||||
})
|
||||
|
||||
if (events.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const parents = createMap("id", await network.loadParents(events))
|
||||
|
||||
const asAlert = (e): AlertEvent => ({
|
||||
repliesFrom: [],
|
||||
likedBy: [],
|
||||
zappedBy: [],
|
||||
isMention: false,
|
||||
...asDisplayEvent(e),
|
||||
})
|
||||
|
||||
const isPubkeyChild = e => {
|
||||
const parentId = findReplyId(e)
|
||||
|
||||
return parents[parentId]?.pubkey === pubkey
|
||||
}
|
||||
|
||||
const [replies, mentions] = partition(isPubkeyChild, events.filter(propEq("kind", 1)))
|
||||
const likes = events.filter(propEq("kind", 7))
|
||||
const zaps = events.filter(propEq("kind", 9735))
|
||||
|
||||
zaps.filter(isPubkeyChild).forEach(e => {
|
||||
const parent = parents[findReplyId(e)]
|
||||
const note = asAlert(alerts.get(parent.id) || parent)
|
||||
const meta = Tags.from(e).asMeta()
|
||||
const request = tryJson(() => JSON.parse(meta.description))
|
||||
|
||||
if (request) {
|
||||
alerts.put({...note, zappedBy: uniq(note.zappedBy.concat(request.pubkey))})
|
||||
}
|
||||
})
|
||||
|
||||
likes.filter(isPubkeyChild).forEach(e => {
|
||||
const parent = parents[findReplyId(e)]
|
||||
const note = asAlert(alerts.get(parent.id) || parent)
|
||||
|
||||
alerts.put({...note, likedBy: uniq(note.likedBy.concat(e.pubkey))})
|
||||
})
|
||||
|
||||
replies.forEach(e => {
|
||||
const parent = parents[findReplyId(e)]
|
||||
const note = asAlert(alerts.get(parent.id) || parent)
|
||||
|
||||
alerts.put({...note, repliesFrom: uniq(note.repliesFrom.concat(e.pubkey))})
|
||||
})
|
||||
|
||||
mentions.forEach(e => {
|
||||
const note = alerts.get(e.id) || asAlert(e)
|
||||
|
||||
alerts.put({...note, isMention: true})
|
||||
})
|
||||
}
|
||||
|
||||
const processMessages = async (pubkey, events) => {
|
||||
const messages = events.filter(propEq("kind", 4))
|
||||
|
||||
if (messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
lastChecked.update($lastChecked => {
|
||||
for (const message of messages) {
|
||||
if (message.pubkey === pubkey) {
|
||||
const recipient = Tags.from(message).type("p").values().first()
|
||||
|
||||
$lastChecked[recipient] = Math.max($lastChecked[recipient] || 0, message.created_at)
|
||||
contacts.patch({pubkey: recipient, accepted: true})
|
||||
} else {
|
||||
const contact = contacts.get(message.pubkey)
|
||||
const lastMessage = Math.max(contact?.lastMessage || 0, message.created_at)
|
||||
|
||||
contacts.patch({pubkey: message.pubkey, lastMessage})
|
||||
}
|
||||
}
|
||||
|
||||
return $lastChecked
|
||||
})
|
||||
}
|
||||
|
||||
const processChats = async (pubkey, events) => {
|
||||
const messages = events.filter(propEq("kind", 42))
|
||||
|
||||
if (messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
lastChecked.update($lastChecked => {
|
||||
for (const message of messages) {
|
||||
const id = Tags.from(message).type("e").values().first()
|
||||
|
||||
if (message.pubkey === pubkey) {
|
||||
$lastChecked[id] = Math.max($lastChecked[id] || 0, message.created_at)
|
||||
} else {
|
||||
const room = rooms.get(id)
|
||||
const lastMessage = Math.max(room?.lastMessage || 0, message.created_at)
|
||||
|
||||
rooms.patch({id, lastMessage})
|
||||
}
|
||||
}
|
||||
|
||||
return $lastChecked
|
||||
})
|
||||
}
|
||||
|
||||
const listen = async pubkey => {
|
||||
// Include an offset so we don't miss alerts on one relay but not another
|
||||
const since = now() - timedelta(30, "days")
|
||||
const roomIds = pluck("id", rooms.all({joined: true}))
|
||||
|
||||
if (listener) {
|
||||
listener.unsub()
|
||||
}
|
||||
|
||||
listener = await network.listen({
|
||||
delay: 10000,
|
||||
relays: getUserReadRelays(),
|
||||
filter: [
|
||||
{kinds: [4], authors: [pubkey], since},
|
||||
{kinds: [1, 7, 4, 9735], "#p": [pubkey], since},
|
||||
{kinds: [42], "#e": roomIds, since},
|
||||
],
|
||||
onChunk: async events => {
|
||||
await network.loadPeople(pluck("pubkey", events))
|
||||
await processMessages(pubkey, events)
|
||||
await processAlerts(pubkey, events)
|
||||
await processChats(pubkey, events)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default {listen}
|
@ -8,13 +8,13 @@ import {getUserReadRelays} from "src/agent/relays"
|
||||
import {getPersonWithFallback} from "src/agent/tables"
|
||||
import network from "src/agent/network"
|
||||
import keys from "src/agent/keys"
|
||||
import alerts from "src/app/alerts"
|
||||
import listener from "src/app/listener"
|
||||
import {routes, modal, toast} from "src/app/ui"
|
||||
|
||||
export const loadAppData = async pubkey => {
|
||||
if (getUserReadRelays().length > 0) {
|
||||
// Start our listener, but don't wait for it
|
||||
alerts.listen(pubkey)
|
||||
listener.listen(pubkey)
|
||||
|
||||
// Make sure the user and their network is loaded
|
||||
await network.loadPeople([pubkey], {force: true})
|
||||
|
121
src/app/listener.ts
Normal file
121
src/app/listener.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import {sortBy, max, find, pluck, slice, propEq} from "ramda"
|
||||
import {derived} from "svelte/store"
|
||||
import {doPipe} from "hurdak/lib/hurdak"
|
||||
import {synced, now, timedelta} from "src/util/misc"
|
||||
import {Tags, isNotification} from "src/util/nostr"
|
||||
import {getUserReadRelays} from "src/agent/relays"
|
||||
import {notifications, userEvents, contacts, rooms} from "src/agent/tables"
|
||||
import {watch} from "src/agent/storage"
|
||||
import network from "src/agent/network"
|
||||
|
||||
let listener
|
||||
|
||||
// State
|
||||
|
||||
export const lastChecked = synced("app/alerts/lastChecked", {})
|
||||
|
||||
export const newNotifications = derived(
|
||||
[watch("notifications", t => pluck("created_at", t.all()).reduce(max, 0)), lastChecked],
|
||||
([$lastNotification, $lastChecked]) => $lastNotification > ($lastChecked.notifications || 0)
|
||||
)
|
||||
|
||||
export const newDirectMessages = derived(
|
||||
[watch("contacts", t => t.all()), lastChecked],
|
||||
([contacts, $lastChecked]) => Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], contacts))
|
||||
)
|
||||
|
||||
export const newChatMessages = derived(
|
||||
[watch("rooms", t => t.all()), lastChecked],
|
||||
([rooms, $lastChecked]) => Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], rooms))
|
||||
)
|
||||
|
||||
// Synchronization from events to state
|
||||
|
||||
const processNotifications = async (pubkey, events) => {
|
||||
notifications.bulkPut(events.filter(e => isNotification(e, pubkey)))
|
||||
}
|
||||
|
||||
const processMessages = async (pubkey, events) => {
|
||||
const messages = events.filter(propEq("kind", 4))
|
||||
|
||||
if (messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
lastChecked.update($lastChecked => {
|
||||
for (const message of messages) {
|
||||
if (message.pubkey === pubkey) {
|
||||
const recipient = Tags.from(message).type("p").values().first()
|
||||
|
||||
$lastChecked[recipient] = Math.max($lastChecked[recipient] || 0, message.created_at)
|
||||
contacts.patch({pubkey: recipient, accepted: true})
|
||||
} else {
|
||||
const contact = contacts.get(message.pubkey)
|
||||
const lastMessage = Math.max(contact?.lastMessage || 0, message.created_at)
|
||||
|
||||
contacts.patch({pubkey: message.pubkey, lastMessage})
|
||||
}
|
||||
}
|
||||
|
||||
return $lastChecked
|
||||
})
|
||||
}
|
||||
|
||||
const processChats = async (pubkey, events) => {
|
||||
const messages = events.filter(propEq("kind", 42))
|
||||
|
||||
if (messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
lastChecked.update($lastChecked => {
|
||||
for (const message of messages) {
|
||||
const id = Tags.from(message).type("e").values().first()
|
||||
|
||||
if (message.pubkey === pubkey) {
|
||||
$lastChecked[id] = Math.max($lastChecked[id] || 0, message.created_at)
|
||||
} else {
|
||||
const room = rooms.get(id)
|
||||
const lastMessage = Math.max(room?.lastMessage || 0, message.created_at)
|
||||
|
||||
rooms.patch({id, lastMessage})
|
||||
}
|
||||
}
|
||||
|
||||
return $lastChecked
|
||||
})
|
||||
}
|
||||
|
||||
const listen = async pubkey => {
|
||||
// Include an offset so we don't miss notifications on one relay but not another
|
||||
const since = now() - timedelta(30, "days")
|
||||
const roomIds = pluck("id", rooms.all({joined: true}))
|
||||
const eventIds = doPipe(userEvents.all({"created_at:gt": since, kind: 1}), [
|
||||
sortBy(e => -e.created_at),
|
||||
slice(0, 256),
|
||||
pluck("id"),
|
||||
])
|
||||
|
||||
if (listener) {
|
||||
listener.unsub()
|
||||
}
|
||||
|
||||
listener = await network.listen({
|
||||
delay: 10000,
|
||||
relays: getUserReadRelays(),
|
||||
filter: [
|
||||
{kinds: [1, 4], authors: [pubkey], since},
|
||||
{kinds: [1, 7, 4, 9735], "#p": [pubkey], since},
|
||||
{kinds: [1, 7, 4, 9735], "#e": eventIds, since},
|
||||
{kinds: [42], "#e": roomIds, since},
|
||||
],
|
||||
onChunk: async events => {
|
||||
await network.loadPeople(pluck("pubkey", events))
|
||||
await processNotifications(pubkey, events)
|
||||
await processMessages(pubkey, events)
|
||||
await processChats(pubkey, events)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default {listen}
|
@ -1,56 +0,0 @@
|
||||
<script>
|
||||
import {sortBy, any, assoc} from "ramda"
|
||||
import {onMount} from "svelte"
|
||||
import {fly} from "svelte/transition"
|
||||
import {now, createScroller} from "src/util/misc"
|
||||
import Spinner from "src/partials/Spinner.svelte"
|
||||
import Content from "src/partials/Content.svelte"
|
||||
import Alert from "src/views/alerts/Alert.svelte"
|
||||
import Mention from "src/views/alerts/Mention.svelte"
|
||||
import {alerts} from "src/agent/tables"
|
||||
import user from "src/agent/user"
|
||||
import {lastChecked} from "src/app/alerts"
|
||||
|
||||
let limit = 0
|
||||
let notes = null
|
||||
|
||||
onMount(() => {
|
||||
document.title = "Notifications"
|
||||
|
||||
lastChecked.update(assoc("alerts", now()))
|
||||
|
||||
return createScroller(async () => {
|
||||
limit += 10
|
||||
|
||||
// Filter out mutes, and alerts for which we failed to find the required context. The bug
|
||||
// is really upstream of this, but it's an easy fix
|
||||
const events = user
|
||||
.applyMutes(alerts.all())
|
||||
.filter(e => any(k => e[k]?.length > 0, ["replies", "likedBy", "zappedBy"]) || e.isMention)
|
||||
|
||||
notes = sortBy(e => -e.created_at, events).slice(0, limit)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if notes}
|
||||
<Content>
|
||||
{#each notes as note (note.id)}
|
||||
<div in:fly={{y: 20}}>
|
||||
{#if note.replies.length > 0}
|
||||
<Alert type="replies" {note} />
|
||||
{:else if note.zappedBy?.length > 0}
|
||||
<Alert type="zaps" {note} />
|
||||
{:else if note.likedBy.length > 0}
|
||||
<Alert type="likes" {note} />
|
||||
{:else}
|
||||
<Mention {note} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Content size="lg" class="text-center">No notifications found - check back later!</Content>
|
||||
{/each}
|
||||
</Content>
|
||||
{:else}
|
||||
<Spinner />
|
||||
{/if}
|
@ -12,7 +12,7 @@
|
||||
import {watch} from "src/agent/storage"
|
||||
import cmd from "src/agent/cmd"
|
||||
import {modal} from "src/app/ui"
|
||||
import {lastChecked} from "src/app/alerts"
|
||||
import {lastChecked} from "src/app/listener"
|
||||
import {renderNote} from "src/app"
|
||||
|
||||
export let entity
|
||||
|
@ -15,7 +15,7 @@
|
||||
import user from "src/agent/user"
|
||||
import cmd from "src/agent/cmd"
|
||||
import {routes} from "src/app/ui"
|
||||
import {lastChecked} from "src/app/alerts"
|
||||
import {lastChecked} from "src/app/listener"
|
||||
import {renderNote} from "src/app"
|
||||
import PersonCircle from "src/partials/PersonCircle.svelte"
|
||||
|
||||
|
72
src/routes/Notifications.svelte
Normal file
72
src/routes/Notifications.svelte
Normal file
@ -0,0 +1,72 @@
|
||||
<script>
|
||||
import {pluck, max, last, sortBy, assoc} from "ramda"
|
||||
import {onMount} from "svelte"
|
||||
import {fly} from "svelte/transition"
|
||||
import {now, createScroller} from "src/util/misc"
|
||||
import {findReplyId} from "src/util/nostr"
|
||||
import Spinner from "src/partials/Spinner.svelte"
|
||||
import Content from "src/partials/Content.svelte"
|
||||
import Notification from "src/views/notifications/Notification.svelte"
|
||||
import {watch} from "src/agent/storage"
|
||||
import {userEvents} from "src/agent/tables"
|
||||
import {lastChecked} from "src/app/listener"
|
||||
|
||||
let limit = 0
|
||||
let events = null
|
||||
|
||||
const prevChecked = $lastChecked.notifications || 0
|
||||
const notifications = watch("notifications", t => sortBy(e => -e.created_at, t.all()))
|
||||
|
||||
// Group notifications so we're only showing the parent once per chunk
|
||||
$: events = $notifications
|
||||
.slice(0, limit)
|
||||
.map(e => [e, findReplyId(e)])
|
||||
.filter(([e, ref]) => userEvents.get(ref))
|
||||
.reduce((r, [e, ref]) => {
|
||||
const prev = last(r)
|
||||
const prevTimestamp = pluck("created_at", prev?.notifications || []).reduce(max, 0)
|
||||
|
||||
if (ref && prev?.ref === ref) {
|
||||
prev.notifications.push(e)
|
||||
} else {
|
||||
r = r.concat({
|
||||
ref,
|
||||
key: e.id,
|
||||
notifications: [e],
|
||||
showLine: e.created_at < prevChecked && prevTimestamp >= prevChecked,
|
||||
})
|
||||
}
|
||||
|
||||
return r
|
||||
}, [])
|
||||
|
||||
onMount(() => {
|
||||
document.title = "Notifications"
|
||||
|
||||
lastChecked.update(assoc("notifications", now()))
|
||||
|
||||
return createScroller(async () => {
|
||||
limit += 50
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if events}
|
||||
<Content>
|
||||
{#each events as event (event.key)}
|
||||
<div in:fly={{y: 20}}>
|
||||
<Notification {event} />
|
||||
</div>
|
||||
{#if event.showLine}
|
||||
<div class="flex items-center gap-4">
|
||||
<small class="whitespace-nowrap text-light">Older notifications</small>
|
||||
<div class="h-px w-full bg-medium" />
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<Content size="lg" class="text-center">No notifications found - check back later!</Content>
|
||||
{/each}
|
||||
</Content>
|
||||
{:else}
|
||||
<Spinner />
|
||||
{/if}
|
@ -376,10 +376,10 @@ export const uploadFile = (url, fileObj) => {
|
||||
return fetchJson(url, {method: "POST", body})
|
||||
}
|
||||
|
||||
export const lnurlEncode = (prefix, url) =>
|
||||
export const hexToBech32 = (prefix, url) =>
|
||||
bech32.encode(prefix, bech32.toWords(utf8.decode(url)), false)
|
||||
|
||||
export const lnurlDecode = b32 => utf8.encode(bech32.fromWords(bech32.decode(b32, false).words))
|
||||
export const bech32ToHex = b32 => utf8.encode(bech32.fromWords(bech32.decode(b32, false).words))
|
||||
|
||||
export const formatSats = sats => {
|
||||
const formatter = new Intl.NumberFormat()
|
||||
|
@ -90,7 +90,7 @@ export const displayRelay = ({url}) => last(url.split("://"))
|
||||
|
||||
export const isLike = content => ["", "+", "🤙", "👍", "❤️", "😎", "🏅"].includes(content)
|
||||
|
||||
export const isAlert = (e, pubkey) => {
|
||||
export const isNotification = (e, pubkey) => {
|
||||
if (![1, 7, 9735].includes(e.kind)) {
|
||||
return false
|
||||
}
|
||||
|
@ -3,7 +3,7 @@
|
||||
import {displayPerson} from "src/util/nostr"
|
||||
import user from "src/agent/user"
|
||||
import {menuIsOpen, installPrompt, routes} from "src/app/ui"
|
||||
import {newAlerts, newDirectMessages, newChatMessages} from "src/app/alerts"
|
||||
import {newNotifications, newDirectMessages, newChatMessages} from "src/app/listener"
|
||||
import {slowConnections} from "src/app/connection"
|
||||
import PersonCircle from "src/partials/PersonCircle.svelte"
|
||||
|
||||
@ -36,9 +36,9 @@
|
||||
</a>
|
||||
</li>
|
||||
<li class="relative cursor-pointer">
|
||||
<a class="block px-4 py-2 transition-all hover:bg-accent" href="/alerts">
|
||||
<a class="block px-4 py-2 transition-all hover:bg-accent" href="/notifications">
|
||||
<i class="fa fa-bell mr-2" /> Notifications
|
||||
{#if $newAlerts}
|
||||
{#if $newNotifications}
|
||||
<div class="absolute top-3 left-6 h-2 w-2 rounded bg-accent" />
|
||||
{/if}
|
||||
</a>
|
||||
|
@ -2,7 +2,7 @@
|
||||
import {onMount} from "svelte"
|
||||
import Anchor from "src/partials/Anchor.svelte"
|
||||
import {menuIsOpen} from "src/app/ui"
|
||||
import {newAlerts} from "src/app/alerts"
|
||||
import {newNotifications} from "src/app/listener"
|
||||
|
||||
const toggleMenu = () => menuIsOpen.update(x => !x)
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
<img alt="Coracle Logo" src="/images/logo.png" class="w-8" />
|
||||
<h1 class="staatliches text-3xl">Coracle</h1>
|
||||
</Anchor>
|
||||
{#if $newAlerts}
|
||||
{#if $newNotifications}
|
||||
<div class="absolute top-4 left-12 h-2 w-2 rounded bg-accent lg:hidden" />
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -1,48 +0,0 @@
|
||||
<script>
|
||||
import {ellipsize, quantify, switcher} from "hurdak/lib/hurdak"
|
||||
import Badge from "src/partials/Badge.svelte"
|
||||
import Popover from "src/partials/Popover.svelte"
|
||||
import {formatTimestamp} from "src/util/misc"
|
||||
import {getPersonWithFallback} from "src/agent/tables"
|
||||
import {modal} from "src/app/ui"
|
||||
|
||||
export let note
|
||||
export let type
|
||||
|
||||
const pubkeys = switcher(type, {
|
||||
replies: note.repliesFrom,
|
||||
likes: note.likedBy,
|
||||
zaps: note.zappedBy,
|
||||
})
|
||||
|
||||
const actionText = switcher(type, {
|
||||
replies: "replied to your note",
|
||||
likes: "liked your note",
|
||||
zaps: "zapped your note",
|
||||
})
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="flex w-full cursor-pointer flex-col gap-2 border border-solid border-black py-2
|
||||
px-3 text-left text-white transition-all hover:border-medium hover:bg-dark"
|
||||
on:click={() => modal.set({type: "note/detail", note})}>
|
||||
<div class="relative flex w-full items-center justify-between gap-2" on:click|stopPropagation>
|
||||
<Popover>
|
||||
<div slot="trigger">
|
||||
{quantify(pubkeys.length, "person", "people")}
|
||||
{actionText}.
|
||||
</div>
|
||||
<div slot="tooltip">
|
||||
<div class="grid grid-cols-2 gap-y-2 gap-x-4">
|
||||
{#each pubkeys as pubkey}
|
||||
<Badge person={getPersonWithFallback(pubkey)} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
<p class="text-sm text-light">{formatTimestamp(note.created_at)}</p>
|
||||
</div>
|
||||
<div class="ml-6 text-light">
|
||||
{ellipsize(note.content, 120)}
|
||||
</div>
|
||||
</button>
|
@ -1,40 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {ellipsize} from "hurdak/lib/hurdak"
|
||||
import {formatTimestamp} from "src/util/misc"
|
||||
import {displayPerson} from "src/util/nostr"
|
||||
import Popover from "src/partials/Popover.svelte"
|
||||
import PersonSummary from "src/views/person/PersonSummary.svelte"
|
||||
import {getPersonWithFallback} from "src/agent/tables"
|
||||
import {modal} from "src/app/ui"
|
||||
import PersonCircle from "src/partials/PersonCircle.svelte"
|
||||
|
||||
export let note
|
||||
|
||||
const person = getPersonWithFallback(note.pubkey)
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="flex w-full cursor-pointer flex-col gap-2 border border-solid border-black py-2
|
||||
px-3 text-left text-white transition-all hover:border-medium hover:bg-dark"
|
||||
on:click={() => modal.set({type: "note/detail", note})}>
|
||||
<div class="relative flex w-full items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<PersonCircle {person} />
|
||||
<div on:click|stopPropagation>
|
||||
<Popover class="inline-block">
|
||||
<div slot="trigger" class="font-bold">
|
||||
{displayPerson(person)}
|
||||
</div>
|
||||
<div slot="tooltip">
|
||||
<PersonSummary pubkey={note.pubkey} />
|
||||
</div>
|
||||
</Popover>
|
||||
<div class="inline-block">mentioned you.</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-light">{formatTimestamp(note.created_at)}</p>
|
||||
</div>
|
||||
<div class="ml-6 text-light">
|
||||
{ellipsize(note.content, 120)}
|
||||
</div>
|
||||
</button>
|
@ -5,7 +5,7 @@
|
||||
import {ellipsize} from "hurdak/lib/hurdak"
|
||||
import {displayPerson} from "src/util/nostr"
|
||||
import {getPersonWithFallback} from "src/agent/tables"
|
||||
import {lastChecked} from "src/app/alerts"
|
||||
import {lastChecked} from "src/app/listener"
|
||||
import PersonCircle from "src/partials/PersonCircle.svelte"
|
||||
|
||||
export let contact
|
||||
|
75
src/views/notifications/Notification.svelte
Normal file
75
src/views/notifications/Notification.svelte
Normal file
@ -0,0 +1,75 @@
|
||||
<script>
|
||||
import {max, pipe, filter, map, when, identity, pluck, propEq, uniq} from "ramda"
|
||||
import {ellipsize, closure, quantify} from "hurdak/lib/hurdak"
|
||||
import {formatTimestamp, tryJson} from "src/util/misc"
|
||||
import {Tags} from "src/util/nostr"
|
||||
import Badge from "src/partials/Badge.svelte"
|
||||
import Popover from "src/partials/Popover.svelte"
|
||||
import NotificationSection from "src/views/notifications/NotificationSection.svelte"
|
||||
import {getPersonWithFallback, userEvents} from "src/agent/tables"
|
||||
import {modal} from "src/app/ui"
|
||||
|
||||
export let event
|
||||
|
||||
// Translate zap confirmations to zap requests
|
||||
const modifyZaps = pipe(
|
||||
map(
|
||||
when(propEq("kind", 9735), e => tryJson(() => JSON.parse(Tags.from(e).asMeta().description)))
|
||||
),
|
||||
filter(identity)
|
||||
)
|
||||
|
||||
const notifications = modifyZaps(event.notifications)
|
||||
const note = event.ref ? userEvents.get(event.ref) : notifications[0]
|
||||
const timestamp = pluck("created_at", notifications).reduce(max, 0)
|
||||
const replies = notifications.filter(propEq("kind", 1))
|
||||
const likes = notifications.filter(propEq("kind", 7))
|
||||
const zaps = notifications.filter(propEq("kind", 9734))
|
||||
const author = getPersonWithFallback(note?.pubkey)
|
||||
const pubkeys = uniq(pluck("pubkey", notifications))
|
||||
|
||||
const actionText = closure(() => {
|
||||
if (replies.length === notifications.length) return "replied to"
|
||||
if (likes.length === notifications.length) return "liked"
|
||||
if (zaps.length === notifications.length) return "zapped"
|
||||
|
||||
return "interacted with"
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if note}
|
||||
<button
|
||||
class="flex w-full cursor-pointer flex-col gap-2 border border-solid border-black py-2
|
||||
px-3 text-left text-white transition-all hover:border-medium hover:bg-dark"
|
||||
on:click={() => modal.set({type: "note/detail", note})}>
|
||||
<div class="relative flex w-full items-center justify-between gap-2" on:click|stopPropagation>
|
||||
{#if !event.ref}
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge person={author} /> mentioned you.
|
||||
</div>
|
||||
{:else}
|
||||
<Popover>
|
||||
<div slot="trigger">
|
||||
{quantify(pubkeys.length, "person", "people")}
|
||||
{actionText} your note.
|
||||
</div>
|
||||
<div slot="tooltip" class="flex flex-col gap-4">
|
||||
{#if zaps.length > 0}
|
||||
<NotificationSection pubkeys={pluck("pubkey", zaps)}>Zapped by</NotificationSection>
|
||||
{/if}
|
||||
{#if likes.length > 0}
|
||||
<NotificationSection pubkeys={pluck("pubkey", likes)}>Liked by</NotificationSection>
|
||||
{/if}
|
||||
{#if replies.length > 0}
|
||||
<NotificationSection pubkeys={pluck("pubkey", replies)}>Replies</NotificationSection>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover>
|
||||
{/if}
|
||||
<p class="text-sm text-light">{formatTimestamp(timestamp)}</p>
|
||||
</div>
|
||||
<div class="ml-6 text-light">
|
||||
{ellipsize(note.content, 120)}
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
15
src/views/notifications/NotificationSection.svelte
Normal file
15
src/views/notifications/NotificationSection.svelte
Normal file
@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import Badge from "src/partials/Badge.svelte"
|
||||
import {getPersonWithFallback} from "src/agent/tables"
|
||||
|
||||
export let pubkeys
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<slot />
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each pubkeys as pubkey}
|
||||
<Badge person={getPersonWithFallback(pubkey)} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
Loading…
Reference in New Issue
Block a user