diff --git a/src/components/LatestFromFollowsSection/index.tsx b/src/components/LatestFromFollowsSection/index.tsx new file mode 100644 index 00000000..3c6ce51e --- /dev/null +++ b/src/components/LatestFromFollowsSection/index.tsx @@ -0,0 +1,385 @@ +import NoteCard from '@/components/NoteCard' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { + FAST_READ_RELAY_URLS, + FAST_WRITE_RELAY_URLS, + ExtendedKind, + SEARCHABLE_RELAY_URLS +} from '@/constants' +import { shouldFilterEvent } from '@/lib/event-filtering' +import { toProfile } from '@/lib/link' +import { getPubkeysFromPTags } from '@/lib/tag' +import { cn } from '@/lib/utils' +import { normalizeUrl } from '@/lib/url' +import { useSecondaryPage } from '@/PageManager' +import { useDeletedEvent } from '@/providers/DeletedEventProvider' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useMuteList } from '@/providers/MuteListProvider' +import { useNostr } from '@/providers/NostrProvider' +import { useUserTrust } from '@/providers/UserTrustProvider' +import { queryService, replaceableEventService } from '@/services/client.service' +import logger from '@/lib/logger' +import { ChevronDown, ChevronRight, Loader2, Star } from 'lucide-react' +import { Event, kinds, nip19, NostrEvent } from 'nostr-tools' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { FormattedTimestamp } from '../FormattedTimestamp' +import UserAvatar from '../UserAvatar' +import Username from '../Username' + +/** Curated follow list for guests (hex from npub). */ +export const RECOMMENDED_FOLLOW_CURATOR_NPUB = + 'npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl' as const + +const MAX_FOLLOWS = 1000 +const AUTHORS_PER_BATCH = 12 +const MAX_POSTS_PER_AUTHOR = 5 +/** Enough headroom to often fill 5 notes per author in a batch. */ +const BATCH_EVENT_LIMIT = 200 + +const FEED_KINDS = [ + kinds.ShortTextNote, + ExtendedKind.DISCUSSION, + kinds.LongFormArticle, + kinds.Highlights, + ExtendedKind.PICTURE, + ExtendedKind.VIDEO, + ExtendedKind.SHORT_VIDEO, + ExtendedKind.COMMENT, + kinds.Repost +] as number[] + +const feedKindSet = new Set(FEED_KINDS) + +function mergeBatchPosts( + prev: Map, + incoming: NostrEvent[], + batchAuthors: string[] +): Map { + const next = new Map(prev) + const authorSet = new Set(batchAuthors) + const filtered = incoming.filter((e) => authorSet.has(e.pubkey)) + for (const pk of batchAuthors) { + const prevList = next.get(pk) ?? [] + const newForPk = filtered.filter((e) => e.pubkey === pk) + const byId = new Map() + for (const e of prevList) byId.set(e.id, e) + for (const e of newForPk) { + const ex = byId.get(e.id) + if (!ex || e.created_at >= ex.created_at) byId.set(e.id, e) + } + const sorted = [...byId.values()] + .sort((a, b) => b.created_at - a.created_at) + .slice(0, MAX_POSTS_PER_AUTHOR) + next.set(pk, sorted) + } + return next +} + +function recommendedCuratorHexPubkey(): string | null { + try { + const dec = nip19.decode(RECOMMENDED_FOLLOW_CURATOR_NPUB) + if (dec.type !== 'npub') return null + return dec.data + } catch { + return null + } +} + +export default function LatestFromFollowsSection() { + const { t } = useTranslation() + const { push } = useSecondaryPage() + const { pubkey, followListEvent, isInitialized, relayList } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const { mutePubkeySet } = useMuteList() + const { isEventDeleted } = useDeletedEvent() + const { hideUntrustedNotes, isUserTrusted } = useUserTrust() + + const loggedInFollowPubkeys = useMemo(() => { + if (!pubkey || !isInitialized) return null + return getPubkeysFromPTags(followListEvent?.tags ?? []).slice(0, MAX_FOLLOWS) + }, [pubkey, isInitialized, followListEvent]) + + const [guestFollowPubkeys, setGuestFollowPubkeys] = useState([]) + const [guestListReady, setGuestListReady] = useState(false) + + const [postsByPubkey, setPostsByPubkey] = useState>(() => new Map()) + const [batchBusy, setBatchBusy] = useState(false) + const [sectionOpen, setSectionOpen] = useState(true) + const abortedRef = useRef(false) + + const followPubkeys = pubkey ? (loggedInFollowPubkeys ?? []) : guestFollowPubkeys + const followsLabel: 'self' | 'recommended' = pubkey ? 'self' : 'recommended' + const loadingFollowList = !pubkey && isInitialized && !guestListReady + + const searchRelays = useMemo(() => { + const relays: string[] = [] + if (relayList) { + relays.push(...(relayList.read || []), ...(relayList.write || [])) + } + relays.push(...(favoriteRelays || [])) + relays.push(...FAST_READ_RELAY_URLS, ...FAST_WRITE_RELAY_URLS, ...SEARCHABLE_RELAY_URLS) + const normalized = Array.from( + new Set(relays.map((url) => normalizeUrl(url) || url).filter((url): url is string => !!url)) + ) + return normalized.filter((relay) => !blockedRelays.some((blocked) => relay.includes(blocked))) + }, [relayList, favoriteRelays, blockedRelays]) + + const acceptEvent = useCallback( + (e: Event) => { + if (!feedKindSet.has(e.kind)) return false + if (isEventDeleted(e)) return false + if (shouldFilterEvent(e)) return false + if (mutePubkeySet.has(e.pubkey)) return false + if (hideUntrustedNotes && !isUserTrusted(e.pubkey)) return false + return true + }, + [hideUntrustedNotes, isEventDeleted, isUserTrusted, mutePubkeySet] + ) + + // Guest: load curated follow list from npub; logged-in list comes from useMemo above. + useEffect(() => { + if (!isInitialized) return + if (pubkey) { + setGuestFollowPubkeys([]) + setGuestListReady(false) + return + } + + let cancelled = false + setGuestListReady(false) + setGuestFollowPubkeys([]) + + ;(async () => { + const hex = recommendedCuratorHexPubkey() + if (!hex) { + if (!cancelled) { + setGuestFollowPubkeys([]) + setGuestListReady(true) + } + return + } + try { + const evt = await replaceableEventService.fetchReplaceableEvent(hex, kinds.Contacts) + if (cancelled) return + const list = evt ? getPubkeysFromPTags(evt.tags).slice(0, MAX_FOLLOWS) : [] + setGuestFollowPubkeys(list) + } catch (err) { + logger.warn('[LatestFromFollows] Failed to load recommended follow list', err) + if (!cancelled) setGuestFollowPubkeys([]) + } finally { + if (!cancelled) setGuestListReady(true) + } + })() + + return () => { + cancelled = true + } + }, [isInitialized, pubkey]) + + // Batch-fetch posts per slice of authors; update UI after each batch. + useEffect(() => { + if (!isInitialized || loadingFollowList) return + if (followPubkeys.length === 0) return + + abortedRef.current = false + let cancelled = false + + const run = async () => { + setBatchBusy(true) + setPostsByPubkey(new Map()) + + for (let i = 0; i < followPubkeys.length; i += AUTHORS_PER_BATCH) { + if (cancelled || abortedRef.current) break + const batch = followPubkeys.slice(i, i + AUTHORS_PER_BATCH) + try { + const raw = await queryService.fetchEvents( + searchRelays, + { + kinds: [...FEED_KINDS], + authors: batch, + limit: BATCH_EVENT_LIMIT + }, + { eoseTimeout: 2800, globalTimeout: 9000 } + ) + if (cancelled || abortedRef.current) break + const filtered = raw.filter((e) => acceptEvent(e)) + setPostsByPubkey((prev) => mergeBatchPosts(prev, filtered, batch)) + } catch (err) { + logger.warn('[LatestFromFollows] Batch fetch failed', { err, batchSize: batch.length }) + } + } + if (!cancelled) setBatchBusy(false) + } + + void run() + return () => { + cancelled = true + abortedRef.current = true + setBatchBusy(false) + } + }, [followPubkeys, searchRelays, loadingFollowList, isInitialized, acceptEvent]) + + const sortedRowPubkeys = useMemo(() => { + const withPosts = followPubkeys.filter((pk) => (postsByPubkey.get(pk)?.length ?? 0) > 0) + const withoutPosts = followPubkeys.filter((pk) => (postsByPubkey.get(pk)?.length ?? 0) === 0) + withPosts.sort((a, b) => { + const ta = postsByPubkey.get(a)?.[0]?.created_at ?? 0 + const tb = postsByPubkey.get(b)?.[0]?.created_at ?? 0 + return tb - ta + }) + return [...withPosts, ...withoutPosts] + }, [followPubkeys, postsByPubkey]) + + const heading = + followsLabel === 'recommended' + ? t('Latest from our recommended follows') + : t('Latest from your follows') + + if (!isInitialized) { + return null + } + + if (loadingFollowList) { + return ( +
+ + {t('Loading follow list…')} +
+ ) + } + + if (followPubkeys.length === 0) { + return ( +
+ {followsLabel === 'recommended' + ? t('Could not load recommended follows') + : t('Your follow list is empty')} +
+ ) + } + + return ( + + + {heading} + + + +
+ {batchBusy && postsByPubkey.size === 0 ? ( +
+ + {t('Loading recent posts from follows…')} +
+ ) : null} + {sortedRowPubkeys.map((pk) => { + const posts = postsByPubkey.get(pk) ?? [] + const count = posts.length + const latest = posts[0]?.created_at + return ( + push(toProfile(pk))} + /> + ) + })} +
+ {batchBusy && postsByPubkey.size > 0 ? ( +
+ + {t('Loading more…')} +
+ ) : null} +
+
+ ) +} + +function FollowRowEmptyPosts() { + const { t } = useTranslation() + return ( +
+ {t('No recent posts from this user in the current fetch')} +
+ ) +} + +function FollowPulseRow({ + pubkey, + count, + latestCreatedAt, + posts, + onOpenProfile +}: { + pubkey: string + count: number + latestCreatedAt?: number + posts: NostrEvent[] + onOpenProfile: () => void +}) { + const [open, setOpen] = useState(false) + + return ( + +
+ + + + +
+ + {posts.length === 0 ? ( + + ) : ( +
+ {posts.map((ev) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 0913298f..10e91443 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -601,6 +601,15 @@ export default { 'Trending Notes': 'Trendende Notizen', 'Trending on Your Favorite Relays': 'Trending auf deinen Lieblings-Relays', 'Trending on the Default Relays': 'Trending auf den Standard-Relays', + 'Latest from your follows': 'Neuestes von deinen Follows', + 'Latest from our recommended follows': 'Neuestes von unseren empfohlenen Follows', + 'Loading follow list…': 'Follow-Liste wird geladen …', + 'Could not load recommended follows': 'Empfohlene Follows konnten nicht geladen werden', + 'Your follow list is empty': 'Deine Follow-Liste ist leer', + 'Loading recent posts from follows…': 'Neueste Beiträge der Follows werden geladen …', + 'Loading more…': 'Weitere werden geladen …', + 'No recent posts from this user in the current fetch': + 'Keine aktuellen Beiträge von diesem Nutzer in dieser Abfrage', 'Loading trending notes from your relays...': 'Trendende Notizen werden geladen …', Sort: 'Sortierung', newest: 'neueste', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b062cd08..70536c62 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -670,6 +670,15 @@ export default { 'Trending Notes': 'Trending Notes', 'Trending on Your Favorite Relays': 'Trending on Your Favorite Relays', 'Trending on the Default Relays': 'Trending on the Default Relays', + 'Latest from your follows': 'Latest from your follows', + 'Latest from our recommended follows': 'Latest from our recommended follows', + 'Loading follow list…': 'Loading follow list…', + 'Could not load recommended follows': 'Could not load recommended follows', + 'Your follow list is empty': 'Your follow list is empty', + 'Loading recent posts from follows…': 'Loading recent posts from follows…', + 'Loading more…': 'Loading more…', + 'No recent posts from this user in the current fetch': + 'No recent posts from this user in the current fetch', 'Loading trending notes from your relays...': 'Loading trending notes from your relays...', Sort: 'Sort', newest: 'newest', diff --git a/src/lib/event.ts b/src/lib/event.ts index 6e3cde19..1de5d43e 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -262,6 +262,18 @@ export function getEmbeddedPubkeys(event: Event) { return embeddedPubkeys } +/** + * Whether `userPubkey` is mentioned on the event: any `p` tag and/or + * `nostr:npub…` / `nostr:nprofile…` in content (see {@link getEmbeddedPubkeys}). + * Events authored by the user are excluded (not treated as incoming mentions). + */ +export function isUserInEventMentions(event: Event, userPubkey: string): boolean { + if (event.pubkey === userPubkey) return false + const inPtags = event.tags.some((t) => t[0] === 'p' && t[1] === userPubkey) + if (inPtags) return true + return getEmbeddedPubkeys(event).includes(userPubkey) +} + export function getLatestEvent(events: Event[]): Event | undefined { return events.sort((a, b) => b.created_at - a.created_at)[0] } diff --git a/src/pages/primary/SearchPage/index.tsx b/src/pages/primary/SearchPage/index.tsx index a97f2514..d9fc3e55 100644 --- a/src/pages/primary/SearchPage/index.tsx +++ b/src/pages/primary/SearchPage/index.tsx @@ -1,3 +1,4 @@ +import LatestFromFollowsSection from '@/components/LatestFromFollowsSection' import SearchBar, { TSearchBarRef } from '@/components/SearchBar' import SearchResult from '@/components/SearchResult' import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' @@ -68,6 +69,7 @@ const SearchPage = forwardRef((_, ref) => {
+ {!searchParams && } diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index 001217aa..3da8819e 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -10,8 +10,8 @@ import { } from '@/constants' import { normalizeTopic } from '@/lib/discussion-topics' import { normalizeUrl } from '@/lib/url' -import type { TFeedSubRequest, TRelayList, TNotificationType } from '@/types' -import { kinds, type Event, type Filter } from 'nostr-tools' +import type { TFeedSubRequest, TRelayList } from '@/types' +import { type Event, type Filter } from 'nostr-tools' const NOTIFICATION_LIMIT = 500 const DISCUSSION_LIMIT = 500 @@ -57,40 +57,10 @@ function dedupe(urls: string[]): string[] { return out } -export function notificationFilterKinds(notificationType: TNotificationType): number[] { - switch (notificationType) { - case 'mentions': - return [ - kinds.ShortTextNote, - ExtendedKind.COMMENT, - ExtendedKind.VOICE_COMMENT, - ExtendedKind.POLL, - ExtendedKind.PUBLIC_MESSAGE, - ExtendedKind.DISCUSSION - ] - case 'reactions': - return [kinds.Reaction, kinds.Repost, ExtendedKind.POLL_RESPONSE] - case 'zaps': - return [kinds.Zap] - default: - return [ - kinds.ShortTextNote, - kinds.Repost, - kinds.Reaction, - kinds.Zap, - ExtendedKind.COMMENT, - ExtendedKind.POLL_RESPONSE, - ExtendedKind.VOICE_COMMENT, - ExtendedKind.POLL, - ExtendedKind.PUBLIC_MESSAGE, - ExtendedKind.DISCUSSION - ] - } -} - -export function buildNotificationFilter(pubkey: string, notificationType: TNotificationType): Filter { +/** Notifications spell: same kind set as profile-style feeds, restricted to `#p` = you on the relay. */ +export function buildMentionsSpellFilter(pubkey: string): Filter { return { - kinds: notificationFilterKinds(notificationType), + kinds: [...PROFILE_FEED_KINDS], limit: NOTIFICATION_LIMIT, '#p': [pubkey] } diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 27c9d4e9..20ac8a44 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -1,6 +1,5 @@ import HideUntrustedContentButton from '@/components/HideUntrustedContentButton' import NoteList from '@/components/NoteList' -import Tabs from '@/components/Tabs' import { Button } from '@/components/ui/button' import { Dialog, @@ -32,10 +31,12 @@ import { cn } from '@/lib/utils' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useKindFilter } from '@/providers/KindFilterProvider' import { useNostr } from '@/providers/NostrProvider' +import { useUserTrust } from '@/providers/UserTrustProvider' import client from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import storage from '@/services/local-storage.service' import { ExtendedKind, FAUX_SPELL_ORDER, PROFILE_FEED_KINDS } from '@/constants' +import { isUserInEventMentions } from '@/lib/event' import { formatPubkey } from '@/lib/pubkey' import { buildSpellCatalogAuthors, @@ -47,7 +48,7 @@ import { SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS, spellEventToFilter } from '@/services/spell.service' -import { TFeedSubRequest, type TNotificationType } from '@/types' +import { TFeedSubRequest } from '@/types' import { Bell, Bookmark, @@ -80,11 +81,10 @@ import { buildFollowPacksSubRequests, buildInterestsSubRequests, buildMediaSpellFilter, - buildNotificationFilter, + buildMentionsSpellFilter, discussionRelayUrls, fauxFavoriteRelayUrls, MEDIA_SPELL_KINDS, - notificationFilterKinds, notificationRelayUrls } from './fauxSpellFeeds' import type { TPageRef } from '@/types' @@ -241,6 +241,7 @@ const SpellsPage = forwardRef(function SpellsPage( const { t } = useTranslation() const { navigate: navigatePrimary } = usePrimaryPage() const { pubkey, relayList, attemptDelete, bookmarkListEvent, interestListEvent } = useNostr() + const { hideUntrustedNotifications } = useUserTrust() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { showKinds: kindFilterShowKinds, @@ -253,7 +254,6 @@ const SpellsPage = forwardRef(function SpellsPage( const [favoriteIds, setFavoriteIds] = useState>(new Set()) const [selectedSpell, setSelectedSpell] = useState(null) const [selectedFauxSpell, setSelectedFauxSpell] = useState(null) - const [notificationType, setNotificationType] = useState('all') const [createOpen, setCreateOpen] = useState(false) const [spellToEdit, setSpellToEdit] = useState(null) const [spellToClone, setSpellToClone] = useState(null) @@ -414,9 +414,9 @@ const SpellsPage = forwardRef(function SpellsPage( if (selectedFauxSpell === 'notifications') { if (!pubkey) return [] - const urls = notificationRelayUrls(relayList, favoriteRelays) + const urls = fauxFavoriteRelayUrls(favoriteRelays, blockedRelays) if (!urls.length) return [] - return [{ urls, filter: buildNotificationFilter(pubkey, notificationType) }] + return [{ urls, filter: buildMentionsSpellFilter(pubkey) }] } if (selectedFauxSpell === 'discussions') { const urls = discussionRelayUrls(relayList, favoriteRelays, blockedRelays) @@ -451,7 +451,6 @@ const SpellsPage = forwardRef(function SpellsPage( }, [ selectedFauxSpell, pubkey, - notificationType, relayList, favoriteRelays, blockedRelays, @@ -557,7 +556,7 @@ const SpellsPage = forwardRef(function SpellsPage( const showKinds = useMemo(() => { if (selectedFauxSpell === 'notifications') { - return notificationFilterKinds(notificationType) + return PROFILE_FEED_KINDS } if (selectedFauxSpell === 'discussions') { return [ExtendedKind.DISCUSSION] @@ -588,7 +587,6 @@ const SpellsPage = forwardRef(function SpellsPage( return kinds.length ? kinds : [1] }, [ selectedFauxSpell, - notificationType, selectedSpell?.id, showKindsTagKey, kindFilterShowKinds @@ -634,6 +632,11 @@ const SpellsPage = forwardRef(function SpellsPage( return selectedFauxSpell !== 'following' && selectedFauxSpell !== 'bookmarks' }, [selectedFauxSpell]) + const notificationsMentionExtraHide = useCallback( + (evt: Event) => (pubkey ? !isUserInEventMentions(evt, pubkey) : false), + [pubkey] + ) + const fauxFeedEmptyMessage = useMemo(() => { if (!selectedFauxSpell || fauxSubRequests.length > 0) return null if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.') @@ -953,17 +956,7 @@ const SpellsPage = forwardRef(function SpellsPage( ) : selectedFauxSpell && fauxSubRequests.length > 0 ? ( <> {selectedFauxSpell === 'notifications' ? ( -
- setNotificationType(tab as TNotificationType)} - /> +
) : null} @@ -976,6 +969,14 @@ const SpellsPage = forwardRef(function SpellsPage( showKind1Replies={selectedFauxSpell === 'following' ? showKind1Replies : true} showKind1111={selectedFauxSpell === 'following' ? showKind1111 : true} hideReplies={selectedFauxSpell === 'following' ? hideRepliesFollowing : false} + extraShouldHideEvent={ + selectedFauxSpell === 'notifications' && pubkey + ? notificationsMentionExtraHide + : undefined + } + hideUntrustedNotes={ + selectedFauxSpell === 'notifications' ? hideUntrustedNotifications : false + } />
diff --git a/src/pages/secondary/SearchPage/index.tsx b/src/pages/secondary/SearchPage/index.tsx index 2126dc1d..f994ab4c 100644 --- a/src/pages/secondary/SearchPage/index.tsx +++ b/src/pages/secondary/SearchPage/index.tsx @@ -1,3 +1,4 @@ +import LatestFromFollowsSection from '@/components/LatestFromFollowsSection' import SearchBar, { TSearchBarRef } from '@/components/SearchBar' import SearchResult from '@/components/SearchResult' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' @@ -124,6 +125,7 @@ const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
+ {!searchParams && }
Trending Notes