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 { 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')
}

View File

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

View File

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

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

View File

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

View File

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

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

View File

@@ -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': '到底了'
}
}

View File

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

View File

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

View File

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

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()
}
setPubkey(null)
client.clearNotificationsCache()
}
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 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 }) => ({

View File

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