feat: notifications

This commit is contained in:
codytseng
2024-12-12 16:58:37 +08:00
parent 57aa3be645
commit 94b9272042
17 changed files with 447 additions and 30 deletions

View File

@@ -1,30 +1,36 @@
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export function formatTimestamp(timestamp: number) { export function FormattedTimestamp({
timestamp,
short = false
}: {
timestamp: number
short?: boolean
}) {
const { t } = useTranslation() const { t } = useTranslation()
const time = dayjs(timestamp * 1000) const time = dayjs(timestamp * 1000)
const now = dayjs() const now = dayjs()
const diffMonth = now.diff(time, 'month') const diffMonth = now.diff(time, 'month')
if (diffMonth >= 1) { if (diffMonth >= 2) {
return t('date', { timestamp: time.valueOf() }) return t('date', { timestamp: time.valueOf() })
} }
const diffDay = now.diff(time, 'day') const diffDay = now.diff(time, 'day')
if (diffDay >= 1) { if (diffDay >= 1) {
return t('n days ago', { n: diffDay }) return short ? t('n d', { n: diffDay }) : t('n days ago', { n: diffDay })
} }
const diffHour = now.diff(time, 'hour') const diffHour = now.diff(time, 'hour')
if (diffHour >= 1) { if (diffHour >= 1) {
return t('n hours ago', { n: diffHour }) return short ? t('n h', { n: diffHour }) : t('n hours ago', { n: diffHour })
} }
const diffMinute = now.diff(time, 'minute') const diffMinute = now.diff(time, 'minute')
if (diffMinute >= 1) { if (diffMinute >= 1) {
return t('n minutes ago', { n: diffMinute }) return short ? t('n m', { n: diffMinute }) : t('n minutes ago', { n: diffMinute })
} }
return t('just now') return short ? t('n s', { n: now.diff(time, 'second') }) : t('just now')
} }

View File

@@ -1,12 +1,12 @@
import { useSecondaryPage } from '@renderer/PageManager' import { useSecondaryPage } from '@renderer/PageManager'
import { toNote } from '@renderer/lib/link' import { toNote } from '@renderer/lib/link'
import { formatTimestamp } from '@renderer/lib/timestamp'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import Content from '../Content' import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp'
import NoteStats from '../NoteStats' import NoteStats from '../NoteStats'
import ParentNotePreview from '../ParentNotePreview'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import Username from '../Username' import Username from '../Username'
import ParentNotePreview from '../ParentNotePreview'
export default function Note({ export default function Note({
event, event,
@@ -38,7 +38,7 @@ export default function Note({
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'} skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
/> />
<div className="text-xs text-muted-foreground line-clamp-1"> <div className="text-xs text-muted-foreground line-clamp-1">
{formatTimestamp(event.created_at)} <FormattedTimestamp timestamp={event.created_at} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -56,9 +56,12 @@ export default function NoteList({
if (!areAlgoRelays) { if (!areAlgoRelays) {
events.sort((a, b) => b.created_at - a.created_at) events.sort((a, b) => b.created_at - a.created_at)
} }
events = events.slice(0, noteFilter.limit)
if (events.length > 0) { if (events.length > 0) {
setEvents((pre) => [...pre, ...events]) setEvents((pre) => [...pre, ...events])
setUntil(events[events.length - 1].created_at - 1) setUntil(events[events.length - 1].created_at - 1)
} else {
setHasMore(false)
} }
if (areAlgoRelays) { if (areAlgoRelays) {
setHasMore(false) setHasMore(false)
@@ -111,7 +114,9 @@ export default function NoteList({
const loadMore = async () => { const loadMore = async () => {
const events = await client.fetchEvents(relayUrls, { ...noteFilter, until }, true) const events = await client.fetchEvents(relayUrls, { ...noteFilter, until }, true)
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) const sortedEvents = events
.sort((a, b) => b.created_at - a.created_at)
.slice(0, noteFilter.limit)
if (sortedEvents.length === 0) { if (sortedEvents.length === 0) {
setHasMore(false) setHasMore(false)
return return

View File

@@ -0,0 +1,39 @@
import { Button } from '@renderer/components/ui/button'
import { toNotifications } from '@renderer/lib/link'
import { useSecondaryPage } from '@renderer/PageManager'
import { Bell } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function NotificationButton({
variant = 'titlebar'
}: {
variant?: 'sidebar' | 'titlebar' | 'small-screen-titlebar'
}) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
if (variant === 'sidebar') {
return (
<Button
variant={variant}
size={variant}
title={t('notifications')}
onClick={() => push(toNotifications())}
>
<Bell />
{t('Notifications')}
</Button>
)
}
return (
<Button
variant={variant}
size={variant}
title={t('notifications')}
onClick={() => push(toNotifications())}
>
<Bell />
</Button>
)
}

View File

@@ -0,0 +1,205 @@
import { useFetchEvent } from '@renderer/hooks'
import { toNote } from '@renderer/lib/link'
import { tagNameEquals } from '@renderer/lib/tag'
import { useSecondaryPage } from '@renderer/PageManager'
import { useNostr } from '@renderer/providers/NostrProvider'
import client from '@renderer/services/client.service'
import dayjs from 'dayjs'
import { Heart, MessageCircle, Repeat } from 'lucide-react'
import { Event, kinds, nip19, validateEvent } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FormattedTimestamp } from '../FormattedTimestamp'
import UserAvatar from '../UserAvatar'
const LIMIT = 50
export default function NotificationList() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const [initialized, setInitialized] = useState(false)
const [notifications, setNotifications] = useState<Event[]>([])
const [until, setUntil] = useState<number>(dayjs().unix())
const bottomRef = useRef<HTMLDivElement | null>(null)
const observer = useRef<IntersectionObserver | null>(null)
const [hasMore, setHasMore] = useState(true)
useEffect(() => {
if (!pubkey) {
setHasMore(false)
return
}
const init = async () => {
setHasMore(true)
const subCloser = await client.subscribeNotifications(pubkey, LIMIT, {
onNotifications: (events, isCache) => {
setNotifications(events)
setUntil(events.length ? events[events.length - 1].created_at - 1 : dayjs().unix())
if (!isCache) {
setInitialized(true)
}
},
onNew: (event) => {
setNotifications((oldEvents) => [event, ...oldEvents])
}
})
return subCloser
}
const promise = init()
return () => {
promise.then((closer) => closer?.())
}
}, [pubkey])
useEffect(() => {
if (!initialized) return
const options = {
root: null,
rootMargin: '10px',
threshold: 1
}
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMore()
}
}, options)
if (bottomRef.current) {
observer.current.observe(bottomRef.current)
}
return () => {
if (observer.current && bottomRef.current) {
observer.current.unobserve(bottomRef.current)
}
}
}, [until, initialized, hasMore])
const loadMore = async () => {
if (!pubkey) return
const notifications = await client.fetchMoreNotifications(pubkey, until, LIMIT)
if (notifications.length === 0) {
setHasMore(false)
return
}
if (notifications.length > 0) {
setNotifications((oldNotifications) => [...oldNotifications, ...notifications])
}
setUntil(notifications[notifications.length - 1].created_at - 1)
}
return (
<div className="">
{notifications.map((notification, index) => (
<NotificationItem key={index} notification={notification} />
))}
<div className="text-center text-sm text-muted-foreground">
{hasMore ? <div ref={bottomRef}>{t('loading...')}</div> : t('no more notifications')}
</div>
</div>
)
}
function NotificationItem({ notification }: { notification: Event }) {
if (notification.kind === kinds.Reaction) {
return <ReactionNotification notification={notification} />
}
if (notification.kind === kinds.ShortTextNote) {
return <ReplyNotification notification={notification} />
}
if (notification.kind === kinds.Repost) {
return <RepostNotification notification={notification} />
}
return null
}
function ReactionNotification({ notification }: { notification: Event }) {
const { push } = useSecondaryPage()
const bech32Id = useMemo(() => {
const eTag = notification.tags.findLast(tagNameEquals('e'))
const pTag = notification.tags.find(tagNameEquals('p'))
const eventId = eTag?.[1]
const author = pTag?.[1]
return eventId
? nip19.neventEncode(author ? { id: eventId, author } : { id: eventId })
: undefined
}, [notification.id])
const { event } = useFetchEvent(bech32Id)
if (!event || !bech32Id || event.kind !== kinds.ShortTextNote) return null
return (
<div
className="flex items-center justify-between cursor-pointer py-2"
onClick={() => push(toNote(bech32Id))}
>
<div className="flex gap-2 items-center flex-1">
<UserAvatar userId={notification.pubkey} size="small" />
<Heart size={24} className="text-red-400" />
<ContentPreview event={event} />
</div>
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
)
}
function ReplyNotification({ notification }: { notification: Event }) {
const { push } = useSecondaryPage()
return (
<div
className="flex gap-2 items-center cursor-pointer py-2"
onClick={() => push(toNote(notification.id))}
>
<UserAvatar userId={notification.pubkey} size="small" />
<MessageCircle size={24} className="text-blue-400" />
<ContentPreview event={notification} />
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
)
}
function RepostNotification({ notification }: { notification: Event }) {
const { push } = useSecondaryPage()
const event = useMemo(() => {
try {
const event = JSON.parse(notification.content) as Event
const isValid = validateEvent(event)
if (!isValid) return null
client.addEventToCache(event)
return event
} catch {
return null
}
}, [])
if (!event) return null
return (
<div
className="flex gap-2 items-center cursor-pointer py-2"
onClick={() => push(toNote(event.id))}
>
<UserAvatar userId={notification.pubkey} size="small" />
<Repeat size={24} className="text-green-400" />
<ContentPreview event={event} />
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
)
}
function ContentPreview({ event }: { event?: Event }) {
if (!event || event.kind !== kinds.ShortTextNote) return null
return <div className="truncate flex-1 w-0">{event.content}</div>
}

View File

@@ -1,13 +1,13 @@
import { formatTimestamp } from '@renderer/lib/timestamp'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Content from '../Content' import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp'
import LikeButton from '../NoteStats/LikeButton' import LikeButton from '../NoteStats/LikeButton'
import ParentNotePreview from '../ParentNotePreview' import ParentNotePreview from '../ParentNotePreview'
import PostDialog from '../PostDialog' import PostDialog from '../PostDialog'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import Username from '../Username' import Username from '../Username'
import { useTranslation } from 'react-i18next'
export default function ReplyNote({ export default function ReplyNote({
event, event,
@@ -39,7 +39,9 @@ export default function ReplyNote({
)} )}
<Content event={event} size="small" /> <Content event={event} size="small" />
<div className="flex gap-2 text-xs"> <div className="flex gap-2 text-xs">
<div className="text-muted-foreground/60">{formatTimestamp(event.created_at)}</div> <div className="text-muted-foreground/60">
<FormattedTimestamp timestamp={event.created_at} />
</div>
<div <div
className="text-muted-foreground hover:text-primary cursor-pointer" className="text-muted-foreground hover:text-primary cursor-pointer"
onClick={() => setIsPostDialogOpen(true)} onClick={() => setIsPostDialogOpen(true)}

View File

@@ -7,6 +7,7 @@ import { Info } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AboutInfoDialog from '../AboutInfoDialog' import AboutInfoDialog from '../AboutInfoDialog'
import AccountButton from '../AccountButton' import AccountButton from '../AccountButton'
import NotificationButton from '../NotificationButton'
import PostButton from '../PostButton' import PostButton from '../PostButton'
import RefreshButton from '../RefreshButton' import RefreshButton from '../RefreshButton'
import RelaySettingsButton from '../RelaySettingsButton' import RelaySettingsButton from '../RelaySettingsButton'
@@ -24,6 +25,7 @@ export default function PrimaryPageSidebar() {
</div> </div>
<PostButton variant="sidebar" /> <PostButton variant="sidebar" />
<RelaySettingsButton variant="sidebar" /> <RelaySettingsButton variant="sidebar" />
<NotificationButton variant="sidebar" />
<SearchButton variant="sidebar" /> <SearchButton variant="sidebar" />
<RefreshButton variant="sidebar" /> <RefreshButton variant="sidebar" />
{!IS_ELECTRON && ( {!IS_ELECTRON && (

View File

@@ -12,9 +12,13 @@ export default {
Following: 'Following', Following: 'Following',
reposted: 'reposted', reposted: 'reposted',
'just now': 'just now', 'just now': 'just now',
'n s': '{{n}}s',
'n minutes ago': '{{n}} minutes ago', 'n minutes ago': '{{n}} minutes ago',
'n m': '{{n}}m',
'n hours ago': '{{n}} hours ago', 'n hours ago': '{{n}} hours ago',
'n h': '{{n}}h',
'n days ago': '{{n}} days ago', 'n days ago': '{{n}} days ago',
'n d': '{{n}}d',
date: '{{timestamp, date}}', date: '{{timestamp, date}}',
Follow: 'Follow', Follow: 'Follow',
Unfollow: 'Unfollow', Unfollow: 'Unfollow',
@@ -72,6 +76,9 @@ export default {
'all users': 'all users', 'all users': 'all users',
'Display replies': 'Display replies', 'Display replies': 'Display replies',
Notes: 'Notes', Notes: 'Notes',
'Notes & Replies': 'Notes & Replies' 'Notes & Replies': 'Notes & Replies',
notifications: 'notifications',
Notifications: 'Notifications',
'no more notifications': 'no more notifications'
} }
} }

View File

@@ -21,11 +21,11 @@ i18n
} }
}) })
i18n.services.formatter?.add('date', (value, lng) => { i18n.services.formatter?.add('date', (timestamp, lng) => {
if (lng?.startsWith('zh')) { if (lng?.startsWith('zh')) {
return dayjs(value).format('YYYY-MM-DD') return dayjs(timestamp).format('YYYY/MM/DD')
} }
return dayjs(value).format('MMM D, YYYY') return dayjs(timestamp).format('MMM D, YYYY')
}) })
export default i18n export default i18n

View File

@@ -12,9 +12,13 @@ export default {
Following: '关注', Following: '关注',
reposted: '转发', reposted: '转发',
'just now': '刚刚', 'just now': '刚刚',
'n s': '{{n}}秒',
'n minutes ago': '{{n}} 分钟前', 'n minutes ago': '{{n}} 分钟前',
'n m': '{{n}}分',
'n hours ago': '{{n}} 小时前', 'n hours ago': '{{n}} 小时前',
'n h': '{{n}}时',
'n days ago': '{{n}} 天前', 'n days ago': '{{n}} 天前',
'n d': '{{n}}天',
date: '{{timestamp, date}}', date: '{{timestamp, date}}',
Follow: '关注', Follow: '关注',
Unfollow: '取消关注', Unfollow: '取消关注',
@@ -71,6 +75,9 @@ export default {
'all users': '所有用户', 'all users': '所有用户',
'Display replies': '显示回复', 'Display replies': '显示回复',
Notes: '笔记', Notes: '笔记',
'Notes & Replies': '笔记 & 回复' 'Notes & Replies': '笔记 & 回复',
notifications: '通知',
Notifications: '通知',
'no more notifications': '到底了'
} }
} }

View File

@@ -1,5 +1,6 @@
import Logo from '@renderer/assets/Logo' import Logo from '@renderer/assets/Logo'
import AccountButton from '@renderer/components/AccountButton' import AccountButton from '@renderer/components/AccountButton'
import NotificationButton from '@renderer/components/NotificationButton'
import PostButton from '@renderer/components/PostButton' import PostButton from '@renderer/components/PostButton'
import RefreshButton from '@renderer/components/RefreshButton' import RefreshButton from '@renderer/components/RefreshButton'
import RelaySettingsButton from '@renderer/components/RelaySettingsButton' import RelaySettingsButton from '@renderer/components/RelaySettingsButton'
@@ -38,12 +39,13 @@ const PrimaryPageLayout = forwardRef(({ children }: { children?: React.ReactNode
return return
} }
if (diff > 50) { if (diff > 20) {
setVisible(false) setVisible(false)
} else if (diff < -50) {
setVisible(true)
}
setLastScrollTop(scrollTop) setLastScrollTop(scrollTop)
} else if (diff < -20) {
setVisible(true)
setLastScrollTop(scrollTop)
}
} }
const scrollArea = scrollAreaRef.current const scrollArea = scrollAreaRef.current
@@ -94,6 +96,7 @@ function PrimaryPageTitlebar({ visible = true }: { visible?: boolean }) {
<SearchButton variant="small-screen-titlebar" /> <SearchButton variant="small-screen-titlebar" />
<PostButton variant="small-screen-titlebar" /> <PostButton variant="small-screen-titlebar" />
<RelaySettingsButton variant="small-screen-titlebar" /> <RelaySettingsButton variant="small-screen-titlebar" />
<NotificationButton variant="small-screen-titlebar" />
<AccountButton variant="small-screen-titlebar" /> <AccountButton variant="small-screen-titlebar" />
</div> </div>
</Titlebar> </Titlebar>
@@ -110,6 +113,7 @@ function PrimaryPageTitlebar({ visible = true }: { visible?: boolean }) {
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<RefreshButton /> <RefreshButton />
<RelaySettingsButton /> <RelaySettingsButton />
<NotificationButton />
</div> </div>
</Titlebar> </Titlebar>
) )

View File

@@ -33,12 +33,13 @@ export default function SecondaryPageLayout({
return return
} }
if (diff > 50) { if (diff > 20) {
setVisible(false) setVisible(false)
} else if (diff < -50) {
setVisible(true)
}
setLastScrollTop(scrollTop) setLastScrollTop(scrollTop)
} else if (diff < -20) {
setVisible(true)
setLastScrollTop(scrollTop)
}
} }
const scrollArea = scrollAreaRef.current const scrollArea = scrollAreaRef.current

View File

@@ -25,6 +25,7 @@ export const toProfileList = ({ search }: { search?: string }) => {
} }
export const toFollowingList = (pubkey: string) => `/users/${pubkey}/following` export const toFollowingList = (pubkey: string) => `/users/${pubkey}/following`
export const toRelaySettings = () => '/relay-settings' export const toRelaySettings = () => '/relay-settings'
export const toNotifications = () => '/notifications'
export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}` export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}`
export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}` export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`

View File

@@ -0,0 +1,15 @@
import NotificationList from '@renderer/components/NotificationList'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { useTranslation } from 'react-i18next'
export default function NotificationListPage() {
const { t } = useTranslation()
return (
<SecondaryPageLayout titlebarContent={t('notifications')}>
<div className="max-sm:px-4">
<NotificationList />
</div>
</SecondaryPageLayout>
)
}

View File

@@ -103,6 +103,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
await window.api.nostr.logout() await window.api.nostr.logout()
} }
setPubkey(null) setPubkey(null)
client.clearNotificationsCache()
} }
const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => { const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => {

View File

@@ -4,6 +4,7 @@ import FollowingListPage from './pages/secondary/FollowingListPage'
import HomePage from './pages/secondary/HomePage' import HomePage from './pages/secondary/HomePage'
import NoteListPage from './pages/secondary/NoteListPage' import NoteListPage from './pages/secondary/NoteListPage'
import NotePage from './pages/secondary/NotePage' import NotePage from './pages/secondary/NotePage'
import NotificationListPage from './pages/secondary/NotificationListPage'
import ProfileListPage from './pages/secondary/ProfileListPage' import ProfileListPage from './pages/secondary/ProfileListPage'
import ProfilePage from './pages/secondary/ProfilePage' import ProfilePage from './pages/secondary/ProfilePage'
import RelaySettingsPage from './pages/secondary/RelaySettingsPage' import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
@@ -15,7 +16,8 @@ const ROUTES = [
{ path: '/users', element: <ProfileListPage /> }, { path: '/users', element: <ProfileListPage /> },
{ path: '/users/:id', element: <ProfilePage /> }, { path: '/users/:id', element: <ProfilePage /> },
{ path: '/users/:id/following', element: <FollowingListPage /> }, { path: '/users/:id/following', element: <FollowingListPage /> },
{ path: '/relay-settings', element: <RelaySettingsPage /> } { path: '/relay-settings', element: <RelaySettingsPage /> },
{ path: '/notifications', element: <NotificationListPage /> }
] ]
export const routes = ROUTES.map(({ path, element }) => ({ export const routes = ROUTES.map(({ path, element }) => ({

View File

@@ -41,6 +41,7 @@ class ClientService {
private repliesCache = new LRUCache<string, { refs: [string, number][]; until?: number }>({ private repliesCache = new LRUCache<string, { refs: [string, number][]; until?: number }>({
max: 1000 max: 1000
}) })
private notificationsCache: [string, number][] = []
private profileCache = new LRUCache<string, Promise<TProfile>>({ max: 10000 }) private profileCache = new LRUCache<string, Promise<TProfile>>({ max: 10000 })
private profileDataloader = new DataLoader<string, TProfile>( private profileDataloader = new DataLoader<string, TProfile>(
(ids) => Promise.all(ids.map((id) => this._fetchProfile(id))), (ids) => Promise.all(ids.map((id) => this._fetchProfile(id))),
@@ -211,9 +212,8 @@ class ClientService {
], ],
{ {
onevent(evt: NEvent) { onevent(evt: NEvent) {
if (!isReplyNoteEvent(evt)) return
if (hasEosed) { if (hasEosed) {
if (!isReplyNoteEvent(evt)) return
onNew(evt) onNew(evt)
} else { } else {
events.push(evt) events.push(evt)
@@ -222,7 +222,10 @@ class ClientService {
}, },
oneose() { oneose() {
hasEosed = true hasEosed = true
const newReplies = events.sort((a, b) => a.created_at - b.created_at) const newReplies = events
.sort((a, b) => a.created_at - b.created_at)
.slice(0, limit)
.filter(isReplyNoteEvent)
replies = replies.concat(newReplies) replies = replies.concat(newReplies)
// first fetch // first fetch
if (!since) { if (!since) {
@@ -250,8 +253,87 @@ class ClientService {
} }
} }
async subscribeNotifications(
pubkey: string,
limit: number,
{
onNotifications,
onNew
}: {
onNotifications: (events: NEvent[], isCache: boolean) => void
onNew: (evt: NEvent) => void
}
) {
let cachedNotifications: NEvent[] = []
if (this.notificationsCache.length) {
cachedNotifications = (
await Promise.all(this.notificationsCache.map(([id]) => this.eventCache.get(id)))
).filter(Boolean) as NEvent[]
onNotifications(cachedNotifications, true)
}
const since = this.notificationsCache.length ? this.notificationsCache[0][1] + 1 : undefined
const relayList = await this.fetchRelayList(pubkey)
// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this
const events: NEvent[] = []
let hasEosed = false
let count = 0
const closer = this.pool.subscribeMany(
relayList.read.length >= 4
? relayList.read
: relayList.read.concat(this.defaultRelayUrls).slice(0, 4),
[
{
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction],
'#p': [pubkey],
limit,
since
}
],
{
onevent(evt: NEvent) {
count++
if (hasEosed) {
if (evt.pubkey === pubkey) return
onNew(evt)
} else {
events.push(evt)
}
that.eventDataLoader.prime(evt.id, Promise.resolve(evt))
},
oneose() {
hasEosed = true
const newNotifications = events
.sort((a, b) => b.created_at - a.created_at)
.slice(0, limit)
.filter((evt) => evt.pubkey !== pubkey)
if (count >= limit) {
that.notificationsCache = newNotifications.map(
(evt) => [evt.id, evt.created_at] as [string, number]
)
onNotifications(newNotifications, false)
} else {
that.notificationsCache = [
...newNotifications.map((evt) => [evt.id, evt.created_at] as [string, number]),
...that.notificationsCache
]
onNotifications(newNotifications.concat(cachedNotifications), false)
}
}
}
)
return () => {
onNotifications = () => {}
onNew = () => {}
closer.close()
}
}
async fetchMoreReplies(relayUrls: string[], parentEventId: string, until: number, limit: number) { async fetchMoreReplies(relayUrls: string[], parentEventId: string, until: number, limit: number) {
const events = await this.pool.querySync(relayUrls, { let events = await this.pool.querySync(relayUrls, {
'#e': [parentEventId], '#e': [parentEventId],
kinds: [kinds.ShortTextNote], kinds: [kinds.ShortTextNote],
limit, limit,
@@ -260,7 +342,7 @@ class ClientService {
events.forEach((evt) => { events.forEach((evt) => {
this.eventDataLoader.prime(evt.id, Promise.resolve(evt)) this.eventDataLoader.prime(evt.id, Promise.resolve(evt))
}) })
events.sort((a, b) => a.created_at - b.created_at) events = events.sort((a, b) => a.created_at - b.created_at).slice(0, limit)
const replies = events.filter((evt) => isReplyNoteEvent(evt)) const replies = events.filter((evt) => isReplyNoteEvent(evt))
let cache = this.repliesCache.get(parentEventId) let cache = this.repliesCache.get(parentEventId)
if (!cache) { if (!cache) {
@@ -282,6 +364,44 @@ class ClientService {
return { replies, until: cache.until } return { replies, until: cache.until }
} }
async fetchMoreNotifications(pubkey: string, until: number, limit: number) {
const relayList = await this.fetchRelayList(pubkey)
const events = await this.pool.querySync(
relayList.read.length >= 4
? relayList.read
: relayList.read.concat(this.defaultRelayUrls).slice(0, 4),
{
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction],
'#p': [pubkey],
limit,
until
}
)
events.forEach((evt) => {
this.eventDataLoader.prime(evt.id, Promise.resolve(evt))
})
const notifications = events
.sort((a, b) => b.created_at - a.created_at)
.slice(0, limit)
.filter((evt) => evt.pubkey !== pubkey)
const cacheLastCreatedAt = this.notificationsCache.length
? this.notificationsCache[this.notificationsCache.length - 1][1]
: undefined
this.notificationsCache = this.notificationsCache.concat(
(cacheLastCreatedAt
? notifications.filter((evt) => evt.created_at < cacheLastCreatedAt)
: notifications
).map((evt) => [evt.id, evt.created_at] as [string, number])
)
return notifications
}
clearNotificationsCache() {
this.notificationsCache = []
}
async fetchEvents(relayUrls: string[], filter: Filter, cache = false) { async fetchEvents(relayUrls: string[], filter: Filter, cache = false) {
const events = await this.pool.querySync( const events = await this.pool.querySync(
relayUrls.length > 0 ? relayUrls : this.defaultRelayUrls, relayUrls.length > 0 ? relayUrls : this.defaultRelayUrls,