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 @@
import { usePrimaryPage } from '@/PageManager' import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useNotification } from '@/providers/NotificationContext'
import { Bell } from 'lucide-react' import { Bell } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem' import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function NotificationsButton() { export default function NotificationsButton() {
const { checkLogin } = useNostr() const { checkLogin } = useNostr()
const { navigate, current, display } = usePrimaryPage() const { navigate, current, display } = usePrimaryPage()
const { hasNewNotification } = useNotification()
return ( return (
<BottomNavigationBarItem <BottomNavigationBarItem
active={current === 'notifications' && display} active={current === 'notifications' && display}
onClick={() => checkLogin(() => navigate('notifications'))} onClick={() => checkLogin(() => navigate('notifications'))}
> >
<div className="relative">
<Bell /> <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> </BottomNavigationBarItem>
) )
} }

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

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

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

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

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

@ -10,68 +10,48 @@ import client from '@/services/client.service'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager' import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useNotification } from '@/providers/NotificationContext'
import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function Notification({ export default function Notification({
icon, icon,
notificationId,
sender, sender,
sentAt, sentAt,
description, description,
middle = null, middle = null,
targetEvent, targetEvent,
isNew = false,
showStats = false, showStats = false,
rightAction = null rightAction = null
}: { }: {
icon: React.ReactNode icon: React.ReactNode
notificationId: string
sender: string sender: string
sentAt: number sentAt: number
description: string description: string
middle?: React.ReactNode middle?: React.ReactNode
targetEvent?: NostrEvent targetEvent?: NostrEvent
isNew?: boolean
showStats?: boolean showStats?: boolean
rightAction?: React.ReactNode rightAction?: React.ReactNode
}) { }) {
const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { isNotificationRead, markNotificationAsRead } = useNotification()
const { notificationListStyle } = useUserPreferences() const { notificationListStyle } = useUserPreferences()
const unread = useMemo(
() => isNew && !isNotificationRead(notificationId),
[isNew, isNotificationRead, notificationId]
)
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
// Don't navigate if clicking on interactive elements (buttons, links, etc.)
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[role="button"]') || target.closest('a')) { if (target.closest('button') || target.closest('[role="button"]') || target.closest('a')) {
return 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]')) { if (target.closest('[data-note-stats]')) {
return 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"]') const hasOpenModal = document.querySelector('[data-radix-dialog-content][data-state="open"]')
if (hasOpenModal) { if (hasOpenModal) {
return return
} }
markNotificationAsRead(notificationId)
if (targetEvent) { if (targetEvent) {
client.addEventToCache(targetEvent) client.addEventToCache(targetEvent)
navigateToNote(toNote(targetEvent.id)) navigateToNote(toNote(targetEvent.id))
@ -91,13 +71,7 @@ export default function Notification({
{icon} {icon}
{middle} {middle}
{targetEvent && ( {targetEvent && (
<ContentPreview <ContentPreview className="truncate flex-1 w-0 text-muted-foreground" event={targetEvent} />
className={cn(
'truncate flex-1 w-0',
unread ? 'font-semibold' : 'text-muted-foreground'
)}
event={targetEvent}
/>
)} )}
</div> </div>
<div className="text-muted-foreground shrink-0"> <div className="text-muted-foreground shrink-0">
@ -126,26 +100,11 @@ export default function Notification({
/> />
<div className="shrink-0 text-muted-foreground text-sm">{description}</div> <div className="shrink-0 text-muted-foreground text-sm">{description}</div>
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">{rightAction}</div>
{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> </div>
{middle} {middle}
{targetEvent && ( {targetEvent && (
<ContentPreview <ContentPreview className={cn('line-clamp-2 text-muted-foreground')} event={targetEvent} />
className={cn('line-clamp-2', !unread && 'text-muted-foreground')}
event={targetEvent}
/>
)} )}
<FormattedTimestamp timestamp={sentAt} className="shrink-0 text-muted-foreground text-sm" /> <FormattedTimestamp timestamp={sentAt} className="shrink-0 text-muted-foreground text-sm" />
{showStats && targetEvent && <NoteStats event={targetEvent} className="mt-1" />} {showStats && targetEvent && <NoteStats event={targetEvent} className="mt-1" />}

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

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

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

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

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

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

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

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

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

@ -7,13 +7,7 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Notification from './Notification' import Notification from './Notification'
export function ZapNotification({ export function ZapNotification({ notification }: { notification: Event }) {
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
const { t } = useTranslation() const { t } = useTranslation()
const { senderPubkey, eventId, amount, comment } = useMemo( const { senderPubkey, eventId, amount, comment } = useMemo(
() => getZapInfoFromEvent(notification) ?? ({} as any), () => getZapInfoFromEvent(notification) ?? ({} as any),
@ -25,7 +19,6 @@ export function ZapNotification({
return ( return (
<Notification <Notification
notificationId={notification.id}
icon={<Zap size={24} className="text-yellow-400 shrink-0" />} icon={<Zap size={24} className="text-yellow-400 shrink-0" />}
sender={senderPubkey} sender={senderPubkey}
sentAt={notification.created_at} sentAt={notification.created_at}
@ -36,7 +29,6 @@ export function ZapNotification({
</div> </div>
} }
description={event ? t('zapped your note') : t('zapped you')} 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'
import { RepostNotification } from './RepostNotification' import { RepostNotification } from './RepostNotification'
import { ZapNotification } from './ZapNotification' import { ZapNotification } from './ZapNotification'
export function NotificationItem({ export function NotificationItem({ notification }: { notification: Event }) {
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
@ -34,7 +28,6 @@ export function NotificationItem({
isUserTrusted isUserTrusted
}) })
return result return result
}, [ }, [
notification, notification,
@ -46,13 +39,13 @@ export function NotificationItem({
if (!canShow) return null if (!canShow) return null
if (notification.kind === 11) { if (notification.kind === 11) {
return <DiscussionNotification notification={notification} isNew={isNew} /> return <DiscussionNotification notification={notification} />
} }
if (notification.kind === kinds.Reaction) { if (notification.kind === kinds.Reaction) {
return <ReactionNotification notification={notification} isNew={isNew} /> return <ReactionNotification notification={notification} />
} }
if (notification.kind === ExtendedKind.PUBLIC_MESSAGE) { if (notification.kind === ExtendedKind.PUBLIC_MESSAGE) {
return <PublicMessageNotification notification={notification} isNew={isNew} /> return <PublicMessageNotification notification={notification} />
} }
if ( if (
notification.kind === kinds.ShortTextNote || notification.kind === kinds.ShortTextNote ||
@ -60,16 +53,16 @@ export function NotificationItem({
notification.kind === ExtendedKind.VOICE_COMMENT || notification.kind === ExtendedKind.VOICE_COMMENT ||
notification.kind === ExtendedKind.POLL notification.kind === ExtendedKind.POLL
) { ) {
return <MentionNotification notification={notification} isNew={isNew} /> return <MentionNotification notification={notification} />
} }
if (notification.kind === kinds.Repost) { if (notification.kind === kinds.Repost) {
return <RepostNotification notification={notification} isNew={isNew} /> return <RepostNotification notification={notification} />
} }
if (notification.kind === kinds.Zap) { if (notification.kind === kinds.Zap) {
return <ZapNotification notification={notification} isNew={isNew} /> return <ZapNotification notification={notification} />
} }
if (notification.kind === ExtendedKind.POLL_RESPONSE) { if (notification.kind === ExtendedKind.POLL_RESPONSE) {
return <PollResponseNotification notification={notification} isNew={isNew} /> return <PollResponseNotification notification={notification} />
} }
return null return null
} }

10
src/components/NotificationList/index.tsx

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

7
src/components/Sidebar/NotificationButton.tsx

@ -1,6 +1,5 @@
import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useNotification } from '@/providers/NotificationContext'
import { Bell } from 'lucide-react' import { Bell } from 'lucide-react'
import SidebarItem from './SidebarItem' import SidebarItem from './SidebarItem'
@ -8,7 +7,6 @@ export default function NotificationsButton() {
const { checkLogin } = useNostr() const { checkLogin } = useNostr()
const { navigate, current, display } = usePrimaryPage() const { navigate, current, display } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView() const { primaryViewType } = usePrimaryNoteView()
const { hasNewNotification } = useNotification()
return ( return (
<SidebarItem <SidebarItem
@ -16,12 +14,7 @@ export default function NotificationsButton() {
onClick={() => checkLogin(() => navigate('notifications'))} onClick={() => checkLogin(() => navigate('notifications'))}
active={display && current === 'notifications' && primaryViewType === null} active={display && current === 'notifications' && primaryViewType === null}
> >
<div className="relative">
<Bell strokeWidth={3} /> <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> </SidebarItem>
) )
} }

5
src/constants.ts

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

11
src/lib/draft-event.ts

@ -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 client from '@/services/client.service'
import customEmojiService from '@/services/custom-emoji.service' import customEmojiService from '@/services/custom-emoji.service'
import mediaUpload from '@/services/media-upload.service' import mediaUpload from '@/services/media-upload.service'
@ -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 { export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraftEvent {
return { return {
kind: kinds.BookmarkList, kind: kinds.BookmarkList,

5
src/main.tsx

@ -28,11 +28,12 @@ setVh()
const SESSION_STORAGE_KEY = 'jumble:session' const SESSION_STORAGE_KEY = 'jumble:session'
async function bootstrap() { 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 { try {
const r = await fetch('/config.json') const r = await fetch('/config.json')
if (r.ok) { if (r.ok) {
const config = (await r.json()) as { NIP66_MONITOR_NPUB?: string } window.__RUNTIME_CONFIG__ = (await r.json()) as { NIP66_MONITOR_NPUB?: string }
window.__RUNTIME_CONFIG__ = config
} }
} catch { } catch {
window.__RUNTIME_CONFIG__ = {} window.__RUNTIME_CONFIG__ = {}

71
src/providers/NostrProvider/index.tsx

@ -1,19 +1,14 @@
import LoginDialog from '@/components/LoginDialog' 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 { import {
buildAltTag, buildAltTag,
buildClientTag, buildClientTag,
createDeletionRequestDraftEvent, createDeletionRequestDraftEvent,
createFollowListDraftEvent, createFollowListDraftEvent,
createMuteListDraftEvent, createMuteListDraftEvent,
createRelayListDraftEvent, createRelayListDraftEvent
createSeenNotificationsAtDraftEvent
} from '@/lib/draft-event' } from '@/lib/draft-event'
import { import { getLatestEvent, minePow } from '@/lib/event'
getLatestEvent,
getReplaceableEventIdentifier,
minePow
} from '@/lib/event'
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
@ -63,7 +58,6 @@ type TNostrContext = {
blockedRelaysEvent: Event | null blockedRelaysEvent: Event | null
userEmojiListEvent: Event | null userEmojiListEvent: Event | null
rssFeedListEvent: Event | null rssFeedListEvent: Event | null
notificationsSeenAt: number
account: TAccountPointer | null account: TAccountPointer | null
accounts: TAccountPointer[] accounts: TAccountPointer[]
nsec: string | null nsec: string | null
@ -97,13 +91,10 @@ type TNostrContext = {
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void> updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updateBlockedRelaysEvent: (blockedRelaysEvent: Event) => Promise<void> updateBlockedRelaysEvent: (blockedRelaysEvent: Event) => Promise<void>
updateRssFeedListEvent: (rssFeedListEvent: Event) => Promise<void> updateRssFeedListEvent: (rssFeedListEvent: Event) => Promise<void>
updateNotificationsSeenAt: (skipPublish?: boolean) => Promise<void>
} }
const NostrContext = createContext<TNostrContext | undefined>(undefined) const NostrContext = createContext<TNostrContext | undefined>(undefined)
const lastPublishedSeenNotificationsAtEventAtMap = new Map<string, number>()
export const useNostr = () => { export const useNostr = () => {
const context = useContext(NostrContext) const context = useContext(NostrContext)
if (!context) { if (!context) {
@ -172,7 +163,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [blockedRelaysEvent, setBlockedRelaysEvent] = useState<Event | null>(null) const [blockedRelaysEvent, setBlockedRelaysEvent] = useState<Event | null>(null)
const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null) const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null)
const [rssFeedListEvent, setRssFeedListEvent] = useState<Event | null>(null) const [rssFeedListEvent, setRssFeedListEvent] = useState<Event | null>(null)
const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1)
const [isInitialized, setIsInitialized] = useState(false) const [isInitialized, setIsInitialized] = useState(false)
useEffect(() => { useEffect(() => {
@ -215,7 +205,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setMuteListEvent(null) setMuteListEvent(null)
setBookmarkListEvent(null) setBookmarkListEvent(null)
setRssFeedListEvent(null) setRssFeedListEvent(null)
setNotificationsSeenAt(-1)
if (!account) { if (!account) {
return return
} }
@ -234,8 +223,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setNcryptsec(null) setNcryptsec(null)
} }
const storedNotificationsSeenAt = storage.getLastReadNotificationTime(account.pubkey)
const [ const [
storedRelayListEvent, storedRelayListEvent,
storedCacheRelayListEvent, storedCacheRelayListEvent,
@ -434,11 +421,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
kinds.UserEmojiList kinds.UserEmojiList
], ],
authors: [account.pubkey] 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)
@ -453,11 +435,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
(e) => e.kind === ExtendedKind.BLOSSOM_SERVER_LIST (e) => e.kind === ExtendedKind.BLOSSOM_SERVER_LIST
) )
const userEmojiListEvent = sortedEvents.find((e) => e.kind === kinds.UserEmojiList) 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) { if (profileEvent) {
const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent) const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent)
if (updatedProfileEvent.id === profileEvent.id) { if (updatedProfileEvent.id === profileEvent.id) {
@ -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) client.initUserIndexFromFollowings(account.pubkey, controller.signal)
return controller return controller
} }
@ -1123,37 +1093,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setRssFeedListEvent(newRssFeedListEvent) 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 ( return (
<NostrContext.Provider <NostrContext.Provider
value={{ value={{
@ -1171,7 +1110,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
blockedRelaysEvent, blockedRelaysEvent,
userEmojiListEvent, userEmojiListEvent,
rssFeedListEvent, rssFeedListEvent,
notificationsSeenAt,
account, account,
accounts, accounts,
nsec, nsec,
@ -1201,8 +1139,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
updateInterestListEvent, updateInterestListEvent,
updateFavoriteRelaysEvent, updateFavoriteRelaysEvent,
updateBlockedRelaysEvent, updateBlockedRelaysEvent,
updateRssFeedListEvent, updateRssFeedListEvent
updateNotificationsSeenAt
}} }}
> >
{children} {children}

18
src/providers/NotificationContext.tsx

@ -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 @@
import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants' import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants'
import { compareEvents } from '@/lib/event' import { compareEvents } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { notificationFilter } from '@/lib/notification'
import { usePrimaryPage } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import storage from '@/services/local-storage.service'
import { kinds, NostrEvent } from 'nostr-tools' import { kinds, NostrEvent } from 'nostr-tools'
import { SubCloser } from 'nostr-tools/abstract-pool' import { SubCloser } from 'nostr-tools/abstract-pool'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useEffect, useRef } from 'react'
import { useContentPolicy } from './ContentPolicyProvider'
import { useMuteList } from './MuteListProvider'
import { useNostr } from './NostrProvider' 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 }) { export function NotificationProvider({ children }: { children: React.ReactNode }) {
const { current } = usePrimaryPage() const { pubkey, relayList } = useNostr()
const active = useMemo(() => current === 'notifications', [current])
const { pubkey, relayList, notificationsSeenAt, updateNotificationsSeenAt } = useNostr()
const { favoriteRelays } = useFavoriteRelays() const { favoriteRelays } = useFavoriteRelays()
const { hideUntrustedNotifications, isUserTrusted } = useUserTrust() const notificationBufferRef = useRef<NostrEvent[]>([])
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])
useEffect(() => { useEffect(() => {
if (!pubkey) return if (!pubkey) return
const deferredReset = setTimeout(() => { const deferredReset = setTimeout(() => {
setNewNotifications([]) notificationBufferRef.current = []
setReadNotificationIdSet(new Set())
}, 0) }, 0)
// Track if component is mounted
const isMountedRef = { current: true } const isMountedRef = { current: true }
const subCloserRef: { const subCloserRef: {
current: SubCloser | null current: SubCloser | null
@ -104,45 +45,38 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
try { try {
let eosed = false let eosed = false
// Use proper fallback hierarchy: user's read/inbox relays → favorite relays → fast read relays
const userRelayList = relayList || { read: [], write: [] } const userRelayList = relayList || { read: [], write: [] }
const userReadRelays = userRelayList.read || [] const userReadRelays = userRelayList.read || []
const userFavoriteRelays = favoriteRelays || [] const userFavoriteRelays = favoriteRelays || []
// Build relay list with proper fallback hierarchy
let notificationRelays: string[] = [] let notificationRelays: string[] = []
if (userReadRelays.length > 0) { if (userReadRelays.length > 0) {
// Priority 1: User's read/inbox relays (kind 10002)
notificationRelays = userReadRelays.slice(0, 5) notificationRelays = userReadRelays.slice(0, 5)
logger.component('NotificationProvider', 'Using user read relays', { logger.component('NotificationProvider', 'Using user read relays', {
count: notificationRelays.length, count: notificationRelays.length,
relays: notificationRelays.slice(0, 3) // Show first 3 for brevity relays: notificationRelays.slice(0, 3)
}) })
} else if (userFavoriteRelays.length > 0) { } else if (userFavoriteRelays.length > 0) {
// Priority 2: User's favorite relays (kind 10012)
notificationRelays = userFavoriteRelays.slice(0, 5) notificationRelays = userFavoriteRelays.slice(0, 5)
logger.component('NotificationProvider', 'Using user favorite relays', { logger.component('NotificationProvider', 'Using user favorite relays', {
count: notificationRelays.length, count: notificationRelays.length,
relays: notificationRelays.slice(0, 3) // Show first 3 for brevity relays: notificationRelays.slice(0, 3)
}) })
} else { } else {
// Priority 3: Fast read relays (reliable defaults)
notificationRelays = FAST_READ_RELAY_URLS.slice(0, 5) notificationRelays = FAST_READ_RELAY_URLS.slice(0, 5)
logger.component('NotificationProvider', 'Using fast read relays fallback', { logger.component('NotificationProvider', 'Using fast read relays fallback', {
count: notificationRelays.length, 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 let discussionEosed = false
const discussionSubCloser = client.subscribe( const discussionSubCloser = client.subscribe(
notificationRelays, notificationRelays,
[ [
{ {
kinds: [11], // Discussion threads kinds: [11],
limit: 20 limit: 20
} }
], ],
@ -153,26 +87,24 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
} }
}, },
onevent: (evt) => { onevent: (evt) => {
// Don't notify about our own threads
if (evt.pubkey !== pubkey) { if (evt.pubkey !== pubkey) {
setNewNotifications((prev) => { const prev = notificationBufferRef.current
if (!discussionEosed) { if (!discussionEosed) {
return [evt, ...prev] notificationBufferRef.current = [evt, ...prev]
return
} }
if (prev.length && compareEvents(prev[0], evt) >= 0) { if (prev.length && compareEvents(prev[0], evt) >= 0) {
return prev return
} }
client.emitNewEvent(evt) client.emitNewEvent(evt)
return [evt, ...prev] notificationBufferRef.current = [evt, ...prev]
})
} }
} }
} }
) )
topicSubCloserRef.current = discussionSubCloser topicSubCloserRef.current = discussionSubCloser
// Regular notifications subscription
const subCloser = client.subscribe( const subCloser = client.subscribe(
notificationRelays, notificationRelays,
[ [
@ -196,24 +128,24 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
oneose: (e) => { oneose: (e) => {
if (e) { if (e) {
eosed = e eosed = e
setNewNotifications((prev) => { notificationBufferRef.current = [
return [...prev.sort((a, b) => compareEvents(b, a))] ...notificationBufferRef.current.sort((a, b) => compareEvents(b, a))
}) ]
} }
}, },
onevent: (evt) => { onevent: (evt) => {
if (evt.pubkey !== pubkey) { if (evt.pubkey !== pubkey) {
setNewNotifications((prev) => { const prev = notificationBufferRef.current
if (!eosed) { if (!eosed) {
return [evt, ...prev] notificationBufferRef.current = [evt, ...prev]
return
} }
if (prev.length && compareEvents(prev[0], evt) >= 0) { if (prev.length && compareEvents(prev[0], evt) >= 0) {
return prev return
} }
client.emitNewEvent(evt) client.emitNewEvent(evt)
return [evt, ...prev] notificationBufferRef.current = [evt, ...prev]
})
} }
}, },
onAllClose: (reasons) => { onAllClose: (reasons) => {
@ -221,15 +153,13 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
return return
} }
// Only reconnect if still mounted and not a manual close
// Increase timeout to prevent rapid reconnection loops
if (isMountedRef.current) { if (isMountedRef.current) {
setTimeout(() => { setTimeout(() => {
if (isMountedRef.current) { if (isMountedRef.current) {
logger.debug('[NotificationProvider] Reconnecting after close...') logger.debug('[NotificationProvider] Reconnecting after close...')
subscribe() subscribe()
} }
}, 15_000) // Increased from 5s to 15s }, 15_000)
} }
} }
} }
@ -240,7 +170,6 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
} catch (error) { } catch (error) {
logger.error('Subscription error', { error }) logger.error('Subscription error', { error })
// Retry on error if still mounted
if (isMountedRef.current) { if (isMountedRef.current) {
setTimeout(() => { setTimeout(() => {
if (isMountedRef.current) { if (isMountedRef.current) {
@ -252,10 +181,8 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
} }
} }
// Initial subscription
subscribe() subscribe()
// Cleanup function
return () => { return () => {
clearTimeout(deferredReset) clearTimeout(deferredReset)
isMountedRef.current = false isMountedRef.current = false
@ -268,93 +195,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
topicSubCloserRef.current = null topicSubCloserRef.current = null
} }
} }
}, [pubkey]) }, [pubkey, relayList, favoriteRelays])
useEffect(() => {
const newNotificationCount = filteredNewNotifications.length
// Update title return <>{children}</>
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>
)
} }

13
src/services/client.service.ts

@ -269,19 +269,6 @@ class ClientService extends EventTarget {
return relays.length > 0 ? relays : [...FAST_WRITE_RELAY_URLS] 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[] let relays: string[]
if (specifiedRelayUrls?.length) { if (specifiedRelayUrls?.length) {
relays = specifiedRelayUrls relays = specifiedRelayUrls

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

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

Loading…
Cancel
Save