Browse Source

topic map

imwald
Silberengel 1 month ago
parent
commit
d1bb4132d7
  1. 1
      src/constants.ts
  2. 12
      src/i18n/locales/en.ts
  3. 11
      src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx
  4. 334
      src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx
  5. 4
      src/pages/primary/SpellsPage/fauxSpellConfig.ts
  6. 9
      src/pages/primary/SpellsPage/index.tsx
  7. 3
      src/pages/primary/SpellsPage/useSpellsPageFeed.ts
  8. 59
      src/pages/secondary/NoteListPage/index.tsx

1
src/constants.ts

@ -907,6 +907,7 @@ export const FAUX_SPELL_ORDER = [
'discussions', 'discussions',
'following', 'following',
'heatMap', 'heatMap',
'topicMap',
'followPacks', 'followPacks',
'media', 'media',
'interests', 'interests',

12
src/i18n/locales/en.ts

@ -800,6 +800,18 @@ export default {
heatMapBubbleStats: "{{posts}} notes · {{people}} people · {{follows}} follows in thread", heatMapBubbleStats: "{{posts}} notes · {{people}} people · {{follows}} follows in thread",
heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»", heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»",
"Please login to view thread heat map": "Please log in to open the thread heat map.", "Please login to view thread heat map": "Please log in to open the thread heat map.",
"Topic map": "Topic map",
topicMapDescription:
"The ten largest bubbles combine how often a normalized string appears as a topic tag (·t·) and as a #hashtag in note text (last ~30 days). Data merges this tab’s session cache, your on-device archive, and your relay stack. Tap a bubble to open one feed that merges #t matches and NIP-50 full-text search.",
topicMapLocalOnlyBanner:
"No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).",
topicMapLoading: "Merging session cache, archive, and relays…",
topicMapEmpty: "No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.",
topicMapFetchError: "Could not build the topic map from your sources.",
topicMapRescan: "Rescan",
topicMapBubbleCounts: "{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text",
topicMapOpenMergedFeed: "Open merged topic and keyword feed",
topicMapClickHint: "Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.",
Calendar: "Calendar", Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",
"No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

11
src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx

@ -21,6 +21,7 @@ import {
type TRelayThreadHeatBubble, type TRelayThreadHeatBubble,
type TRelayThreadHeatEdge type TRelayThreadHeatEdge
} from '@/lib/relay-thread-heat' } from '@/lib/relay-thread-heat'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSmartNoteNavigation, useSmartProfileInteractionsNavigation } from '@/PageManager' import { useSmartNoteNavigation, useSmartProfileInteractionsNavigation } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
@ -84,6 +85,7 @@ type Props = {
export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) { export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate: navigatePrimary } = usePrimaryPage()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const { navigateToProfileInteractions } = useSmartProfileInteractionsNavigation() const { navigateToProfileInteractions } = useSmartProfileInteractionsNavigation()
const { pubkey, relayList } = useNostr() const { pubkey, relayList } = useNostr()
@ -445,6 +447,15 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
<LayoutGrid className="size-4 shrink-0" aria-hidden /> <LayoutGrid className="size-4 shrink-0" aria-hidden />
{t('interactionMapMenu')} {t('interactionMapMenu')}
</Button> </Button>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => navigatePrimary('spells', { spell: 'topicMap' })}
>
{t('Topic map')}
</Button>
</div> </div>
</div> </div>

334
src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx

@ -0,0 +1,334 @@
import { Button } from '@/components/ui/button'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { ExtendedKind } from '@/constants'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { filterEventsExcludingTombstones } from '@/lib/event'
import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics'
import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { toNoteList } from '@/lib/link'
import logger from '@/lib/logger'
import { useSmartHashtagNavigation } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useNostr } from '@/providers/NostrProvider'
import client, { eventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { cn } from '@/lib/utils'
import { Loader2, RefreshCw } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { kinds, verifyEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
const HEAT_WINDOW_SEC = 30 * 24 * 3600
const HEAT_REQ_LIMIT = 1500
const MAX_BUBBLES = 10
const SESSION_LIMIT = 4000
const ARCHIVE_MAX_SCAN = 35_000
const ARCHIVE_MAX_MATCHES = 2500
const MAP_KINDS = [kinds.ShortTextNote, ExtendedKind.DISCUSSION] as const
const ARCHIVE_SCAN_TIMEOUT_MS = 22_000
const RELAY_FETCH_TIMEOUT_MS = 26_000
const TOMBSTONES_TIMEOUT_MS = 8_000
export type TTopicKeywordBubble = {
key: string
score: number
topicNoteCount: number
keywordNoteCount: number
}
function raceWithTimeout<T>(promise: Promise<T>, ms: number, fallback: T, label: string): Promise<T> {
let settled = false
return new Promise((resolve) => {
const to = setTimeout(() => {
if (settled) return
settled = true
logger.warn('[TopicKeywordHeatMap] timed out', { label, ms })
resolve(fallback)
}, ms)
promise
.then((v) => {
if (settled) return
settled = true
clearTimeout(to)
resolve(v)
})
.catch((e) => {
if (settled) return
settled = true
clearTimeout(to)
logger.warn('[TopicKeywordHeatMap] source failed', { label, err: e })
resolve(fallback)
})
})
}
function buildTopicKeywordBubbles(
events: Event[],
showKinds: readonly number[],
showKind1OPs: boolean,
showKind1Replies: boolean,
showKind1111: boolean
): TTopicKeywordBubble[] {
const topicHits = new Map<string, number>()
const kwHits = new Map<string, number>()
for (const ev of events) {
if (!eventPassesNoteListKindPicker(ev, showKinds, showKind1OPs, showKind1Replies, showKind1111)) continue
const topics = new Set<string>()
for (const row of ev.tags) {
if (row[0] === 't' && row[1]) {
const n = normalizeTopic(row[1])
if (n) topics.add(n)
}
}
const kws = new Set(extractHashtagsFromContent(ev.content ?? ''))
for (const k of topics) {
topicHits.set(k, (topicHits.get(k) ?? 0) + 1)
}
for (const k of kws) {
kwHits.set(k, (kwHits.get(k) ?? 0) + 1)
}
}
const keys = new Set<string>([...topicHits.keys(), ...kwHits.keys()])
const out: TTopicKeywordBubble[] = []
for (const key of keys) {
const a = topicHits.get(key) ?? 0
const b = kwHits.get(key) ?? 0
const score = a + b
if (score <= 0) continue
out.push({ key, score, topicNoteCount: a, keywordNoteCount: b })
}
out.sort((x, y) => y.score - x.score || x.key.localeCompare(y.key))
return out.slice(0, MAX_BUBBLES)
}
type Props = {
refreshKey: number
}
export default function TopicKeywordHeatMap({ refreshKey }: Props) {
const { t } = useTranslation()
const { navigateToHashtag } = useSmartHashtagNavigation()
const { relayList } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults()
const relayUrls = useMemo(
() =>
getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
userReadRelaysWithHttp(relayList),
{
userWriteRelays: relayList?.write ?? [],
applySocialKindBlockedFilter: false
}
),
[favoriteRelays, blockedRelays, relayList]
)
const [rows, setRows] = useState<TTopicKeywordBubble[]>([])
const [loading, setLoading] = useState(true)
const [isMerging, setIsMerging] = useState(false)
const [error, setError] = useState<string | null>(null)
const [rescanTick, setRescanTick] = useState(0)
const mergeData = useCallback(async (): Promise<TTopicKeywordBubble[]> => {
const windowStart = Math.floor(Date.now() / 1000) - HEAT_WINDOW_SEC
const sessionEv = eventService.listSessionEventsByKinds(MAP_KINDS, { limit: SESSION_LIMIT })
const archiveScan = indexedDb.scanEventArchiveByKinds({
kinds: [...MAP_KINDS],
since: windowStart,
maxRowsScanned: ARCHIVE_MAX_SCAN,
maxMatches: ARCHIVE_MAX_MATCHES
})
const relayFetch =
relayUrls.length > 0
? client.fetchEvents(
relayUrls,
{ kinds: [...MAP_KINDS], limit: HEAT_REQ_LIMIT },
{ eoseTimeout: 8000, globalTimeout: 20000 }
)
: Promise.resolve([] as Event[])
const tombstonesPromise = indexedDb.getAllTombstones()
const [idbEv, relayRaw, tombstones] = await Promise.all([
raceWithTimeout(archiveScan, ARCHIVE_SCAN_TIMEOUT_MS, [] as Event[], 'archive-scan'),
raceWithTimeout(relayFetch, RELAY_FETCH_TIMEOUT_MS, [] as Event[], 'relay-fetch'),
raceWithTimeout(tombstonesPromise, TOMBSTONES_TIMEOUT_MS, new Set<string>(), 'tombstones')
])
const mergedById = new Map<string, Event>()
for (const ev of [...sessionEv, ...idbEv, ...relayRaw]) {
mergedById.set(ev.id.toLowerCase(), ev)
}
let merged = [...mergedById.values()].filter((e) => e.created_at >= windowStart)
if (merged.length === 0 && mergedById.size > 0) {
merged = [...mergedById.values()]
}
const dedup = new Map<string, Event>()
for (const ev of merged) {
if (!verifyEvent(ev)) continue
dedup.set(ev.id.toLowerCase(), ev)
}
if (dedup.size === 0 && merged.length > 0) {
for (const ev of merged) {
if (!/^[0-9a-f]{64}$/i.test(ev.id) || !/^[0-9a-f]{64}$/i.test(ev.pubkey)) continue
dedup.set(ev.id.toLowerCase(), ev)
}
}
const clean = filterEventsExcludingTombstones([...dedup.values()], tombstones)
return buildTopicKeywordBubbles(clean, showKinds, showKind1OPs, showKind1Replies, showKind1111)
}, [relayUrls, showKinds, showKind1OPs, showKind1Replies, showKind1111])
useEffect(() => {
let cancelled = false
setError(null)
setLoading(true)
setIsMerging(true)
void (async () => {
try {
const bubbles = await mergeData()
if (!cancelled) {
setRows(bubbles)
}
} catch (e) {
if (!cancelled) {
logger.warn('[TopicKeywordHeatMap] merge failed', { err: e })
setError(t('topicMapFetchError'))
setRows([])
}
} finally {
if (!cancelled) {
setLoading(false)
setIsMerging(false)
}
}
})()
return () => {
cancelled = true
}
}, [mergeData, refreshKey, rescanTick, t])
const maxScore = useMemo(() => rows.reduce((m, r) => Math.max(m, r.score), 0) || 1, [rows])
const openMergedFeed = useCallback(
(key: string) => {
const searchPhrase = key.replace(/-/g, ' ')
navigateToHashtag(toNoteList({ hashtag: key, search: searchPhrase }))
},
[navigateToHashtag]
)
const displayLabel = (key: string) => `#${key.replace(/-/g, ' ')}`
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div className="space-y-1 text-sm text-muted-foreground">
{relayUrls.length === 0 ? (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-950 dark:text-amber-100">
{t('topicMapLocalOnlyBanner')}
</p>
) : null}
<p>{t('topicMapDescription')}</p>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
disabled={isMerging && rows.length === 0}
onClick={() => setRescanTick((n) => n + 1)}
>
{isMerging ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<RefreshCw className="size-4" aria-hidden />
)}
{t('topicMapRescan')}
</Button>
</div>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{rows.length === 0 && (loading || isMerging) ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-8 animate-spin" aria-hidden />
<p className="text-sm">{t('topicMapLoading')}</p>
</div>
) : !loading && rows.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/80 px-4 py-12 text-center text-sm text-muted-foreground">
{t('topicMapEmpty')}
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-4">
<div className="relative flex w-full flex-wrap content-start items-start justify-center gap-4 pt-2">
{rows.map((row) => {
const intensity = Math.min(1, row.score / maxScore)
const size = Math.min(200, Math.max(76, 52 + Math.sqrt(row.score) * 10))
const countsLine = t('topicMapBubbleCounts', {
topic: row.topicNoteCount,
kw: row.keywordNoteCount
})
const ariaLabel = [displayLabel(row.key), countsLine, t('topicMapOpenMergedFeed')].join('. ')
return (
<HoverCard key={row.key} openDelay={160} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
className={cn(
'group relative shrink-0 rounded-full border shadow-sm transition-transform',
'flex items-center justify-center px-2 text-center',
'hover:z-10 hover:scale-[1.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'border-border/70 bg-card/90 backdrop-blur-sm'
)}
style={{
width: size,
height: size,
boxShadow: `0 0 0 1px hsl(var(--border) / 0.35), inset 0 0 40px hsl(var(--primary) / ${0.06 + intensity * 0.28})`
}}
onClick={() => openMergedFeed(row.key)}
aria-label={ariaLabel}
>
<span
className="rounded-full bg-primary/25 ring-2 ring-primary/35 transition-[width,height,opacity] group-hover:bg-primary/35"
style={{
width: `${22 + intensity * 48}%`,
height: `${22 + intensity * 48}%`,
opacity: 0.55 + intensity * 0.45
}}
aria-hidden
/>
<span className="pointer-events-none absolute inset-2 flex items-center justify-center text-pretty text-xs font-semibold leading-tight text-foreground drop-shadow-sm sm:text-sm">
{displayLabel(row.key)}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
side="top"
align="center"
className="w-72 max-w-[min(92vw,18rem)] border-border/80 p-3 text-sm shadow-lg"
collisionPadding={12}
>
<p className="font-medium text-foreground">{displayLabel(row.key)}</p>
<p className="mt-1 text-xs text-muted-foreground">{countsLine}</p>
<p className="mt-2 text-xs text-muted-foreground">{t('topicMapClickHint')}</p>
</HoverCardContent>
</HoverCard>
)
})}
</div>
</div>
)}
</div>
)
}

4
src/pages/primary/SpellsPage/fauxSpellConfig.ts

@ -11,6 +11,7 @@ import {
Bookmark, Bookmark,
CalendarDays, CalendarDays,
Flame, Flame,
Map as MapIcon,
Gift, Gift,
Hash, Hash,
Image as ImageIcon, Image as ImageIcon,
@ -46,6 +47,8 @@ export function fauxSpellLabelKey(name: FauxSpellName): string {
return 'Following' return 'Following'
case 'heatMap': case 'heatMap':
return 'Heat map' return 'Heat map'
case 'topicMap':
return 'Topic map'
case 'followPacks': case 'followPacks':
return 'Follow Packs' return 'Follow Packs'
case 'media': case 'media':
@ -66,6 +69,7 @@ export const FAUX_SPELL_ICON: Record<FauxSpellName, LucideIcon> = {
discussions: MessageSquare, discussions: MessageSquare,
following: Users, following: Users,
heatMap: Flame, heatMap: Flame,
topicMap: MapIcon,
followPacks: Gift, followPacks: Gift,
media: ImageIcon, media: ImageIcon,
interests: Hash, interests: Hash,

9
src/pages/primary/SpellsPage/index.tsx

@ -52,6 +52,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import CreateSpellDialog from './CreateSpellDialog' import CreateSpellDialog from './CreateSpellDialog'
import RelayThreadHeatMap from './RelayThreadHeatMap' import RelayThreadHeatMap from './RelayThreadHeatMap'
import TopicKeywordHeatMap from './TopicKeywordHeatMap'
import type { TPageRef } from '@/types' import type { TPageRef } from '@/types'
import { import {
decodeFollowSetSpellId, decodeFollowSetSpellId,
@ -115,6 +116,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const selectedFauxSpellRefreshRef = useRef<string | null>(null) const selectedFauxSpellRefreshRef = useRef<string | null>(null)
selectedFauxSpellRefreshRef.current = selectedFauxSpell selectedFauxSpellRefreshRef.current = selectedFauxSpell
const [heatMapRefreshKey, setHeatMapRefreshKey] = useState(0) const [heatMapRefreshKey, setHeatMapRefreshKey] = useState(0)
const [topicMapRefreshKey, setTopicMapRefreshKey] = useState(0)
const layoutRef = useRef<TPrimaryPageLayoutRef>(null) const layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const [spellPickerOpen, setSpellPickerOpen] = useState(false) const [spellPickerOpen, setSpellPickerOpen] = useState(false)
@ -189,6 +191,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (selectedFauxSpellRefreshRef.current === 'heatMap') { if (selectedFauxSpellRefreshRef.current === 'heatMap') {
setHeatMapRefreshKey((k) => k + 1) setHeatMapRefreshKey((k) => k + 1)
} }
if (selectedFauxSpellRefreshRef.current === 'topicMap') {
setTopicMapRefreshKey((k) => k + 1)
}
spellFeedListRef.current?.refresh() spellFeedListRef.current?.refresh()
}, [loadSpells, pubkey]) }, [loadSpells, pubkey])
@ -999,6 +1004,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
<div className="min-h-0 min-w-0 flex-1"> <div className="min-h-0 min-w-0 flex-1">
<RelayThreadHeatMap followPubkeys={contacts} refreshKey={heatMapRefreshKey} /> <RelayThreadHeatMap followPubkeys={contacts} refreshKey={heatMapRefreshKey} />
</div> </div>
) : selectedFauxSpell === 'topicMap' ? (
<div className="min-h-0 min-w-0 flex-1">
<TopicKeywordHeatMap refreshKey={topicMapRefreshKey} />
</div>
) : selectedFauxSpell && fauxSubRequests.length === 0 ? ( ) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div> <div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div>
) : selectedFauxSpell && fauxSubRequests.length > 0 ? ( ) : selectedFauxSpell && fauxSubRequests.length > 0 ? (

3
src/pages/primary/SpellsPage/useSpellsPageFeed.ts

@ -340,7 +340,8 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
if ( if (
!selectedFauxSpell || !selectedFauxSpell ||
isFollowFeedFauxSpellId(selectedFauxSpell) || isFollowFeedFauxSpellId(selectedFauxSpell) ||
selectedFauxSpell === 'heatMap' selectedFauxSpell === 'heatMap' ||
selectedFauxSpell === 'topicMap'
) )
return [] return []
const fauxSpellSkipSocialKindBlocked = const fauxSpellSkipSocialKindBlocked =

59
src/pages/secondary/NoteListPage/index.tsx

@ -3,7 +3,12 @@ import type { TNoteListRef } from '@/components/NoteList'
import NormalFeed from '@/components/NormalFeed' import NormalFeed from '@/components/NormalFeed'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { isSocialKindBlockedKind, NIP_SEARCH_DOCUMENT_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants' import {
isSocialKindBlockedKind,
NIP_SEARCH_DOCUMENT_KINDS,
NIP_SEARCH_PAGE_KINDS,
SEARCHABLE_RELAY_URLS
} from '@/constants'
import { import {
augmentSubRequestsWithFavoritesFastReadAndInbox, augmentSubRequestsWithFavoritesFastReadAndInbox,
getRelayUrlsWithFavoritesFastReadAndInbox, getRelayUrlsWithFavoritesFastReadAndInbox,
@ -44,7 +49,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
const [controls, setControls] = useState<React.ReactNode>(null) const [controls, setControls] = useState<React.ReactNode>(null)
const [data, setData] = useState< const [data, setData] = useState<
| { | {
type: 'hashtag' | 'search' | 'externalContent' | 'dtag' type: 'hashtag' | 'hashtagSearch' | 'search' | 'externalContent' | 'dtag'
kinds?: number[] kinds?: number[]
dtag?: string dtag?: string
} }
@ -59,7 +64,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
// Get hashtag from URL if this is a hashtag page // Get hashtag from URL if this is a hashtag page
const hashtag = useMemo(() => { const hashtag = useMemo(() => {
if (data?.type === 'hashtag') { if (data?.type === 'hashtag' || data?.type === 'hashtagSearch') {
const searchParams = new URLSearchParams(window.location.search) const searchParams = new URLSearchParams(window.location.search)
return searchParams.get('t') return searchParams.get('t')
} }
@ -92,6 +97,46 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
applySocialKindBlockedFilter: kinds.length === 0 || kinds.some(isSocialKindBlockedKind) applySocialKindBlockedFilter: kinds.length === 0 || kinds.some(isSocialKindBlockedKind)
} }
const hashtag = searchParams.get('t') const hashtag = searchParams.get('t')
const searchFromUrl = searchParams.get('s')
if (hashtag && searchFromUrl) {
setData({ type: 'hashtagSearch' })
setTitle(`${t('Search')}: #${hashtag} · ${searchFromUrl}`)
const relayUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
userReadRelaysWithHttp(relayList),
readUrlOpts
)
const mergedSearchKinds = Array.from(
new Set<number>([...NIP_SEARCH_PAGE_KINDS, ...(kinds.length > 0 ? kinds : [])])
).sort((a, b) => a - b)
setSubRequests([
{
filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) },
urls: relayUrls
},
{
filter: { search: searchFromUrl, kinds: mergedSearchKinds },
urls: [...new Set([...relayUrls, ...SEARCHABLE_RELAY_URLS])]
}
])
const isSubscribedToHashtag = isSubscribed(hashtag)
if (pubkey) {
setControls(
<Button
variant="ghost"
className="h-10 [&_svg]:size-3"
onClick={handleSubscribeHashtag}
disabled={isSubscribedToHashtag}
>
{isSubscribedToHashtag ? t('Subscribed') : t('Subscribe')} <Plus />
</Button>
)
} else {
setControls(null)
}
return
}
if (hashtag) { if (hashtag) {
setData({ type: 'hashtag' }) setData({ type: 'hashtag' })
setTitle(`# ${hashtag}`) setTitle(`# ${hashtag}`)
@ -267,7 +312,7 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
// Update controls when subscription status changes // Update controls when subscription status changes
useEffect(() => { useEffect(() => {
if (data?.type === 'hashtag' && pubkey) { if ((data?.type === 'hashtag' || data?.type === 'hashtagSearch') && pubkey) {
setControls( setControls(
<Button <Button
variant="ghost" variant="ghost"
@ -283,7 +328,8 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
useEffect(() => { useEffect(() => {
const inlineHeader = const inlineHeader =
hideTitlebar && (data?.type === 'hashtag' || data?.type === 'dtag') hideTitlebar &&
(data?.type === 'hashtag' || data?.type === 'hashtagSearch' || data?.type === 'dtag')
if (!hideTitlebar || inlineHeader) { if (!hideTitlebar || inlineHeader) {
registerPrimaryPanelRefresh(null) registerPrimaryPanelRefresh(null)
return return
@ -337,7 +383,8 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
} }
displayScrollToTopButton displayScrollToTopButton
> >
{hideTitlebar && (data?.type === 'hashtag' || data?.type === 'dtag') ? ( {hideTitlebar &&
(data?.type === 'hashtag' || data?.type === 'hashtagSearch' || data?.type === 'dtag') ? (
<> <>
<div className="px-4 py-2 border-b"> <div className="px-4 py-2 border-b">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">

Loading…
Cancel
Save