Browse Source

dynamic subtopics cont.

imwald
Silberengel 5 months ago
parent
commit
a8bea51636
  1. 15
      src/App.tsx
  2. 33
      src/components/NotificationList/NotificationItem/DiscussionNotification.tsx
  3. 4
      src/components/NotificationList/NotificationItem/index.tsx
  4. 90
      src/components/TopicSubscribeButton/index.tsx
  5. 2
      src/constants.ts
  6. 2
      src/lib/discussion-topics.ts
  7. 9
      src/lib/draft-event.ts
  8. 29
      src/pages/primary/DiscussionsPage/index.tsx
  9. 155
      src/providers/InterestListProvider.tsx
  10. 20
      src/providers/NostrProvider/index.tsx
  11. 57
      src/providers/NotificationProvider.tsx
  12. 4
      src/services/client.service.ts
  13. 8
      src/services/indexed-db.service.ts

15
src/App.tsx

@ -8,6 +8,7 @@ import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider' import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
import { FeedProvider } from '@/providers/FeedProvider' import { FeedProvider } from '@/providers/FeedProvider'
import { FollowListProvider } from '@/providers/FollowListProvider' import { FollowListProvider } from '@/providers/FollowListProvider'
import { InterestListProvider } from '@/providers/InterestListProvider'
import { KindFilterProvider } from '@/providers/KindFilterProvider' import { KindFilterProvider } from '@/providers/KindFilterProvider'
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider' import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
import { MuteListProvider } from '@/providers/MuteListProvider' import { MuteListProvider } from '@/providers/MuteListProvider'
@ -33,9 +34,10 @@ export default function App(): JSX.Element {
<FavoriteRelaysProvider> <FavoriteRelaysProvider>
<FollowListProvider> <FollowListProvider>
<MuteListProvider> <MuteListProvider>
<UserTrustProvider> <InterestListProvider>
<BookmarksProvider> <UserTrustProvider>
<FeedProvider> <BookmarksProvider>
<FeedProvider>
<ReplyProvider> <ReplyProvider>
<MediaUploadServiceProvider> <MediaUploadServiceProvider>
<KindFilterProvider> <KindFilterProvider>
@ -46,9 +48,10 @@ export default function App(): JSX.Element {
</KindFilterProvider> </KindFilterProvider>
</MediaUploadServiceProvider> </MediaUploadServiceProvider>
</ReplyProvider> </ReplyProvider>
</FeedProvider> </FeedProvider>
</BookmarksProvider> </BookmarksProvider>
</UserTrustProvider> </UserTrustProvider>
</InterestListProvider>
</MuteListProvider> </MuteListProvider>
</FollowListProvider> </FollowListProvider>
</FavoriteRelaysProvider> </FavoriteRelaysProvider>

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

@ -0,0 +1,33 @@
import { MessageCircle } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function DiscussionNotification({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
const { t } = useTranslation()
// Get the topic from t-tags
const topicTags = notification.tags.filter(tag => tag[0] === 't' && tag[1])
const topics = topicTags.map(tag => tag[1])
const topicString = topics.length > 0 ? topics.join(', ') : t('general')
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}
/>
)
}

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

@ -6,6 +6,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { DiscussionNotification } from './DiscussionNotification'
import { MentionNotification } from './MentionNotification' import { MentionNotification } from './MentionNotification'
import { PollResponseNotification } from './PollResponseNotification' import { PollResponseNotification } from './PollResponseNotification'
import { PublicMessageNotification } from './PublicMessageNotification' import { PublicMessageNotification } from './PublicMessageNotification'
@ -41,6 +42,9 @@ export function NotificationItem({
]) ])
if (!canShow) return null if (!canShow) return null
if (notification.kind === 11) {
return <DiscussionNotification notification={notification} isNew={isNew} />
}
if (notification.kind === kinds.Reaction) { if (notification.kind === kinds.Reaction) {
return <ReactionNotification notification={notification} isNew={isNew} /> return <ReactionNotification notification={notification} isNew={isNew} />
} }

90
src/components/TopicSubscribeButton/index.tsx

@ -0,0 +1,90 @@
import { Button } from '@/components/ui/button'
import { useInterestList } from '@/providers/InterestListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Bell, BellOff, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
interface TopicSubscribeButtonProps {
topic: string
variant?: 'default' | 'outline' | 'ghost' | 'icon'
size?: 'default' | 'sm' | 'lg' | 'icon'
showLabel?: boolean
}
export default function TopicSubscribeButton({
topic,
variant = 'outline',
size = 'sm',
showLabel = true
}: TopicSubscribeButtonProps) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { isSubscribed, subscribe, unsubscribe, changing } = useInterestList()
if (!pubkey) {
return null
}
const subscribed = isSubscribed(topic)
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
if (changing) return
if (subscribed) {
await unsubscribe(topic)
} else {
await subscribe(topic)
}
}
if (variant === 'icon' || !showLabel) {
return (
<Button
variant={subscribed ? 'default' : 'outline'}
size={size === 'icon' ? 'icon' : size}
onClick={handleClick}
disabled={changing}
title={subscribed ? t('Unsubscribe') : t('Subscribe')}
>
{changing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : subscribed ? (
<Bell className="h-4 w-4" fill="currentColor" />
) : (
<BellOff className="h-4 w-4" />
)}
</Button>
)
}
return (
<Button
variant={subscribed ? 'default' : variant}
size={size}
onClick={handleClick}
disabled={changing}
className="flex items-center gap-2"
>
{changing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{subscribed ? t('Unsubscribing...') : t('Subscribing...')}
</>
) : subscribed ? (
<>
<Bell className="h-4 w-4" fill="currentColor" />
{t('Subscribed')}
</>
) : (
<>
<BellOff className="h-4 w-4" />
{t('Subscribe')}
</>
)}
</Button>
)
}

2
src/constants.ts

@ -157,7 +157,7 @@ export const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
export const EMOJI_SHORT_CODE_REGEX = /:[a-zA-Z0-9_-]+:/g export const EMOJI_SHORT_CODE_REGEX = /:[a-zA-Z0-9_-]+:/g
export const EMBEDDED_EVENT_REGEX = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g export const EMBEDDED_EVENT_REGEX = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
export const EMBEDDED_MENTION_REGEX = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g export const EMBEDDED_MENTION_REGEX = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g
export const HASHTAG_REGEX = /#[\p{L}\p{N}\p{M}_]+/gu export const HASHTAG_REGEX = /#[a-zA-Z0-9_\u00C0-\u017F\u0100-\u017F\u0180-\u024F\u1E00-\u1EFF]+/g
export const LN_INVOICE_REGEX = /(ln(?:bc|tb|bcrt))([0-9]+[munp]?)?1([02-9ac-hj-np-z]+)/g export const LN_INVOICE_REGEX = /(ln(?:bc|tb|bcrt))([0-9]+[munp]?)?1([02-9ac-hj-np-z]+)/g
export const EMOJI_REGEX = export const EMOJI_REGEX =
/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA70}-\u{1FAFF}]|[\u{1F004}]|[\u{1F0CF}]|[\u{1F18E}]|[\u{3030}]|[\u{2B50}]|[\u{2B55}]|[\u{2934}-\u{2935}]|[\u{2B05}-\u{2B07}]|[\u{2B1B}-\u{2B1C}]|[\u{3297}]|[\u{3299}]|[\u{303D}]|[\u{00A9}]|[\u{00AE}]|[\u{2122}]|[\u{23E9}-\u{23EF}]|[\u{23F0}]|[\u{23F3}]|[\u{FE00}-\u{FE0F}]|[\u{200D}]/gu /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA70}-\u{1FAFF}]|[\u{1F004}]|[\u{1F0CF}]|[\u{1F18E}]|[\u{3030}]|[\u{2B50}]|[\u{2B55}]|[\u{2934}-\u{2935}]|[\u{2B05}-\u{2B07}]|[\u{2B1B}-\u{2B1C}]|[\u{3297}]|[\u{3299}]|[\u{303D}]|[\u{00A9}]|[\u{00AE}]|[\u{2122}]|[\u{23E9}-\u{23EF}]|[\u{23F0}]|[\u{23F3}]|[\u{FE00}-\u{FE0F}]|[\u{200D}]/gu

2
src/lib/discussion-topics.ts

@ -69,6 +69,7 @@ export function analyzeThreadTopics(
for (const thread of threads) { for (const thread of threads) {
const allTopics = extractAllTopics(thread) const allTopics = extractAllTopics(thread)
// Find the primary topic (first match from available topics) // Find the primary topic (first match from available topics)
let primaryTopic = 'general' let primaryTopic = 'general'
for (const topic of allTopics) { for (const topic of allTopics) {
@ -118,6 +119,7 @@ export function getDynamicSubtopics(
const subtopics: string[] = [] const subtopics: string[] = []
for (const [subtopic, npubs] of analysis.subtopics.entries()) { for (const [subtopic, npubs] of analysis.subtopics.entries()) {
if (npubs.size >= minNpubs) { if (npubs.size >= minNpubs) {
subtopics.push(subtopic) subtopics.push(subtopic)

9
src/lib/draft-event.ts

@ -452,6 +452,15 @@ export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraft
} }
} }
export function createInterestListDraftEvent(topics: string[], content = ''): TDraftEvent {
return {
kind: 10015,
content,
tags: topics.map(topic => ['t', topic]),
created_at: dayjs().unix()
}
}
export function createBlossomServerListDraftEvent(servers: string[]): TDraftEvent { export function createBlossomServerListDraftEvent(servers: string[]): TDraftEvent {
return { return {
kind: ExtendedKind.BLOSSOM_SERVER_LIST, kind: ExtendedKind.BLOSSOM_SERVER_LIST,

29
src/pages/primary/DiscussionsPage/index.tsx

@ -14,6 +14,7 @@ import ThreadSort, { SortOption } from '@/pages/primary/DiscussionsPage/ThreadSo
import CreateThreadDialog, { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/CreateThreadDialog' import CreateThreadDialog, { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/CreateThreadDialog'
import ViewToggle from '@/pages/primary/DiscussionsPage/ViewToggle' import ViewToggle from '@/pages/primary/DiscussionsPage/ViewToggle'
import SubtopicFilter from '@/pages/primary/DiscussionsPage/SubtopicFilter' import SubtopicFilter from '@/pages/primary/DiscussionsPage/SubtopicFilter'
import TopicSubscribeButton from '@/components/TopicSubscribeButton'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import client from '@/services/client.service' import client from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
@ -122,7 +123,6 @@ const DiscussionsPage = forwardRef((_, ref) => {
setCustomVoteStats({}) // Clear custom stats when fetching setCustomVoteStats({}) // Clear custom stats when fetching
try { try {
// Fetch all kind 11 events (limit 100, newest first) // Fetch all kind 11 events (limit 100, newest first)
console.log('Fetching kind 11 events from relays:', relayUrls)
// Fetch recent kind 11 events (last 30 days) // Fetch recent kind 11 events (last 30 days)
const thirtyDaysAgo = Math.floor((Date.now() - (30 * 24 * 60 * 60 * 1000)) / 1000) const thirtyDaysAgo = Math.floor((Date.now() - (30 * 24 * 60 * 60 * 1000)) / 1000)
@ -168,14 +168,14 @@ const DiscussionsPage = forwardRef((_, ref) => {
}, [fetchAllThreads]) }, [fetchAllThreads])
// Analyze topics whenever threads change // Analyze topics whenever threads change
useEffect(() => { useEffect(() => {
if (allThreads.length > 0) { if (allThreads.length > 0) {
const analysis = analyzeThreadTopics(allThreads, availableTopicIds) const analysis = analyzeThreadTopics(allThreads, availableTopicIds)
setTopicAnalysis(analysis) setTopicAnalysis(analysis)
} else { } else {
setTopicAnalysis(new Map()) setTopicAnalysis(new Map())
} }
}, [allThreads, availableTopicIds]) }, [allThreads, availableTopicIds])
// Update available subtopics when topic analysis or selected topic changes // Update available subtopics when topic analysis or selected topic changes
useEffect(() => { useEffect(() => {
@ -526,9 +526,14 @@ const DiscussionsPage = forwardRef((_, ref) => {
> >
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"> <div className="flex items-center gap-3">
{t('Discussions')} - {selectedTopic === 'all' ? t('All Topics') : DISCUSSION_TOPICS.find(t => t.id === selectedTopic)?.label} <h1 className="text-2xl font-bold">
</h1> {t('Discussions')} - {selectedTopic === 'all' ? t('All Topics') : DISCUSSION_TOPICS.find(t => t.id === selectedTopic)?.label}
</h1>
{selectedTopic !== 'all' && selectedTopic !== 'general' && (
<TopicSubscribeButton topic={selectedTopic} size="sm" />
)}
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selectedTopic === 'all' && ( {selectedTopic === 'all' && (
<ViewToggle <ViewToggle

155
src/providers/InterestListProvider.tsx

@ -0,0 +1,155 @@
import { createInterestListDraftEvent } from '@/lib/draft-event'
import { normalizeTopic } from '@/lib/discussion-topics'
import client from '@/services/client.service'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useNostr } from './NostrProvider'
type TInterestListContext = {
subscribedTopics: Set<string>
changing: boolean
isSubscribed: (topic: string) => boolean
subscribe: (topic: string) => Promise<void>
unsubscribe: (topic: string) => Promise<void>
getSubscribedTopics: () => string[]
}
const InterestListContext = createContext<TInterestListContext | undefined>(undefined)
export const useInterestList = () => {
const context = useContext(InterestListContext)
if (!context) {
throw new Error('useInterestList must be used within an InterestListProvider')
}
return context
}
export function InterestListProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
const { pubkey: accountPubkey, interestListEvent, publish, updateInterestListEvent } = useNostr()
const [topics, setTopics] = useState<string[]>([])
const subscribedTopics = useMemo(() => new Set(topics), [topics])
const [changing, setChanging] = useState(false)
useEffect(() => {
const updateTopics = () => {
if (!interestListEvent) {
setTopics([])
return
}
// Extract t-tags from the interest list
const topicTags = interestListEvent.tags
.filter(tag => tag[0] === 't' && tag[1])
.map(tag => normalizeTopic(tag[1]))
setTopics(topicTags)
}
updateTopics()
}, [interestListEvent])
const getSubscribedTopics = useCallback(() => {
return Array.from(subscribedTopics)
}, [subscribedTopics])
const isSubscribed = useCallback(
(topic: string): boolean => {
return subscribedTopics.has(normalizeTopic(topic))
},
[subscribedTopics]
)
const publishNewInterestListEvent = async (newTopics: string[]) => {
const newInterestListEvent = createInterestListDraftEvent(newTopics)
const publishedEvent = await publish(newInterestListEvent)
return publishedEvent
}
const subscribe = async (topic: string) => {
if (!accountPubkey || changing) return
const normalizedTopic = normalizeTopic(topic)
if (subscribedTopics.has(normalizedTopic)) {
return
}
setChanging(true)
try {
const interestListEvent = await client.fetchInterestListEvent(accountPubkey)
const currentTopics = interestListEvent
? interestListEvent.tags
.filter(tag => tag[0] === 't' && tag[1])
.map(tag => normalizeTopic(tag[1]))
: []
if (currentTopics.includes(normalizedTopic)) {
// Already subscribed
return
}
const newTopics = [...currentTopics, normalizedTopic]
const newInterestListEvent = await publishNewInterestListEvent(newTopics)
await updateInterestListEvent(newInterestListEvent)
toast.success(t('Subscribed to topic'))
} catch (error) {
console.error('Failed to subscribe to topic:', error)
toast.error(t('Failed to subscribe to topic') + ': ' + (error as Error).message)
} finally {
setChanging(false)
}
}
const unsubscribe = async (topic: string) => {
if (!accountPubkey || changing) return
const normalizedTopic = normalizeTopic(topic)
if (!subscribedTopics.has(normalizedTopic)) {
return
}
setChanging(true)
try {
const interestListEvent = await client.fetchInterestListEvent(accountPubkey)
if (!interestListEvent) return
const currentTopics = interestListEvent.tags
.filter(tag => tag[0] === 't' && tag[1])
.map(tag => normalizeTopic(tag[1]))
const newTopics = currentTopics.filter(t => t !== normalizedTopic)
if (newTopics.length === currentTopics.length) {
// Topic wasn't in the list
return
}
const newInterestListEvent = await publishNewInterestListEvent(newTopics)
await updateInterestListEvent(newInterestListEvent)
toast.success(t('Unsubscribed from topic'))
} catch (error) {
console.error('Failed to unsubscribe from topic:', error)
toast.error(t('Failed to unsubscribe from topic') + ': ' + (error as Error).message)
} finally {
setChanging(false)
}
}
return (
<InterestListContext.Provider
value={{
subscribedTopics,
changing,
isSubscribed,
subscribe,
unsubscribe,
getSubscribedTopics
}}
>
{children}
</InterestListContext.Provider>
)
}

20
src/providers/NostrProvider/index.tsx

@ -53,6 +53,7 @@ type TNostrContext = {
followListEvent: Event | null followListEvent: Event | null
muteListEvent: Event | null muteListEvent: Event | null
bookmarkListEvent: Event | null bookmarkListEvent: Event | null
interestListEvent: Event | null
favoriteRelaysEvent: Event | null favoriteRelaysEvent: Event | null
userEmojiListEvent: Event | null userEmojiListEvent: Event | null
notificationsSeenAt: number notificationsSeenAt: number
@ -84,6 +85,7 @@ type TNostrContext = {
updateFollowListEvent: (followListEvent: Event) => Promise<void> updateFollowListEvent: (followListEvent: Event) => Promise<void>
updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise<void> updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise<void>
updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise<void> updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise<void>
updateInterestListEvent: (interestListEvent: Event) => Promise<void>
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void> updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updateNotificationsSeenAt: (skipPublish?: boolean) => Promise<void> updateNotificationsSeenAt: (skipPublish?: boolean) => Promise<void>
} }
@ -117,6 +119,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [followListEvent, setFollowListEvent] = useState<Event | null>(null) const [followListEvent, setFollowListEvent] = useState<Event | null>(null)
const [muteListEvent, setMuteListEvent] = useState<Event | null>(null) const [muteListEvent, setMuteListEvent] = useState<Event | null>(null)
const [bookmarkListEvent, setBookmarkListEvent] = useState<Event | null>(null) const [bookmarkListEvent, setBookmarkListEvent] = useState<Event | null>(null)
const [interestListEvent, setInterestListEvent] = useState<Event | null>(null)
const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(null) const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(null)
const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null) const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null)
const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1) const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1)
@ -241,6 +244,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
kinds.Contacts, kinds.Contacts,
kinds.Mutelist, kinds.Mutelist,
kinds.BookmarkList, kinds.BookmarkList,
10015, // Interest list
ExtendedKind.FAVORITE_RELAYS, ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOSSOM_SERVER_LIST, ExtendedKind.BLOSSOM_SERVER_LIST,
kinds.UserEmojiList kinds.UserEmojiList
@ -258,6 +262,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts) const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts)
const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist) const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist)
const bookmarkListEvent = sortedEvents.find((e) => e.kind === kinds.BookmarkList) const bookmarkListEvent = sortedEvents.find((e) => e.kind === kinds.BookmarkList)
const interestListEvent = sortedEvents.find((e) => e.kind === 10015)
const favoriteRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.FAVORITE_RELAYS) const favoriteRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.FAVORITE_RELAYS)
const blossomServerListEvent = sortedEvents.find( const blossomServerListEvent = sortedEvents.find(
(e) => e.kind === ExtendedKind.BLOSSOM_SERVER_LIST (e) => e.kind === ExtendedKind.BLOSSOM_SERVER_LIST
@ -299,6 +304,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setBookmarkListEvent(bookmarkListEvent) setBookmarkListEvent(bookmarkListEvent)
} }
} }
if (interestListEvent) {
const updatedInterestListEvent = await indexedDb.putReplaceableEvent(interestListEvent)
if (updatedInterestListEvent.id === interestListEvent.id) {
setInterestListEvent(interestListEvent)
}
}
if (favoriteRelaysEvent) { if (favoriteRelaysEvent) {
const updatedFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent) const updatedFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
if (updatedFavoriteRelaysEvent.id === favoriteRelaysEvent.id) { if (updatedFavoriteRelaysEvent.id === favoriteRelaysEvent.id) {
@ -746,6 +757,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setBookmarkListEvent(newBookmarkListEvent) setBookmarkListEvent(newBookmarkListEvent)
} }
const updateInterestListEvent = async (interestListEvent: Event) => {
const newInterestListEvent = await indexedDb.putReplaceableEvent(interestListEvent)
if (newInterestListEvent.id !== interestListEvent.id) return
setInterestListEvent(newInterestListEvent)
}
const updateFavoriteRelaysEvent = async (favoriteRelaysEvent: Event) => { const updateFavoriteRelaysEvent = async (favoriteRelaysEvent: Event) => {
const newFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent) const newFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
if (newFavoriteRelaysEvent.id !== favoriteRelaysEvent.id) return if (newFavoriteRelaysEvent.id !== favoriteRelaysEvent.id) return
@ -786,6 +804,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
followListEvent, followListEvent,
muteListEvent, muteListEvent,
bookmarkListEvent, bookmarkListEvent,
interestListEvent,
favoriteRelaysEvent, favoriteRelaysEvent,
userEmojiListEvent, userEmojiListEvent,
notificationsSeenAt, notificationsSeenAt,
@ -814,6 +833,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
updateFollowListEvent, updateFollowListEvent,
updateMuteListEvent, updateMuteListEvent,
updateBookmarkListEvent, updateBookmarkListEvent,
updateInterestListEvent,
updateFavoriteRelaysEvent, updateFavoriteRelaysEvent,
updateNotificationsSeenAt updateNotificationsSeenAt
}} }}

57
src/providers/NotificationProvider.tsx

@ -11,6 +11,7 @@ import { useContentPolicy } from './ContentPolicyProvider'
import { useMuteList } from './MuteListProvider' import { useMuteList } from './MuteListProvider'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
import { useUserTrust } from './UserTrustProvider' import { useUserTrust } from './UserTrustProvider'
import { useInterestList } from './InterestListProvider'
type TNotificationContext = { type TNotificationContext = {
hasNewNotification: boolean hasNewNotification: boolean
@ -36,6 +37,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
const { hideUntrustedNotifications, isUserTrusted } = useUserTrust() const { hideUntrustedNotifications, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { getSubscribedTopics } = useInterestList()
const [newNotifications, setNewNotifications] = useState<NostrEvent[]>([]) const [newNotifications, setNewNotifications] = useState<NostrEvent[]>([])
const [readNotificationIdSet, setReadNotificationIdSet] = useState<Set<string>>(new Set()) const [readNotificationIdSet, setReadNotificationIdSet] = useState<Set<string>>(new Set())
const filteredNewNotifications = useMemo(() => { const filteredNewNotifications = useMemo(() => {
@ -87,18 +89,67 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
const subCloserRef: { const subCloserRef: {
current: SubCloser | null current: SubCloser | null
} = { current: null } } = { current: null }
const topicSubCloserRef: {
current: SubCloser | null
} = { current: null }
const subscribe = async () => { const subscribe = async () => {
if (subCloserRef.current) { if (subCloserRef.current) {
subCloserRef.current.close() subCloserRef.current.close()
subCloserRef.current = null subCloserRef.current = null
} }
if (topicSubCloserRef.current) {
topicSubCloserRef.current.close()
topicSubCloserRef.current = null
}
if (!isMountedRef.current) return null if (!isMountedRef.current) return null
try { try {
let eosed = false let eosed = false
const relayList = await client.fetchRelayList(pubkey) const relayList = await client.fetchRelayList(pubkey)
const notificationRelays = relayList.read.length > 0 ? relayList.read.slice(0, 5) : BIG_RELAY_URLS const notificationRelays = relayList.read.length > 0 ? relayList.read.slice(0, 5) : BIG_RELAY_URLS
// Subscribe to subscribed topics (kind 11 discussions)
const subscribedTopics = getSubscribedTopics()
if (subscribedTopics.length > 0) {
let topicEosed = false
const topicSubCloser = client.subscribe(
notificationRelays,
[
{
kinds: [11], // Discussion threads
'#t': subscribedTopics,
limit: 10
}
],
{
oneose: (e) => {
if (e) {
topicEosed = e
}
},
onevent: (evt) => {
// Don't notify about our own threads
if (evt.pubkey !== pubkey) {
setNewNotifications((prev) => {
if (!topicEosed) {
return [evt, ...prev]
}
if (prev.length && compareEvents(prev[0], evt) >= 0) {
return prev
}
client.emitNewEvent(evt)
return [evt, ...prev]
})
}
}
}
)
topicSubCloserRef.current = topicSubCloser
}
// Regular notifications subscription
const subCloser = client.subscribe( const subCloser = client.subscribe(
notificationRelays, notificationRelays,
[ [
@ -187,8 +238,12 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
subCloserRef.current.close() subCloserRef.current.close()
subCloserRef.current = null subCloserRef.current = null
} }
if (topicSubCloserRef.current) {
topicSubCloserRef.current.close()
topicSubCloserRef.current = null
}
} }
}, [pubkey]) }, [pubkey, getSubscribedTopics])
useEffect(() => { useEffect(() => {
const newNotificationCount = filteredNewNotifications.length const newNotificationCount = filteredNewNotifications.length

4
src/services/client.service.ts

@ -1685,6 +1685,10 @@ class ClientService extends EventTarget {
return this.fetchReplaceableEvent(pubkey, kinds.BookmarkList) return this.fetchReplaceableEvent(pubkey, kinds.BookmarkList)
} }
async fetchInterestListEvent(pubkey: string) {
return this.fetchReplaceableEvent(pubkey, 10015)
}
async fetchBlossomServerListEvent(pubkey: string) { async fetchBlossomServerListEvent(pubkey: string) {
return await this.fetchReplaceableEvent(pubkey, ExtendedKind.BLOSSOM_SERVER_LIST) return await this.fetchReplaceableEvent(pubkey, ExtendedKind.BLOSSOM_SERVER_LIST)
} }

8
src/services/indexed-db.service.ts

@ -16,6 +16,7 @@ const StoreNames = {
MUTE_LIST_EVENTS: 'muteListEvents', MUTE_LIST_EVENTS: 'muteListEvents',
BOOKMARK_LIST_EVENTS: 'bookmarkListEvents', BOOKMARK_LIST_EVENTS: 'bookmarkListEvents',
BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents', BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents',
INTEREST_LIST_EVENTS: 'interestListEvents',
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents', USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents',
EMOJI_SET_EVENTS: 'emojiSetEvents', EMOJI_SET_EVENTS: 'emojiSetEvents',
@ -70,6 +71,9 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.BOOKMARK_LIST_EVENTS)) { if (!db.objectStoreNames.contains(StoreNames.BOOKMARK_LIST_EVENTS)) {
db.createObjectStore(StoreNames.BOOKMARK_LIST_EVENTS, { keyPath: 'key' }) db.createObjectStore(StoreNames.BOOKMARK_LIST_EVENTS, { keyPath: 'key' })
} }
if (!db.objectStoreNames.contains(StoreNames.INTEREST_LIST_EVENTS)) {
db.createObjectStore(StoreNames.INTEREST_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) { if (!db.objectStoreNames.contains(StoreNames.MUTE_DECRYPTED_TAGS)) {
db.createObjectStore(StoreNames.MUTE_DECRYPTED_TAGS, { keyPath: 'key' }) db.createObjectStore(StoreNames.MUTE_DECRYPTED_TAGS, { keyPath: 'key' })
} }
@ -447,6 +451,10 @@ class IndexedDbService {
return StoreNames.FOLLOW_LIST_EVENTS return StoreNames.FOLLOW_LIST_EVENTS
case kinds.Mutelist: case kinds.Mutelist:
return StoreNames.MUTE_LIST_EVENTS return StoreNames.MUTE_LIST_EVENTS
case kinds.BookmarkList:
return StoreNames.BOOKMARK_LIST_EVENTS
case 10015: // Interest list
return StoreNames.INTEREST_LIST_EVENTS
case ExtendedKind.BLOSSOM_SERVER_LIST: case ExtendedKind.BLOSSOM_SERVER_LIST:
return StoreNames.BLOSSOM_SERVER_LIST_EVENTS return StoreNames.BLOSSOM_SERVER_LIST_EVENTS
case kinds.Relaysets: case kinds.Relaysets:

Loading…
Cancel
Save