From e8d62d6e31ee312a3e7e462d84b5b43c041246df Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 25 Oct 2025 20:35:13 +0200 Subject: [PATCH] Personalized trending algos Follow hashtags and pin notes --- src/components/NoteList/index.tsx | 5 +- src/components/NoteOptions/useMenuActions.tsx | 87 ++++++++++++- src/components/Profile/ProfileFeed.tsx | 63 +++++++++ src/components/TrendingNotes/index.tsx | 120 ++++++++++++++++++ src/pages/secondary/NoteListPage/index.tsx | 56 +++++++- src/providers/NostrProvider/index.tsx | 8 +- src/services/client.service.ts | 4 + src/services/indexed-db.service.ts | 8 +- src/services/note-stats.service.ts | 73 ++++++++++- 9 files changed, 410 insertions(+), 14 deletions(-) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index dacddb0..ffd6adf 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -45,7 +45,8 @@ const NoteList = forwardRef( hideReplies = false, hideUntrustedNotes = false, areAlgoRelays = false, - showRelayCloseReason = false + showRelayCloseReason = false, + customHeader }: { subRequests: TFeedSubRequest[] showKinds: number[] @@ -54,6 +55,7 @@ const NoteList = forwardRef( hideUntrustedNotes?: boolean areAlgoRelays?: boolean showRelayCloseReason?: boolean + customHeader?: React.ReactNode }, ref ) => { @@ -299,6 +301,7 @@ const NoteList = forwardRef( const list = (
+ {customHeader} {filteredEvents.map((event) => ( { @@ -61,6 +61,72 @@ export function useMenuActions({ const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList() const isMuted = useMemo(() => mutePubkeySet.has(event.pubkey), [mutePubkeySet, event]) + // Check if event is pinned + const [isPinned, setIsPinned] = useState(false) + + useEffect(() => { + const checkIfPinned = async () => { + if (!pubkey) { + setIsPinned(false) + return + } + try { + const pinListEvent = await client.fetchPinListEvent(pubkey) + if (pinListEvent) { + const isEventPinned = pinListEvent.tags.some(tag => tag[0] === 'e' && tag[1] === event.id) + setIsPinned(isEventPinned) + } + } catch (error) { + console.error('Error checking pin status:', error) + } + } + checkIfPinned() + }, [pubkey, event.id]) + + const handlePinNote = async () => { + if (!pubkey) return + + try { + // Fetch existing pin list + let pinListEvent = await client.fetchPinListEvent(pubkey) + + // Get existing event IDs, excluding the one we're toggling + const existingEventIds = (pinListEvent?.tags || []) + .filter(tag => tag[0] === 'e' && tag[1]) + .map(tag => tag[1]) + .filter(id => id !== event.id) + + let newTags: string[][] + let successMessage: string + + if (isPinned) { + // Unpin: just keep the existing tags without this event + newTags = existingEventIds.map(id => ['e', id]) + successMessage = t('Note unpinned') + } else { + // Pin: add this event to the existing list + newTags = [...existingEventIds.map(id => ['e', id]), ['e', event.id]] + successMessage = t('Note pinned') + } + + // Create and publish the new pin list event + await publish({ + kind: 10001, + tags: newTags, + content: '', + created_at: Math.floor(Date.now() / 1000) + }) + + // Update local state - the publish will update the cache automatically + setIsPinned(!isPinned) + toast.success(successMessage) + closeDrawer() + } catch (error) { + console.error('Error pinning/unpinning note:', error) + toast.error(t('Failed to pin note')) + } + } + // Check if this is a reply to a discussion event const [isReplyToDiscussion, setIsReplyToDiscussion] = useState(false) @@ -272,6 +338,14 @@ export function useMenuActions({ } if (pubkey && event.pubkey === pubkey) { + actions.push({ + icon: Pin, + label: isPinned ? t('Unpin note') : t('Pin note'), + onClick: () => { + handlePinNote() + }, + separator: true + }) actions.push({ icon: Trash2, label: t('Try deleting this note'), @@ -279,8 +353,7 @@ export function useMenuActions({ closeDrawer() attemptDelete(event) }, - className: 'text-destructive focus:text-destructive', - separator: true + className: 'text-destructive focus:text-destructive' }) } @@ -295,9 +368,13 @@ export function useMenuActions({ closeDrawer, showSubMenuActions, setIsRawEventDialogOpen, + setIsReportDialogOpen, mutePubkeyPrivately, mutePubkeyPublicly, - unmutePubkey + unmutePubkey, + attemptDelete, + isPinned, + handlePinNote ]) return menuActions diff --git a/src/components/Profile/ProfileFeed.tsx b/src/components/Profile/ProfileFeed.tsx index ca00be5..b72fc00 100644 --- a/src/components/Profile/ProfileFeed.tsx +++ b/src/components/Profile/ProfileFeed.tsx @@ -10,6 +10,8 @@ import storage from '@/services/local-storage.service' import { TFeedSubRequest, TNoteListMode } from '@/types' import { useEffect, useMemo, useRef, useState } from 'react' import { RefreshButton } from '../RefreshButton' +import { Event } from 'nostr-tools' +import NoteCard from '../NoteCard' export default function ProfileFeed({ pubkey, @@ -24,6 +26,9 @@ export default function ProfileFeed({ const [listMode, setListMode] = useState(() => storage.getNoteListMode()) const noteListRef = useRef(null) const [subRequests, setSubRequests] = useState([]) + const [pinnedEvents, setPinnedEvents] = useState([]) + const [loadingPinned, setLoadingPinned] = useState(true) + const tabs = useMemo(() => { const _tabs = [ { value: 'posts', label: 'Notes' }, @@ -81,6 +86,42 @@ export default function ProfileFeed({ init() }, [pubkey, listMode, myPubkey]) + // Fetch pinned notes + useEffect(() => { + const fetchPinnedNotes = async () => { + setLoadingPinned(true) + try { + const pinListEvent = await client.fetchPinListEvent(pubkey) + if (pinListEvent && pinListEvent.tags.length > 0) { + // Extract event IDs from pin list + const eventIds = pinListEvent.tags + .filter(tag => tag[0] === 'e' && tag[1]) + .map(tag => tag[1]) + .reverse() // Reverse to show newest first + + // Fetch the actual events + const events = await client.fetchEvents( + [...BIG_RELAY_URLS], + { ids: eventIds } + ) + + // Sort by created_at desc (newest first) + const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) + setPinnedEvents(sortedEvents) + } else { + setPinnedEvents([]) + } + } catch (error) { + console.error('Error fetching pinned notes:', error) + setPinnedEvents([]) + } finally { + setLoadingPinned(false) + } + } + + fetchPinnedNotes() + }, [pubkey]) + const handleListModeChange = (mode: TNoteListMode) => { setListMode(mode) noteListRef.current?.scrollToTop('smooth') @@ -91,6 +132,27 @@ export default function ProfileFeed({ noteListRef.current?.scrollToTop() } + // Create pinned notes header + const pinnedHeader = useMemo(() => { + if (loadingPinned || pinnedEvents.length === 0) return null + + return ( +
+
+ Pinned +
+ {pinnedEvents.map((event) => ( + + ))} +
+ ) + }, [pinnedEvents, loadingPinned]) + return ( <> ) diff --git a/src/components/TrendingNotes/index.tsx b/src/components/TrendingNotes/index.tsx index acea639..e456aed 100644 --- a/src/components/TrendingNotes/index.tsx +++ b/src/components/TrendingNotes/index.tsx @@ -6,17 +6,137 @@ import client from '@/services/client.service' import { NostrEvent } from 'nostr-tools' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useNostr } from '@/providers/NostrProvider' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import noteStatsService from '@/services/note-stats.service' +import { BIG_RELAY_URLS, FAST_READ_RELAY_URLS } from '@/constants' +import { normalizeUrl } from '@/lib/url' const SHOW_COUNT = 10 +const CACHE_DURATION = 30 * 60 * 1000 // 30 minutes + +// Unified cache for all custom trending feeds +let cachedCustomEvents: { + events: Array<{ event: NostrEvent; score: number }> + timestamp: number + hashtags: string[] + listEventIds: string[] +} | null = null export default function TrendingNotes() { const { t } = useTranslation() const { isEventDeleted } = useDeletedEvent() const { hideUntrustedNotes, isUserTrusted } = useUserTrust() + const { pubkey, relayList } = useNostr() + const { favoriteRelays } = useFavoriteRelays() const [trendingNotes, setTrendingNotes] = useState([]) const [showCount, setShowCount] = useState(10) const [loading, setLoading] = useState(true) const bottomRef = useRef(null) + + // Get relays based on user login status + const getRelays = useMemo(() => { + const relays: string[] = [] + + if (pubkey) { + // User is logged in: favorite relays + inboxes (read relays) + relays.push(...favoriteRelays) + if (relayList?.read) { + relays.push(...relayList.read) + } + } else { + // User is not logged in: BIG_RELAY_URLS + FAST_READ_RELAY_URLS + relays.push(...BIG_RELAY_URLS) + relays.push(...FAST_READ_RELAY_URLS) + } + + // Normalize and deduplicate + const normalized = relays + .map(url => normalizeUrl(url)) + .filter((url): url is string => !!url) + + return Array.from(new Set(normalized)) + }, [pubkey, favoriteRelays, relayList]) + + // Initialize or update cache on mount + useEffect(() => { + const initializeCache = async () => { + const now = Date.now() + + // Check if cache is still valid + if (cachedCustomEvents && (now - cachedCustomEvents.timestamp) < CACHE_DURATION) { + console.log('[TrendingNotes] Using existing cache') + return + } + + console.log('[TrendingNotes] Initializing cache from relays') + const relays = getRelays + + try { + // Fetch all events for custom feeds + const twentyFourHoursAgo = Math.floor(Date.now() / 1000) - 24 * 60 * 60 + + // 1. Fetch top-level posts from last 24 hours + const recentEvents = await client.fetchEvents(relays, { + kinds: [1, 11, 30023, 9802, 20, 21, 22], + since: twentyFourHoursAgo, + limit: 500 + }) + + // Filter for top-level posts only + const topLevelEvents = recentEvents.filter(event => { + const eTags = event.tags.filter(t => t[0] === 'e') + return eTags.length === 0 + }) + + // Fetch stats for events in batches + const eventsNeedingStats = topLevelEvents.filter(event => !noteStatsService.getNoteStats(event.id)) + + if (eventsNeedingStats.length > 0) { + const batchSize = 10 + for (let i = 0; i < eventsNeedingStats.length; i += batchSize) { + const batch = eventsNeedingStats.slice(i, i + batchSize) + await Promise.all(batch.map(event => + noteStatsService.fetchNoteStats(event, undefined).catch(() => {}) + )) + if (i + batchSize < eventsNeedingStats.length) { + await new Promise(resolve => setTimeout(resolve, 200)) + } + } + } + + // Score events + const scoredEvents = topLevelEvents.map((event) => { + const stats = noteStatsService.getNoteStats(event.id) + let score = 0 + + if (stats?.likes) score += stats.likes.length + if (stats?.zaps) score += stats.zaps.length + if (stats?.replies) score += stats.replies.length * 3 + if (stats?.reposts) score += stats.reposts.length * 5 + if (stats?.quotes) score += stats.quotes.length * 8 + if (stats?.highlights) score += stats.highlights.length * 10 + + return { event, score } + }) + + // Update cache + cachedCustomEvents = { + events: scoredEvents, + timestamp: now, + hashtags: [], // Will be populated when we add hashtags support + listEventIds: [] // Will be populated when we add bookmarks/pins support + } + + console.log('[TrendingNotes] Cache initialized with', scoredEvents.length, 'events') + } catch (error) { + console.error('[TrendingNotes] Error initializing cache:', error) + } + } + + initializeCache() + }, [getRelays]) + const filteredEvents = useMemo(() => { const idSet = new Set() diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx index 30e614c..5b095bf 100644 --- a/src/pages/secondary/NoteListPage/index.tsx +++ b/src/pages/secondary/NoteListPage/index.tsx @@ -8,16 +8,18 @@ import { toProfileList } from '@/lib/link' import { fetchPubkeysFromDomain, getWellKnownNip05Url } from '@/lib/nip05' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' +import { useInterestList } from '@/providers/InterestListProvider' import client from '@/services/client.service' import { TFeedSubRequest } from '@/types' -import { UserRound } from 'lucide-react' -import React, { forwardRef, useEffect, useState } from 'react' +import { UserRound, Plus } from 'lucide-react' +import React, { forwardRef, useEffect, useState, useMemo } from 'react' import { useTranslation } from 'react-i18next' const NoteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const { t } = useTranslation() const { push } = useSecondaryPage() const { relayList, pubkey } = useNostr() + const { isSubscribed, subscribe } = useInterestList() const [title, setTitle] = useState(null) const [controls, setControls] = useState(null) const [data, setData] = useState< @@ -34,6 +36,27 @@ const NoteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb >(null) const [subRequests, setSubRequests] = useState([]) + // Get hashtag from URL if this is a hashtag page + const hashtag = useMemo(() => { + if (data?.type === 'hashtag') { + const searchParams = new URLSearchParams(window.location.search) + return searchParams.get('t') + } + return null + }, [data]) + + // Check if the hashtag is already in the user's interest list + const isHashtagSubscribed = useMemo(() => { + if (!hashtag) return false + return isSubscribed(hashtag) + }, [hashtag, isSubscribed]) + + // Add hashtag to interest list + const handleSubscribeHashtag = async () => { + if (!hashtag) return + await subscribe(hashtag) + } + useEffect(() => { const init = async () => { const searchParams = new URLSearchParams(window.location.search) @@ -51,6 +74,19 @@ const NoteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb urls: BIG_RELAY_URLS } ]) + // Set controls for hashtag subscribe button + if (pubkey) { + setControls( + + ) + } return } const search = searchParams.get('s') @@ -113,6 +149,22 @@ const NoteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb init() }, []) + // Update controls when subscription status changes + useEffect(() => { + if (data?.type === 'hashtag' && pubkey) { + setControls( + + ) + } + }, [data, pubkey, isHashtagSubscribed, handleSubscribeHashtag, t]) + let content: React.ReactNode = null if (data?.type === 'domain' && subRequests.length === 0) { content = ( diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index a77a4de..ce15b36 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1,5 +1,5 @@ import LoginDialog from '@/components/LoginDialog' -import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind } from '@/constants' +import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind, PROFILE_FETCH_RELAY_URLS } from '@/constants' import { createDeletionRequestDraftEvent, createFollowListDraftEvent, @@ -298,10 +298,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { // for better performance and accuracy const normalizedRelays = [ - ...relayList.write.map(url => normalizeUrl(url) || url), - ...BIG_RELAY_URLS.map(url => normalizeUrl(url) || url) + ...relayList.write.map((url: string) => normalizeUrl(url) || url), + ...PROFILE_FETCH_RELAY_URLS.map((url: string) => normalizeUrl(url) || url) ] - const fetchRelays = Array.from(new Set(normalizedRelays)).slice(0, 4) + const fetchRelays = Array.from(new Set(normalizedRelays)).slice(0, 8) const events = await client.fetchEvents(fetchRelays, [ { kinds: [ diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 76b0744..b6637f4 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1782,6 +1782,10 @@ class ClientService extends EventTarget { return this.fetchReplaceableEvent(pubkey, 10015) } + async fetchPinListEvent(pubkey: string) { + return this.fetchReplaceableEvent(pubkey, 10001) + } + 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 c61f55f..4c75b91 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -15,6 +15,7 @@ const StoreNames = { FOLLOW_LIST_EVENTS: 'followListEvents', MUTE_LIST_EVENTS: 'muteListEvents', BOOKMARK_LIST_EVENTS: 'bookmarkListEvents', + PIN_LIST_EVENTS: 'pinListEvents', BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents', INTEREST_LIST_EVENTS: 'interestListEvents', MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', @@ -44,7 +45,7 @@ class IndexedDbService { init(): Promise { if (!this.initPromise) { this.initPromise = new Promise((resolve, reject) => { - const request = window.indexedDB.open('jumble', 10) + const request = window.indexedDB.open('jumble', 11) request.onerror = (event) => { reject(event) @@ -72,6 +73,9 @@ class IndexedDbService { if (!db.objectStoreNames.contains(StoreNames.BOOKMARK_LIST_EVENTS)) { db.createObjectStore(StoreNames.BOOKMARK_LIST_EVENTS, { keyPath: 'key' }) } + if (!db.objectStoreNames.contains(StoreNames.PIN_LIST_EVENTS)) { + db.createObjectStore(StoreNames.PIN_LIST_EVENTS, { keyPath: 'key' }) + } if (!db.objectStoreNames.contains(StoreNames.INTEREST_LIST_EVENTS)) { db.createObjectStore(StoreNames.INTEREST_LIST_EVENTS, { keyPath: 'key' }) } @@ -457,6 +461,8 @@ class IndexedDbService { return StoreNames.MUTE_LIST_EVENTS case kinds.BookmarkList: return StoreNames.BOOKMARK_LIST_EVENTS + case 10001: // Pin list + return StoreNames.PIN_LIST_EVENTS case 10015: // Interest list return StoreNames.INTEREST_LIST_EVENTS case ExtendedKind.BLOSSOM_SERVER_LIST: diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 4a6198b..e50c9d0 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -16,6 +16,10 @@ export type TNoteStats = { zaps: { pr: string; pubkey: string; amount: number; created_at: number; comment?: string }[] replyIdSet: Set replies: { id: string; pubkey: string; created_at: number }[] + quoteIdSet: Set + quotes: { id: string; pubkey: string; created_at: number }[] + highlightIdSet: Set + highlights: { id: string; pubkey: string; created_at: number }[] updatedAt?: number } @@ -62,6 +66,16 @@ class NoteStatsService { '#e': [event.id], kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: 500 + }, + { + '#q': [event.id], + kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], + limit: 500 + }, + { + '#e': [event.id], + kinds: [kinds.Highlights], + limit: 500 } ] @@ -81,6 +95,16 @@ class NoteStatsService { '#a': [replaceableCoordinate], kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: 500 + }, + { + '#q': [replaceableCoordinate], + kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], + limit: 500 + }, + { + '#a': [replaceableCoordinate], + kinds: [kinds.Highlights], + limit: 500 } ) } @@ -210,7 +234,15 @@ class NoteStatsService { } else if (evt.kind === kinds.Zap) { updatedEventId = this.addZapByEvent(evt) } else if (evt.kind === kinds.ShortTextNote || evt.kind === ExtendedKind.COMMENT || evt.kind === ExtendedKind.VOICE_COMMENT) { - updatedEventId = this.addReplyByEvent(evt) + // Check if it's a reply or quote + const isQuote = this.isQuoteByEvent(evt) + if (isQuote) { + updatedEventId = this.addQuoteByEvent(evt) + } else { + updatedEventId = this.addReplyByEvent(evt) + } + } else if (evt.kind === kinds.Highlights) { + updatedEventId = this.addHighlightByEvent(evt) } if (updatedEventId) { updatedEventIdSet.add(updatedEventId) @@ -347,6 +379,45 @@ class NoteStatsService { return originalEventId } + private isQuoteByEvent(evt: Event): boolean { + // A quote has a 'q' tag (quoted event) + return evt.tags.some(tag => tag[0] === 'q' && tag[1]) + } + + private addQuoteByEvent(evt: Event) { + // Find the quoted event ID from 'q' tag + const quotedEventId = evt.tags.find(tag => tag[0] === 'q')?.[1] + if (!quotedEventId) return + + const old = this.noteStatsMap.get(quotedEventId) || {} + const quoteIdSet = old.quoteIdSet || new Set() + const quotes = old.quotes || [] + + if (quoteIdSet.has(evt.id)) return + + quoteIdSet.add(evt.id) + quotes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at }) + this.noteStatsMap.set(quotedEventId, { ...old, quoteIdSet, quotes }) + return quotedEventId + } + + private addHighlightByEvent(evt: Event) { + // Find the event ID from 'e' tag + const highlightedEventId = evt.tags.find(tag => tag[0] === 'e')?.[1] + if (!highlightedEventId) return + + const old = this.noteStatsMap.get(highlightedEventId) || {} + const highlightIdSet = old.highlightIdSet || new Set() + const highlights = old.highlights || [] + + if (highlightIdSet.has(evt.id)) return + + highlightIdSet.add(evt.id) + highlights.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at }) + this.noteStatsMap.set(highlightedEventId, { ...old, highlightIdSet, highlights }) + return highlightedEventId + } + private getEmbeddedNoteBech32Ids(event: Event): string[] { // Simple implementation - in practice, this should match the logic in lib/event.ts const embeddedIds: string[] = []