Rename alerts to notifications

This commit is contained in:
Jonathan Staab 2023-03-16 09:37:56 -05:00
parent 5138f14e00
commit fa2693d09e
22 changed files with 333 additions and 366 deletions

View File

@ -1,5 +1,6 @@
# Current
- [ ] Update license
- [ ] Test migration
- [ ] Fix notifications
- [ ] Add quotes to notifications

View File

@ -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 />

View File

@ -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) {

View File

@ -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", () => {

View File

@ -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, {

View File

@ -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")

View File

@ -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}

View File

@ -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
View 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}

View File

@ -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}

View File

@ -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

View File

@ -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"

View 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}

View File

@ -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()

View File

@ -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
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View 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}

View 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>