feat: notifications
This commit is contained in:
@@ -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')
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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 { 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)}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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': '到底了'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
@@ -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()
|
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[] = []) => {
|
||||||
|
|||||||
@@ -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 }) => ({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user