feat: sync notifications read time

This commit is contained in:
codytseng
2025-04-12 17:18:44 +08:00
parent 776f290ef9
commit 30da0319ce
7 changed files with 96 additions and 46 deletions

View File

@@ -15,7 +15,7 @@ export default function NotificationsButton() {
<div className="relative"> <div className="relative">
<Bell /> <Bell />
{hasNewNotification && ( {hasNewNotification && (
<div className="absolute -top-0.5 right-0.5 w-2 h-2 bg-primary rounded-full" /> <div className="absolute -top-0.5 right-0.5 w-2 h-2 ring-2 ring-background bg-primary rounded-full" />
)} )}
</div> </div>
</BottomNavigationBarItem> </BottomNavigationBarItem>

View File

@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils'
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider' import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useNoteStats } from '@/providers/NoteStatsProvider' import { useNoteStats } from '@/providers/NoteStatsProvider'
import { useNotification } from '@/providers/NotificationProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { TNotificationType } from '@/types' import { TNotificationType } from '@/types'
@@ -21,6 +22,7 @@ const SHOW_COUNT = 30
const NotificationList = forwardRef((_, ref) => { const NotificationList = forwardRef((_, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { clearNewNotifications: updateReadNotificationTime } = useNotification()
const { updateNoteStatsByEvents } = useNoteStats() const { updateNoteStatsByEvents } = useNoteStats()
const [notificationType, setNotificationType] = useState<TNotificationType>('all') const [notificationType, setNotificationType] = useState<TNotificationType>('all')
const [lastReadTime, setLastReadTime] = useState(0) const [lastReadTime, setLastReadTime] = useState(0)
@@ -67,6 +69,7 @@ const NotificationList = forwardRef((_, ref) => {
setNotifications([]) setNotifications([])
setShowCount(SHOW_COUNT) setShowCount(SHOW_COUNT)
setLastReadTime(storage.getLastReadNotificationTime(pubkey)) setLastReadTime(storage.getLastReadNotificationTime(pubkey))
updateReadNotificationTime()
const relayList = await client.fetchRelayList(pubkey) const relayList = await client.fetchRelayList(pubkey)
const { closer, timelineKey } = await client.subscribeTimeline( const { closer, timelineKey } = await client.subscribeTimeline(

View File

@@ -16,7 +16,7 @@ export default function NotificationsButton() {
<div className="relative"> <div className="relative">
<Bell strokeWidth={3} /> <Bell strokeWidth={3} />
{hasNewNotification && ( {hasNewNotification && (
<div className="absolute -top-1 right-0 w-2 h-2 bg-primary rounded-full" /> <div className="absolute -top-1 right-0 w-2 h-2 ring-2 ring-background bg-primary rounded-full" />
)} )}
</div> </div>
</SidebarItem> </SidebarItem>

View File

@@ -22,6 +22,10 @@ export const StorageKey = {
FEED_TYPE: 'feedType' // deprecated FEED_TYPE: 'feedType' // deprecated
} }
export const ApplicationDataKey = {
NOTIFICATIONS_SEEN_AT: 'seen_notifications_at'
}
export const BIG_RELAY_URLS = [ export const BIG_RELAY_URLS = [
'wss://relay.damus.io/', 'wss://relay.damus.io/',
'wss://nos.lol/', 'wss://nos.lol/',

View File

@@ -1,4 +1,4 @@
import { ExtendedKind } from '@/constants' import { ApplicationDataKey, ExtendedKind } from '@/constants'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TDraftEvent, TMailboxRelay, TRelaySet } from '@/types' import { TDraftEvent, TMailboxRelay, TRelaySet } from '@/types'
import dayjs from 'dayjs' 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[][] }[]) { function generateImetaTags(imageUrls: string[], pictureInfos: { url: string; tags: string[][] }[]) {
return imageUrls.map((imageUrl) => { return imageUrls.map((imageUrl) => {
const pictureInfo = pictureInfos.find((info) => info.url === imageUrl) const pictureInfo = pictureInfos.find((info) => info.url === imageUrl)

View File

@@ -1,10 +1,12 @@
import LoginDialog from '@/components/LoginDialog' 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 { useToast } from '@/hooks'
import { createSeenNotificationsAtDraftEvent } from '@/lib/draft-event'
import { import {
getLatestEvent, getLatestEvent,
getProfileFromProfileEvent, getProfileFromProfileEvent,
getRelayListFromRelayListEvent getRelayListFromRelayListEvent,
getReplaceableEventIdentifier
} from '@/lib/event' } from '@/lib/event'
import { formatPubkey, isValidPubkey } from '@/lib/pubkey' import { formatPubkey, isValidPubkey } from '@/lib/pubkey'
import client from '@/services/client.service' import client from '@/services/client.service'
@@ -20,8 +22,8 @@ import { createContext, useContext, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BunkerSigner } from './bunker.signer' import { BunkerSigner } from './bunker.signer'
import { Nip07Signer } from './nip-07.signer' import { Nip07Signer } from './nip-07.signer'
import { NsecSigner } from './nsec.signer'
import { NpubSigner } from './npub.signer' import { NpubSigner } from './npub.signer'
import { NsecSigner } from './nsec.signer'
type TNostrContext = { type TNostrContext = {
isInitialized: boolean isInitialized: boolean
@@ -32,6 +34,7 @@ type TNostrContext = {
followListEvent?: Event followListEvent?: Event
muteListEvent?: Event muteListEvent?: Event
favoriteRelaysEvent: Event | null favoriteRelaysEvent: Event | null
notificationsSeenAt: number
account: TAccountPointer | null account: TAccountPointer | null
accounts: TAccountPointer[] accounts: TAccountPointer[]
nsec: string | null nsec: string | null
@@ -58,6 +61,7 @@ type TNostrContext = {
updateFollowListEvent: (followListEvent: Event) => Promise<void> updateFollowListEvent: (followListEvent: Event) => Promise<void>
updateMuteListEvent: (muteListEvent: Event, tags: string[][]) => Promise<void> updateMuteListEvent: (muteListEvent: Event, tags: string[][]) => Promise<void>
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void> updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updateNotificationsSeenAt: () => Promise<void>
} }
const NostrContext = createContext<TNostrContext | undefined>(undefined) const NostrContext = createContext<TNostrContext | undefined>(undefined)
@@ -84,6 +88,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined) const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined)
const [muteListEvent, setMuteListEvent] = useState<Event | undefined>(undefined) const [muteListEvent, setMuteListEvent] = useState<Event | undefined>(undefined)
const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(null) const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(null)
const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1)
const [isInitialized, setIsInitialized] = useState(false) const [isInitialized, setIsInitialized] = useState(false)
useEffect(() => { useEffect(() => {
@@ -183,15 +188,27 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
setRelayList(relayList) setRelayList(relayList)
const events = await client.fetchEvents(relayList.write.concat(BIG_RELAY_URLS).slice(0, 4), { const events = await client.fetchEvents(relayList.write.concat(BIG_RELAY_URLS).slice(0, 4), [
{
kinds: [kinds.Metadata, kinds.Contacts, kinds.Mutelist, ExtendedKind.FAVORITE_RELAYS], kinds: [kinds.Metadata, kinds.Contacts, kinds.Mutelist, ExtendedKind.FAVORITE_RELAYS],
authors: [account.pubkey] 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 sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const profileEvent = sortedEvents.find((e) => e.kind === kinds.Metadata) const profileEvent = sortedEvents.find((e) => e.kind === kinds.Metadata)
const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts) const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts)
const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist) const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist)
const favoriteRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.FAVORITE_RELAYS) 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) { if (profileEvent) {
setProfileEvent(profileEvent) setProfileEvent(profileEvent)
setProfile(getProfileFromProfileEvent(profileEvent)) setProfile(getProfileFromProfileEvent(profileEvent))
@@ -215,6 +232,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
await indexedDb.putReplaceableEvent(favoriteRelaysEvent) 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) client.initUserIndexFromFollowings(account.pubkey, controller.signal)
return controller return controller
} }
@@ -429,6 +457,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
draftEvent: TDraftEvent, draftEvent: TDraftEvent,
{ specifiedRelayUrls }: { specifiedRelayUrls?: string[] } = {} { specifiedRelayUrls }: { specifiedRelayUrls?: string[] } = {}
) => { ) => {
if (!account || !signer || account.signerType === 'npub') {
throw new Error('You need to login first')
}
const additionalRelayUrls: string[] = [] const additionalRelayUrls: string[] = []
if ( if (
!specifiedRelayUrls?.length && !specifiedRelayUrls?.length &&
@@ -538,6 +570,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setFavoriteRelaysEvent(newFavoriteRelaysEvent) 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 ( return (
<NostrContext.Provider <NostrContext.Provider
value={{ value={{
@@ -549,6 +592,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
followListEvent, followListEvent,
muteListEvent, muteListEvent,
favoriteRelaysEvent, favoriteRelaysEvent,
notificationsSeenAt,
account, account,
accounts: storage accounts: storage
.getAccounts() .getAccounts()
@@ -573,7 +617,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
updateProfileEvent, updateProfileEvent,
updateFollowListEvent, updateFollowListEvent,
updateMuteListEvent, updateMuteListEvent,
updateFavoriteRelaysEvent updateFavoriteRelaysEvent,
updateNotificationsSeenAt
}} }}
> >
{children} {children}

View File

@@ -1,16 +1,14 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { TPrimaryPageName, usePrimaryPage } from '@/PageManager'
import client from '@/services/client.service' import client from '@/services/client.service'
import storage from '@/services/local-storage.service'
import dayjs from 'dayjs'
import { kinds } from 'nostr-tools' import { kinds } 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, useState } from 'react'
import { useMuteList } from './MuteListProvider' import { useMuteList } from './MuteListProvider'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
type TNotificationContext = { type TNotificationContext = {
hasNewNotification: boolean hasNewNotification: boolean
clearNewNotifications: () => Promise<void>
} }
const NotificationContext = createContext<TNotificationContext | undefined>(undefined) const NotificationContext = createContext<TNotificationContext | undefined>(undefined)
@@ -24,38 +22,14 @@ export const useNotification = () => {
} }
export function NotificationProvider({ children }: { children: React.ReactNode }) { export function NotificationProvider({ children }: { children: React.ReactNode }) {
const { pubkey } = useNostr() const { pubkey, notificationsSeenAt, updateNotificationsSeenAt } = useNostr()
const { mutePubkeys } = useMuteList() const { mutePubkeys } = useMuteList()
const { current } = usePrimaryPage()
const [hasNewNotification, setHasNewNotification] = useState(false) const [hasNewNotification, setHasNewNotification] = useState(false)
const [lastReadTime, setLastReadTime] = useState(-1)
const previousPageRef = useRef<TPrimaryPageName | null>(null)
useEffect(() => { useEffect(() => {
if (current !== 'notifications' && previousPageRef.current === 'notifications') { if (!pubkey || notificationsSeenAt < 0) return
// navigate from notifications to other pages
setLastReadTime(dayjs().unix())
setHasNewNotification(false) 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])
useEffect(() => {
if (!pubkey) return
setLastReadTime(storage.getLastReadNotificationTime(pubkey))
setHasNewNotification(false)
}, [pubkey])
useEffect(() => {
if (!pubkey || lastReadTime < 0) return
// Track if component is mounted // Track if component is mounted
const isMountedRef = { current: true } const isMountedRef = { current: true }
@@ -79,7 +53,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
kinds.Zap kinds.Zap
], ],
'#p': [pubkey], '#p': [pubkey],
since: lastReadTime ?? dayjs().unix(), since: notificationsSeenAt,
limit: 10 limit: 10
} }
], ],
@@ -102,7 +76,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
if (isMountedRef.current) { if (isMountedRef.current) {
subscribe() subscribe()
} }
}, 5000) }, 5_000)
} }
} }
} }
@@ -119,7 +93,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
if (isMountedRef.current) { if (isMountedRef.current) {
subscribe() subscribe()
} }
}, 5000) }, 5_000)
} }
return null return null
} }
@@ -136,10 +110,25 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
currentSubCloser = null 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 ( return (
<NotificationContext.Provider value={{ hasNewNotification }}> <NotificationContext.Provider value={{ hasNewNotification, clearNewNotifications }}>
{children} {children}
</NotificationContext.Provider> </NotificationContext.Provider>
) )