feat: sync notifications read time
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user