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[] = []