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">
<Bell />
{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>
</BottomNavigationBarItem>

View File

@@ -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<TNotificationType>('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(

View File

@@ -16,7 +16,7 @@ export default function NotificationsButton() {
<div className="relative">
<Bell strokeWidth={3} />
{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>
</SidebarItem>

View File

@@ -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/',

View File

@@ -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)

View File

@@ -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<void>
updateMuteListEvent: (muteListEvent: Event, tags: string[][]) => Promise<void>
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updateNotificationsSeenAt: () => Promise<void>
}
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 [muteListEvent, setMuteListEvent] = useState<Event | undefined>(undefined)
const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(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 (
<NostrContext.Provider
value={{
@@ -549,6 +592,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
followListEvent,
muteListEvent,
favoriteRelaysEvent,
notificationsSeenAt,
account,
accounts: storage
.getAccounts()
@@ -573,7 +617,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
updateProfileEvent,
updateFollowListEvent,
updateMuteListEvent,
updateFavoriteRelaysEvent
updateFavoriteRelaysEvent,
updateNotificationsSeenAt
}}
>
{children}

View File

@@ -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<void>
}
const NotificationContext = createContext<TNotificationContext | undefined>(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<TPrimaryPageName | null>(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])
if (!pubkey || notificationsSeenAt < 0) return
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
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 (
<NotificationContext.Provider value={{ hasNewNotification }}>
<NotificationContext.Provider value={{ hasNewNotification, clearNewNotifications }}>
{children}
</NotificationContext.Provider>
)