diff --git a/package-lock.json b/package-lock.json index 470b56c3..f0f6e32f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "21.4.0", + "version": "22.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "21.4.0", + "version": "22.0.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index f12b595d..aa2b73ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "21.4.0", + "version": "22.0.0", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 22c2d699..d5feff92 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -2990,9 +2990,15 @@ const NoteList = forwardRef( for (const event of clientFilteredEvents) { const labels: string[] = [] for (const req of reqs) { - if (eventMatchesSubRequestFilter(event, req.filter as Filter)) { - labels.push(req.reasonLabel as string) + if (!eventMatchesSubRequestFilter(event, req.filter as Filter)) continue + if (req.reasonLabelIfSeenOnRelay) { + const target = normalizeUrl(req.reasonLabelIfSeenOnRelay) || req.reasonLabelIfSeenOnRelay + const seenNorm = client + .getSeenEventRelayUrls(event.id) + .map((u) => normalizeUrl(u) || u) + if (!seenNorm.includes(target)) continue } + labels.push(req.reasonLabel as string) } if (labels.length) { map.set(event.id, Array.from(new Set(labels)).join(' · ')) diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 47e55e8b..5cfeca9c 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1059,6 +1059,7 @@ export default { '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 …', + 'Trending on Nostr': 'Trending auf Nostr', Sort: 'Sortierung', newest: 'neueste', oldest: 'älteste', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1c52e661..09a13e83 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1057,6 +1057,7 @@ export default { '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...', + 'Trending on Nostr': 'Trending on Nostr', Sort: 'Sort', newest: 'newest', oldest: 'oldest', diff --git a/src/lib/wisp-trending-relay.ts b/src/lib/wisp-trending-relay.ts new file mode 100644 index 00000000..8ccda9e1 --- /dev/null +++ b/src/lib/wisp-trending-relay.ts @@ -0,0 +1,18 @@ +/** + * Trending notes stream from nostrarchives, consumed by + * {@link https://github.com/barrydeen/wisp | Wisp} (Android). Same URL shape as Wisp’s + * `buildTrendingRelayUrl` / `FEED_KINDS` REQ. + */ +export type WispTrendingMetric = 'reactions' | 'replies' | 'reposts' | 'zaps' + +export type WispTrendingTimeframe = 'today' | '7d' | '30d' | '1y' | 'all' + +export function buildWispTrendingNotesRelayUrl( + metric: WispTrendingMetric = 'reactions', + timeframe: WispTrendingTimeframe = 'today' +): string { + return `wss://feeds.nostrarchives.com/notes/trending/${metric}/${timeframe}` +} + +/** Wisp `FeedSubscriptionManager` FEED_KINDS when subscribing to trending notes. */ +export const WISP_TRENDING_FEED_KINDS: readonly number[] = [1, 6, 1068, 6969, 30023, 20, 21, 22] diff --git a/src/pages/primary/NoteListPage/FollowingFeed.tsx b/src/pages/primary/NoteListPage/FollowingFeed.tsx index 5ef7ea54..f0f930bd 100644 --- a/src/pages/primary/NoteListPage/FollowingFeed.tsx +++ b/src/pages/primary/NoteListPage/FollowingFeed.tsx @@ -11,10 +11,15 @@ import logger from '@/lib/logger' import { useFeed } from '@/providers/FeedProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' +import { + buildWispTrendingNotesRelayUrl, + WISP_TRENDING_FEED_KINDS +} from '@/lib/wisp-trending-relay' import client from '@/services/client.service' import { TFeedSubRequest } from '@/types' import type { ReactNode } from 'react' import { forwardRef, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' const FollowingFeed = forwardRef< TNoteListRef, @@ -23,6 +28,7 @@ const FollowingFeed = forwardRef< onSubHeaderRefresh?: () => void } >(function FollowingFeed({ setSubHeader, onSubHeaderRefresh }, ref) { + const { t, i18n } = useTranslation() const { pubkey, relayList, followListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { feedInfo } = useFeed() @@ -91,6 +97,15 @@ const FollowingFeed = forwardRef< { userWriteRelays: relayList?.write ?? [] } ) + const trendingRelayUrl = buildWispTrendingNotesRelayUrl() + const wispTrendingShard: TFeedSubRequest = { + urls: [trendingRelayUrl], + filter: { kinds: [...WISP_TRENDING_FEED_KINDS], limit: 100 }, + reasonLabel: t('Trending on Nostr'), + reasonLabelIfSeenOnRelay: trendingRelayUrl + } + const appendTrending = (batch: TFeedSubRequest[]) => [...batch, wispTrendingShard] + const fromTags = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] const provisionalAuthors = [...new Set([pubkey, ...fromTags])] const provisionalAuthorLower = provisionalAuthors.map((p) => p.toLowerCase()) @@ -101,7 +116,8 @@ const FollowingFeed = forwardRef< } catch (error) { logger.warn('[FollowingFeed] provisional generateSubRequestsForPubkeys failed', { error }) } - const provAug = augment(rawProv) + const provAugCore = augment(rawProv) + const provAug = appendTrending(provAugCore) if (!cancelled) setSubRequests(provAug) let followings: string[] = fromTags @@ -120,15 +136,15 @@ const FollowingFeed = forwardRef< try { const rawFull = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey) if (cancelled) return - const fullAug = augment(rawFull) - const delta = buildFollowingFeedDeltaSubRequests(fullAug, provAug, provisionalAuthorLower) + const fullAugCore = augment(rawFull) + const delta = buildFollowingFeedDeltaSubRequests(fullAugCore, provAugCore, provisionalAuthorLower) if (!cancelled) { setDeltaSubRequests(delta) if (delta.length > 0) { logger.info('[FollowingFeed] delta wave subRequests', { deltaShardCount: delta.length, - provisionalShardCount: provAug.length, - fullShardCount: fullAug.length + provisionalShardCount: provAugCore.length, + fullShardCount: fullAugCore.length }) } } @@ -149,7 +165,8 @@ const FollowingFeed = forwardRef< favoriteRelaysKey, blockedRelaysKey, relayReadKey, - relayWriteKey + relayWriteKey, + i18n.language ]) return ( diff --git a/src/types/index.d.ts b/src/types/index.d.ts index a1290488..c7f0498d 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -8,6 +8,11 @@ export type TFeedSubRequest = { filter: Omit /** Optional UI hint used by feed UIs (e.g. Favorites) to explain why an event was included. */ reasonLabel?: string + /** + * When set with {@link reasonLabel}, the label is shown only if the event was received from this relay + * (normalized like other relay URLs), so broad filters (e.g. kinds-only) do not mis-tag other shards’ events. + */ + reasonLabelIfSeenOnRelay?: string } export type TProfile = {