diff --git a/src/components/NotificationList/NotificationItem/MentionNotification.tsx b/src/components/NotificationList/NotificationItem/MentionNotification.tsx index d76aef2..f6c6972 100644 --- a/src/components/NotificationList/NotificationItem/MentionNotification.tsx +++ b/src/components/NotificationList/NotificationItem/MentionNotification.tsx @@ -1,14 +1,13 @@ -import { getEmbeddedPubkeys } from '@/lib/event' +import ParentNotePreview from '@/components/ParentNotePreview' +import { getEmbeddedPubkeys, getParentBech32Id } from '@/lib/event' import { toNote } from '@/lib/link' -import { cn } from '@/lib/utils' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' -import { AtSign, MessageCircle } from 'lucide-react' +import { AtSign, MessageCircle, Quote } from 'lucide-react' import { Event } from 'nostr-tools' import { useMemo } from 'react' -import ContentPreview from '../../ContentPreview' -import { FormattedTimestamp } from '../../FormattedTimestamp' -import UserAvatar from '../../UserAvatar' +import { useTranslation } from 'react-i18next' +import Notification from './Notification' export function MentionNotification({ notification, @@ -17,6 +16,7 @@ export function MentionNotification({ notification: Event isNew?: boolean }) { + const { t } = useTranslation() const { push } = useSecondaryPage() const { pubkey } = useNostr() const isMention = useMemo(() => { @@ -24,25 +24,40 @@ export function MentionNotification({ const mentions = getEmbeddedPubkeys(notification) return mentions.includes(pubkey) }, [pubkey, notification]) + const parentEventId = useMemo(() => getParentBech32Id(notification), [notification]) return ( -
push(toNote(notification))} - > - - {isMention ? ( - - ) : ( - - )} - -
- -
-
+ + ) : parentEventId ? ( + + ) : ( + + ) + } + sender={notification.pubkey} + sentAt={notification.created_at} + targetEvent={notification} + middle={ + parentEventId && ( + { + e.stopPropagation() + push(toNote(parentEventId)) + }} + /> + ) + } + description={ + isMention ? t('mentioned you in a note') : parentEventId ? '' : t('quoted your note') + } + isNew={isNew} + showStats + /> ) } diff --git a/src/components/NotificationList/NotificationItem/Notification.tsx b/src/components/NotificationList/NotificationItem/Notification.tsx new file mode 100644 index 0000000..06c9678 --- /dev/null +++ b/src/components/NotificationList/NotificationItem/Notification.tsx @@ -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 ( +
{ + markNotificationAsRead(notificationId) + if (targetEvent) { + push(toNote(targetEvent.id)) + } else if (pubkey) { + push(toProfile(pubkey)) + } + }} + > +
+ {icon} + +
+
+
+
+ +
{description}
+
+ {unread && ( +
+ {middle} + {targetEvent && ( + + )} + + {showStats && targetEvent && } +
+
+ ) +} + +export function NotificationSkeleton() { + return ( +
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+
+ ) +} diff --git a/src/components/NotificationList/NotificationItem/PollResponseNotification.tsx b/src/components/NotificationList/NotificationItem/PollResponseNotification.tsx index 3d96fab..cbb4068 100644 --- a/src/components/NotificationList/NotificationItem/PollResponseNotification.tsx +++ b/src/components/NotificationList/NotificationItem/PollResponseNotification.tsx @@ -1,14 +1,10 @@ import { useFetchEvent } from '@/hooks' -import { toNote } from '@/lib/link' import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag' -import { cn } from '@/lib/utils' -import { useSecondaryPage } from '@/PageManager' import { Vote } from 'lucide-react' import { Event } from 'nostr-tools' import { useMemo } from 'react' -import ContentPreview from '../../ContentPreview' -import { FormattedTimestamp } from '../../FormattedTimestamp' -import UserAvatar from '../../UserAvatar' +import Notification from './Notification' +import { useTranslation } from 'react-i18next' export function PollResponseNotification({ notification, @@ -17,7 +13,7 @@ export function PollResponseNotification({ notification: Event isNew?: boolean }) { - const { push } = useSecondaryPage() + const { t } = useTranslation() const eventId = useMemo(() => { const eTag = notification.tags.find(tagNameEquals('e')) return eTag ? generateBech32IdFromETag(eTag) : undefined @@ -29,19 +25,14 @@ export function PollResponseNotification({ } return ( -
push(toNote(pollEvent))} - > - - - -
- -
-
+ } + sender={notification.pubkey} + sentAt={notification.created_at} + targetEvent={pollEvent} + description={t('voted in your poll')} + isNew={isNew} + /> ) } diff --git a/src/components/NotificationList/NotificationItem/ReactionNotification.tsx b/src/components/NotificationList/NotificationItem/ReactionNotification.tsx index b6164d3..3e1d154 100644 --- a/src/components/NotificationList/NotificationItem/ReactionNotification.tsx +++ b/src/components/NotificationList/NotificationItem/ReactionNotification.tsx @@ -1,16 +1,12 @@ import Image from '@/components/Image' import { useFetchEvent } from '@/hooks' -import { toNote } from '@/lib/link' import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag' -import { cn } from '@/lib/utils' -import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import { Heart } from 'lucide-react' import { Event } from 'nostr-tools' import { useMemo } from 'react' -import ContentPreview from '../../ContentPreview' -import { FormattedTimestamp } from '../../FormattedTimestamp' -import UserAvatar from '../../UserAvatar' +import { useTranslation } from 'react-i18next' +import Notification from './Notification' export function ReactionNotification({ notification, @@ -19,7 +15,7 @@ export function ReactionNotification({ notification: Event isNew?: boolean }) { - const { push } = useSecondaryPage() + const { t } = useTranslation() const { pubkey } = useNostr() const eventId = useMemo(() => { const targetPubkey = notification.tags.findLast(tagNameEquals('p'))?.[1] @@ -58,21 +54,14 @@ export function ReactionNotification({ } return ( -
push(toNote(event))} - > -
- -
{reaction}
- -
-
- -
-
+ {reaction}} + sender={notification.pubkey} + sentAt={notification.created_at} + targetEvent={event} + description={t('reacted to your note')} + isNew={isNew} + /> ) } diff --git a/src/components/NotificationList/NotificationItem/RepostNotification.tsx b/src/components/NotificationList/NotificationItem/RepostNotification.tsx index ee58feb..d4e45ea 100644 --- a/src/components/NotificationList/NotificationItem/RepostNotification.tsx +++ b/src/components/NotificationList/NotificationItem/RepostNotification.tsx @@ -1,13 +1,9 @@ -import { toNote } from '@/lib/link' -import { cn } from '@/lib/utils' -import { useSecondaryPage } from '@/PageManager' import client from '@/services/client.service' import { Repeat } from 'lucide-react' import { Event, validateEvent } from 'nostr-tools' import { useMemo } from 'react' -import ContentPreview from '../../ContentPreview' -import { FormattedTimestamp } from '../../FormattedTimestamp' -import UserAvatar from '../../UserAvatar' +import { useTranslation } from 'react-i18next' +import Notification from './Notification' export function RepostNotification({ notification, @@ -16,7 +12,7 @@ export function RepostNotification({ notification: Event isNew?: boolean }) { - const { push } = useSecondaryPage() + const { t } = useTranslation() const event = useMemo(() => { try { const event = JSON.parse(notification.content) as Event @@ -31,19 +27,14 @@ export function RepostNotification({ if (!event) return null return ( -
push(toNote(event))} - > - - - -
- -
-
+ } + sender={notification.pubkey} + sentAt={notification.created_at} + targetEvent={event} + description={t('reposted your note')} + isNew={isNew} + /> ) } diff --git a/src/components/NotificationList/NotificationItem/ZapNotification.tsx b/src/components/NotificationList/NotificationItem/ZapNotification.tsx index dc0107d..03d6ed5 100644 --- a/src/components/NotificationList/NotificationItem/ZapNotification.tsx +++ b/src/components/NotificationList/NotificationItem/ZapNotification.tsx @@ -1,17 +1,11 @@ import { useFetchEvent } from '@/hooks' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { formatAmount } from '@/lib/lightning' -import { toNote, toProfile } from '@/lib/link' -import { cn } from '@/lib/utils' -import { useSecondaryPage } from '@/PageManager' -import { useNostr } from '@/providers/NostrProvider' import { Zap } from 'lucide-react' import { Event } from 'nostr-tools' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import ContentPreview from '../../ContentPreview' -import { FormattedTimestamp } from '../../FormattedTimestamp' -import UserAvatar from '../../UserAvatar' +import Notification from './Notification' export function ZapNotification({ notification, @@ -21,38 +15,28 @@ export function ZapNotification({ isNew?: boolean }) { const { t } = useTranslation() - const { push } = useSecondaryPage() - const { pubkey } = useNostr() const { senderPubkey, eventId, amount, comment } = useMemo( () => getZapInfoFromEvent(notification) ?? ({} as any), [notification] ) - const { event, isFetching } = useFetchEvent(eventId) + const { event } = useFetchEvent(eventId) if (!senderPubkey || !amount) return null return ( -
(eventId ? push(toNote(eventId)) : pubkey ? push(toProfile(pubkey)) : null)} - > -
- - + } + sender={senderPubkey} + sentAt={notification.created_at} + targetEvent={event} + middle={
- {formatAmount(amount)} {t('sats')} + {formatAmount(amount)} {t('sats')} {comment}
- {comment &&
{comment}
} - {eventId && !isFetching && ( - - )} -
-
- -
-
+ } + description={event ? t('zapped your note') : t('zapped you')} + isNew={isNew} + /> ) } diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index f01a496..31a1b56 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -1,6 +1,5 @@ -import { Separator } from '@/components/ui/separator' -import { Skeleton } from '@/components/ui/skeleton' import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' +import { compareEvents } from '@/lib/event' import { usePrimaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import { useNotification } from '@/providers/NotificationProvider' @@ -9,30 +8,39 @@ import client from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import { TNotificationType } from '@/types' import dayjs from 'dayjs' -import { Event, kinds } from 'nostr-tools' -import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { NostrEvent, kinds, matchFilter } from 'nostr-tools' +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from 'react' import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' import Tabs from '../Tabs' import { NotificationItem } from './NotificationItem' +import { NotificationSkeleton } from './NotificationItem/Notification' const LIMIT = 100 const SHOW_COUNT = 30 const NotificationList = forwardRef((_, ref) => { const { t } = useTranslation() - const { current } = usePrimaryPage() + const { current, display } = usePrimaryPage() + const active = useMemo(() => current === 'notifications' && display, [current, display]) const { pubkey } = useNostr() const { hideUntrustedNotifications, isUserTrusted } = useUserTrust() - const { clearNewNotifications, getNotificationsSeenAt } = useNotification() + const { getNotificationsSeenAt } = useNotification() const [notificationType, setNotificationType] = useState('all') const [lastReadTime, setLastReadTime] = useState(0) const [refreshCount, setRefreshCount] = useState(0) const [timelineKey, setTimelineKey] = useState(undefined) const [loading, setLoading] = useState(true) - const [notifications, setNotifications] = useState([]) - const [newNotifications, setNewNotifications] = useState([]) - const [oldNotifications, setOldNotifications] = useState([]) + const [notifications, setNotifications] = useState([]) + const [visibleNotifications, setVisibleNotifications] = useState([]) const [showCount, setShowCount] = useState(SHOW_COUNT) const [until, setUntil] = useState(dayjs().unix()) const bottomRef = useRef(null) @@ -73,6 +81,25 @@ const NotificationList = forwardRef((_, ref) => { [loading] ) + const handleNewEvent = useCallback( + (event: NostrEvent) => { + if (event.pubkey === pubkey) return + setNotifications((oldEvents) => { + const index = oldEvents.findIndex((oldEvent) => compareEvents(oldEvent, event) <= 0) + if (index !== -1 && oldEvents[index].id === event.id) { + return oldEvents + } + + noteStatsService.updateNoteStatsByEvents([event]) + if (index === -1) { + return [...oldEvents, event] + } + return [...oldEvents.slice(0, index), event, ...oldEvents.slice(index)] + }) + }, + [pubkey] + ) + useEffect(() => { if (current !== 'notifications') return @@ -86,7 +113,6 @@ const NotificationList = forwardRef((_, ref) => { setNotifications([]) setShowCount(SHOW_COUNT) setLastReadTime(getNotificationsSeenAt()) - clearNewNotifications() const relayList = await client.fetchRelayList(pubkey) const { closer, timelineKey } = await client.subscribeTimeline( @@ -112,17 +138,7 @@ const NotificationList = forwardRef((_, ref) => { } }, onNew: (event) => { - if (event.pubkey === pubkey) return - setNotifications((oldEvents) => { - const index = oldEvents.findIndex( - (oldEvent) => oldEvent.created_at < event.created_at - ) - if (index === -1) { - return [...oldEvents, event] - } - return [...oldEvents.slice(0, index), event, ...oldEvents.slice(index)] - }) - noteStatsService.updateNoteStatsByEvents([event]) + handleNewEvent(event) } } ) @@ -136,21 +152,39 @@ const NotificationList = forwardRef((_, ref) => { } }, [pubkey, refreshCount, filterKinds, current]) + useEffect(() => { + if (!active || !pubkey) return + + const handler = (data: Event) => { + const customEvent = data as CustomEvent + const evt = customEvent.detail + if ( + matchFilter( + { + kinds: filterKinds, + '#p': [pubkey] + }, + evt + ) + ) { + handleNewEvent(evt) + } + } + + client.addEventListener('newEvent', handler) + return () => { + client.removeEventListener('newEvent', handler) + } + }, [pubkey, active, filterKinds, handleNewEvent]) + useEffect(() => { let visibleNotifications = notifications.slice(0, showCount) if (hideUntrustedNotifications) { visibleNotifications = visibleNotifications.filter((event) => isUserTrusted(event.pubkey)) } - const index = visibleNotifications.findIndex((event) => event.created_at <= lastReadTime) - if (index === -1) { - setNewNotifications(visibleNotifications) - setOldNotifications([]) - } else { - setNewNotifications(visibleNotifications.slice(0, index)) - setOldNotifications(visibleNotifications.slice(index)) - } - }, [notifications, lastReadTime, showCount, hideUntrustedNotifications, isUserTrusted]) + setVisibleNotifications(visibleNotifications) + }, [notifications, showCount, hideUntrustedNotifications, isUserTrusted]) useEffect(() => { const options = { @@ -228,28 +262,18 @@ const NotificationList = forwardRef((_, ref) => { }} pullingContent="" > -
- {newNotifications.map((notification) => ( - - ))} - {!!newNotifications.length && ( -
- - - {t('Earlier notifications')} - -
- )} - {oldNotifications.map((notification) => ( - +
+ {visibleNotifications.map((notification) => ( + lastReadTime} + /> ))}
{until || loading ? (
-
- - -
+
) : ( t('no more notifications') diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index ce1cba1..f87acba 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -125,9 +125,9 @@ export default function ReplyNoteList({ index, event }: { index?: number; event: } } - client.addEventListener('eventPublished', handleEventPublished) + client.addEventListener('newEvent', handleEventPublished) return () => { - client.removeEventListener('eventPublished', handleEventPublished) + client.removeEventListener('newEvent', handleEventPublished) } }, [rootInfo, onNewReply]) diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index 8112dc6..75a12e4 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -375,6 +375,14 @@ export default { 'اكتب للبحث عن أشخاص، كلمات مفتاحية، أو ريلايات', 'Hide content mentioning muted users': 'إخفاء المحتوى الذي يذكر المستخدمين المكتومين', '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': 'تعليم كمقروء' } } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index b9d069a..bfaface 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -384,6 +384,14 @@ export default { 'Hide content mentioning muted users': 'Inhalte ausblenden, die stumme Benutzer erwähnen', 'This note mentions a user you muted': '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' } } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9684045..1732931 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -374,6 +374,14 @@ export default { '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' + 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' } } diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index b5a616d..d6b34a6 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -380,6 +380,14 @@ export default { 'Escribe para buscar personas, palabras clave o relés', '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', - 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' } } diff --git a/src/i18n/locales/fa.ts b/src/i18n/locales/fa.ts index 64f0f8e..323e8ad 100644 --- a/src/i18n/locales/fa.ts +++ b/src/i18n/locales/fa.ts @@ -376,6 +376,14 @@ export default { 'برای جستجو افراد، کلمات کلیدی یا رله‌ها تایپ کنید', 'Hide content mentioning muted users': 'مخفی کردن محتوای اشاره کننده به کاربران بی‌صدا شده', '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': 'علامت‌گذاری به عنوان خوانده شده' } } diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index d2e4d04..21ff91b 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -384,6 +384,14 @@ export default { 'Masquer le contenu mentionnant des utilisateurs masqués', 'This note mentions a user you muted': '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' } } diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index 31f8773..31bbaf8 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -380,6 +380,14 @@ export default { 'Digita per cercare persone, parole chiave o relays', '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', - 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' } } diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index c0a02e5..bd36ec1 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -377,6 +377,14 @@ export default { '人、キーワード、またはリレーを検索するために入力してください', 'Hide content mentioning muted users': 'ミュートしたユーザーを言及するコンテンツを非表示', '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': '既読にする' } } diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 2be6a35..83f2178 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -377,6 +377,14 @@ export default { '사람, 키워드 또는 릴레이를 검색하려면 입력하세요', 'Hide content mentioning muted users': '뮤트된 사용자를 언급하는 콘텐츠 숨기기', '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': '읽음으로 표시' } } diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index a711aff..b70e311 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -381,6 +381,14 @@ export default { 'Wpisz, aby wyszukać osoby, słowa kluczowe lub przekaźniki', '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ś', - 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' } } diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts index e781ef5..779dd83 100644 --- a/src/i18n/locales/pt-BR.ts +++ b/src/i18n/locales/pt-BR.ts @@ -377,6 +377,14 @@ export default { 'Digite para buscar pessoas, palavras-chave ou relays', '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', - 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' } } diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts index d3c8cd3..ecc11b6 100644 --- a/src/i18n/locales/pt-PT.ts +++ b/src/i18n/locales/pt-PT.ts @@ -380,6 +380,14 @@ export default { 'Digite para buscar pessoas, palavras-chave ou relays', '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', - 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' } } diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index db17d97..86ebf29 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -381,6 +381,14 @@ export default { 'Hide content mentioning muted users': 'Скрыть контент, упоминающий заглушённых пользователей', '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': 'Отметить как прочитанное' } } diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index 69597af..59b1d92 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -372,6 +372,14 @@ export default { 'Type searching for people, keywords, or relays': 'พิมพ์เพื่อค้นหาผู้คน คีย์เวิร์ด หรือรีเลย์', 'Hide content mentioning muted users': 'ซ่อนเนื้อหาที่กล่าวถึงผู้ใช้ที่ปิดเสียง', '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': 'ทำเครื่องหมายว่าอ่านแล้ว' } } diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index f2be313..4371bda 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -370,6 +370,14 @@ export default { 'Type searching for people, keywords, or relays': '输入以搜索用户、关键词或服务器', 'Hide content mentioning muted users': '隐藏提及已屏蔽用户的内容', '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': '标记为已读' } } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 9cffc10..79ae022 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -80,7 +80,7 @@ type TNostrContext = { updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise - updateNotificationsSeenAt: () => Promise + updateNotificationsSeenAt: (skipPublish?: boolean) => Promise } const NostrContext = createContext(undefined) @@ -711,7 +711,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { setFavoriteRelaysEvent(newFavoriteRelaysEvent) } - const updateNotificationsSeenAt = async () => { + const updateNotificationsSeenAt = async (skipPublish = false) => { if (!account) return const now = dayjs().unix() @@ -724,8 +724,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const lastPublishedSeenNotificationsAtEventAt = lastPublishedSeenNotificationsAtEventAtMap.get(account.pubkey) ?? -1 if ( - lastPublishedSeenNotificationsAtEventAt < 0 || - now - lastPublishedSeenNotificationsAtEventAt > 10 * 60 // 10 minutes + !skipPublish && + (lastPublishedSeenNotificationsAtEventAt < 0 || + now - lastPublishedSeenNotificationsAtEventAt > 10 * 60) // 10 minutes ) { await publish(createSeenNotificationsAtDraftEvent()) lastPublishedSeenNotificationsAtEventAtMap.set(account.pubkey, now) diff --git a/src/providers/NotificationProvider.tsx b/src/providers/NotificationProvider.tsx index 7baca4b..f46332b 100644 --- a/src/providers/NotificationProvider.tsx +++ b/src/providers/NotificationProvider.tsx @@ -1,9 +1,11 @@ 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 { 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 { createContext, useContext, useEffect, useRef, useState } from 'react' +import { createContext, useContext, useEffect, useMemo, useState } from 'react' import { useContentPolicy } from './ContentPolicyProvider' import { useMuteList } from './MuteListProvider' import { useNostr } from './NostrProvider' @@ -12,7 +14,8 @@ import { useUserTrust } from './UserTrustProvider' type TNotificationContext = { hasNewNotification: boolean getNotificationsSeenAt: () => number - clearNewNotifications: () => Promise + isNotificationRead: (id: string) => boolean + markNotificationAsRead: (id: string) => void } const NotificationContext = createContext(undefined) @@ -26,25 +29,69 @@ export const useNotification = () => { } export function NotificationProvider({ children }: { children: React.ReactNode }) { + const { current } = usePrimaryPage() + const active = useMemo(() => current === 'notifications', [current]) const { pubkey, notificationsSeenAt, updateNotificationsSeenAt } = useNostr() const { hideUntrustedNotifications, isUserTrusted } = useUserTrust() const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() - const [newNotificationIds, setNewNotificationIds] = useState(new Set()) - const subCloserRef = useRef(null) + const [newNotifications, setNewNotifications] = useState([]) + const [readNotificationIdSet, setReadNotificationIdSet] = useState>(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(() => { - if (!pubkey || notificationsSeenAt < 0) return + setNewNotifications([]) + updateNotificationsSeenAt(!active) + }, [active]) + + useEffect(() => { + if (!pubkey) return - setNewNotificationIds(new Set()) + setNewNotifications([]) + setReadNotificationIdSet(new Set()) // Track if component is mounted const isMountedRef = { current: true } + const subCloserRef: { + current: SubCloser | null + } = { current: null } const subscribe = async () => { + if (subCloserRef.current) { + subCloserRef.current.close() + subCloserRef.current = null + } if (!isMountedRef.current) return null try { + let eosed = false const relayList = await client.fetchRelayList(pubkey) const relayUrls = relayList.read.concat(BIG_RELAY_URLS).slice(0, 4) const subCloser = client.subscribe( @@ -53,32 +100,39 @@ export function NotificationProvider({ children }: { children: React.ReactNode } { kinds: [ kinds.ShortTextNote, - kinds.Reaction, kinds.Repost, + kinds.Reaction, kinds.Zap, ExtendedKind.COMMENT, ExtendedKind.POLL_RESPONSE, - ExtendedKind.VOICE_COMMENT + ExtendedKind.VOICE_COMMENT, + ExtendedKind.POLL ], '#p': [pubkey], - since: notificationsSeenAt, limit: 20 } ], { + oneose: (e) => { + if (e) { + eosed = e + setNewNotifications((prev) => { + return [...prev.sort((a, b) => compareEvents(b, a))] + }) + } + }, onevent: (evt) => { - // Only show notification if not from self and not muted - if ( - evt.pubkey !== pubkey && - !mutePubkeySet.has(evt.pubkey) && - (!hideContentMentioningMutedUsers || !isMentioningMutedUsers(evt, mutePubkeySet)) && - (!hideUntrustedNotifications || isUserTrusted(evt.pubkey)) - ) { - setNewNotificationIds((prev) => { - if (prev.has(evt.id)) { + if (evt.pubkey !== pubkey) { + setNewNotifications((prev) => { + if (!eosed) { + return [evt, ...prev] + } + if (prev.length && compareEvents(prev[0], evt) >= 0) { 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 - if (isMountedRef.current && subCloserRef.current) { + if (isMountedRef.current) { setTimeout(() => { if (isMountedRef.current) { subscribe() @@ -127,17 +181,10 @@ export function NotificationProvider({ children }: { children: React.ReactNode } subCloserRef.current = null } } - }, [notificationsSeenAt, pubkey]) - - useEffect(() => { - if (newNotificationIds.size >= 10 && subCloserRef.current) { - subCloserRef.current.close() - subCloserRef.current = null - } - }, [newNotificationIds]) + }, [pubkey]) useEffect(() => { - const newNotificationCount = newNotificationIds.size + const newNotificationCount = filteredNewNotifications.length // Update title if (newNotificationCount > 0) { @@ -175,30 +222,33 @@ export function NotificationProvider({ children }: { children: React.ReactNode } }) } } - }, [newNotificationIds]) + }, [filteredNewNotifications]) const getNotificationsSeenAt = () => { - return notificationsSeenAt + if (notificationsSeenAt >= 0) { + return notificationsSeenAt + } + if (pubkey) { + return storage.getLastReadNotificationTime(pubkey) + } + return 0 } - const clearNewNotifications = async () => { - if (!pubkey) return - - if (subCloserRef.current) { - subCloserRef.current.close() - subCloserRef.current = null - } + const isNotificationRead = (notificationId: string): boolean => { + return readNotificationIdSet.has(notificationId) + } - setNewNotificationIds(new Set()) - await updateNotificationsSeenAt() + const markNotificationAsRead = (notificationId: string): void => { + setReadNotificationIdSet((prev) => new Set([...prev, notificationId])) } return ( 0, - clearNewNotifications, - getNotificationsSeenAt + hasNewNotification: filteredNewNotifications.length > 0, + getNotificationsSeenAt, + isNotificationRead, + markNotificationAsRead }} > {children} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 752c2ce..e1a25a2 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -164,7 +164,7 @@ class ClientService extends EventTarget { }) }) ) - this.dispatchEvent(new CustomEvent('eventPublished', { detail: event })) + this.emitNewEvent(event) return result } catch (error) { 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 = '') { if (!this.signer) { throw new Error('Please login first to sign the event')