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