From ceb9fd0d9ea8d2961dc99893432acc0cc5eaa108 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 18 Mar 2026 12:31:47 +0100 Subject: [PATCH] bug-fixes --- .../NotificationsButton.tsx | 2 +- .../NotificationItem/Notification.tsx | 2 +- src/components/NotificationList/index.tsx | 2 +- .../PostTextarea/Mention/suggestion.ts | 5 ++ src/components/Sidebar/NotificationButton.tsx | 2 +- .../TextareaWithMentionAutocomplete/index.tsx | 6 ++ src/providers/NostrProvider/index.tsx | 13 ++- src/providers/NotificationContext.tsx | 18 ++++ src/providers/NotificationProvider.tsx | 83 ++++++++++--------- src/services/client.service.ts | 13 +++ 10 files changed, 101 insertions(+), 45 deletions(-) create mode 100644 src/providers/NotificationContext.tsx diff --git a/src/components/BottomNavigationBar/NotificationsButton.tsx b/src/components/BottomNavigationBar/NotificationsButton.tsx index b6055303..3b273d7f 100644 --- a/src/components/BottomNavigationBar/NotificationsButton.tsx +++ b/src/components/BottomNavigationBar/NotificationsButton.tsx @@ -1,6 +1,6 @@ import { usePrimaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' -import { useNotification } from '@/providers/NotificationProvider' +import { useNotification } from '@/providers/NotificationContext' import { Bell } from 'lucide-react' import BottomNavigationBarItem from './BottomNavigationBarItem' diff --git a/src/components/NotificationList/NotificationItem/Notification.tsx b/src/components/NotificationList/NotificationItem/Notification.tsx index 75039e28..a223e005 100644 --- a/src/components/NotificationList/NotificationItem/Notification.tsx +++ b/src/components/NotificationList/NotificationItem/Notification.tsx @@ -9,7 +9,7 @@ import { toNote, toProfile } from '@/lib/link' import { cn } from '@/lib/utils' import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' -import { useNotification } from '@/providers/NotificationProvider' +import { useNotification } from '@/providers/NotificationContext' import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { NostrEvent } from 'nostr-tools' import { useMemo } from 'react' diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index f7cd392b..64074654 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -3,7 +3,7 @@ import { compareEvents } from '@/lib/event' import logger from '@/lib/logger' import { usePrimaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' -import { useNotification } from '@/providers/NotificationProvider' +import { useNotification } from '@/providers/NotificationContext' import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import client from '@/services/client.service' diff --git a/src/components/PostEditor/PostTextarea/Mention/suggestion.ts b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts index 7534c2a1..a742f7ab 100644 --- a/src/components/PostEditor/PostTextarea/Mention/suggestion.ts +++ b/src/components/PostEditor/PostTextarea/Mention/suggestion.ts @@ -8,6 +8,11 @@ import MentionList, { MentionListHandle, MentionListProps } from './MentionList' const suggestion = { items: async ({ query }: { query: string }) => { + const q = query.trim().toLowerCase() + // Reserved for future nevent/naddr picker; don't treat as npub handle + if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) { + return [] + } const result = await client.searchNpubsFromLocal(query, 20) return result ?? [] }, diff --git a/src/components/Sidebar/NotificationButton.tsx b/src/components/Sidebar/NotificationButton.tsx index 178dc23b..5f1392b9 100644 --- a/src/components/Sidebar/NotificationButton.tsx +++ b/src/components/Sidebar/NotificationButton.tsx @@ -1,6 +1,6 @@ import { usePrimaryPage, usePrimaryNoteView } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' -import { useNotification } from '@/providers/NotificationProvider' +import { useNotification } from '@/providers/NotificationContext' import { Bell } from 'lucide-react' import SidebarItem from './SidebarItem' diff --git a/src/components/TextareaWithMentionAutocomplete/index.tsx b/src/components/TextareaWithMentionAutocomplete/index.tsx index 7f9ff6cc..191ac929 100644 --- a/src/components/TextareaWithMentionAutocomplete/index.tsx +++ b/src/components/TextareaWithMentionAutocomplete/index.tsx @@ -64,6 +64,12 @@ const TextareaWithMentionAutocomplete = forwardRef { client diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index d2c5e8fe..bb545f38 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1123,6 +1123,8 @@ 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: user’s 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 @@ -1140,8 +1142,15 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { (lastPublishedSeenNotificationsAtEventAt < 0 || now - lastPublishedSeenNotificationsAtEventAt > 10 * 60) // 10 minutes ) { - await publish(createSeenNotificationsAtDraftEvent()) - lastPublishedSeenNotificationsAtEventAtMap.set(account.pubkey, now) + 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) + }) + } } } diff --git a/src/providers/NotificationContext.tsx b/src/providers/NotificationContext.tsx new file mode 100644 index 00000000..ea2e78a7 --- /dev/null +++ b/src/providers/NotificationContext.tsx @@ -0,0 +1,18 @@ +import { createContext, useContext } from 'react' + +export type TNotificationContext = { + hasNewNotification: boolean + getNotificationsSeenAt: () => number + isNotificationRead: (id: string) => boolean + markNotificationAsRead: (id: string) => void +} + +export const NotificationContext = createContext(undefined) + +export const useNotification = () => { + const context = useContext(NotificationContext) + if (!context) { + throw new Error('useNotification must be used within a NotificationProvider') + } + return context +} diff --git a/src/providers/NotificationProvider.tsx b/src/providers/NotificationProvider.tsx index f0a976f4..b8cafd96 100644 --- a/src/providers/NotificationProvider.tsx +++ b/src/providers/NotificationProvider.tsx @@ -8,29 +8,12 @@ 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 { createContext, useContext, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useContentPolicy } from './ContentPolicyProvider' import { useMuteList } from './MuteListProvider' import { useNostr } from './NostrProvider' import { useUserTrust } from './UserTrustProvider' -// import { useInterestList } from './InterestListProvider' // No longer needed - -type TNotificationContext = { - hasNewNotification: boolean - getNotificationsSeenAt: () => number - isNotificationRead: (id: string) => boolean - markNotificationAsRead: (id: string) => void -} - -const NotificationContext = createContext(undefined) - -export const useNotification = () => { - const context = useContext(NotificationContext) - if (!context) { - throw new Error('useNotification must be used within a NotificationProvider') - } - return context -} +import { NotificationContext } from './NotificationContext' export function NotificationProvider({ children }: { children: React.ReactNode }) { const { current } = usePrimaryPage() @@ -76,16 +59,28 @@ export function NotificationProvider({ children }: { children: React.ReactNode } active ]) + // Defer so we don't trigger state updates during the same commit as consumer renders (avoids "Cannot update NotificationList while rendering NotificationProvider") useEffect(() => { - setNewNotifications([]) - updateNotificationsSeenAt(!active) - }, [active]) + let t2: ReturnType | null = null + const t = setTimeout(() => { + setNewNotifications([]) + t2 = setTimeout(() => { + updateNotificationsSeenAt(!active) + }, 0) + }, 0) + return () => { + clearTimeout(t) + if (t2 !== null) clearTimeout(t2) + } + }, [active, updateNotificationsSeenAt]) useEffect(() => { if (!pubkey) return - setNewNotifications([]) - setReadNotificationIdSet(new Set()) + const deferredReset = setTimeout(() => { + setNewNotifications([]) + setReadNotificationIdSet(new Set()) + }, 0) // Track if component is mounted const isMountedRef = { current: true } @@ -262,6 +257,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } // Cleanup function return () => { + clearTimeout(deferredReset) isMountedRef.current = false if (subCloserRef.current) { subCloserRef.current.close() @@ -322,7 +318,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } } }, [filteredNewNotifications]) - const getNotificationsSeenAt = () => { + const getNotificationsSeenAt = useCallback(() => { if (notificationsSeenAt >= 0) { return notificationsSeenAt } @@ -330,25 +326,34 @@ export function NotificationProvider({ children }: { children: React.ReactNode } return storage.getLastReadNotificationTime(pubkey) } return 0 - } + }, [notificationsSeenAt, pubkey]) - const isNotificationRead = (notificationId: string): boolean => { - return readNotificationIdSet.has(notificationId) - } + const isNotificationRead = useCallback( + (notificationId: string): boolean => readNotificationIdSet.has(notificationId), + [readNotificationIdSet] + ) - const markNotificationAsRead = (notificationId: string): void => { + 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 ( - 0, - getNotificationsSeenAt, - isNotificationRead, - markNotificationAsRead - }} - > + {children} ) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 87b44cb5..fc359546 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -269,6 +269,19 @@ 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