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