feat: optimize notifications
This commit is contained in:
@@ -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 { toNote } from '@/lib/link'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { AtSign, MessageCircle } from 'lucide-react'
|
import { AtSign, MessageCircle, Quote } from 'lucide-react'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import ContentPreview from '../../ContentPreview'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FormattedTimestamp } from '../../FormattedTimestamp'
|
import Notification from './Notification'
|
||||||
import UserAvatar from '../../UserAvatar'
|
|
||||||
|
|
||||||
export function MentionNotification({
|
export function MentionNotification({
|
||||||
notification,
|
notification,
|
||||||
@@ -17,6 +16,7 @@ export function MentionNotification({
|
|||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
const isMention = useMemo(() => {
|
const isMention = useMemo(() => {
|
||||||
@@ -24,25 +24,40 @@ export function MentionNotification({
|
|||||||
const mentions = getEmbeddedPubkeys(notification)
|
const mentions = getEmbeddedPubkeys(notification)
|
||||||
return mentions.includes(pubkey)
|
return mentions.includes(pubkey)
|
||||||
}, [pubkey, notification])
|
}, [pubkey, notification])
|
||||||
|
const parentEventId = useMemo(() => getParentBech32Id(notification), [notification])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Notification
|
||||||
className="flex gap-2 items-center cursor-pointer py-2"
|
notificationId={notification.id}
|
||||||
onClick={() => push(toNote(notification))}
|
icon={
|
||||||
>
|
isMention ? (
|
||||||
<UserAvatar userId={notification.pubkey} size="small" />
|
|
||||||
{isMention ? (
|
|
||||||
<AtSign size={24} className="text-pink-400" />
|
<AtSign size={24} className="text-pink-400" />
|
||||||
) : (
|
) : parentEventId ? (
|
||||||
<MessageCircle size={24} className="text-blue-400" />
|
<MessageCircle size={24} className="text-blue-400" />
|
||||||
)}
|
) : (
|
||||||
<ContentPreview
|
<Quote size={24} className="text-green-400" />
|
||||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
|
)
|
||||||
event={notification}
|
}
|
||||||
|
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
|
||||||
/>
|
/>
|
||||||
<div className="text-muted-foreground">
|
|
||||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import { useFetchEvent } from '@/hooks'
|
import { useFetchEvent } from '@/hooks'
|
||||||
import { toNote } from '@/lib/link'
|
|
||||||
import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
|
import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
|
||||||
import { Vote } from 'lucide-react'
|
import { Vote } from 'lucide-react'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import ContentPreview from '../../ContentPreview'
|
import Notification from './Notification'
|
||||||
import { FormattedTimestamp } from '../../FormattedTimestamp'
|
import { useTranslation } from 'react-i18next'
|
||||||
import UserAvatar from '../../UserAvatar'
|
|
||||||
|
|
||||||
export function PollResponseNotification({
|
export function PollResponseNotification({
|
||||||
notification,
|
notification,
|
||||||
@@ -17,7 +13,7 @@ export function PollResponseNotification({
|
|||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { t } = useTranslation()
|
||||||
const eventId = useMemo(() => {
|
const eventId = useMemo(() => {
|
||||||
const eTag = notification.tags.find(tagNameEquals('e'))
|
const eTag = notification.tags.find(tagNameEquals('e'))
|
||||||
return eTag ? generateBech32IdFromETag(eTag) : undefined
|
return eTag ? generateBech32IdFromETag(eTag) : undefined
|
||||||
@@ -29,19 +25,14 @@ export function PollResponseNotification({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Notification
|
||||||
className="flex gap-2 items-center cursor-pointer py-2"
|
notificationId={notification.id}
|
||||||
onClick={() => push(toNote(pollEvent))}
|
icon={<Vote size={24} className="text-violet-400" />}
|
||||||
>
|
sender={notification.pubkey}
|
||||||
<UserAvatar userId={notification.pubkey} size="small" />
|
sentAt={notification.created_at}
|
||||||
<Vote size={24} className="text-violet-400" />
|
targetEvent={pollEvent}
|
||||||
<ContentPreview
|
description={t('voted in your poll')}
|
||||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
|
isNew={isNew}
|
||||||
event={pollEvent}
|
|
||||||
/>
|
/>
|
||||||
<div className="text-muted-foreground">
|
|
||||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import Image from '@/components/Image'
|
import Image from '@/components/Image'
|
||||||
import { useFetchEvent } from '@/hooks'
|
import { useFetchEvent } from '@/hooks'
|
||||||
import { toNote } from '@/lib/link'
|
|
||||||
import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
|
import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { Heart } from 'lucide-react'
|
import { Heart } from 'lucide-react'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import ContentPreview from '../../ContentPreview'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FormattedTimestamp } from '../../FormattedTimestamp'
|
import Notification from './Notification'
|
||||||
import UserAvatar from '../../UserAvatar'
|
|
||||||
|
|
||||||
export function ReactionNotification({
|
export function ReactionNotification({
|
||||||
notification,
|
notification,
|
||||||
@@ -19,7 +15,7 @@ export function ReactionNotification({
|
|||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { t } = useTranslation()
|
||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
const eventId = useMemo(() => {
|
const eventId = useMemo(() => {
|
||||||
const targetPubkey = notification.tags.findLast(tagNameEquals('p'))?.[1]
|
const targetPubkey = notification.tags.findLast(tagNameEquals('p'))?.[1]
|
||||||
@@ -58,21 +54,14 @@ export function ReactionNotification({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Notification
|
||||||
className="flex items-center justify-between cursor-pointer py-2"
|
notificationId={notification.id}
|
||||||
onClick={() => push(toNote(event))}
|
icon={<div className="text-xl min-w-6 text-center">{reaction}</div>}
|
||||||
>
|
sender={notification.pubkey}
|
||||||
<div className="flex gap-2 items-center flex-1">
|
sentAt={notification.created_at}
|
||||||
<UserAvatar userId={notification.pubkey} size="small" />
|
targetEvent={event}
|
||||||
<div className="text-xl min-w-6 text-center">{reaction}</div>
|
description={t('reacted to your note')}
|
||||||
<ContentPreview
|
isNew={isNew}
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 client from '@/services/client.service'
|
||||||
import { Repeat } from 'lucide-react'
|
import { Repeat } from 'lucide-react'
|
||||||
import { Event, validateEvent } from 'nostr-tools'
|
import { Event, validateEvent } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import ContentPreview from '../../ContentPreview'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FormattedTimestamp } from '../../FormattedTimestamp'
|
import Notification from './Notification'
|
||||||
import UserAvatar from '../../UserAvatar'
|
|
||||||
|
|
||||||
export function RepostNotification({
|
export function RepostNotification({
|
||||||
notification,
|
notification,
|
||||||
@@ -16,7 +12,7 @@ export function RepostNotification({
|
|||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { t } = useTranslation()
|
||||||
const event = useMemo(() => {
|
const event = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(notification.content) as Event
|
const event = JSON.parse(notification.content) as Event
|
||||||
@@ -31,19 +27,14 @@ export function RepostNotification({
|
|||||||
if (!event) return null
|
if (!event) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Notification
|
||||||
className="flex gap-2 items-center cursor-pointer py-2"
|
notificationId={notification.id}
|
||||||
onClick={() => push(toNote(event))}
|
icon={<Repeat size={24} className="text-green-400" />}
|
||||||
>
|
sender={notification.pubkey}
|
||||||
<UserAvatar userId={notification.pubkey} size="small" />
|
sentAt={notification.created_at}
|
||||||
<Repeat size={24} className="text-green-400" />
|
targetEvent={event}
|
||||||
<ContentPreview
|
description={t('reposted your note')}
|
||||||
className={cn('truncate flex-1 w-0', isNew ? 'font-semibold' : 'text-muted-foreground')}
|
isNew={isNew}
|
||||||
event={event}
|
|
||||||
/>
|
/>
|
||||||
<div className="text-muted-foreground">
|
|
||||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import { useFetchEvent } from '@/hooks'
|
import { useFetchEvent } from '@/hooks'
|
||||||
import { getZapInfoFromEvent } from '@/lib/event-metadata'
|
import { getZapInfoFromEvent } from '@/lib/event-metadata'
|
||||||
import { formatAmount } from '@/lib/lightning'
|
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 { Zap } from 'lucide-react'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ContentPreview from '../../ContentPreview'
|
import Notification from './Notification'
|
||||||
import { FormattedTimestamp } from '../../FormattedTimestamp'
|
|
||||||
import UserAvatar from '../../UserAvatar'
|
|
||||||
|
|
||||||
export function ZapNotification({
|
export function ZapNotification({
|
||||||
notification,
|
notification,
|
||||||
@@ -21,38 +15,28 @@ export function ZapNotification({
|
|||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { push } = useSecondaryPage()
|
|
||||||
const { pubkey } = useNostr()
|
|
||||||
const { senderPubkey, eventId, amount, comment } = useMemo(
|
const { senderPubkey, eventId, amount, comment } = useMemo(
|
||||||
() => getZapInfoFromEvent(notification) ?? ({} as any),
|
() => getZapInfoFromEvent(notification) ?? ({} as any),
|
||||||
[notification]
|
[notification]
|
||||||
)
|
)
|
||||||
const { event, isFetching } = useFetchEvent(eventId)
|
const { event } = useFetchEvent(eventId)
|
||||||
|
|
||||||
if (!senderPubkey || !amount) return null
|
if (!senderPubkey || !amount) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Notification
|
||||||
className="flex items-center justify-between cursor-pointer py-2"
|
notificationId={notification.id}
|
||||||
onClick={() => (eventId ? push(toNote(eventId)) : pubkey ? push(toProfile(pubkey)) : null)}
|
icon={<Zap size={24} className="text-yellow-400 shrink-0" />}
|
||||||
>
|
sender={senderPubkey}
|
||||||
<div className="flex gap-2 items-center flex-1 w-0">
|
sentAt={notification.created_at}
|
||||||
<UserAvatar userId={senderPubkey} size="small" />
|
targetEvent={event}
|
||||||
<Zap size={24} className="text-yellow-400 shrink-0" />
|
middle={
|
||||||
<div className="font-semibold text-yellow-400 shrink-0">
|
<div className="font-semibold text-yellow-400 shrink-0">
|
||||||
{formatAmount(amount)} {t('sats')}
|
{formatAmount(amount)} {t('sats')} {comment}
|
||||||
</div>
|
</div>
|
||||||
{comment && <div className="text-yellow-400 truncate">{comment}</div>}
|
}
|
||||||
{eventId && !isFetching && (
|
description={event ? t('zapped your note') : t('zapped you')}
|
||||||
<ContentPreview
|
isNew={isNew}
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||||
|
import { compareEvents } from '@/lib/event'
|
||||||
import { usePrimaryPage } from '@/PageManager'
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useNotification } from '@/providers/NotificationProvider'
|
import { useNotification } from '@/providers/NotificationProvider'
|
||||||
@@ -9,30 +8,39 @@ import client from '@/services/client.service'
|
|||||||
import noteStatsService from '@/services/note-stats.service'
|
import noteStatsService from '@/services/note-stats.service'
|
||||||
import { TNotificationType } from '@/types'
|
import { TNotificationType } from '@/types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { NostrEvent, kinds, matchFilter } from 'nostr-tools'
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import PullToRefresh from 'react-simple-pull-to-refresh'
|
import PullToRefresh from 'react-simple-pull-to-refresh'
|
||||||
import Tabs from '../Tabs'
|
import Tabs from '../Tabs'
|
||||||
import { NotificationItem } from './NotificationItem'
|
import { NotificationItem } from './NotificationItem'
|
||||||
|
import { NotificationSkeleton } from './NotificationItem/Notification'
|
||||||
|
|
||||||
const LIMIT = 100
|
const LIMIT = 100
|
||||||
const SHOW_COUNT = 30
|
const SHOW_COUNT = 30
|
||||||
|
|
||||||
const NotificationList = forwardRef((_, ref) => {
|
const NotificationList = forwardRef((_, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { current } = usePrimaryPage()
|
const { current, display } = usePrimaryPage()
|
||||||
|
const active = useMemo(() => current === 'notifications' && display, [current, display])
|
||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
const { hideUntrustedNotifications, isUserTrusted } = useUserTrust()
|
const { hideUntrustedNotifications, isUserTrusted } = useUserTrust()
|
||||||
const { clearNewNotifications, getNotificationsSeenAt } = useNotification()
|
const { getNotificationsSeenAt } = useNotification()
|
||||||
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
|
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
|
||||||
const [lastReadTime, setLastReadTime] = useState(0)
|
const [lastReadTime, setLastReadTime] = useState(0)
|
||||||
const [refreshCount, setRefreshCount] = useState(0)
|
const [refreshCount, setRefreshCount] = useState(0)
|
||||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [notifications, setNotifications] = useState<Event[]>([])
|
const [notifications, setNotifications] = useState<NostrEvent[]>([])
|
||||||
const [newNotifications, setNewNotifications] = useState<Event[]>([])
|
const [visibleNotifications, setVisibleNotifications] = useState<NostrEvent[]>([])
|
||||||
const [oldNotifications, setOldNotifications] = useState<Event[]>([])
|
|
||||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
||||||
const [until, setUntil] = useState<number | undefined>(dayjs().unix())
|
const [until, setUntil] = useState<number | undefined>(dayjs().unix())
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||||
@@ -73,6 +81,25 @@ const NotificationList = forwardRef((_, ref) => {
|
|||||||
[loading]
|
[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(() => {
|
useEffect(() => {
|
||||||
if (current !== 'notifications') return
|
if (current !== 'notifications') return
|
||||||
|
|
||||||
@@ -86,7 +113,6 @@ const NotificationList = forwardRef((_, ref) => {
|
|||||||
setNotifications([])
|
setNotifications([])
|
||||||
setShowCount(SHOW_COUNT)
|
setShowCount(SHOW_COUNT)
|
||||||
setLastReadTime(getNotificationsSeenAt())
|
setLastReadTime(getNotificationsSeenAt())
|
||||||
clearNewNotifications()
|
|
||||||
const relayList = await client.fetchRelayList(pubkey)
|
const relayList = await client.fetchRelayList(pubkey)
|
||||||
|
|
||||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||||
@@ -112,17 +138,7 @@ const NotificationList = forwardRef((_, ref) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNew: (event) => {
|
onNew: (event) => {
|
||||||
if (event.pubkey === pubkey) return
|
handleNewEvent(event)
|
||||||
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])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -136,21 +152,39 @@ const NotificationList = forwardRef((_, ref) => {
|
|||||||
}
|
}
|
||||||
}, [pubkey, refreshCount, filterKinds, current])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let visibleNotifications = notifications.slice(0, showCount)
|
let visibleNotifications = notifications.slice(0, showCount)
|
||||||
if (hideUntrustedNotifications) {
|
if (hideUntrustedNotifications) {
|
||||||
visibleNotifications = visibleNotifications.filter((event) => isUserTrusted(event.pubkey))
|
visibleNotifications = visibleNotifications.filter((event) => isUserTrusted(event.pubkey))
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = visibleNotifications.findIndex((event) => event.created_at <= lastReadTime)
|
setVisibleNotifications(visibleNotifications)
|
||||||
if (index === -1) {
|
}, [notifications, showCount, hideUntrustedNotifications, isUserTrusted])
|
||||||
setNewNotifications(visibleNotifications)
|
|
||||||
setOldNotifications([])
|
|
||||||
} else {
|
|
||||||
setNewNotifications(visibleNotifications.slice(0, index))
|
|
||||||
setOldNotifications(visibleNotifications.slice(index))
|
|
||||||
}
|
|
||||||
}, [notifications, lastReadTime, showCount, hideUntrustedNotifications, isUserTrusted])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const options = {
|
const options = {
|
||||||
@@ -228,28 +262,18 @@ const NotificationList = forwardRef((_, ref) => {
|
|||||||
}}
|
}}
|
||||||
pullingContent=""
|
pullingContent=""
|
||||||
>
|
>
|
||||||
<div className="px-4 pt-2">
|
<div>
|
||||||
{newNotifications.map((notification) => (
|
{visibleNotifications.map((notification) => (
|
||||||
<NotificationItem key={notification.id} notification={notification} isNew />
|
<NotificationItem
|
||||||
))}
|
key={notification.id}
|
||||||
{!!newNotifications.length && (
|
notification={notification}
|
||||||
<div className="relative my-2">
|
isNew={notification.created_at > lastReadTime}
|
||||||
<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 className="text-center text-sm text-muted-foreground">
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
{until || loading ? (
|
{until || loading ? (
|
||||||
<div ref={bottomRef}>
|
<div ref={bottomRef}>
|
||||||
<div className="flex gap-2 items-center h-11 py-2">
|
<NotificationSkeleton />
|
||||||
<Skeleton className="w-7 h-7 rounded-full" />
|
|
||||||
<Skeleton className="h-6 flex-1 w-0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
t('no more notifications')
|
t('no more notifications')
|
||||||
|
|||||||
@@ -125,9 +125,9 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client.addEventListener('eventPublished', handleEventPublished)
|
client.addEventListener('newEvent', handleEventPublished)
|
||||||
return () => {
|
return () => {
|
||||||
client.removeEventListener('eventPublished', handleEventPublished)
|
client.removeEventListener('newEvent', handleEventPublished)
|
||||||
}
|
}
|
||||||
}, [rootInfo, onNewReply])
|
}, [rootInfo, onNewReply])
|
||||||
|
|
||||||
|
|||||||
@@ -375,6 +375,14 @@ export default {
|
|||||||
'اكتب للبحث عن أشخاص، كلمات مفتاحية، أو ريلايات',
|
'اكتب للبحث عن أشخاص، كلمات مفتاحية، أو ريلايات',
|
||||||
'Hide content mentioning muted users': 'إخفاء المحتوى الذي يذكر المستخدمين المكتومين',
|
'Hide content mentioning muted users': 'إخفاء المحتوى الذي يذكر المستخدمين المكتومين',
|
||||||
'This note mentions a user you muted': 'هذه الملاحظة تذكر مستخدماً قمت بكتمه',
|
'This note mentions a user you muted': 'هذه الملاحظة تذكر مستخدماً قمت بكتمه',
|
||||||
Filter: 'مرشح'
|
Filter: 'مرشح',
|
||||||
|
'mentioned you in a note': 'ذكرك في ملاحظة',
|
||||||
|
'quoted your note': 'اقتبس ملاحظتك',
|
||||||
|
'voted in your poll': 'صوت في استطلاعك',
|
||||||
|
'reacted to your note': 'تفاعل مع ملاحظتك',
|
||||||
|
'reposted your note': 'أعاد نشر ملاحظتك',
|
||||||
|
'zapped your note': 'زاب ملاحظتك',
|
||||||
|
'zapped you': 'زابك',
|
||||||
|
'Mark as read': 'تعليم كمقروء'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,6 +384,14 @@ export default {
|
|||||||
'Hide content mentioning muted users': 'Inhalte ausblenden, die stumme Benutzer erwähnen',
|
'Hide content mentioning muted users': 'Inhalte ausblenden, die stumme Benutzer erwähnen',
|
||||||
'This note mentions a user you muted':
|
'This note mentions a user you muted':
|
||||||
'Diese Notiz erwähnt einen Benutzer, den Sie stumm geschaltet haben',
|
'Diese Notiz erwähnt einen Benutzer, den Sie stumm geschaltet haben',
|
||||||
Filter: 'Filter'
|
Filter: 'Filter',
|
||||||
|
'mentioned you in a note': 'hat Sie in einer Notiz erwähnt',
|
||||||
|
'quoted your note': 'hat Ihre Notiz zitiert',
|
||||||
|
'voted in your poll': 'hat in Ihrer Umfrage abgestimmt',
|
||||||
|
'reacted to your note': 'hat auf Ihre Notiz reagiert',
|
||||||
|
'reposted your note': 'hat Ihre Notiz geteilt',
|
||||||
|
'zapped your note': 'hat Ihre Notiz gezappt',
|
||||||
|
'zapped you': 'hat Sie gezappt',
|
||||||
|
'Mark as read': 'Als gelesen markieren'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,6 +374,14 @@ export default {
|
|||||||
'Type searching for people, keywords, or relays',
|
'Type searching for people, keywords, or relays',
|
||||||
'Hide content mentioning muted users': 'Hide content mentioning muted users',
|
'Hide content mentioning muted users': 'Hide content mentioning muted users',
|
||||||
'This note mentions a user you muted': 'This note mentions a user you muted',
|
'This note mentions a user you muted': 'This note mentions a user you muted',
|
||||||
Filter: 'Filter'
|
Filter: 'Filter',
|
||||||
|
'mentioned you in a note': 'mentioned you in a note',
|
||||||
|
'quoted your note': 'quoted your note',
|
||||||
|
'voted in your poll': 'voted in your poll',
|
||||||
|
'reacted to your note': 'reacted to your note',
|
||||||
|
'reposted your note': 'reposted your note',
|
||||||
|
'zapped your note': 'zapped your note',
|
||||||
|
'zapped you': 'zapped you',
|
||||||
|
'Mark as read': 'Mark as read'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -380,6 +380,14 @@ export default {
|
|||||||
'Escribe para buscar personas, palabras clave o relés',
|
'Escribe para buscar personas, palabras clave o relés',
|
||||||
'Hide content mentioning muted users': 'Ocultar contenido que mencione usuarios silenciados',
|
'Hide content mentioning muted users': 'Ocultar contenido que mencione usuarios silenciados',
|
||||||
'This note mentions a user you muted': 'Esta nota menciona a un usuario que silenciaste',
|
'This note mentions a user you muted': 'Esta nota menciona a un usuario que silenciaste',
|
||||||
Filter: 'Filtro'
|
Filter: 'Filtro',
|
||||||
|
'mentioned you in a note': 'te mencionó en una nota',
|
||||||
|
'quoted your note': 'citó tu nota',
|
||||||
|
'voted in your poll': 'votó en tu encuesta',
|
||||||
|
'reacted to your note': 'reaccionó a tu nota',
|
||||||
|
'reposted your note': 'reposteó tu nota',
|
||||||
|
'zapped your note': 'zappeó tu nota',
|
||||||
|
'zapped you': 'te zappeó',
|
||||||
|
'Mark as read': 'Marcar como leído'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,6 +376,14 @@ export default {
|
|||||||
'برای جستجو افراد، کلمات کلیدی یا رلهها تایپ کنید',
|
'برای جستجو افراد، کلمات کلیدی یا رلهها تایپ کنید',
|
||||||
'Hide content mentioning muted users': 'مخفی کردن محتوای اشاره کننده به کاربران بیصدا شده',
|
'Hide content mentioning muted users': 'مخفی کردن محتوای اشاره کننده به کاربران بیصدا شده',
|
||||||
'This note mentions a user you muted': 'این یادداشت به کاربری که بیصدا کردهاید اشاره میکند',
|
'This note mentions a user you muted': 'این یادداشت به کاربری که بیصدا کردهاید اشاره میکند',
|
||||||
Filter: 'فیلتر'
|
Filter: 'فیلتر',
|
||||||
|
'mentioned you in a note': 'در یادداشتی از شما نام برد',
|
||||||
|
'quoted your note': 'یادداشت شما را نقل قول کرد',
|
||||||
|
'voted in your poll': 'در نظرسنجی شما رأی داد',
|
||||||
|
'reacted to your note': 'به یادداشت شما واکنش نشان داد',
|
||||||
|
'reposted your note': 'یادداشت شما را بازنشر کرد',
|
||||||
|
'zapped your note': 'یادداشت شما را زپ کرد',
|
||||||
|
'zapped you': 'شما را زپ کرد',
|
||||||
|
'Mark as read': 'علامتگذاری به عنوان خوانده شده'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,6 +384,14 @@ export default {
|
|||||||
'Masquer le contenu mentionnant des utilisateurs masqués',
|
'Masquer le contenu mentionnant des utilisateurs masqués',
|
||||||
'This note mentions a user you muted':
|
'This note mentions a user you muted':
|
||||||
'Cette note mentionne un utilisateur que vous avez masqué',
|
'Cette note mentionne un utilisateur que vous avez masqué',
|
||||||
Filter: 'Filtre'
|
Filter: 'Filtre',
|
||||||
|
'mentioned you in a note': 'vous a mentionné dans une note',
|
||||||
|
'quoted your note': 'a cité votre note',
|
||||||
|
'voted in your poll': 'a voté dans votre sondage',
|
||||||
|
'reacted to your note': 'a réagi à votre note',
|
||||||
|
'reposted your note': 'a repartagé votre note',
|
||||||
|
'zapped your note': 'a zappé votre note',
|
||||||
|
'zapped you': 'vous a zappé',
|
||||||
|
'Mark as read': 'Marquer comme lu'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -380,6 +380,14 @@ export default {
|
|||||||
'Digita per cercare persone, parole chiave o relays',
|
'Digita per cercare persone, parole chiave o relays',
|
||||||
'Hide content mentioning muted users': 'Nascondi contenuto che menziona utenti silenziati',
|
'Hide content mentioning muted users': 'Nascondi contenuto che menziona utenti silenziati',
|
||||||
'This note mentions a user you muted': 'Questa nota menziona un utente che hai silenziato',
|
'This note mentions a user you muted': 'Questa nota menziona un utente che hai silenziato',
|
||||||
Filter: 'Filtro'
|
Filter: 'Filtro',
|
||||||
|
'mentioned you in a note': 'ti ha menzionato in una nota',
|
||||||
|
'quoted your note': 'ha citato la tua nota',
|
||||||
|
'voted in your poll': 'ha votato nel tuo sondaggio',
|
||||||
|
'reacted to your note': 'ha reagito alla tua nota',
|
||||||
|
'reposted your note': 'ha ricondiviso la tua nota',
|
||||||
|
'zapped your note': 'ha zappato la tua nota',
|
||||||
|
'zapped you': 'ti ha zappato',
|
||||||
|
'Mark as read': 'Segna come letto'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,6 +377,14 @@ export default {
|
|||||||
'人、キーワード、またはリレーを検索するために入力してください',
|
'人、キーワード、またはリレーを検索するために入力してください',
|
||||||
'Hide content mentioning muted users': 'ミュートしたユーザーを言及するコンテンツを非表示',
|
'Hide content mentioning muted users': 'ミュートしたユーザーを言及するコンテンツを非表示',
|
||||||
'This note mentions a user you muted': 'このノートはミュートしたユーザーを言及しています',
|
'This note mentions a user you muted': 'このノートはミュートしたユーザーを言及しています',
|
||||||
Filter: 'フィルター'
|
Filter: 'フィルター',
|
||||||
|
'mentioned you in a note': 'ノートであなたに言及しました',
|
||||||
|
'quoted your note': 'あなたのノートを引用しました',
|
||||||
|
'voted in your poll': 'あなたの投票に投票しました',
|
||||||
|
'reacted to your note': 'あなたのノートにリアクションしました',
|
||||||
|
'reposted your note': 'あなたのノートをリポストしました',
|
||||||
|
'zapped your note': 'あなたのノートにザップしました',
|
||||||
|
'zapped you': 'あなたにザップしました',
|
||||||
|
'Mark as read': '既読にする'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,6 +377,14 @@ export default {
|
|||||||
'사람, 키워드 또는 릴레이를 검색하려면 입력하세요',
|
'사람, 키워드 또는 릴레이를 검색하려면 입력하세요',
|
||||||
'Hide content mentioning muted users': '뮤트된 사용자를 언급하는 콘텐츠 숨기기',
|
'Hide content mentioning muted users': '뮤트된 사용자를 언급하는 콘텐츠 숨기기',
|
||||||
'This note mentions a user you muted': '이 노트는 뮤트한 사용자를 언급합니다',
|
'This note mentions a user you muted': '이 노트는 뮤트한 사용자를 언급합니다',
|
||||||
Filter: '필터'
|
Filter: '필터',
|
||||||
|
'mentioned you in a note': '노트에서 당신을 언급했습니다',
|
||||||
|
'quoted your note': '당신의 노트를 인용했습니다',
|
||||||
|
'voted in your poll': '당신의 투표에 참여했습니다',
|
||||||
|
'reacted to your note': '당신의 노트에 반응했습니다',
|
||||||
|
'reposted your note': '당신의 노트를 리포스트했습니다',
|
||||||
|
'zapped your note': '당신의 노트를 잽했습니다',
|
||||||
|
'zapped you': '당신을 잽했습니다',
|
||||||
|
'Mark as read': '읽음으로 표시'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,6 +381,14 @@ export default {
|
|||||||
'Wpisz, aby wyszukać osoby, słowa kluczowe lub przekaźniki',
|
'Wpisz, aby wyszukać osoby, słowa kluczowe lub przekaźniki',
|
||||||
'Hide content mentioning muted users': 'Ukryj treści wspominające wyciszonych użytkowników',
|
'Hide content mentioning muted users': 'Ukryj treści wspominające wyciszonych użytkowników',
|
||||||
'This note mentions a user you muted': 'Ten wpis wspomina użytkownika, którego wyciszyłeś',
|
'This note mentions a user you muted': 'Ten wpis wspomina użytkownika, którego wyciszyłeś',
|
||||||
Filter: 'Filtr'
|
Filter: 'Filtr',
|
||||||
|
'mentioned you in a note': 'wspomniał o tobie w notatce',
|
||||||
|
'quoted your note': 'zacytował twoją notatkę',
|
||||||
|
'voted in your poll': 'zagłosował w twojej ankiecie',
|
||||||
|
'reacted to your note': 'zareagował na twoją notatkę',
|
||||||
|
'reposted your note': 'przepostował twoją notatkę',
|
||||||
|
'zapped your note': 'zappował twoją notatkę',
|
||||||
|
'zapped you': 'zappował cię',
|
||||||
|
'Mark as read': 'Oznacz jako przeczytane'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,6 +377,14 @@ export default {
|
|||||||
'Digite para buscar pessoas, palavras-chave ou relays',
|
'Digite para buscar pessoas, palavras-chave ou relays',
|
||||||
'Hide content mentioning muted users': 'Ocultar conteúdo que menciona usuários silenciados',
|
'Hide content mentioning muted users': 'Ocultar conteúdo que menciona usuários silenciados',
|
||||||
'This note mentions a user you muted': 'Esta nota menciona um usuário que você silenciou',
|
'This note mentions a user you muted': 'Esta nota menciona um usuário que você silenciou',
|
||||||
Filter: 'Filtro'
|
Filter: 'Filtro',
|
||||||
|
'mentioned you in a note': 'mencionou você em uma nota',
|
||||||
|
'quoted your note': 'citou sua nota',
|
||||||
|
'voted in your poll': 'votou na sua enquete',
|
||||||
|
'reacted to your note': 'reagiu à sua nota',
|
||||||
|
'reposted your note': 'republicou sua nota',
|
||||||
|
'zapped your note': 'zappeou sua nota',
|
||||||
|
'zapped you': 'zappeou você',
|
||||||
|
'Mark as read': 'Marcar como lida'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -380,6 +380,14 @@ export default {
|
|||||||
'Digite para buscar pessoas, palavras-chave ou relays',
|
'Digite para buscar pessoas, palavras-chave ou relays',
|
||||||
'Hide content mentioning muted users': 'Ocultar conteúdo que menciona utilizadores silenciados',
|
'Hide content mentioning muted users': 'Ocultar conteúdo que menciona utilizadores silenciados',
|
||||||
'This note mentions a user you muted': 'Esta nota menciona um utilizador que silenciou',
|
'This note mentions a user you muted': 'Esta nota menciona um utilizador que silenciou',
|
||||||
Filter: 'Filtro'
|
Filter: 'Filtro',
|
||||||
|
'mentioned you in a note': 'mencionou-o numa nota',
|
||||||
|
'quoted your note': 'citou a sua nota',
|
||||||
|
'voted in your poll': 'votou na sua sondagem',
|
||||||
|
'reacted to your note': 'reagiu à sua nota',
|
||||||
|
'reposted your note': 'republicou a sua nota',
|
||||||
|
'zapped your note': 'zappeou a sua nota',
|
||||||
|
'zapped you': 'zappeou-o',
|
||||||
|
'Mark as read': 'Marcar como lida'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,6 +381,14 @@ export default {
|
|||||||
'Hide content mentioning muted users': 'Скрыть контент, упоминающий заглушённых пользователей',
|
'Hide content mentioning muted users': 'Скрыть контент, упоминающий заглушённых пользователей',
|
||||||
'This note mentions a user you muted':
|
'This note mentions a user you muted':
|
||||||
'Эта заметка упоминает пользователя, которого вы заглушили',
|
'Эта заметка упоминает пользователя, которого вы заглушили',
|
||||||
Filter: 'Фильтр'
|
Filter: 'Фильтр',
|
||||||
|
'mentioned you in a note': 'упомянул вас в заметке',
|
||||||
|
'quoted your note': 'процитировал вашу заметку',
|
||||||
|
'voted in your poll': 'проголосовал в вашем опросе',
|
||||||
|
'reacted to your note': 'отреагировал на вашу заметку',
|
||||||
|
'reposted your note': 'репостнул вашу заметку',
|
||||||
|
'zapped your note': 'заппил вашу заметку',
|
||||||
|
'zapped you': 'заппил вас',
|
||||||
|
'Mark as read': 'Отметить как прочитанное'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,6 +372,14 @@ export default {
|
|||||||
'Type searching for people, keywords, or relays': 'พิมพ์เพื่อค้นหาผู้คน คีย์เวิร์ด หรือรีเลย์',
|
'Type searching for people, keywords, or relays': 'พิมพ์เพื่อค้นหาผู้คน คีย์เวิร์ด หรือรีเลย์',
|
||||||
'Hide content mentioning muted users': 'ซ่อนเนื้อหาที่กล่าวถึงผู้ใช้ที่ปิดเสียง',
|
'Hide content mentioning muted users': 'ซ่อนเนื้อหาที่กล่าวถึงผู้ใช้ที่ปิดเสียง',
|
||||||
'This note mentions a user you muted': 'โน้ตนี้กล่าวถึงผู้ใช้ที่คุณปิดเสียง',
|
'This note mentions a user you muted': 'โน้ตนี้กล่าวถึงผู้ใช้ที่คุณปิดเสียง',
|
||||||
Filter: 'ตัวกรอง'
|
Filter: 'ตัวกรอง',
|
||||||
|
'mentioned you in a note': 'ได้กล่าวถึงคุณในโน้ต',
|
||||||
|
'quoted your note': 'ได้ยกคำพูดจากโน้ตของคุณ',
|
||||||
|
'voted in your poll': 'ได้โหวตในการสำรวจของคุณ',
|
||||||
|
'reacted to your note': 'ได้แสดงปฏิกิริยาต่อโน้ตของคุณ',
|
||||||
|
'reposted your note': 'ได้รีโพสต์โน้ตของคุณ',
|
||||||
|
'zapped your note': 'ได้แซปโน้ตของคุณ',
|
||||||
|
'zapped you': 'ได้แซปคุณ',
|
||||||
|
'Mark as read': 'ทำเครื่องหมายว่าอ่านแล้ว'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,6 +370,14 @@ export default {
|
|||||||
'Type searching for people, keywords, or relays': '输入以搜索用户、关键词或服务器',
|
'Type searching for people, keywords, or relays': '输入以搜索用户、关键词或服务器',
|
||||||
'Hide content mentioning muted users': '隐藏提及已屏蔽用户的内容',
|
'Hide content mentioning muted users': '隐藏提及已屏蔽用户的内容',
|
||||||
'This note mentions a user you muted': '此笔记提及了您已屏蔽的用户',
|
'This note mentions a user you muted': '此笔记提及了您已屏蔽的用户',
|
||||||
Filter: '过滤器'
|
Filter: '过滤器',
|
||||||
|
'mentioned you in a note': '在笔记中提及了您',
|
||||||
|
'quoted your note': '引用了您的笔记',
|
||||||
|
'voted in your poll': '在您的投票中投票',
|
||||||
|
'reacted to your note': '对您的笔记做出了反应',
|
||||||
|
'reposted your note': '转发了您的笔记',
|
||||||
|
'zapped your note': '打闪了您的笔记',
|
||||||
|
'zapped you': '给您打闪',
|
||||||
|
'Mark as read': '标记为已读'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ type TNostrContext = {
|
|||||||
updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise<void>
|
updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise<void>
|
||||||
updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise<void>
|
updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise<void>
|
||||||
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
|
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
|
||||||
updateNotificationsSeenAt: () => Promise<void>
|
updateNotificationsSeenAt: (skipPublish?: boolean) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const NostrContext = createContext<TNostrContext | undefined>(undefined)
|
const NostrContext = createContext<TNostrContext | undefined>(undefined)
|
||||||
@@ -711,7 +711,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setFavoriteRelaysEvent(newFavoriteRelaysEvent)
|
setFavoriteRelaysEvent(newFavoriteRelaysEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateNotificationsSeenAt = async () => {
|
const updateNotificationsSeenAt = async (skipPublish = false) => {
|
||||||
if (!account) return
|
if (!account) return
|
||||||
|
|
||||||
const now = dayjs().unix()
|
const now = dayjs().unix()
|
||||||
@@ -724,8 +724,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const lastPublishedSeenNotificationsAtEventAt =
|
const lastPublishedSeenNotificationsAtEventAt =
|
||||||
lastPublishedSeenNotificationsAtEventAtMap.get(account.pubkey) ?? -1
|
lastPublishedSeenNotificationsAtEventAtMap.get(account.pubkey) ?? -1
|
||||||
if (
|
if (
|
||||||
lastPublishedSeenNotificationsAtEventAt < 0 ||
|
!skipPublish &&
|
||||||
now - lastPublishedSeenNotificationsAtEventAt > 10 * 60 // 10 minutes
|
(lastPublishedSeenNotificationsAtEventAt < 0 ||
|
||||||
|
now - lastPublishedSeenNotificationsAtEventAt > 10 * 60) // 10 minutes
|
||||||
) {
|
) {
|
||||||
await publish(createSeenNotificationsAtDraftEvent())
|
await publish(createSeenNotificationsAtDraftEvent())
|
||||||
lastPublishedSeenNotificationsAtEventAtMap.set(account.pubkey, now)
|
lastPublishedSeenNotificationsAtEventAtMap.set(account.pubkey, now)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||||
import { isMentioningMutedUsers } from '@/lib/event'
|
import { compareEvents, isMentioningMutedUsers } from '@/lib/event'
|
||||||
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { kinds } from 'nostr-tools'
|
import storage from '@/services/local-storage.service'
|
||||||
|
import { kinds, NostrEvent } from 'nostr-tools'
|
||||||
import { SubCloser } from 'nostr-tools/abstract-pool'
|
import { SubCloser } from 'nostr-tools/abstract-pool'
|
||||||
import { createContext, useContext, useEffect, useRef, useState } from 'react'
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
import { useContentPolicy } from './ContentPolicyProvider'
|
import { useContentPolicy } from './ContentPolicyProvider'
|
||||||
import { useMuteList } from './MuteListProvider'
|
import { useMuteList } from './MuteListProvider'
|
||||||
import { useNostr } from './NostrProvider'
|
import { useNostr } from './NostrProvider'
|
||||||
@@ -12,7 +14,8 @@ import { useUserTrust } from './UserTrustProvider'
|
|||||||
type TNotificationContext = {
|
type TNotificationContext = {
|
||||||
hasNewNotification: boolean
|
hasNewNotification: boolean
|
||||||
getNotificationsSeenAt: () => number
|
getNotificationsSeenAt: () => number
|
||||||
clearNewNotifications: () => Promise<void>
|
isNotificationRead: (id: string) => boolean
|
||||||
|
markNotificationAsRead: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotificationContext = createContext<TNotificationContext | undefined>(undefined)
|
const NotificationContext = createContext<TNotificationContext | undefined>(undefined)
|
||||||
@@ -26,25 +29,69 @@ export const useNotification = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationProvider({ children }: { children: React.ReactNode }) {
|
export function NotificationProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { current } = usePrimaryPage()
|
||||||
|
const active = useMemo(() => current === 'notifications', [current])
|
||||||
const { pubkey, notificationsSeenAt, updateNotificationsSeenAt } = useNostr()
|
const { pubkey, notificationsSeenAt, updateNotificationsSeenAt } = useNostr()
|
||||||
const { hideUntrustedNotifications, isUserTrusted } = useUserTrust()
|
const { hideUntrustedNotifications, isUserTrusted } = useUserTrust()
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
const [newNotificationIds, setNewNotificationIds] = useState(new Set<string>())
|
const [newNotifications, setNewNotifications] = useState<NostrEvent[]>([])
|
||||||
const subCloserRef = useRef<SubCloser | null>(null)
|
const [readNotificationIdSet, setReadNotificationIdSet] = useState<Set<string>>(new Set())
|
||||||
|
const filteredNewNotifications = useMemo(() => {
|
||||||
|
if (active || notificationsSeenAt < 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const filtered: NostrEvent[] = []
|
||||||
|
for (const notification of newNotifications) {
|
||||||
|
if (notification.created_at <= notificationsSeenAt || filtered.length >= 10) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
mutePubkeySet.has(notification.pubkey) ||
|
||||||
|
(hideContentMentioningMutedUsers && isMentioningMutedUsers(notification, mutePubkeySet)) ||
|
||||||
|
(hideUntrustedNotifications && !isUserTrusted(notification.pubkey))
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered.push(notification)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}, [
|
||||||
|
newNotifications,
|
||||||
|
notificationsSeenAt,
|
||||||
|
mutePubkeySet,
|
||||||
|
hideContentMentioningMutedUsers,
|
||||||
|
hideUntrustedNotifications,
|
||||||
|
isUserTrusted,
|
||||||
|
active
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pubkey || notificationsSeenAt < 0) return
|
setNewNotifications([])
|
||||||
|
updateNotificationsSeenAt(!active)
|
||||||
|
}, [active])
|
||||||
|
|
||||||
setNewNotificationIds(new Set())
|
useEffect(() => {
|
||||||
|
if (!pubkey) return
|
||||||
|
|
||||||
|
setNewNotifications([])
|
||||||
|
setReadNotificationIdSet(new Set())
|
||||||
|
|
||||||
// Track if component is mounted
|
// Track if component is mounted
|
||||||
const isMountedRef = { current: true }
|
const isMountedRef = { current: true }
|
||||||
|
const subCloserRef: {
|
||||||
|
current: SubCloser | null
|
||||||
|
} = { current: null }
|
||||||
|
|
||||||
const subscribe = async () => {
|
const subscribe = async () => {
|
||||||
|
if (subCloserRef.current) {
|
||||||
|
subCloserRef.current.close()
|
||||||
|
subCloserRef.current = null
|
||||||
|
}
|
||||||
if (!isMountedRef.current) return null
|
if (!isMountedRef.current) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let eosed = false
|
||||||
const relayList = await client.fetchRelayList(pubkey)
|
const relayList = await client.fetchRelayList(pubkey)
|
||||||
const relayUrls = relayList.read.concat(BIG_RELAY_URLS).slice(0, 4)
|
const relayUrls = relayList.read.concat(BIG_RELAY_URLS).slice(0, 4)
|
||||||
const subCloser = client.subscribe(
|
const subCloser = client.subscribe(
|
||||||
@@ -53,32 +100,39 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
|
|||||||
{
|
{
|
||||||
kinds: [
|
kinds: [
|
||||||
kinds.ShortTextNote,
|
kinds.ShortTextNote,
|
||||||
kinds.Reaction,
|
|
||||||
kinds.Repost,
|
kinds.Repost,
|
||||||
|
kinds.Reaction,
|
||||||
kinds.Zap,
|
kinds.Zap,
|
||||||
ExtendedKind.COMMENT,
|
ExtendedKind.COMMENT,
|
||||||
ExtendedKind.POLL_RESPONSE,
|
ExtendedKind.POLL_RESPONSE,
|
||||||
ExtendedKind.VOICE_COMMENT
|
ExtendedKind.VOICE_COMMENT,
|
||||||
|
ExtendedKind.POLL
|
||||||
],
|
],
|
||||||
'#p': [pubkey],
|
'#p': [pubkey],
|
||||||
since: notificationsSeenAt,
|
|
||||||
limit: 20
|
limit: 20
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
oneose: (e) => {
|
||||||
|
if (e) {
|
||||||
|
eosed = e
|
||||||
|
setNewNotifications((prev) => {
|
||||||
|
return [...prev.sort((a, b) => compareEvents(b, a))]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
onevent: (evt) => {
|
onevent: (evt) => {
|
||||||
// Only show notification if not from self and not muted
|
if (evt.pubkey !== pubkey) {
|
||||||
if (
|
setNewNotifications((prev) => {
|
||||||
evt.pubkey !== pubkey &&
|
if (!eosed) {
|
||||||
!mutePubkeySet.has(evt.pubkey) &&
|
return [evt, ...prev]
|
||||||
(!hideContentMentioningMutedUsers || !isMentioningMutedUsers(evt, mutePubkeySet)) &&
|
}
|
||||||
(!hideUntrustedNotifications || isUserTrusted(evt.pubkey))
|
if (prev.length && compareEvents(prev[0], evt) >= 0) {
|
||||||
) {
|
|
||||||
setNewNotificationIds((prev) => {
|
|
||||||
if (prev.has(evt.id)) {
|
|
||||||
return prev
|
return prev
|
||||||
}
|
}
|
||||||
return new Set([...prev, evt.id])
|
|
||||||
|
client.emitNewEvent(evt)
|
||||||
|
return [evt, ...prev]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -88,7 +142,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only reconnect if still mounted and not a manual close
|
// Only reconnect if still mounted and not a manual close
|
||||||
if (isMountedRef.current && subCloserRef.current) {
|
if (isMountedRef.current) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
subscribe()
|
subscribe()
|
||||||
@@ -127,17 +181,10 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
|
|||||||
subCloserRef.current = null
|
subCloserRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [notificationsSeenAt, pubkey])
|
}, [pubkey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (newNotificationIds.size >= 10 && subCloserRef.current) {
|
const newNotificationCount = filteredNewNotifications.length
|
||||||
subCloserRef.current.close()
|
|
||||||
subCloserRef.current = null
|
|
||||||
}
|
|
||||||
}, [newNotificationIds])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const newNotificationCount = newNotificationIds.size
|
|
||||||
|
|
||||||
// Update title
|
// Update title
|
||||||
if (newNotificationCount > 0) {
|
if (newNotificationCount > 0) {
|
||||||
@@ -175,30 +222,33 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [newNotificationIds])
|
}, [filteredNewNotifications])
|
||||||
|
|
||||||
const getNotificationsSeenAt = () => {
|
const getNotificationsSeenAt = () => {
|
||||||
|
if (notificationsSeenAt >= 0) {
|
||||||
return notificationsSeenAt
|
return notificationsSeenAt
|
||||||
}
|
}
|
||||||
|
if (pubkey) {
|
||||||
const clearNewNotifications = async () => {
|
return storage.getLastReadNotificationTime(pubkey)
|
||||||
if (!pubkey) return
|
}
|
||||||
|
return 0
|
||||||
if (subCloserRef.current) {
|
|
||||||
subCloserRef.current.close()
|
|
||||||
subCloserRef.current = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setNewNotificationIds(new Set())
|
const isNotificationRead = (notificationId: string): boolean => {
|
||||||
await updateNotificationsSeenAt()
|
return readNotificationIdSet.has(notificationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const markNotificationAsRead = (notificationId: string): void => {
|
||||||
|
setReadNotificationIdSet((prev) => new Set([...prev, notificationId]))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationContext.Provider
|
<NotificationContext.Provider
|
||||||
value={{
|
value={{
|
||||||
hasNewNotification: newNotificationIds.size > 0,
|
hasNewNotification: filteredNewNotifications.length > 0,
|
||||||
clearNewNotifications,
|
getNotificationsSeenAt,
|
||||||
getNotificationsSeenAt
|
isNotificationRead,
|
||||||
|
markNotificationAsRead
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ class ClientService extends EventTarget {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
this.dispatchEvent(new CustomEvent('eventPublished', { detail: event }))
|
this.emitNewEvent(event)
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AggregateError) {
|
if (error instanceof AggregateError) {
|
||||||
@@ -174,6 +174,10 @@ class ClientService extends EventTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitNewEvent(event: NEvent) {
|
||||||
|
this.dispatchEvent(new CustomEvent('newEvent', { detail: event }))
|
||||||
|
}
|
||||||
|
|
||||||
async signHttpAuth(url: string, method: string, description = '') {
|
async signHttpAuth(url: string, method: string, description = '') {
|
||||||
if (!this.signer) {
|
if (!this.signer) {
|
||||||
throw new Error('Please login first to sign the event')
|
throw new Error('Please login first to sign the event')
|
||||||
|
|||||||
Reference in New Issue
Block a user