feat: optimize notifications

This commit is contained in:
codytseng
2025-09-05 22:08:17 +08:00
parent 47e7a18f2e
commit 2855754648
26 changed files with 520 additions and 234 deletions

View File

@@ -1,14 +1,13 @@
import { getEmbeddedPubkeys } from '@/lib/event'
import ParentNotePreview from '@/components/ParentNotePreview'
import { getEmbeddedPubkeys, getParentBech32Id } from '@/lib/event'
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { AtSign, MessageCircle } from 'lucide-react'
import { AtSign, MessageCircle, Quote } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import ContentPreview from '../../ContentPreview'
import { FormattedTimestamp } from '../../FormattedTimestamp'
import UserAvatar from '../../UserAvatar'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function MentionNotification({
notification,
@@ -17,6 +16,7 @@ export function MentionNotification({
notification: Event
isNew?: boolean
}) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { pubkey } = useNostr()
const isMention = useMemo(() => {
@@ -24,25 +24,40 @@ export function MentionNotification({
const mentions = getEmbeddedPubkeys(notification)
return mentions.includes(pubkey)
}, [pubkey, notification])
const parentEventId = useMemo(() => getParentBech32Id(notification), [notification])
return (
<div
className="flex gap-2 items-center cursor-pointer py-2"
onClick={() => push(toNote(notification))}
>
<UserAvatar userId={notification.pubkey} size="small" />
{isMention ? (
<AtSign size={24} className="text-pink-400" />
) : (
<MessageCircle size={24} className="text-blue-400" />
)}
<ContentPreview
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
event={notification}
/>
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
<Notification
notificationId={notification.id}
icon={
isMention ? (
<AtSign size={24} className="text-pink-400" />
) : parentEventId ? (
<MessageCircle size={24} className="text-blue-400" />
) : (
<Quote size={24} className="text-green-400" />
)
}
sender={notification.pubkey}
sentAt={notification.created_at}
targetEvent={notification}
middle={
parentEventId && (
<ParentNotePreview
eventId={parentEventId}
className=""
onClick={(e) => {
e.stopPropagation()
push(toNote(parentEventId))
}}
/>
)
}
description={
isMention ? t('mentioned you in a note') : parentEventId ? '' : t('quoted your note')
}
isNew={isNew}
showStats
/>
)
}

View File

@@ -0,0 +1,117 @@
import ContentPreview from '@/components/ContentPreview'
import { FormattedTimestamp } from '@/components/FormattedTimestamp'
import NoteStats from '@/components/NoteStats'
import { Skeleton } from '@/components/ui/skeleton'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { toNote, toProfile } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useNotification } from '@/providers/NotificationProvider'
import { NostrEvent } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function Notification({
icon,
notificationId,
sender,
sentAt,
description,
middle = null,
targetEvent,
isNew = false,
showStats = false
}: {
icon: React.ReactNode
notificationId: string
sender: string
sentAt: number
description: string
middle?: React.ReactNode
targetEvent?: NostrEvent
isNew?: boolean
showStats?: boolean
}) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { pubkey } = useNostr()
const { isNotificationRead, markNotificationAsRead } = useNotification()
const unread = useMemo(
() => isNew && !isNotificationRead(notificationId),
[isNew, isNotificationRead, notificationId]
)
return (
<div
className="clickable flex items-start gap-2 cursor-pointer py-2 px-4 border-b"
onClick={() => {
markNotificationAsRead(notificationId)
if (targetEvent) {
push(toNote(targetEvent.id))
} else if (pubkey) {
push(toProfile(pubkey))
}
}}
>
<div className="flex gap-2 items-center mt-1.5">
{icon}
<UserAvatar userId={sender} size="medium" />
</div>
<div className="flex-1 w-0">
<div className="flex items-center justify-between gap-1">
<div className="flex gap-1 items-center">
<Username
userId={sender}
className="flex-1 max-w-fit truncate font-semibold"
skeletonClassName="h-4"
/>
<div className="shrink-0 text-muted-foreground text-sm">{description}</div>
</div>
{unread && (
<button
className="m-0.5 size-3 bg-primary rounded-full shrink-0 transition-all hover:ring-4 hover:ring-primary/20"
title={t('Mark as read')}
onClick={(e) => {
e.stopPropagation()
markNotificationAsRead(notificationId)
}}
/>
)}
</div>
{middle}
{targetEvent && (
<ContentPreview
className={cn('line-clamp-2', !unread && 'text-muted-foreground')}
event={targetEvent}
/>
)}
<FormattedTimestamp timestamp={sentAt} className="shrink-0 text-muted-foreground text-sm" />
{showStats && targetEvent && <NoteStats event={targetEvent} className="mt-1" />}
</div>
</div>
)
}
export function NotificationSkeleton() {
return (
<div className="flex items-start gap-2 cursor-pointer py-2 px-4">
<div className="flex gap-2 items-center mt-1.5">
<Skeleton className="w-6 h-6" />
<Skeleton className="w-9 h-9 rounded-full" />
</div>
<div className="flex-1 w-0">
<div className="py-1">
<Skeleton className="w-16 h-4" />
</div>
<div className="py-1">
<Skeleton className="w-full h-4" />
</div>
<div className="py-1">
<Skeleton className="w-12 h-4" />
</div>
</div>
</div>
)
}

View File

@@ -1,14 +1,10 @@
import { useFetchEvent } from '@/hooks'
import { toNote } from '@/lib/link'
import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { Vote } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import ContentPreview from '../../ContentPreview'
import { FormattedTimestamp } from '../../FormattedTimestamp'
import UserAvatar from '../../UserAvatar'
import Notification from './Notification'
import { useTranslation } from 'react-i18next'
export function PollResponseNotification({
notification,
@@ -17,7 +13,7 @@ export function PollResponseNotification({
notification: Event
isNew?: boolean
}) {
const { push } = useSecondaryPage()
const { t } = useTranslation()
const eventId = useMemo(() => {
const eTag = notification.tags.find(tagNameEquals('e'))
return eTag ? generateBech32IdFromETag(eTag) : undefined
@@ -29,19 +25,14 @@ export function PollResponseNotification({
}
return (
<div
className="flex gap-2 items-center cursor-pointer py-2"
onClick={() => push(toNote(pollEvent))}
>
<UserAvatar userId={notification.pubkey} size="small" />
<Vote size={24} className="text-violet-400" />
<ContentPreview
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
event={pollEvent}
/>
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
<Notification
notificationId={notification.id}
icon={<Vote size={24} className="text-violet-400" />}
sender={notification.pubkey}
sentAt={notification.created_at}
targetEvent={pollEvent}
description={t('voted in your poll')}
isNew={isNew}
/>
)
}

View File

@@ -1,16 +1,12 @@
import Image from '@/components/Image'
import { useFetchEvent } from '@/hooks'
import { toNote } from '@/lib/link'
import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { Heart } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import ContentPreview from '../../ContentPreview'
import { FormattedTimestamp } from '../../FormattedTimestamp'
import UserAvatar from '../../UserAvatar'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function ReactionNotification({
notification,
@@ -19,7 +15,7 @@ export function ReactionNotification({
notification: Event
isNew?: boolean
}) {
const { push } = useSecondaryPage()
const { t } = useTranslation()
const { pubkey } = useNostr()
const eventId = useMemo(() => {
const targetPubkey = notification.tags.findLast(tagNameEquals('p'))?.[1]
@@ -58,21 +54,14 @@ export function ReactionNotification({
}
return (
<div
className="flex items-center justify-between cursor-pointer py-2"
onClick={() => push(toNote(event))}
>
<div className="flex gap-2 items-center flex-1">
<UserAvatar userId={notification.pubkey} size="small" />
<div className="text-xl min-w-6 text-center">{reaction}</div>
<ContentPreview
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
event={event}
/>
</div>
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
<Notification
notificationId={notification.id}
icon={<div className="text-xl min-w-6 text-center">{reaction}</div>}
sender={notification.pubkey}
sentAt={notification.created_at}
targetEvent={event}
description={t('reacted to your note')}
isNew={isNew}
/>
)
}

View File

@@ -1,13 +1,9 @@
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import client from '@/services/client.service'
import { Repeat } from 'lucide-react'
import { Event, validateEvent } from 'nostr-tools'
import { useMemo } from 'react'
import ContentPreview from '../../ContentPreview'
import { FormattedTimestamp } from '../../FormattedTimestamp'
import UserAvatar from '../../UserAvatar'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function RepostNotification({
notification,
@@ -16,7 +12,7 @@ export function RepostNotification({
notification: Event
isNew?: boolean
}) {
const { push } = useSecondaryPage()
const { t } = useTranslation()
const event = useMemo(() => {
try {
const event = JSON.parse(notification.content) as Event
@@ -31,19 +27,14 @@ export function RepostNotification({
if (!event) return null
return (
<div
className="flex gap-2 items-center cursor-pointer py-2"
onClick={() => push(toNote(event))}
>
<UserAvatar userId={notification.pubkey} size="small" />
<Repeat size={24} className="text-green-400" />
<ContentPreview
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
event={event}
/>
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
<Notification
notificationId={notification.id}
icon={<Repeat size={24} className="text-green-400" />}
sender={notification.pubkey}
sentAt={notification.created_at}
targetEvent={event}
description={t('reposted your note')}
isNew={isNew}
/>
)
}

View File

@@ -1,17 +1,11 @@
import { useFetchEvent } from '@/hooks'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { formatAmount } from '@/lib/lightning'
import { toNote, toProfile } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { Zap } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ContentPreview from '../../ContentPreview'
import { FormattedTimestamp } from '../../FormattedTimestamp'
import UserAvatar from '../../UserAvatar'
import Notification from './Notification'
export function ZapNotification({
notification,
@@ -21,38 +15,28 @@ export function ZapNotification({
isNew?: boolean
}) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { pubkey } = useNostr()
const { senderPubkey, eventId, amount, comment } = useMemo(
() => getZapInfoFromEvent(notification) ?? ({} as any),
[notification]
)
const { event, isFetching } = useFetchEvent(eventId)
const { event } = useFetchEvent(eventId)
if (!senderPubkey || !amount) return null
return (
<div
className="flex items-center justify-between cursor-pointer py-2"
onClick={() => (eventId ? push(toNote(eventId)) : pubkey ? push(toProfile(pubkey)) : null)}
>
<div className="flex gap-2 items-center flex-1 w-0">
<UserAvatar userId={senderPubkey} size="small" />
<Zap size={24} className="text-yellow-400 shrink-0" />
<Notification
notificationId={notification.id}
icon={<Zap size={24} className="text-yellow-400 shrink-0" />}
sender={senderPubkey}
sentAt={notification.created_at}
targetEvent={event}
middle={
<div className="font-semibold text-yellow-400 shrink-0">
{formatAmount(amount)} {t('sats')}
{formatAmount(amount)} {t('sats')} {comment}
</div>
{comment && <div className="text-yellow-400 truncate">{comment}</div>}
{eventId && !isFetching && (
<ContentPreview
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
event={event}
/>
)}
</div>
<div className="text-muted-foreground shrink-0">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
}
description={event ? t('zapped your note') : t('zapped you')}
isNew={isNew}
/>
)
}

View File

@@ -1,6 +1,5 @@
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { compareEvents } from '@/lib/event'
import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useNotification } from '@/providers/NotificationProvider'
@@ -9,30 +8,39 @@ import client from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service'
import { TNotificationType } from '@/types'
import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { NostrEvent, kinds, matchFilter } from 'nostr-tools'
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh'
import Tabs from '../Tabs'
import { NotificationItem } from './NotificationItem'
import { NotificationSkeleton } from './NotificationItem/Notification'
const LIMIT = 100
const SHOW_COUNT = 30
const NotificationList = forwardRef((_, ref) => {
const { t } = useTranslation()
const { current } = usePrimaryPage()
const { current, display } = usePrimaryPage()
const active = useMemo(() => current === 'notifications' && display, [current, display])
const { pubkey } = useNostr()
const { hideUntrustedNotifications, isUserTrusted } = useUserTrust()
const { clearNewNotifications, getNotificationsSeenAt } = useNotification()
const { getNotificationsSeenAt } = useNotification()
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
const [lastReadTime, setLastReadTime] = useState(0)
const [refreshCount, setRefreshCount] = useState(0)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(true)
const [notifications, setNotifications] = useState<Event[]>([])
const [newNotifications, setNewNotifications] = useState<Event[]>([])
const [oldNotifications, setOldNotifications] = useState<Event[]>([])
const [notifications, setNotifications] = useState<NostrEvent[]>([])
const [visibleNotifications, setVisibleNotifications] = useState<NostrEvent[]>([])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const [until, setUntil] = useState<number | undefined>(dayjs().unix())
const bottomRef = useRef<HTMLDivElement | null>(null)
@@ -73,6 +81,25 @@ const NotificationList = forwardRef((_, ref) => {
[loading]
)
const handleNewEvent = useCallback(
(event: NostrEvent) => {
if (event.pubkey === pubkey) return
setNotifications((oldEvents) => {
const index = oldEvents.findIndex((oldEvent) => compareEvents(oldEvent, event) <= 0)
if (index !== -1 && oldEvents[index].id === event.id) {
return oldEvents
}
noteStatsService.updateNoteStatsByEvents([event])
if (index === -1) {
return [...oldEvents, event]
}
return [...oldEvents.slice(0, index), event, ...oldEvents.slice(index)]
})
},
[pubkey]
)
useEffect(() => {
if (current !== 'notifications') return
@@ -86,7 +113,6 @@ const NotificationList = forwardRef((_, ref) => {
setNotifications([])
setShowCount(SHOW_COUNT)
setLastReadTime(getNotificationsSeenAt())
clearNewNotifications()
const relayList = await client.fetchRelayList(pubkey)
const { closer, timelineKey } = await client.subscribeTimeline(
@@ -112,17 +138,7 @@ const NotificationList = forwardRef((_, ref) => {
}
},
onNew: (event) => {
if (event.pubkey === pubkey) return
setNotifications((oldEvents) => {
const index = oldEvents.findIndex(
(oldEvent) => oldEvent.created_at < event.created_at
)
if (index === -1) {
return [...oldEvents, event]
}
return [...oldEvents.slice(0, index), event, ...oldEvents.slice(index)]
})
noteStatsService.updateNoteStatsByEvents([event])
handleNewEvent(event)
}
}
)
@@ -136,21 +152,39 @@ const NotificationList = forwardRef((_, ref) => {
}
}, [pubkey, refreshCount, filterKinds, current])
useEffect(() => {
if (!active || !pubkey) return
const handler = (data: Event) => {
const customEvent = data as CustomEvent<NostrEvent>
const evt = customEvent.detail
if (
matchFilter(
{
kinds: filterKinds,
'#p': [pubkey]
},
evt
)
) {
handleNewEvent(evt)
}
}
client.addEventListener('newEvent', handler)
return () => {
client.removeEventListener('newEvent', handler)
}
}, [pubkey, active, filterKinds, handleNewEvent])
useEffect(() => {
let visibleNotifications = notifications.slice(0, showCount)
if (hideUntrustedNotifications) {
visibleNotifications = visibleNotifications.filter((event) => isUserTrusted(event.pubkey))
}
const index = visibleNotifications.findIndex((event) => event.created_at <= lastReadTime)
if (index === -1) {
setNewNotifications(visibleNotifications)
setOldNotifications([])
} else {
setNewNotifications(visibleNotifications.slice(0, index))
setOldNotifications(visibleNotifications.slice(index))
}
}, [notifications, lastReadTime, showCount, hideUntrustedNotifications, isUserTrusted])
setVisibleNotifications(visibleNotifications)
}, [notifications, showCount, hideUntrustedNotifications, isUserTrusted])
useEffect(() => {
const options = {
@@ -228,28 +262,18 @@ const NotificationList = forwardRef((_, ref) => {
}}
pullingContent=""
>
<div className="px-4 pt-2">
{newNotifications.map((notification) => (
<NotificationItem key={notification.id} notification={notification} isNew />
))}
{!!newNotifications.length && (
<div className="relative my-2">
<Separator />
<span className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-background px-2 text-xs text-muted-foreground">
{t('Earlier notifications')}
</span>
</div>
)}
{oldNotifications.map((notification) => (
<NotificationItem key={notification.id} notification={notification} />
<div>
{visibleNotifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
isNew={notification.created_at > lastReadTime}
/>
))}
<div className="text-center text-sm text-muted-foreground">
{until || loading ? (
<div ref={bottomRef}>
<div className="flex gap-2 items-center h-11 py-2">
<Skeleton className="w-7 h-7 rounded-full" />
<Skeleton className="h-6 flex-1 w-0" />
</div>
<NotificationSkeleton />
</div>
) : (
t('no more notifications')