feat: notifications
This commit is contained in:
@@ -1,30 +1,36 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function formatTimestamp(timestamp: number) {
|
||||
export function FormattedTimestamp({
|
||||
timestamp,
|
||||
short = false
|
||||
}: {
|
||||
timestamp: number
|
||||
short?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const time = dayjs(timestamp * 1000)
|
||||
const now = dayjs()
|
||||
|
||||
const diffMonth = now.diff(time, 'month')
|
||||
if (diffMonth >= 1) {
|
||||
if (diffMonth >= 2) {
|
||||
return t('date', { timestamp: time.valueOf() })
|
||||
}
|
||||
|
||||
const diffDay = now.diff(time, 'day')
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useSecondaryPage } from '@renderer/PageManager'
|
||||
import { toNote } from '@renderer/lib/link'
|
||||
import { formatTimestamp } from '@renderer/lib/timestamp'
|
||||
import { Event } from 'nostr-tools'
|
||||
import Content from '../Content'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import NoteStats from '../NoteStats'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
|
||||
export default function Note({
|
||||
event,
|
||||
@@ -38,7 +38,7 @@ export default function Note({
|
||||
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{formatTimestamp(event.created_at)}
|
||||
<FormattedTimestamp timestamp={event.created_at} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,9 +56,12 @@ export default function NoteList({
|
||||
if (!areAlgoRelays) {
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
}
|
||||
events = events.slice(0, noteFilter.limit)
|
||||
if (events.length > 0) {
|
||||
setEvents((pre) => [...pre, ...events])
|
||||
setUntil(events[events.length - 1].created_at - 1)
|
||||
} else {
|
||||
setHasMore(false)
|
||||
}
|
||||
if (areAlgoRelays) {
|
||||
setHasMore(false)
|
||||
@@ -111,7 +114,9 @@ export default function NoteList({
|
||||
|
||||
const loadMore = async () => {
|
||||
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) {
|
||||
setHasMore(false)
|
||||
return
|
||||
|
||||
39
src/renderer/src/components/NotificationButton/index.tsx
Normal file
39
src/renderer/src/components/NotificationButton/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
205
src/renderer/src/components/NotificationList/index.tsx
Normal file
205
src/renderer/src/components/NotificationList/index.tsx
Normal 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>
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { formatTimestamp } from '@renderer/lib/timestamp'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Content from '../Content'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import LikeButton from '../NoteStats/LikeButton'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import PostDialog from '../PostDialog'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ReplyNote({
|
||||
event,
|
||||
@@ -39,7 +39,9 @@ export default function ReplyNote({
|
||||
)}
|
||||
<Content event={event} size="small" />
|
||||
<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
|
||||
className="text-muted-foreground hover:text-primary cursor-pointer"
|
||||
onClick={() => setIsPostDialogOpen(true)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Info } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AboutInfoDialog from '../AboutInfoDialog'
|
||||
import AccountButton from '../AccountButton'
|
||||
import NotificationButton from '../NotificationButton'
|
||||
import PostButton from '../PostButton'
|
||||
import RefreshButton from '../RefreshButton'
|
||||
import RelaySettingsButton from '../RelaySettingsButton'
|
||||
@@ -24,6 +25,7 @@ export default function PrimaryPageSidebar() {
|
||||
</div>
|
||||
<PostButton variant="sidebar" />
|
||||
<RelaySettingsButton variant="sidebar" />
|
||||
<NotificationButton variant="sidebar" />
|
||||
<SearchButton variant="sidebar" />
|
||||
<RefreshButton variant="sidebar" />
|
||||
{!IS_ELECTRON && (
|
||||
|
||||
@@ -12,9 +12,13 @@ export default {
|
||||
Following: 'Following',
|
||||
reposted: 'reposted',
|
||||
'just now': 'just now',
|
||||
'n s': '{{n}}s',
|
||||
'n minutes ago': '{{n}} minutes ago',
|
||||
'n m': '{{n}}m',
|
||||
'n hours ago': '{{n}} hours ago',
|
||||
'n h': '{{n}}h',
|
||||
'n days ago': '{{n}} days ago',
|
||||
'n d': '{{n}}d',
|
||||
date: '{{timestamp, date}}',
|
||||
Follow: 'Follow',
|
||||
Unfollow: 'Unfollow',
|
||||
@@ -72,6 +76,9 @@ export default {
|
||||
'all users': 'all users',
|
||||
'Display replies': 'Display replies',
|
||||
Notes: 'Notes',
|
||||
'Notes & Replies': 'Notes & Replies'
|
||||
'Notes & Replies': 'Notes & Replies',
|
||||
notifications: 'notifications',
|
||||
Notifications: 'Notifications',
|
||||
'no more notifications': 'no more notifications'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@ i18n
|
||||
}
|
||||
})
|
||||
|
||||
i18n.services.formatter?.add('date', (value, lng) => {
|
||||
i18n.services.formatter?.add('date', (timestamp, lng) => {
|
||||
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
|
||||
|
||||
@@ -12,9 +12,13 @@ export default {
|
||||
Following: '关注',
|
||||
reposted: '转发',
|
||||
'just now': '刚刚',
|
||||
'n s': '{{n}}秒',
|
||||
'n minutes ago': '{{n}} 分钟前',
|
||||
'n m': '{{n}}分',
|
||||
'n hours ago': '{{n}} 小时前',
|
||||
'n h': '{{n}}时',
|
||||
'n days ago': '{{n}} 天前',
|
||||
'n d': '{{n}}天',
|
||||
date: '{{timestamp, date}}',
|
||||
Follow: '关注',
|
||||
Unfollow: '取消关注',
|
||||
@@ -71,6 +75,9 @@ export default {
|
||||
'all users': '所有用户',
|
||||
'Display replies': '显示回复',
|
||||
Notes: '笔记',
|
||||
'Notes & Replies': '笔记 & 回复'
|
||||
'Notes & Replies': '笔记 & 回复',
|
||||
notifications: '通知',
|
||||
Notifications: '通知',
|
||||
'no more notifications': '到底了'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Logo from '@renderer/assets/Logo'
|
||||
import AccountButton from '@renderer/components/AccountButton'
|
||||
import NotificationButton from '@renderer/components/NotificationButton'
|
||||
import PostButton from '@renderer/components/PostButton'
|
||||
import RefreshButton from '@renderer/components/RefreshButton'
|
||||
import RelaySettingsButton from '@renderer/components/RelaySettingsButton'
|
||||
@@ -38,12 +39,13 @@ const PrimaryPageLayout = forwardRef(({ children }: { children?: React.ReactNode
|
||||
return
|
||||
}
|
||||
|
||||
if (diff > 50) {
|
||||
if (diff > 20) {
|
||||
setVisible(false)
|
||||
} else if (diff < -50) {
|
||||
setLastScrollTop(scrollTop)
|
||||
} else if (diff < -20) {
|
||||
setVisible(true)
|
||||
setLastScrollTop(scrollTop)
|
||||
}
|
||||
setLastScrollTop(scrollTop)
|
||||
}
|
||||
|
||||
const scrollArea = scrollAreaRef.current
|
||||
@@ -94,6 +96,7 @@ function PrimaryPageTitlebar({ visible = true }: { visible?: boolean }) {
|
||||
<SearchButton variant="small-screen-titlebar" />
|
||||
<PostButton variant="small-screen-titlebar" />
|
||||
<RelaySettingsButton variant="small-screen-titlebar" />
|
||||
<NotificationButton variant="small-screen-titlebar" />
|
||||
<AccountButton variant="small-screen-titlebar" />
|
||||
</div>
|
||||
</Titlebar>
|
||||
@@ -110,6 +113,7 @@ function PrimaryPageTitlebar({ visible = true }: { visible?: boolean }) {
|
||||
<div className="flex gap-2 items-center">
|
||||
<RefreshButton />
|
||||
<RelaySettingsButton />
|
||||
<NotificationButton />
|
||||
</div>
|
||||
</Titlebar>
|
||||
)
|
||||
|
||||
@@ -33,12 +33,13 @@ export default function SecondaryPageLayout({
|
||||
return
|
||||
}
|
||||
|
||||
if (diff > 50) {
|
||||
if (diff > 20) {
|
||||
setVisible(false)
|
||||
} else if (diff < -50) {
|
||||
setLastScrollTop(scrollTop)
|
||||
} else if (diff < -20) {
|
||||
setVisible(true)
|
||||
setLastScrollTop(scrollTop)
|
||||
}
|
||||
setLastScrollTop(scrollTop)
|
||||
}
|
||||
|
||||
const scrollArea = scrollAreaRef.current
|
||||
|
||||
@@ -25,6 +25,7 @@ export const toProfileList = ({ search }: { search?: string }) => {
|
||||
}
|
||||
export const toFollowingList = (pubkey: string) => `/users/${pubkey}/following`
|
||||
export const toRelaySettings = () => '/relay-settings'
|
||||
export const toNotifications = () => '/notifications'
|
||||
|
||||
export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}`
|
||||
export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -103,6 +103,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
await window.api.nostr.logout()
|
||||
}
|
||||
setPubkey(null)
|
||||
client.clearNotificationsCache()
|
||||
}
|
||||
|
||||
const publish = async (draftEvent: TDraftEvent, additionalRelayUrls: string[] = []) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import FollowingListPage from './pages/secondary/FollowingListPage'
|
||||
import HomePage from './pages/secondary/HomePage'
|
||||
import NoteListPage from './pages/secondary/NoteListPage'
|
||||
import NotePage from './pages/secondary/NotePage'
|
||||
import NotificationListPage from './pages/secondary/NotificationListPage'
|
||||
import ProfileListPage from './pages/secondary/ProfileListPage'
|
||||
import ProfilePage from './pages/secondary/ProfilePage'
|
||||
import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
|
||||
@@ -15,7 +16,8 @@ const ROUTES = [
|
||||
{ path: '/users', element: <ProfileListPage /> },
|
||||
{ path: '/users/:id', element: <ProfilePage /> },
|
||||
{ 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 }) => ({
|
||||
|
||||
@@ -41,6 +41,7 @@ class ClientService {
|
||||
private repliesCache = new LRUCache<string, { refs: [string, number][]; until?: number }>({
|
||||
max: 1000
|
||||
})
|
||||
private notificationsCache: [string, number][] = []
|
||||
private profileCache = new LRUCache<string, Promise<TProfile>>({ max: 10000 })
|
||||
private profileDataloader = new DataLoader<string, TProfile>(
|
||||
(ids) => Promise.all(ids.map((id) => this._fetchProfile(id))),
|
||||
@@ -211,9 +212,8 @@ class ClientService {
|
||||
],
|
||||
{
|
||||
onevent(evt: NEvent) {
|
||||
if (!isReplyNoteEvent(evt)) return
|
||||
|
||||
if (hasEosed) {
|
||||
if (!isReplyNoteEvent(evt)) return
|
||||
onNew(evt)
|
||||
} else {
|
||||
events.push(evt)
|
||||
@@ -222,7 +222,10 @@ class ClientService {
|
||||
},
|
||||
oneose() {
|
||||
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)
|
||||
// first fetch
|
||||
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) {
|
||||
const events = await this.pool.querySync(relayUrls, {
|
||||
let events = await this.pool.querySync(relayUrls, {
|
||||
'#e': [parentEventId],
|
||||
kinds: [kinds.ShortTextNote],
|
||||
limit,
|
||||
@@ -260,7 +342,7 @@ class ClientService {
|
||||
events.forEach((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))
|
||||
let cache = this.repliesCache.get(parentEventId)
|
||||
if (!cache) {
|
||||
@@ -282,6 +364,44 @@ class ClientService {
|
||||
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) {
|
||||
const events = await this.pool.querySync(
|
||||
relayUrls.length > 0 ? relayUrls : this.defaultRelayUrls,
|
||||
|
||||
Reference in New Issue
Block a user