From 30da0319ce210764234146428862af9f30553ec1 Mon Sep 17 00:00:00 2001 From: codytseng Date: Sat, 12 Apr 2025 17:18:44 +0800 Subject: [PATCH] feat: sync notifications read time --- .../NotificationsButton.tsx | 2 +- src/components/NotificationList/index.tsx | 3 + src/components/Sidebar/NotificationButton.tsx | 2 +- src/constants.ts | 4 ++ src/lib/draft-event.ts | 11 +++- src/providers/NostrProvider/index.tsx | 61 ++++++++++++++++--- src/providers/NotificationProvider.tsx | 59 ++++++++---------- 7 files changed, 96 insertions(+), 46 deletions(-) diff --git a/src/components/BottomNavigationBar/NotificationsButton.tsx b/src/components/BottomNavigationBar/NotificationsButton.tsx index 06898c9..14c76f7 100644 --- a/src/components/BottomNavigationBar/NotificationsButton.tsx +++ b/src/components/BottomNavigationBar/NotificationsButton.tsx @@ -15,7 +15,7 @@ export default function NotificationsButton() {
{hasNewNotification && ( -
+
)}
diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index 8bcc8e5..92b0506 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -5,6 +5,7 @@ import { cn } from '@/lib/utils' import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider' import { useNostr } from '@/providers/NostrProvider' import { useNoteStats } from '@/providers/NoteStatsProvider' +import { useNotification } from '@/providers/NotificationProvider' import client from '@/services/client.service' import storage from '@/services/local-storage.service' import { TNotificationType } from '@/types' @@ -21,6 +22,7 @@ const SHOW_COUNT = 30 const NotificationList = forwardRef((_, ref) => { const { t } = useTranslation() const { pubkey } = useNostr() + const { clearNewNotifications: updateReadNotificationTime } = useNotification() const { updateNoteStatsByEvents } = useNoteStats() const [notificationType, setNotificationType] = useState('all') const [lastReadTime, setLastReadTime] = useState(0) @@ -67,6 +69,7 @@ const NotificationList = forwardRef((_, ref) => { setNotifications([]) setShowCount(SHOW_COUNT) setLastReadTime(storage.getLastReadNotificationTime(pubkey)) + updateReadNotificationTime() const relayList = await client.fetchRelayList(pubkey) const { closer, timelineKey } = await client.subscribeTimeline( diff --git a/src/components/Sidebar/NotificationButton.tsx b/src/components/Sidebar/NotificationButton.tsx index f95398c..3f862d8 100644 --- a/src/components/Sidebar/NotificationButton.tsx +++ b/src/components/Sidebar/NotificationButton.tsx @@ -16,7 +16,7 @@ export default function NotificationsButton() {
{hasNewNotification && ( -
+
)}
diff --git a/src/constants.ts b/src/constants.ts index 00db37a..a52c722 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -22,6 +22,10 @@ export const StorageKey = { FEED_TYPE: 'feedType' // deprecated } +export const ApplicationDataKey = { + NOTIFICATIONS_SEEN_AT: 'seen_notifications_at' +} + export const BIG_RELAY_URLS = [ 'wss://relay.damus.io/', 'wss://nos.lol/', diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 2f07043..4796fa1 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -1,4 +1,4 @@ -import { ExtendedKind } from '@/constants' +import { ApplicationDataKey, ExtendedKind } from '@/constants' import client from '@/services/client.service' import { TDraftEvent, TMailboxRelay, TRelaySet } from '@/types' import dayjs from 'dayjs' @@ -274,6 +274,15 @@ export function createFavoriteRelaysDraftEvent( } } +export function createSeenNotificationsAtDraftEvent(): TDraftEvent { + return { + kind: kinds.Application, + content: 'Records read time to sync notification status across devices.', + tags: [['d', ApplicationDataKey.NOTIFICATIONS_SEEN_AT]], + created_at: dayjs().unix() + } +} + function generateImetaTags(imageUrls: string[], pictureInfos: { url: string; tags: string[][] }[]) { return imageUrls.map((imageUrl) => { const pictureInfo = pictureInfos.find((info) => info.url === imageUrl) diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index b6d7715..ea77c28 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1,10 +1,12 @@ import LoginDialog from '@/components/LoginDialog' -import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' +import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind } from '@/constants' import { useToast } from '@/hooks' +import { createSeenNotificationsAtDraftEvent } from '@/lib/draft-event' import { getLatestEvent, getProfileFromProfileEvent, - getRelayListFromRelayListEvent + getRelayListFromRelayListEvent, + getReplaceableEventIdentifier } from '@/lib/event' import { formatPubkey, isValidPubkey } from '@/lib/pubkey' import client from '@/services/client.service' @@ -20,8 +22,8 @@ import { createContext, useContext, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { BunkerSigner } from './bunker.signer' import { Nip07Signer } from './nip-07.signer' -import { NsecSigner } from './nsec.signer' import { NpubSigner } from './npub.signer' +import { NsecSigner } from './nsec.signer' type TNostrContext = { isInitialized: boolean @@ -32,6 +34,7 @@ type TNostrContext = { followListEvent?: Event muteListEvent?: Event favoriteRelaysEvent: Event | null + notificationsSeenAt: number account: TAccountPointer | null accounts: TAccountPointer[] nsec: string | null @@ -58,6 +61,7 @@ type TNostrContext = { updateFollowListEvent: (followListEvent: Event) => Promise updateMuteListEvent: (muteListEvent: Event, tags: string[][]) => Promise updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise + updateNotificationsSeenAt: () => Promise } const NostrContext = createContext(undefined) @@ -84,6 +88,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const [followListEvent, setFollowListEvent] = useState(undefined) const [muteListEvent, setMuteListEvent] = useState(undefined) const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState(null) + const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1) const [isInitialized, setIsInitialized] = useState(false) useEffect(() => { @@ -183,15 +188,27 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } setRelayList(relayList) - const events = await client.fetchEvents(relayList.write.concat(BIG_RELAY_URLS).slice(0, 4), { - kinds: [kinds.Metadata, kinds.Contacts, kinds.Mutelist, ExtendedKind.FAVORITE_RELAYS], - authors: [account.pubkey] - }) + const events = await client.fetchEvents(relayList.write.concat(BIG_RELAY_URLS).slice(0, 4), [ + { + kinds: [kinds.Metadata, kinds.Contacts, kinds.Mutelist, ExtendedKind.FAVORITE_RELAYS], + authors: [account.pubkey] + }, + { + kinds: [kinds.Application], + authors: [account.pubkey], + '#d': [ApplicationDataKey.NOTIFICATIONS_SEEN_AT] + } + ]) const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) const profileEvent = sortedEvents.find((e) => e.kind === kinds.Metadata) const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts) const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist) const favoriteRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.FAVORITE_RELAYS) + const notificationsSeenAtEvent = sortedEvents.find( + (e) => + e.kind === kinds.Application && + getReplaceableEventIdentifier(e) === ApplicationDataKey.NOTIFICATIONS_SEEN_AT + ) if (profileEvent) { setProfileEvent(profileEvent) setProfile(getProfileFromProfileEvent(profileEvent)) @@ -215,6 +232,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { await indexedDb.putReplaceableEvent(favoriteRelaysEvent) } + const storedNotificationsSeenAt = storage.getLastReadNotificationTime(account.pubkey) + if ( + notificationsSeenAtEvent && + notificationsSeenAtEvent.created_at > storedNotificationsSeenAt + ) { + setNotificationsSeenAt(notificationsSeenAtEvent.created_at) + storage.setLastReadNotificationTime(account.pubkey, notificationsSeenAtEvent.created_at) + } else { + setNotificationsSeenAt(storedNotificationsSeenAt) + } + client.initUserIndexFromFollowings(account.pubkey, controller.signal) return controller } @@ -429,6 +457,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { draftEvent: TDraftEvent, { specifiedRelayUrls }: { specifiedRelayUrls?: string[] } = {} ) => { + if (!account || !signer || account.signerType === 'npub') { + throw new Error('You need to login first') + } + const additionalRelayUrls: string[] = [] if ( !specifiedRelayUrls?.length && @@ -538,6 +570,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { setFavoriteRelaysEvent(newFavoriteRelaysEvent) } + const updateNotificationsSeenAt = async () => { + if (!account) return + + const now = dayjs().unix() + storage.setLastReadNotificationTime(account.pubkey, now) + setTimeout(() => { + setNotificationsSeenAt(now) + }, 5_000) + await publish(createSeenNotificationsAtDraftEvent()) + } + return ( {children} diff --git a/src/providers/NotificationProvider.tsx b/src/providers/NotificationProvider.tsx index 21c57fa..002eac4 100644 --- a/src/providers/NotificationProvider.tsx +++ b/src/providers/NotificationProvider.tsx @@ -1,16 +1,14 @@ import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' -import { TPrimaryPageName, usePrimaryPage } from '@/PageManager' import client from '@/services/client.service' -import storage from '@/services/local-storage.service' -import dayjs from 'dayjs' import { kinds } from 'nostr-tools' import { SubCloser } from 'nostr-tools/abstract-pool' -import { createContext, useContext, useEffect, useRef, useState } from 'react' +import { createContext, useContext, useEffect, useState } from 'react' import { useMuteList } from './MuteListProvider' import { useNostr } from './NostrProvider' type TNotificationContext = { hasNewNotification: boolean + clearNewNotifications: () => Promise } const NotificationContext = createContext(undefined) @@ -24,38 +22,14 @@ export const useNotification = () => { } export function NotificationProvider({ children }: { children: React.ReactNode }) { - const { pubkey } = useNostr() + const { pubkey, notificationsSeenAt, updateNotificationsSeenAt } = useNostr() const { mutePubkeys } = useMuteList() - const { current } = usePrimaryPage() const [hasNewNotification, setHasNewNotification] = useState(false) - const [lastReadTime, setLastReadTime] = useState(-1) - const previousPageRef = useRef(null) useEffect(() => { - if (current !== 'notifications' && previousPageRef.current === 'notifications') { - // navigate from notifications to other pages - setLastReadTime(dayjs().unix()) - setHasNewNotification(false) - } else if (current === 'notifications' && previousPageRef.current !== null) { - // navigate to notifications - setHasNewNotification(false) - } - previousPageRef.current = current - }, [current]) - - useEffect(() => { - if (!pubkey || lastReadTime < 0) return - storage.setLastReadNotificationTime(pubkey, lastReadTime) - }, [lastReadTime]) + if (!pubkey || notificationsSeenAt < 0) return - useEffect(() => { - if (!pubkey) return - setLastReadTime(storage.getLastReadNotificationTime(pubkey)) setHasNewNotification(false) - }, [pubkey]) - - useEffect(() => { - if (!pubkey || lastReadTime < 0) return // Track if component is mounted const isMountedRef = { current: true } @@ -79,7 +53,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } kinds.Zap ], '#p': [pubkey], - since: lastReadTime ?? dayjs().unix(), + since: notificationsSeenAt, limit: 10 } ], @@ -102,7 +76,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } if (isMountedRef.current) { subscribe() } - }, 5000) + }, 5_000) } } } @@ -119,7 +93,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } if (isMountedRef.current) { subscribe() } - }, 5000) + }, 5_000) } return null } @@ -136,10 +110,25 @@ export function NotificationProvider({ children }: { children: React.ReactNode } currentSubCloser = null } } - }, [lastReadTime, pubkey]) + }, [notificationsSeenAt, pubkey]) + + useEffect(() => { + if (hasNewNotification) { + document.title = '📩 Jumble' + } else { + document.title = 'Jumble' + } + }, [hasNewNotification]) + + const clearNewNotifications = async () => { + if (!pubkey) return + + setHasNewNotification(false) + await updateNotificationsSeenAt() + } return ( - + {children} )