-
- {t('Discussions')} - {selectedTopic === 'all' ? t('All Topics') : DISCUSSION_TOPICS.find(t => t.id === selectedTopic)?.label}
-
+
+
+ {t('Discussions')} - {selectedTopic === 'all' ? t('All Topics') : DISCUSSION_TOPICS.find(t => t.id === selectedTopic)?.label}
+
+ {selectedTopic !== 'all' && selectedTopic !== 'general' && (
+
+ )}
+
{selectedTopic === 'all' && (
+ changing: boolean
+ isSubscribed: (topic: string) => boolean
+ subscribe: (topic: string) => Promise
+ unsubscribe: (topic: string) => Promise
+ getSubscribedTopics: () => string[]
+}
+
+const InterestListContext = createContext(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([])
+ 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 (
+
+ {children}
+
+ )
+}
+
diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx
index 97efcfc..0fdad10 100644
--- a/src/providers/NostrProvider/index.tsx
+++ b/src/providers/NostrProvider/index.tsx
@@ -53,6 +53,7 @@ type TNostrContext = {
followListEvent: Event | null
muteListEvent: Event | null
bookmarkListEvent: Event | null
+ interestListEvent: Event | null
favoriteRelaysEvent: Event | null
userEmojiListEvent: Event | null
notificationsSeenAt: number
@@ -84,6 +85,7 @@ type TNostrContext = {
updateFollowListEvent: (followListEvent: Event) => Promise
updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise
updateBookmarkListEvent: (bookmarkListEvent: Event) => Promise
+ updateInterestListEvent: (interestListEvent: Event) => Promise
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise
updateNotificationsSeenAt: (skipPublish?: boolean) => Promise
}
@@ -117,6 +119,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [followListEvent, setFollowListEvent] = useState(null)
const [muteListEvent, setMuteListEvent] = useState(null)
const [bookmarkListEvent, setBookmarkListEvent] = useState(null)
+ const [interestListEvent, setInterestListEvent] = useState(null)
const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState(null)
const [userEmojiListEvent, setUserEmojiListEvent] = useState(null)
const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1)
@@ -241,6 +244,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
kinds.Contacts,
kinds.Mutelist,
kinds.BookmarkList,
+ 10015, // Interest list
ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOSSOM_SERVER_LIST,
kinds.UserEmojiList
@@ -258,6 +262,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts)
const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist)
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 blossomServerListEvent = sortedEvents.find(
(e) => e.kind === ExtendedKind.BLOSSOM_SERVER_LIST
@@ -299,6 +304,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setBookmarkListEvent(bookmarkListEvent)
}
}
+ if (interestListEvent) {
+ const updatedInterestListEvent = await indexedDb.putReplaceableEvent(interestListEvent)
+ if (updatedInterestListEvent.id === interestListEvent.id) {
+ setInterestListEvent(interestListEvent)
+ }
+ }
if (favoriteRelaysEvent) {
const updatedFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
if (updatedFavoriteRelaysEvent.id === favoriteRelaysEvent.id) {
@@ -746,6 +757,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
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 newFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
if (newFavoriteRelaysEvent.id !== favoriteRelaysEvent.id) return
@@ -786,6 +804,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
followListEvent,
muteListEvent,
bookmarkListEvent,
+ interestListEvent,
favoriteRelaysEvent,
userEmojiListEvent,
notificationsSeenAt,
@@ -814,6 +833,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
updateFollowListEvent,
updateMuteListEvent,
updateBookmarkListEvent,
+ updateInterestListEvent,
updateFavoriteRelaysEvent,
updateNotificationsSeenAt
}}
diff --git a/src/providers/NotificationProvider.tsx b/src/providers/NotificationProvider.tsx
index f452970..a4b8039 100644
--- a/src/providers/NotificationProvider.tsx
+++ b/src/providers/NotificationProvider.tsx
@@ -11,6 +11,7 @@ import { useContentPolicy } from './ContentPolicyProvider'
import { useMuteList } from './MuteListProvider'
import { useNostr } from './NostrProvider'
import { useUserTrust } from './UserTrustProvider'
+import { useInterestList } from './InterestListProvider'
type TNotificationContext = {
hasNewNotification: boolean
@@ -36,6 +37,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
const { hideUntrustedNotifications, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
+ const { getSubscribedTopics } = useInterestList()
const [newNotifications, setNewNotifications] = useState([])
const [readNotificationIdSet, setReadNotificationIdSet] = useState>(new Set())
const filteredNewNotifications = useMemo(() => {
@@ -87,18 +89,67 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
const subCloserRef: {
current: SubCloser | null
} = { current: null }
+ const topicSubCloserRef: {
+ current: SubCloser | null
+ } = { current: null }
const subscribe = async () => {
if (subCloserRef.current) {
subCloserRef.current.close()
subCloserRef.current = null
}
+ if (topicSubCloserRef.current) {
+ topicSubCloserRef.current.close()
+ topicSubCloserRef.current = null
+ }
if (!isMountedRef.current) return null
try {
let eosed = false
const relayList = await client.fetchRelayList(pubkey)
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(
notificationRelays,
[
@@ -187,8 +238,12 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
subCloserRef.current.close()
subCloserRef.current = null
}
+ if (topicSubCloserRef.current) {
+ topicSubCloserRef.current.close()
+ topicSubCloserRef.current = null
+ }
}
- }, [pubkey])
+ }, [pubkey, getSubscribedTopics])
useEffect(() => {
const newNotificationCount = filteredNewNotifications.length
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index a562c65..cb720b3 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -1685,6 +1685,10 @@ class ClientService extends EventTarget {
return this.fetchReplaceableEvent(pubkey, kinds.BookmarkList)
}
+ async fetchInterestListEvent(pubkey: string) {
+ return this.fetchReplaceableEvent(pubkey, 10015)
+ }
+
async fetchBlossomServerListEvent(pubkey: string) {
return await this.fetchReplaceableEvent(pubkey, ExtendedKind.BLOSSOM_SERVER_LIST)
}
diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts
index 3bf4481..adb961f 100644
--- a/src/services/indexed-db.service.ts
+++ b/src/services/indexed-db.service.ts
@@ -16,6 +16,7 @@ const StoreNames = {
MUTE_LIST_EVENTS: 'muteListEvents',
BOOKMARK_LIST_EVENTS: 'bookmarkListEvents',
BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents',
+ INTEREST_LIST_EVENTS: 'interestListEvents',
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents',
EMOJI_SET_EVENTS: 'emojiSetEvents',
@@ -70,6 +71,9 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.BOOKMARK_LIST_EVENTS)) {
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)) {
db.createObjectStore(StoreNames.MUTE_DECRYPTED_TAGS, { keyPath: 'key' })
}
@@ -447,6 +451,10 @@ class IndexedDbService {
return StoreNames.FOLLOW_LIST_EVENTS
case kinds.Mutelist:
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:
return StoreNames.BLOSSOM_SERVER_LIST_EVENTS
case kinds.Relaysets: