Browse Source

remove read receipts

imwald
Silberengel 1 month ago
parent
commit
966044e13d
  1. 7
      src/components/BottomNavigationBar/NotificationsButton.tsx
  2. 10
      src/components/NotificationList/NotificationItem/DiscussionNotification.tsx
  3. 10
      src/components/NotificationList/NotificationItem/MentionNotification.tsx
  4. 47
      src/components/NotificationList/NotificationItem/Notification.tsx
  5. 10
      src/components/NotificationList/NotificationItem/PollResponseNotification.tsx
  6. 10
      src/components/NotificationList/NotificationItem/PublicMessageNotification.tsx
  7. 10
      src/components/NotificationList/NotificationItem/ReactionNotification.tsx
  8. 10
      src/components/NotificationList/NotificationItem/RepostNotification.tsx
  9. 10
      src/components/NotificationList/NotificationItem/ZapNotification.tsx
  10. 23
      src/components/NotificationList/NotificationItem/index.tsx
  11. 10
      src/components/NotificationList/index.tsx
  12. 7
      src/components/Sidebar/NotificationButton.tsx
  13. 5
      src/constants.ts
  14. 11
      src/lib/draft-event.ts
  15. 5
      src/main.tsx
  16. 71
      src/providers/NostrProvider/index.tsx
  17. 18
      src/providers/NotificationContext.tsx
  18. 215
      src/providers/NotificationProvider.tsx
  19. 13
      src/services/client.service.ts
  20. 20
      src/services/local-storage.service.ts

7
src/components/BottomNavigationBar/NotificationsButton.tsx

@ -1,25 +1,18 @@ @@ -1,25 +1,18 @@
import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useNotification } from '@/providers/NotificationContext'
import { Bell } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function NotificationsButton() {
const { checkLogin } = useNostr()
const { navigate, current, display } = usePrimaryPage()
const { hasNewNotification } = useNotification()
return (
<BottomNavigationBarItem
active={current === 'notifications' && display}
onClick={() => checkLogin(() => navigate('notifications'))}
>
<div className="relative">
<Bell />
{hasNewNotification && (
<div className="absolute -top-0.5 right-0.5 w-2 h-2 ring-2 ring-background bg-primary rounded-full" />
)}
</div>
</BottomNavigationBarItem>
)
}

10
src/components/NotificationList/NotificationItem/DiscussionNotification.tsx

@ -3,13 +3,7 @@ import { Event } from 'nostr-tools' @@ -3,13 +3,7 @@ import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function DiscussionNotification({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
export function DiscussionNotification({ notification }: { notification: Event }) {
const { t } = useTranslation()
// Get the topic from t-tags
@ -19,13 +13,11 @@ export function DiscussionNotification({ @@ -19,13 +13,11 @@ export function DiscussionNotification({
return (
<Notification
notificationId={notification.id}
sender={notification.pubkey}
sentAt={notification.created_at}
description={t('started a discussion in {{topic}}', { topic: topicString })}
icon={<MessageCircle className="w-4 h-4 text-primary" />}
targetEvent={notification}
isNew={isNew}
showStats={false}
/>
)

10
src/components/NotificationList/NotificationItem/MentionNotification.tsx

@ -11,13 +11,7 @@ import { useMemo } from 'react' @@ -11,13 +11,7 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function MentionNotification({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
export function MentionNotification({ notification }: { notification: Event }) {
const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation()
const { pubkey } = useNostr()
@ -31,7 +25,6 @@ export function MentionNotification({ @@ -31,7 +25,6 @@ export function MentionNotification({
return (
<Notification
notificationId={notification.id}
icon={
isMention ? (
<AtSign size={24} className="text-pink-400" />
@ -60,7 +53,6 @@ export function MentionNotification({ @@ -60,7 +53,6 @@ export function MentionNotification({
description={
isMention ? t('mentioned you in a note') : parentEventId ? '' : t('quoted your note')
}
isNew={isNew}
showStats
/>
)

47
src/components/NotificationList/NotificationItem/Notification.tsx

@ -10,68 +10,48 @@ import client from '@/services/client.service' @@ -10,68 +10,48 @@ import client from '@/services/client.service'
import { cn } from '@/lib/utils'
import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useNotification } from '@/providers/NotificationContext'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { NostrEvent } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function Notification({
icon,
notificationId,
sender,
sentAt,
description,
middle = null,
targetEvent,
isNew = false,
showStats = false,
rightAction = null
}: {
icon: React.ReactNode
notificationId: string
sender: string
sentAt: number
description: string
middle?: React.ReactNode
targetEvent?: NostrEvent
isNew?: boolean
showStats?: boolean
rightAction?: React.ReactNode
}) {
const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation()
const { push } = useSecondaryPage()
const { pubkey } = useNostr()
const { isNotificationRead, markNotificationAsRead } = useNotification()
const { notificationListStyle } = useUserPreferences()
const unread = useMemo(
() => isNew && !isNotificationRead(notificationId),
[isNew, isNotificationRead, notificationId]
)
const handleClick = (e: React.MouseEvent) => {
// Don't navigate if clicking on interactive elements (buttons, links, etc.)
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[role="button"]') || target.closest('a')) {
return
}
// Don't navigate if clicking within NoteStats (which contains the ReplyButton)
// NoteStats is rendered inside the notification, so we need to check for it
if (target.closest('[data-note-stats]')) {
return
}
// Don't navigate if a modal/dialog/sheet is currently open
// Check for Radix UI dialog/sheet elements in the DOM
// Radix UI uses data-radix-dialog-content for the dialog content
const hasOpenModal = document.querySelector('[data-radix-dialog-content][data-state="open"]')
if (hasOpenModal) {
return
}
markNotificationAsRead(notificationId)
if (targetEvent) {
client.addEventToCache(targetEvent)
navigateToNote(toNote(targetEvent.id))
@ -91,13 +71,7 @@ export default function Notification({ @@ -91,13 +71,7 @@ export default function Notification({
{icon}
{middle}
{targetEvent && (
<ContentPreview
className={cn(
'truncate flex-1 w-0',
unread ? 'font-semibold' : 'text-muted-foreground'
)}
event={targetEvent}
/>
<ContentPreview className="truncate flex-1 w-0 text-muted-foreground" event={targetEvent} />
)}
</div>
<div className="text-muted-foreground shrink-0">
@ -126,26 +100,11 @@ export default function Notification({ @@ -126,26 +100,11 @@ export default function Notification({
/>
<div className="shrink-0 text-muted-foreground text-sm">{description}</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{rightAction}
{unread && (
<button
className="m-0.5 size-3 bg-primary rounded-full shrink-0 transition-all hover:ring-4 hover:ring-primary/20"
title={t('Mark as read')}
onClick={(e) => {
e.stopPropagation()
markNotificationAsRead(notificationId)
}}
/>
)}
</div>
<div className="flex items-center gap-1 shrink-0">{rightAction}</div>
</div>
{middle}
{targetEvent && (
<ContentPreview
className={cn('line-clamp-2', !unread && 'text-muted-foreground')}
event={targetEvent}
/>
<ContentPreview className={cn('line-clamp-2 text-muted-foreground')} event={targetEvent} />
)}
<FormattedTimestamp timestamp={sentAt} className="shrink-0 text-muted-foreground text-sm" />
{showStats && targetEvent && <NoteStats event={targetEvent} className="mt-1" />}

10
src/components/NotificationList/NotificationItem/PollResponseNotification.tsx

@ -6,13 +6,7 @@ import { useMemo } from 'react' @@ -6,13 +6,7 @@ import { useMemo } from 'react'
import Notification from './Notification'
import { useTranslation } from 'react-i18next'
export function PollResponseNotification({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
export function PollResponseNotification({ notification }: { notification: Event }) {
const { t } = useTranslation()
const eventId = useMemo(() => {
const eTag = notification.tags.find(tagNameEquals('e'))
@ -26,13 +20,11 @@ export function PollResponseNotification({ @@ -26,13 +20,11 @@ export function PollResponseNotification({
return (
<Notification
notificationId={notification.id}
icon={<Vote size={24} className="text-violet-400" />}
sender={notification.pubkey}
sentAt={notification.created_at}
targetEvent={pollEvent}
description={t('voted in your poll')}
isNew={isNew}
/>
)
}

10
src/components/NotificationList/NotificationItem/PublicMessageNotification.tsx

@ -5,13 +5,7 @@ import { useMemo } from 'react' @@ -5,13 +5,7 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function PublicMessageNotification({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
export function PublicMessageNotification({ notification }: { notification: Event }) {
const { t } = useTranslation()
const { pubkey } = useNostr()
@ -43,13 +37,11 @@ export function PublicMessageNotification({ @@ -43,13 +37,11 @@ export function PublicMessageNotification({
return (
<Notification
notificationId={notification.id}
icon={<MessageCircle size={24} className="text-purple-400" />}
sender={notification.pubkey}
sentAt={notification.created_at}
targetEvent={notification}
description={description}
isNew={isNew}
showStats
/>
)

10
src/components/NotificationList/NotificationItem/ReactionNotification.tsx

@ -8,13 +8,7 @@ import { useMemo } from 'react' @@ -8,13 +8,7 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function ReactionNotification({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
export function ReactionNotification({ notification }: { notification: Event }) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const eventId = useMemo(() => {
@ -56,13 +50,11 @@ export function ReactionNotification({ @@ -56,13 +50,11 @@ export function ReactionNotification({
return (
<Notification
notificationId={notification.id}
icon={<div className="text-xl min-w-6 text-center">{reaction}</div>}
sender={notification.pubkey}
sentAt={notification.created_at}
targetEvent={event}
description={t('reacted to your note')}
isNew={isNew}
/>
)
}

10
src/components/NotificationList/NotificationItem/RepostNotification.tsx

@ -5,13 +5,7 @@ import { useMemo } from 'react' @@ -5,13 +5,7 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function RepostNotification({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
export function RepostNotification({ notification }: { notification: Event }) {
const { t } = useTranslation()
const event = useMemo(() => {
try {
@ -28,13 +22,11 @@ export function RepostNotification({ @@ -28,13 +22,11 @@ export function RepostNotification({
return (
<Notification
notificationId={notification.id}
icon={<Repeat size={24} className="text-green-400" />}
sender={notification.pubkey}
sentAt={notification.created_at}
targetEvent={event}
description={t('reposted your note')}
isNew={isNew}
/>
)
}

10
src/components/NotificationList/NotificationItem/ZapNotification.tsx

@ -7,13 +7,7 @@ import { useMemo } from 'react' @@ -7,13 +7,7 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function ZapNotification({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
export function ZapNotification({ notification }: { notification: Event }) {
const { t } = useTranslation()
const { senderPubkey, eventId, amount, comment } = useMemo(
() => getZapInfoFromEvent(notification) ?? ({} as any),
@ -25,7 +19,6 @@ export function ZapNotification({ @@ -25,7 +19,6 @@ export function ZapNotification({
return (
<Notification
notificationId={notification.id}
icon={<Zap size={24} className="text-yellow-400 shrink-0" />}
sender={senderPubkey}
sentAt={notification.created_at}
@ -36,7 +29,6 @@ export function ZapNotification({ @@ -36,7 +29,6 @@ export function ZapNotification({
</div>
}
description={event ? t('zapped your note') : t('zapped you')}
isNew={isNew}
/>
)
}

23
src/components/NotificationList/NotificationItem/index.tsx

@ -14,13 +14,7 @@ import { ReactionNotification } from './ReactionNotification' @@ -14,13 +14,7 @@ import { ReactionNotification } from './ReactionNotification'
import { RepostNotification } from './RepostNotification'
import { ZapNotification } from './ZapNotification'
export function NotificationItem({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
export function NotificationItem({ notification }: { notification: Event }) {
const { pubkey } = useNostr()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
@ -34,7 +28,6 @@ export function NotificationItem({ @@ -34,7 +28,6 @@ export function NotificationItem({
isUserTrusted
})
return result
}, [
notification,
@ -46,13 +39,13 @@ export function NotificationItem({ @@ -46,13 +39,13 @@ export function NotificationItem({
if (!canShow) return null
if (notification.kind === 11) {
return <DiscussionNotification notification={notification} isNew={isNew} />
return <DiscussionNotification notification={notification} />
}
if (notification.kind === kinds.Reaction) {
return <ReactionNotification notification={notification} isNew={isNew} />
return <ReactionNotification notification={notification} />
}
if (notification.kind === ExtendedKind.PUBLIC_MESSAGE) {
return <PublicMessageNotification notification={notification} isNew={isNew} />
return <PublicMessageNotification notification={notification} />
}
if (
notification.kind === kinds.ShortTextNote ||
@ -60,16 +53,16 @@ export function NotificationItem({ @@ -60,16 +53,16 @@ export function NotificationItem({
notification.kind === ExtendedKind.VOICE_COMMENT ||
notification.kind === ExtendedKind.POLL
) {
return <MentionNotification notification={notification} isNew={isNew} />
return <MentionNotification notification={notification} />
}
if (notification.kind === kinds.Repost) {
return <RepostNotification notification={notification} isNew={isNew} />
return <RepostNotification notification={notification} />
}
if (notification.kind === kinds.Zap) {
return <ZapNotification notification={notification} isNew={isNew} />
return <ZapNotification notification={notification} />
}
if (notification.kind === ExtendedKind.POLL_RESPONSE) {
return <PollResponseNotification notification={notification} isNew={isNew} />
return <PollResponseNotification notification={notification} />
}
return null
}

10
src/components/NotificationList/index.tsx

@ -3,7 +3,6 @@ import { compareEvents } from '@/lib/event' @@ -3,7 +3,6 @@ import { compareEvents } from '@/lib/event'
import logger from '@/lib/logger'
import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useNotification } from '@/providers/NotificationContext'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service'
@ -41,10 +40,8 @@ const NotificationList = forwardRef( @@ -41,10 +40,8 @@ const NotificationList = forwardRef(
const { current, display } = usePrimaryPage()
const active = useMemo(() => current === 'notifications' && display, [current, display])
const { pubkey, relayList } = useNostr()
const { getNotificationsSeenAt } = useNotification()
const { notificationListStyle } = useUserPreferences()
const { favoriteRelays } = useFavoriteRelays()
const [lastReadTime, setLastReadTime] = useState(0)
const [refreshCount, setRefreshCount] = useState(0)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(true)
@ -132,7 +129,6 @@ const NotificationList = forwardRef( @@ -132,7 +129,6 @@ const NotificationList = forwardRef(
setLoading(true)
setNotifications([])
setShowCount(SHOW_COUNT)
setLastReadTime(getNotificationsSeenAt())
// Use proper fallback hierarchy: user's read/inbox relays → favorite relays → fast read relays
const userRelayList = relayList || { read: [], write: [] }
const userReadRelays = userRelayList.read || []
@ -295,11 +291,7 @@ const NotificationList = forwardRef( @@ -295,11 +291,7 @@ const NotificationList = forwardRef(
const list = (
<div className={notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT ? 'pt-2' : ''}>
{visibleNotifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
isNew={notification.created_at > lastReadTime}
/>
<NotificationItem key={notification.id} notification={notification} />
))}
<div className="text-center text-sm text-muted-foreground">
{until || loading ? (

7
src/components/Sidebar/NotificationButton.tsx

@ -1,6 +1,5 @@ @@ -1,6 +1,5 @@
import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useNotification } from '@/providers/NotificationContext'
import { Bell } from 'lucide-react'
import SidebarItem from './SidebarItem'
@ -8,7 +7,6 @@ export default function NotificationsButton() { @@ -8,7 +7,6 @@ export default function NotificationsButton() {
const { checkLogin } = useNostr()
const { navigate, current, display } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView()
const { hasNewNotification } = useNotification()
return (
<SidebarItem
@ -16,12 +14,7 @@ export default function NotificationsButton() { @@ -16,12 +14,7 @@ export default function NotificationsButton() {
onClick={() => checkLogin(() => navigate('notifications'))}
active={display && current === 'notifications' && primaryViewType === null}
>
<div className="relative">
<Bell strokeWidth={3} />
{hasNewNotification && (
<div className="absolute -top-1 right-0 w-2 h-2 ring-2 ring-background bg-primary rounded-full" />
)}
</div>
</SidebarItem>
)
}

5
src/constants.ts

@ -38,7 +38,6 @@ export const StorageKey = { @@ -38,7 +38,6 @@ export const StorageKey = {
DEFAULT_ZAP_COMMENT: 'defaultZapComment',
QUICK_ZAP: 'quickZap',
ZAP_REPLY_THRESHOLD: 'zapReplyThreshold',
LAST_READ_NOTIFICATION_TIME_MAP: 'lastReadNotificationTimeMap',
ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap',
AUTOPLAY: 'autoplay',
HIDE_UNTRUSTED_INTERACTIONS: 'hideUntrustedInteractions',
@ -85,10 +84,6 @@ export const FONT_SIZE = { @@ -85,10 +84,6 @@ export const FONT_SIZE = {
LARGE: 'large'
} as const
export const ApplicationDataKey = {
NOTIFICATIONS_SEEN_AT: 'seen_notifications_at'
}
export const BIG_RELAY_URLS = [
'wss://theforest.nostr1.com',
'wss://orly-relay.imwald.eu',

11
src/lib/draft-event.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { ApplicationDataKey, EMBEDDED_EVENT_REGEX, ExtendedKind, POLL_TYPE } from '@/constants'
import { EMBEDDED_EVENT_REGEX, ExtendedKind, POLL_TYPE } from '@/constants'
import client from '@/services/client.service'
import customEmojiService from '@/services/custom-emoji.service'
import mediaUpload from '@/services/media-upload.service'
@ -737,15 +737,6 @@ export function createBlockedRelaysDraftEvent(blockedRelays: string[]): TDraftEv @@ -737,15 +737,6 @@ export function createBlockedRelaysDraftEvent(blockedRelays: string[]): TDraftEv
}
}
export function createSeenNotificationsAtDraftEvent(): TDraftEvent {
return {
kind: kinds.Application,
content: 'Records read time to sync notification status across devices.',
tags: [buildDTag(ApplicationDataKey.NOTIFICATIONS_SEEN_AT)],
created_at: dayjs().unix()
}
}
export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraftEvent {
return {
kind: kinds.BookmarkList,

5
src/main.tsx

@ -28,11 +28,12 @@ setVh() @@ -28,11 +28,12 @@ setVh()
const SESSION_STORAGE_KEY = 'jumble:session'
async function bootstrap() {
// Always defined: fetch does not throw on 4xx/5xx, so non-OK responses must not leave this unset.
window.__RUNTIME_CONFIG__ = {}
try {
const r = await fetch('/config.json')
if (r.ok) {
const config = (await r.json()) as { NIP66_MONITOR_NPUB?: string }
window.__RUNTIME_CONFIG__ = config
window.__RUNTIME_CONFIG__ = (await r.json()) as { NIP66_MONITOR_NPUB?: string }
}
} catch {
window.__RUNTIME_CONFIG__ = {}

71
src/providers/NostrProvider/index.tsx

@ -1,19 +1,14 @@ @@ -1,19 +1,14 @@
import LoginDialog from '@/components/LoginDialog'
import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants'
import { BIG_RELAY_URLS, ExtendedKind, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants'
import {
buildAltTag,
buildClientTag,
createDeletionRequestDraftEvent,
createFollowListDraftEvent,
createMuteListDraftEvent,
createRelayListDraftEvent,
createSeenNotificationsAtDraftEvent
createRelayListDraftEvent
} from '@/lib/draft-event'
import {
getLatestEvent,
getReplaceableEventIdentifier,
minePow
} from '@/lib/event'
import { getLatestEvent, minePow } from '@/lib/event'
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url'
@ -63,7 +58,6 @@ type TNostrContext = { @@ -63,7 +58,6 @@ type TNostrContext = {
blockedRelaysEvent: Event | null
userEmojiListEvent: Event | null
rssFeedListEvent: Event | null
notificationsSeenAt: number
account: TAccountPointer | null
accounts: TAccountPointer[]
nsec: string | null
@ -97,13 +91,10 @@ type TNostrContext = { @@ -97,13 +91,10 @@ type TNostrContext = {
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updateBlockedRelaysEvent: (blockedRelaysEvent: Event) => Promise<void>
updateRssFeedListEvent: (rssFeedListEvent: Event) => Promise<void>
updateNotificationsSeenAt: (skipPublish?: boolean) => Promise<void>
}
const NostrContext = createContext<TNostrContext | undefined>(undefined)
const lastPublishedSeenNotificationsAtEventAtMap = new Map<string, number>()
export const useNostr = () => {
const context = useContext(NostrContext)
if (!context) {
@ -172,7 +163,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -172,7 +163,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [blockedRelaysEvent, setBlockedRelaysEvent] = useState<Event | null>(null)
const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null)
const [rssFeedListEvent, setRssFeedListEvent] = useState<Event | null>(null)
const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1)
const [isInitialized, setIsInitialized] = useState(false)
useEffect(() => {
@ -215,7 +205,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -215,7 +205,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setMuteListEvent(null)
setBookmarkListEvent(null)
setRssFeedListEvent(null)
setNotificationsSeenAt(-1)
if (!account) {
return
}
@ -234,8 +223,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -234,8 +223,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setNcryptsec(null)
}
const storedNotificationsSeenAt = storage.getLastReadNotificationTime(account.pubkey)
const [
storedRelayListEvent,
storedCacheRelayListEvent,
@ -434,11 +421,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -434,11 +421,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
kinds.UserEmojiList
],
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)
@ -453,11 +435,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -453,11 +435,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
(e) => e.kind === ExtendedKind.BLOSSOM_SERVER_LIST
)
const userEmojiListEvent = sortedEvents.find((e) => e.kind === kinds.UserEmojiList)
const notificationsSeenAtEvent = sortedEvents.find(
(e) =>
e.kind === kinds.Application &&
getReplaceableEventIdentifier(e) === ApplicationDataKey.NOTIFICATIONS_SEEN_AT
)
if (profileEvent) {
const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent)
if (updatedProfileEvent.id === profileEvent.id) {
@ -534,13 +511,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -534,13 +511,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
}
const notificationsSeenAt = Math.max(
notificationsSeenAtEvent?.created_at ?? 0,
storedNotificationsSeenAt
)
setNotificationsSeenAt(notificationsSeenAt)
storage.setLastReadNotificationTime(account.pubkey, notificationsSeenAt)
client.initUserIndexFromFollowings(account.pubkey, controller.signal)
return controller
}
@ -1123,37 +1093,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1123,37 +1093,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setRssFeedListEvent(newRssFeedListEvent)
}
/** Updates local last read time and optionally publishes kind 30078 (notification seen-at) for cross-device sync.
* Relay list: users write relays (first 5) or FAST_WRITE_RELAY_URLS; read-only relays are excluded (see client.determineTargetRelays for kind 30078). */
const updateNotificationsSeenAt = async (skipPublish = false) => {
if (!account) return
const now = dayjs().unix()
storage.setLastReadNotificationTime(account.pubkey, now)
setTimeout(() => {
setNotificationsSeenAt(now)
}, 5_000)
// Prevent too frequent requests for signing seen notifications events
const lastPublishedSeenNotificationsAtEventAt =
lastPublishedSeenNotificationsAtEventAtMap.get(account.pubkey) ?? -1
if (
!skipPublish &&
(lastPublishedSeenNotificationsAtEventAt < 0 ||
now - lastPublishedSeenNotificationsAtEventAt > 10 * 60) // 10 minutes
) {
try {
await publish(createSeenNotificationsAtDraftEvent())
lastPublishedSeenNotificationsAtEventAtMap.set(account.pubkey, now)
} catch (err) {
// Notification seen-at sync is best-effort; local state already updated above
logger.warn('[updateNotificationsSeenAt] Publish failed (sync across devices may be delayed)', {
error: err instanceof Error ? err.message : String(err)
})
}
}
}
return (
<NostrContext.Provider
value={{
@ -1171,7 +1110,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1171,7 +1110,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
blockedRelaysEvent,
userEmojiListEvent,
rssFeedListEvent,
notificationsSeenAt,
account,
accounts,
nsec,
@ -1201,8 +1139,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1201,8 +1139,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
updateInterestListEvent,
updateFavoriteRelaysEvent,
updateBlockedRelaysEvent,
updateRssFeedListEvent,
updateNotificationsSeenAt
updateRssFeedListEvent
}}
>
{children}

18
src/providers/NotificationContext.tsx

@ -1,18 +0,0 @@ @@ -1,18 +0,0 @@
import { createContext, useContext } from 'react'
export type TNotificationContext = {
hasNewNotification: boolean
getNotificationsSeenAt: () => number
isNotificationRead: (id: string) => boolean
markNotificationAsRead: (id: string) => void
}
export const NotificationContext = createContext<TNotificationContext | undefined>(undefined)
export const useNotification = () => {
const context = useContext(NotificationContext)
if (!context) {
throw new Error('useNotification must be used within a NotificationProvider')
}
return context
}

215
src/providers/NotificationProvider.tsx

@ -1,88 +1,29 @@ @@ -1,88 +1,29 @@
import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants'
import { compareEvents } from '@/lib/event'
import logger from '@/lib/logger'
import { notificationFilter } from '@/lib/notification'
import { usePrimaryPage } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service'
import storage from '@/services/local-storage.service'
import { kinds, NostrEvent } from 'nostr-tools'
import { SubCloser } from 'nostr-tools/abstract-pool'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useContentPolicy } from './ContentPolicyProvider'
import { useMuteList } from './MuteListProvider'
import { useEffect, useRef } from 'react'
import { useNostr } from './NostrProvider'
import { useUserTrust } from './UserTrustProvider'
import { NotificationContext } from './NotificationContext'
/**
* Subscribes to live notifications and forwards new events via {@link client.emitNewEvent}.
* (Read/unread UI and cross-device seen at sync were removed.)
*/
export function NotificationProvider({ children }: { children: React.ReactNode }) {
const { current } = usePrimaryPage()
const active = useMemo(() => current === 'notifications', [current])
const { pubkey, relayList, notificationsSeenAt, updateNotificationsSeenAt } = useNostr()
const { pubkey, relayList } = useNostr()
const { favoriteRelays } = useFavoriteRelays()
const { hideUntrustedNotifications, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
// const { getSubscribedTopics } = useInterestList() // No longer needed since we subscribe to all discussions
const [newNotifications, setNewNotifications] = useState<NostrEvent[]>([])
const [readNotificationIdSet, setReadNotificationIdSet] = useState<Set<string>>(new Set())
const filteredNewNotifications = useMemo(() => {
if (active || notificationsSeenAt < 0) {
return []
}
const filtered: NostrEvent[] = []
for (const notification of newNotifications) {
if (notification.created_at <= notificationsSeenAt || filtered.length >= 10) {
break
}
if (
!notificationFilter(notification, {
pubkey,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedNotifications,
isUserTrusted
})
) {
continue
}
filtered.push(notification)
}
return filtered
}, [
newNotifications,
notificationsSeenAt,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedNotifications,
isUserTrusted,
active
])
// Defer so we don't trigger state updates during the same commit as consumer renders (avoids "Cannot update NotificationList while rendering NotificationProvider")
useEffect(() => {
let t2: ReturnType<typeof setTimeout> | null = null
const t = setTimeout(() => {
setNewNotifications([])
t2 = setTimeout(() => {
updateNotificationsSeenAt(!active)
}, 0)
}, 0)
return () => {
clearTimeout(t)
if (t2 !== null) clearTimeout(t2)
}
}, [active, updateNotificationsSeenAt])
const notificationBufferRef = useRef<NostrEvent[]>([])
useEffect(() => {
if (!pubkey) return
const deferredReset = setTimeout(() => {
setNewNotifications([])
setReadNotificationIdSet(new Set())
notificationBufferRef.current = []
}, 0)
// Track if component is mounted
const isMountedRef = { current: true }
const subCloserRef: {
current: SubCloser | null
@ -104,45 +45,38 @@ export function NotificationProvider({ children }: { children: React.ReactNode } @@ -104,45 +45,38 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
try {
let eosed = false
// Use proper fallback hierarchy: user's read/inbox relays → favorite relays → fast read relays
const userRelayList = relayList || { read: [], write: [] }
const userReadRelays = userRelayList.read || []
const userFavoriteRelays = favoriteRelays || []
// Build relay list with proper fallback hierarchy
let notificationRelays: string[] = []
if (userReadRelays.length > 0) {
// Priority 1: User's read/inbox relays (kind 10002)
notificationRelays = userReadRelays.slice(0, 5)
logger.component('NotificationProvider', 'Using user read relays', {
count: notificationRelays.length,
relays: notificationRelays.slice(0, 3) // Show first 3 for brevity
relays: notificationRelays.slice(0, 3)
})
} else if (userFavoriteRelays.length > 0) {
// Priority 2: User's favorite relays (kind 10012)
notificationRelays = userFavoriteRelays.slice(0, 5)
logger.component('NotificationProvider', 'Using user favorite relays', {
count: notificationRelays.length,
relays: notificationRelays.slice(0, 3) // Show first 3 for brevity
relays: notificationRelays.slice(0, 3)
})
} else {
// Priority 3: Fast read relays (reliable defaults)
notificationRelays = FAST_READ_RELAY_URLS.slice(0, 5)
logger.component('NotificationProvider', 'Using fast read relays fallback', {
count: notificationRelays.length,
relays: notificationRelays.slice(0, 3) // Show first 3 for brevity
relays: notificationRelays.slice(0, 3)
})
}
// Subscribe to discussion notifications (kind 11)
// Subscribe to all discussions, not just subscribed topics
let discussionEosed = false
const discussionSubCloser = client.subscribe(
notificationRelays,
[
{
kinds: [11], // Discussion threads
kinds: [11],
limit: 20
}
],
@ -153,26 +87,24 @@ export function NotificationProvider({ children }: { children: React.ReactNode } @@ -153,26 +87,24 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
}
},
onevent: (evt) => {
// Don't notify about our own threads
if (evt.pubkey !== pubkey) {
setNewNotifications((prev) => {
const prev = notificationBufferRef.current
if (!discussionEosed) {
return [evt, ...prev]
notificationBufferRef.current = [evt, ...prev]
return
}
if (prev.length && compareEvents(prev[0], evt) >= 0) {
return prev
return
}
client.emitNewEvent(evt)
return [evt, ...prev]
})
notificationBufferRef.current = [evt, ...prev]
}
}
}
)
topicSubCloserRef.current = discussionSubCloser
// Regular notifications subscription
const subCloser = client.subscribe(
notificationRelays,
[
@ -196,24 +128,24 @@ export function NotificationProvider({ children }: { children: React.ReactNode } @@ -196,24 +128,24 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
oneose: (e) => {
if (e) {
eosed = e
setNewNotifications((prev) => {
return [...prev.sort((a, b) => compareEvents(b, a))]
})
notificationBufferRef.current = [
...notificationBufferRef.current.sort((a, b) => compareEvents(b, a))
]
}
},
onevent: (evt) => {
if (evt.pubkey !== pubkey) {
setNewNotifications((prev) => {
const prev = notificationBufferRef.current
if (!eosed) {
return [evt, ...prev]
notificationBufferRef.current = [evt, ...prev]
return
}
if (prev.length && compareEvents(prev[0], evt) >= 0) {
return prev
return
}
client.emitNewEvent(evt)
return [evt, ...prev]
})
notificationBufferRef.current = [evt, ...prev]
}
},
onAllClose: (reasons) => {
@ -221,15 +153,13 @@ export function NotificationProvider({ children }: { children: React.ReactNode } @@ -221,15 +153,13 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
return
}
// Only reconnect if still mounted and not a manual close
// Increase timeout to prevent rapid reconnection loops
if (isMountedRef.current) {
setTimeout(() => {
if (isMountedRef.current) {
logger.debug('[NotificationProvider] Reconnecting after close...')
subscribe()
}
}, 15_000) // Increased from 5s to 15s
}, 15_000)
}
}
}
@ -240,7 +170,6 @@ export function NotificationProvider({ children }: { children: React.ReactNode } @@ -240,7 +170,6 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
} catch (error) {
logger.error('Subscription error', { error })
// Retry on error if still mounted
if (isMountedRef.current) {
setTimeout(() => {
if (isMountedRef.current) {
@ -252,10 +181,8 @@ export function NotificationProvider({ children }: { children: React.ReactNode } @@ -252,10 +181,8 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
}
}
// Initial subscription
subscribe()
// Cleanup function
return () => {
clearTimeout(deferredReset)
isMountedRef.current = false
@ -268,93 +195,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } @@ -268,93 +195,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
topicSubCloserRef.current = null
}
}
}, [pubkey])
useEffect(() => {
const newNotificationCount = filteredNewNotifications.length
}, [pubkey, relayList, favoriteRelays])
// Update title
if (newNotificationCount > 0) {
document.title = `(${newNotificationCount >= 10 ? '9+' : newNotificationCount}) Jumble`
} else {
document.title = 'Jumble'
}
// Update favicons
const favicons = document.querySelectorAll<HTMLLinkElement>("link[rel*='icon']")
if (!favicons.length) return
const treeFavicon = "data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌲</text></svg>"
if (newNotificationCount === 0) {
favicons.forEach((favicon) => {
favicon.href = treeFavicon
})
} else {
// Create a canvas with the tree emoji and a notification badge
const canvas = document.createElement('canvas')
const size = 64
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d', { willReadFrequently: true }) // Optimize for frequent readback operations
if (!ctx) return
// Draw tree emoji as text
ctx.font = `${size * 0.9}px Arial`
ctx.textBaseline = 'middle'
ctx.textAlign = 'center'
ctx.fillText('🌲', size / 2, size / 2)
// Draw red notification badge
const r = size * 0.16
ctx.beginPath()
ctx.arc(size - r - 6, r + 6, r, 0, 2 * Math.PI)
ctx.fillStyle = '#FF0000'
ctx.fill()
favicons.forEach((favicon) => {
favicon.href = canvas.toDataURL('image/png')
})
}
}, [filteredNewNotifications])
const getNotificationsSeenAt = useCallback(() => {
if (notificationsSeenAt >= 0) {
return notificationsSeenAt
}
if (pubkey) {
return storage.getLastReadNotificationTime(pubkey)
}
return 0
}, [notificationsSeenAt, pubkey])
const isNotificationRead = useCallback(
(notificationId: string): boolean => readNotificationIdSet.has(notificationId),
[readNotificationIdSet]
)
const markNotificationAsRead = useCallback((notificationId: string) => {
setReadNotificationIdSet((prev) => new Set([...prev, notificationId]))
}, [])
const value = useMemo(
() => ({
hasNewNotification: filteredNewNotifications.length > 0,
getNotificationsSeenAt,
isNotificationRead,
markNotificationAsRead
}),
[
filteredNewNotifications.length,
getNotificationsSeenAt,
isNotificationRead,
markNotificationAsRead
]
)
return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>
)
return <>{children}</>
}

13
src/services/client.service.ts

@ -269,19 +269,6 @@ class ClientService extends EventTarget { @@ -269,19 +269,6 @@ class ClientService extends EventTarget {
return relays.length > 0 ? relays : [...FAST_WRITE_RELAY_URLS]
}
// Notification seen-at (kind 30078): use only user write relays to avoid paid/slow relays
if (event.kind === kinds.Application) {
const dTag = event.tags.find((t) => t[0] === 'd')?.[1]
if (dTag === 'seen_notifications_at') {
const relayList = await this.fetchRelayList(event.pubkey).catch(() => ({ write: [] as string[], read: [] as string[] }))
const userWrite = (relayList?.write ?? []).slice(0, 5).map((url) => normalizeUrl(url)).filter(Boolean) as string[]
const list = userWrite.length > 0 ? userWrite : [...FAST_WRITE_RELAY_URLS]
const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u))
const filtered = list.filter((url) => !readOnlySet.has(normalizeUrl(url) || url))
return filtered.length > 0 ? filtered : [...FAST_WRITE_RELAY_URLS]
}
}
let relays: string[]
if (specifiedRelayUrls?.length) {
relays = specifiedRelayUrls

20
src/services/local-storage.service.ts

@ -38,7 +38,6 @@ const SETTINGS_KEYS = [ @@ -38,7 +38,6 @@ const SETTINGS_KEYS = [
StorageKey.DEFAULT_ZAP_COMMENT,
StorageKey.QUICK_ZAP,
StorageKey.ZAP_REPLY_THRESHOLD,
StorageKey.LAST_READ_NOTIFICATION_TIME_MAP,
StorageKey.ACCOUNT_FEED_INFO_MAP,
StorageKey.AUTOPLAY,
StorageKey.HIDE_UNTRUSTED_INTERACTIONS,
@ -79,7 +78,6 @@ class LocalStorageService { @@ -79,7 +78,6 @@ class LocalStorageService {
private accounts: TAccount[] = []
private currentAccount: TAccount | null = null
private noteListMode: TNoteListMode = 'posts'
private lastReadNotificationTimeMap: Record<string, number> = {}
private defaultZapSats: number = 21
private defaultZapComment: string = 'Zap!'
private quickZap: boolean = false
@ -138,10 +136,6 @@ class LocalStorageService { @@ -138,10 +136,6 @@ class LocalStorageService {
noteListModeStr && ['posts', 'postsAndReplies', 'pictures'].includes(noteListModeStr)
? (noteListModeStr as TNoteListMode)
: 'posts'
const lastReadNotificationTimeMapStr =
window.localStorage.getItem(StorageKey.LAST_READ_NOTIFICATION_TIME_MAP) ?? '{}'
this.lastReadNotificationTimeMap = JSON.parse(lastReadNotificationTimeMapStr)
const relaySetsStr = window.localStorage.getItem(StorageKey.RELAY_SETS)
if (!relaySetsStr) {
let relaySets: TRelaySet[] = []
@ -462,8 +456,6 @@ class LocalStorageService { @@ -462,8 +456,6 @@ class LocalStorageService {
if (accountsStr != null) this.accounts = JSON.parse(accountsStr) as TAccount[]
const currentAccountStr = get(StorageKey.CURRENT_ACCOUNT)
if (currentAccountStr != null) this.currentAccount = JSON.parse(currentAccountStr) as TAccount | null
const lastReadStr = get(StorageKey.LAST_READ_NOTIFICATION_TIME_MAP)
if (lastReadStr != null) this.lastReadNotificationTimeMap = JSON.parse(lastReadStr) as Record<string, number>
const relaySetsStr = get(StorageKey.RELAY_SETS)
if (relaySetsStr != null) this.relaySets = JSON.parse(relaySetsStr) as TRelaySet[]
const defaultZapSatsStr = get(StorageKey.DEFAULT_ZAP_SATS)
@ -676,18 +668,6 @@ class LocalStorageService { @@ -676,18 +668,6 @@ class LocalStorageService {
this.persistSetting(StorageKey.ZAP_REPLY_THRESHOLD, sats.toString())
}
getLastReadNotificationTime(pubkey: string) {
return this.lastReadNotificationTimeMap[pubkey] ?? 0
}
setLastReadNotificationTime(pubkey: string, time: number) {
this.lastReadNotificationTimeMap[pubkey] = time
this.persistSetting(
StorageKey.LAST_READ_NOTIFICATION_TIME_MAP,
JSON.stringify(this.lastReadNotificationTimeMap)
)
}
getFeedInfo(pubkey: string) {
return this.accountFeedInfoMap[pubkey]
}

Loading…
Cancel
Save