Browse Source

bug-fixes

make search more thorough
fix blossom image rendering
adjust pickers
add generic event creator
imwald
Silberengel 4 weeks ago
parent
commit
83f6cee991
  1. 33
      src/components/EmojiPicker/index.tsx
  2. 23
      src/components/EmojiPickerDialog/index.tsx
  3. 27
      src/components/NormalFeed/index.tsx
  4. 46
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  5. 333
      src/components/NoteList/index.tsx
  6. 167
      src/components/NoteOptions/EditOrCloneEventDialog.tsx
  7. 2
      src/components/PostEditor/HighlightEditor.tsx
  8. 6
      src/components/PostEditor/Mentions.tsx
  9. 3
      src/components/PostEditor/PollEditor.tsx
  10. 101
      src/components/PostEditor/PostContent.tsx
  11. 6
      src/components/PostEditor/PostRelaySelector.tsx
  12. 3
      src/components/PostEditor/PostTextarea/Emoji/suggestion.ts
  13. 8
      src/components/PostEditor/PostTextarea/index.tsx
  14. 17
      src/components/SearchBar/index.tsx
  15. 15
      src/components/SearchResult/index.tsx
  16. 2
      src/components/SuggestedEmojis/index.tsx
  17. 6
      src/components/TextareaWithMentionAutocomplete/index.tsx
  18. 28
      src/components/UserAvatar/index.tsx
  19. 9
      src/components/UserItem/index.tsx
  20. 16
      src/components/Username/index.tsx
  21. 23
      src/components/YoutubeEmbeddedPlayer/index.tsx
  22. 13
      src/constants.ts
  23. 3
      src/i18n/locales/de.ts
  24. 3
      src/i18n/locales/en.ts
  25. 47
      src/lib/dtag-search.ts
  26. 55
      src/lib/youtube-iframe-api.ts
  27. 30
      src/pages/primary/FollowsLatestPage/index.tsx
  28. 22
      src/pages/primary/SearchPage/index.tsx
  29. 53
      src/pages/secondary/NoteListPage/index.tsx
  30. 8
      src/providers/NostrProvider/index.tsx
  31. 18
      src/services/client-events.service.ts
  32. 48
      src/services/client.service.ts
  33. 133
      src/services/custom-emoji.service.ts
  34. 18
      src/services/gif.service.ts
  35. 71
      src/services/indexed-db.service.ts
  36. 22
      src/services/meme.service.ts
  37. 2
      src/types/index.d.ts

33
src/components/EmojiPicker/index.tsx

@ -1,4 +1,5 @@
import { parseEmojiPickerUnified } from '@/lib/utils' import { parseEmojiPickerUnified } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useTheme } from '@/providers/ThemeProvider' import { useTheme } from '@/providers/ThemeProvider'
import customEmojiService from '@/services/custom-emoji.service' import customEmojiService from '@/services/custom-emoji.service'
@ -9,6 +10,7 @@ import EmojiPickerReact, {
SuggestionMode, SuggestionMode,
Theme Theme
} from 'emoji-picker-react' } from 'emoji-picker-react'
import { useEffect, useMemo, useState } from 'react'
export { EMOJI_PICKER_REACTIONS } from '@/lib/like-reaction-emojis' export { EMOJI_PICKER_REACTIONS } from '@/lib/like-reaction-emojis'
@ -25,13 +27,40 @@ export default function EmojiPicker({
}) { }) {
const { themeSetting } = useTheme() const { themeSetting } = useTheme()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr()
const [viewportW, setViewportW] = useState(
() => (typeof window !== 'undefined' ? window.innerWidth : 390)
)
const [viewportH, setViewportH] = useState(
() => (typeof window !== 'undefined' ? window.innerHeight : 700)
)
useEffect(() => {
const onResize = () => {
setViewportW(window.innerWidth)
setViewportH(window.innerHeight)
}
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
const [customEmojiTick, setCustomEmojiTick] = useState(0)
useEffect(() => customEmojiService.subscribeIndexUpdate(() => setCustomEmojiTick((t) => t + 1)), [])
const customEmojis = useMemo(
() => customEmojiService.getAllCustomEmojisForPicker(pubkey ?? null),
[pubkey, customEmojiTick]
)
const pickerWidth = isSmallScreen ? Math.max(260, viewportW - 24) : 350
const pickerHeight = isSmallScreen
? Math.max(280, Math.min(Math.round(viewportH * 0.52), 460))
: 450
return ( return (
<EmojiPickerReact <EmojiPickerReact
theme={ theme={
themeSetting === 'system' ? Theme.AUTO : themeSetting === 'dark' ? Theme.DARK : Theme.LIGHT themeSetting === 'system' ? Theme.AUTO : themeSetting === 'dark' ? Theme.DARK : Theme.LIGHT
} }
width={isSmallScreen ? '100%' : 350} width={pickerWidth}
height={pickerHeight}
autoFocusSearch={false} autoFocusSearch={false}
emojiStyle={EmojiStyle.NATIVE} emojiStyle={EmojiStyle.NATIVE}
skinTonePickerLocation={SkinTonePickerLocation.PREVIEW} skinTonePickerLocation={SkinTonePickerLocation.PREVIEW}
@ -50,7 +79,7 @@ export default function EmojiPicker({
const emoji = parseEmojiPickerUnified(data.unified) const emoji = parseEmojiPickerUnified(data.unified)
onEmojiClick(emoji, e) onEmojiClick(emoji, e)
}} }}
customEmojis={customEmojiService.getAllCustomEmojisForPicker()} customEmojis={customEmojis}
{...(reactionsDefaultOpen !== undefined ? { reactionsDefaultOpen } : {})} {...(reactionsDefaultOpen !== undefined ? { reactionsDefaultOpen } : {})}
{...(reactions !== undefined ? { reactions } : {})} {...(reactions !== undefined ? { reactions } : {})}
/> />

23
src/components/EmojiPickerDialog/index.tsx

@ -28,6 +28,7 @@ export default function EmojiPickerDialog({
<DrawerTrigger asChild>{children}</DrawerTrigger> <DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent <DrawerContent
portalContainer={portalContainer} portalContainer={portalContainer}
className="max-h-[min(88dvh,calc(100dvh-5rem))] px-2"
onPointerDownOutside={(e) => { onPointerDownOutside={(e) => {
const t = e.target as HTMLElement | null const t = e.target as HTMLElement | null
if (t?.closest?.('[data-vaul-overlay]')) return if (t?.closest?.('[data-vaul-overlay]')) return
@ -37,13 +38,15 @@ export default function EmojiPickerDialog({
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
<DrawerTitle>Emoji Picker</DrawerTitle> <DrawerTitle>Emoji Picker</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<EmojiPicker <div className="flex w-full max-w-[100vw] min-w-0 min-h-0 shrink flex-col items-stretch overflow-x-hidden pb-1">
onEmojiClick={(emoji, e) => { <EmojiPicker
e.stopPropagation() onEmojiClick={(emoji, e) => {
setOpen(false) e.stopPropagation()
onEmojiClick?.(emoji) setOpen(false)
}} onEmojiClick?.(emoji)
/> }}
/>
</div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
) )
@ -52,7 +55,11 @@ export default function EmojiPickerDialog({
return ( return (
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0 w-fit" portalContainer={portalContainer}> <DropdownMenuContent
side="top"
className="p-0 w-[min(100vw-1rem,350px)] max-w-[calc(100vw-1rem)] overflow-hidden"
portalContainer={portalContainer}
>
<EmojiPicker <EmojiPicker
onEmojiClick={(emoji, e) => { onEmojiClick={(emoji, e) => {
e.stopPropagation() e.stopPropagation()

27
src/components/NormalFeed/index.tsx

@ -7,6 +7,7 @@ import storage from '@/services/local-storage.service'
import type { TPrimaryPageName } from '@/PageManager' import type { TPrimaryPageName } from '@/PageManager'
import { TFeedSubRequest, TNoteListMode } from '@/types' import { TFeedSubRequest, TNoteListMode } from '@/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import KindFilter from '../KindFilter' import KindFilter from '../KindFilter'
@ -56,6 +57,16 @@ const NormalFeed = forwardRef<TNoteListRef, {
onSingleRelayKindlessEmpty?: () => void onSingleRelayKindlessEmpty?: () => void
/** Shown above the feed list (e.g. after kindless→kinds fallback on a single-relay chip). */ /** Shown above the feed list (e.g. after kindless→kinds fallback on a single-relay chip). */
feedTopNotice?: ReactNode feedTopNotice?: ReactNode
/** Passed through to {@link NoteList} (d-tag browse one-shot). */
oneShotFetch?: boolean
progressiveWarmupQuery?: string
progressiveWarmupMatch?: (ev: Event) => boolean
/** Union into kind picker kinds for REQ + UI when set (e.g. document kinds on search / d-tag feeds). */
progressiveDocumentKinds?: readonly number[]
oneShotAfterMergeComparator?: (a: Event, b: Event) => number
extraShouldHideEvent?: (ev: Event) => boolean
/** Override default cap for merged one-shot batches (wide d-tag / search merges). */
oneShotMergedCap?: number
}>(function NormalFeed( }>(function NormalFeed(
{ {
subRequests, subRequests,
@ -77,7 +88,14 @@ const NormalFeed = forwardRef<TNoteListRef, {
showFeedClientFilter: showFeedClientFilterProp, showFeedClientFilter: showFeedClientFilterProp,
hostPrimaryPageName, hostPrimaryPageName,
onSingleRelayKindlessEmpty, onSingleRelayKindlessEmpty,
feedTopNotice feedTopNotice,
oneShotFetch = false,
progressiveWarmupQuery,
progressiveWarmupMatch,
progressiveDocumentKinds,
oneShotAfterMergeComparator,
extraShouldHideEvent,
oneShotMergedCap
}, },
ref ref
) { ) {
@ -249,6 +267,13 @@ const NormalFeed = forwardRef<TNoteListRef, {
feedClientFilterTabRowHost={mergeFilterWithTabsRow ? feedFilterTabRowHost : undefined} feedClientFilterTabRowHost={mergeFilterWithTabsRow ? feedFilterTabRowHost : undefined}
onSingleRelayKindlessEmpty={onSingleRelayKindlessEmpty} onSingleRelayKindlessEmpty={onSingleRelayKindlessEmpty}
feedTopNotice={feedTopNotice} feedTopNotice={feedTopNotice}
oneShotFetch={oneShotFetch}
progressiveWarmupQuery={progressiveWarmupQuery}
progressiveWarmupMatch={progressiveWarmupMatch}
progressiveDocumentKinds={progressiveDocumentKinds}
oneShotAfterMergeComparator={oneShotAfterMergeComparator}
extraShouldHideEvent={extraShouldHideEvent}
oneShotMergedCap={oneShotMergedCap}
/> />
</div> </div>
</> </>

46
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -26,7 +26,7 @@ import { ExtendedKind, WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants'
import { EMOJI_SHORT_CODE_REGEX, NOSTR_URI_INLINE_REGEX } from '@/lib/content-patterns' import { EMOJI_SHORT_CODE_REGEX, NOSTR_URI_INLINE_REGEX } from '@/lib/content-patterns'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { TEmoji } from '@/types' import { TEmoji, TImetaInfo } from '@/types'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react' import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
@ -2958,6 +2958,44 @@ function parseMarkdownContentMarked(
containingEvent containingEvent
} = options } = options
/** Direct image URLs on their own line: render Image (NIP-94 / Amethyst-style), not WebPreview — WebPreview returns null when autoLoadMedia is off. */
const imetaInfoForStandaloneImageUrl = (cleaned: string): TImetaInfo => {
if (containingEvent) {
const infos = getImetaInfosFromEvent(containingEvent)
const hit = infos.find((i) => cleanUrl(i.url) === cleaned)
if (hit) return { ...hit, url: cleaned }
}
return { url: cleaned, pubkey: eventPubkey }
}
const renderStandaloneHttpsImageBlock = (cleaned: string, reactKey: string) => {
let imageIndex = imageIndexMap.get(cleaned)
if (imageIndex === undefined && getImageIdentifier) {
const identifier = getImageIdentifier(cleaned)
if (identifier) {
imageIndex = imageIndexMap.get(`__img_id:${identifier}`)
}
}
return (
<div key={reactKey} className="my-2 block max-w-[400px] mx-auto">
<Image
image={imetaInfoForStandaloneImageUrl(cleaned)}
className="w-full rounded-lg cursor-zoom-in"
classNames={{
wrapper: 'rounded-lg block w-full',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
onClick={(e) => {
e.stopPropagation()
if (imageIndex !== undefined) {
openLightbox(imageIndex)
}
}}
/>
</div>
)
}
const hashtagsInContent = new Set<string>() const hashtagsInContent = new Set<string>()
const footnotes = new Map<string, string>() const footnotes = new Map<string, string>()
const citations: Array<{ id: string; type: string; citationId: string }> = [] const citations: Array<{ id: string; type: string; citationId: string }> = []
@ -3250,6 +3288,9 @@ function parseMarkdownContentMarked(
</div> </div>
) )
} }
if (isImage(cleaned) && isSafeMediaUrl(cleaned)) {
return renderStandaloneHttpsImageBlock(cleaned, `${key}-line-img-${lineIdx}`)
}
if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) { if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) {
return ( return (
<p key={`${key}-line-inline-link-${lineIdx}`} className="mb-1 last:mb-0"> <p key={`${key}-line-inline-link-${lineIdx}`} className="mb-1 last:mb-0">
@ -3387,6 +3428,9 @@ function parseMarkdownContentMarked(
</div> </div>
) )
} }
if (isImage(cleaned) && isSafeMediaUrl(cleaned)) {
return renderStandaloneHttpsImageBlock(cleaned, `${key}-para-img`)
}
if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) { if (suppressStandaloneWebPreviewCleanedUrls?.has(cleaned)) {
return ( return (
<p key={`${key}-inline-link`} className="mb-1 last:mb-0"> <p key={`${key}-inline-link`} className="mb-1 last:mb-0">

333
src/components/NoteList/index.tsx

@ -25,6 +25,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { import {
getSessionFeedSnapshot, getSessionFeedSnapshot,
hardReloadPreservingFeedSnapshots, hardReloadPreservingFeedSnapshots,
@ -48,7 +49,9 @@ import {
useMemo, useMemo,
useRef, useRef,
useState, useState,
type ReactNode type Dispatch,
type ReactNode,
type SetStateAction
} from 'react' } from 'react'
import { CircleAlert } from 'lucide-react' import { CircleAlert } from 'lucide-react'
import { useLongPressAction } from '@/hooks/use-long-press-action' import { useLongPressAction } from '@/hooks/use-long-press-action'
@ -104,6 +107,8 @@ const MAX_TIMELINE_EVENTS_SCAN_FOR_VISIBLE = 2500
const ONE_SHOT_MERGED_CAP =100 const ONE_SHOT_MERGED_CAP =100
/** Max events kept after merging parallel full-search REQ results across relays. */ /** Max events kept after merging parallel full-search REQ results across relays. */
const FEED_FULL_SEARCH_MERGE_CAP = 400 const FEED_FULL_SEARCH_MERGE_CAP = 400
/** Cap archive cursor time so progressive search does not monopolize the main thread; pub-store hits are unchanged. */
const PROGRESSIVE_IDB_ARCHIVE_SCAN_MAX_MS = 3_200
/** Client-side feed time window units (Day.js `.subtract` names). */ /** Client-side feed time window units (Day.js `.subtract` names). */
type TFeedClientTimeUnit = 'minute' | 'day' | 'week' | 'month' | 'year' type TFeedClientTimeUnit = 'minute' | 'day' | 'week' | 'month' | 'year'
@ -130,6 +135,94 @@ function mergeEventBatchesById(prev: Event[], incoming: Event[], cap: number): E
.slice(0, cap) .slice(0, cap)
} }
/** Multi-layer search: keep all existing rows, add new ids only; newer `created_at` wins on duplicate id. No cap. */
function mergeProgressiveSearchEvents(
prev: Event[],
incoming: Event[],
afterSort?: (a: Event, b: Event) => number
): Event[] {
const byId = new Map<string, Event>()
for (const e of prev) {
byId.set(e.id, e)
}
for (const e of incoming) {
const o = byId.get(e.id)
if (!o) {
byId.set(e.id, e)
} else if (e.created_at > o.created_at) {
byId.set(e.id, e)
}
}
const arr = Array.from(byId.values())
if (afterSort) {
arr.sort(afterSort)
} else {
arr.sort((a, b) => b.created_at - a.created_at)
}
return arr
}
function mergeKindsForProgressiveWarmup(
showKindsFromPicker: number[],
progressiveDocumentKinds: readonly number[] | undefined
): number[] {
const base = showKindsFromPicker.length > 0 ? showKindsFromPicker : [kinds.ShortTextNote]
if (!progressiveDocumentKinds?.length) return base
return Array.from(new Set([...base, ...progressiveDocumentKinds])).sort((a, b) => a - b)
}
type ProgressiveSearchLocalLayerOpts = {
warmQ: string
isStale: () => boolean
kindsForWarm: number[]
warmMatch?: (ev: Event) => boolean
afterSort?: (a: Event, b: Event) => number
setEvents: Dispatch<SetStateAction<Event[]>>
setLoading: (loading: boolean) => void
}
/** In-memory session hits only (sync). Relay / IndexedDB run in parallel via {@link kickProgressiveSearchLocalLayers}. */
function applyProgressiveSessionSearchLayer(params: ProgressiveSearchLocalLayerOpts): void {
const { warmQ, isStale, kindsForWarm, warmMatch, afterSort, setEvents, setLoading } = params
const cap = FEED_FULL_SEARCH_MERGE_CAP
let boot = client.getSessionEventsMatchingSearch(warmQ, cap, kindsForWarm)
if (warmMatch) boot = boot.filter(warmMatch)
const sortCreated = (evs: Event[]) => [...evs].sort((a, b) => b.created_at - a.created_at)
const finalizeOrder = (evs: Event[]) => (afterSort ? [...evs].sort(afterSort) : sortCreated(evs))
if (!isStale() && boot.length) {
setEvents((prev) => mergeProgressiveSearchEvents(prev, finalizeOrder(boot), afterSort))
setLoading(false)
}
}
function startProgressiveIdbSearchLayer(params: ProgressiveSearchLocalLayerOpts): void {
const { warmQ, isStale, kindsForWarm, warmMatch, afterSort, setEvents, setLoading } = params
const cap = FEED_FULL_SEARCH_MERGE_CAP
void (async () => {
try {
const idbE = await indexedDb.getCachedAndArchivedEventsMatchingLocalSearch(
warmQ,
cap,
kindsForWarm,
{ archiveScanMaxMs: PROGRESSIVE_IDB_ARCHIVE_SCAN_MAX_MS }
)
if (isStale()) return
const idbUse = warmMatch ? idbE.filter(warmMatch) : idbE
if (idbUse.length) {
setEvents((prev) => mergeProgressiveSearchEvents(prev, idbUse, afterSort))
setLoading(false)
}
} catch {
/* ignore */
}
})()
}
function kickProgressiveSearchLocalLayers(params: ProgressiveSearchLocalLayerOpts): void {
applyProgressiveSessionSearchLayer(params)
startProgressiveIdbSearchLayer(params)
}
/** When omitting `kinds` from a live REQ, require another scope so we never subscribe to a whole relay. */ /** When omitting `kinds` from a live REQ, require another scope so we never subscribe to a whole relay. */
function timelineFilterHasNonKindScope(f: Filter): boolean { function timelineFilterHasNonKindScope(f: Filter): boolean {
const search = f.search const search = f.search
@ -333,6 +426,22 @@ const NoteList = forwardRef(
revealBatchSize, revealBatchSize,
/** When set with {@link oneShotFetch}, logs fetch + filter diagnostics to the console (e.g. faux spells). */ /** When set with {@link oneShotFetch}, logs fetch + filter diagnostics to the console (e.g. faux spells). */
oneShotDebugLabel, oneShotDebugLabel,
/**
* When set, session cache + IndexedDB are scanned for this string before relay REQ completes, merged into the
* timeline immediately (optional {@link progressiveWarmupMatch} narrows rows). Used for NIP-50 search + d-tag browse.
*/
progressiveWarmupQuery,
/** Optional extra filter for {@link progressiveWarmupQuery} hits (e.g. d-tag substring semantics). */
progressiveWarmupMatch,
/**
* Union these kinds into {@link showKinds} for REQ mapping, UI kind gates, progressive warmup, and load-more
* narrowing (e.g. long-form / publication kinds on d-tag + NIP-50 search feeds).
*/
progressiveDocumentKinds,
/**
* When set with {@link oneShotFetch}, sort merged one-shot results with this comparator (e.g. exact d-tag first).
*/
oneShotAfterMergeComparator,
/** /**
* When true (default), show the 🔍 client-side filter bar (search / from me / time window). * When true (default), show the 🔍 client-side filter bar (search / from me / time window).
* Set false on feeds where it should stay hidden (e.g. main following). * Set false on feeds where it should stay hidden (e.g. main following).
@ -388,6 +497,10 @@ const NoteList = forwardRef(
oneShotMergedCap?: number oneShotMergedCap?: number
revealBatchSize?: number revealBatchSize?: number
oneShotDebugLabel?: string oneShotDebugLabel?: string
progressiveWarmupQuery?: string
progressiveWarmupMatch?: (ev: Event) => boolean
progressiveDocumentKinds?: readonly number[]
oneShotAfterMergeComparator?: (a: Event, b: Event) => number
oneShotGlobalTimeoutMs?: number oneShotGlobalTimeoutMs?: number
oneShotEoseTimeoutMs?: number oneShotEoseTimeoutMs?: number
oneShotFirstRelayGraceMs?: number | false oneShotFirstRelayGraceMs?: number | false
@ -418,6 +531,8 @@ const NoteList = forwardRef(
const [newEvents, setNewEvents] = useState<Event[]>([]) const [newEvents, setNewEvents] = useState<Event[]>([])
const [hasMore, setHasMore] = useState<boolean>(true) const [hasMore, setHasMore] = useState<boolean>(true)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
/** Session/IDB/relay layers still running for {@link progressiveWarmupQuery} feeds (drives “Looking for more…”). */
const [progressiveLayersSearching, setProgressiveLayersSearching] = useState(false)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined) const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [refreshCount, setRefreshCount] = useState(0) const [refreshCount, setRefreshCount] = useState(0)
const [showCount, setShowCount] = useState(SHOW_COUNT) const [showCount, setShowCount] = useState(SHOW_COUNT)
@ -521,9 +636,14 @@ const NoteList = forwardRef(
[followingFeedDeltaSubRequests] [followingFeedDeltaSubRequests]
) )
const effectiveShowKinds = useMemo(() => {
if (!progressiveDocumentKinds?.length) return showKinds
return Array.from(new Set([...showKinds, ...progressiveDocumentKinds])).sort((a, b) => a - b)
}, [showKinds, progressiveDocumentKinds])
const mapLiveSubRequestsForTimeline = useCallback( const mapLiveSubRequestsForTimeline = useCallback(
(requests: TFeedSubRequest[]) => { (requests: TFeedSubRequest[]) => {
const defaultKinds = showKinds.length > 0 ? showKinds : [kinds.ShortTextNote] const defaultKinds = effectiveShowKinds.length > 0 ? effectiveShowKinds : [kinds.ShortTextNote]
const seeAllNoSpell = seeAllFeedEvents && !useFilterAsIs const seeAllNoSpell = seeAllFeedEvents && !useFilterAsIs
return requests.map(({ urls, filter }) => { return requests.map(({ urls, filter }) => {
const baseLimit = filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT) const baseLimit = filter.limit ?? (areAlgoRelays ? ALGO_LIMIT : LIMIT)
@ -576,7 +696,7 @@ const NoteList = forwardRef(
areAlgoRelays, areAlgoRelays,
clientSideKindFilter, clientSideKindFilter,
seeAllFeedEvents, seeAllFeedEvents,
showKinds, effectiveShowKinds,
useFilterAsIs useFilterAsIs
] ]
) )
@ -695,9 +815,9 @@ const NoteList = forwardRef(
// Stable key for kind filter so subscription effect doesn't re-run on parent re-renders with same kinds // Stable key for kind filter so subscription effect doesn't re-run on parent re-renders with same kinds
// Use sorted array and JSON.stringify to create a stable key that only changes when content changes // Use sorted array and JSON.stringify to create a stable key that only changes when content changes
const showKindsKey = useMemo(() => { const showKindsKey = useMemo(() => {
if (!showKinds || showKinds.length === 0) return '' if (!effectiveShowKinds || effectiveShowKinds.length === 0) return ''
return JSON.stringify([...showKinds].sort((a, b) => a - b)) return JSON.stringify([...effectiveShowKinds].sort((a, b) => a - b))
}, [showKinds]) }, [effectiveShowKinds])
/** /**
* Session snapshot identity: feed + kind UI toggles that affect **REQ** / merged rows. * Session snapshot identity: feed + kind UI toggles that affect **REQ** / merged rows.
@ -737,6 +857,16 @@ const NoteList = forwardRef(
const showKindsRef = useRef(showKinds) const showKindsRef = useRef(showKinds)
showKindsRef.current = showKinds showKindsRef.current = showKinds
const effectiveShowKindsRef = useRef(effectiveShowKinds)
effectiveShowKindsRef.current = effectiveShowKinds
const progressiveDocumentKindsRef = useRef(progressiveDocumentKinds)
progressiveDocumentKindsRef.current = progressiveDocumentKinds
const progressiveWarmupQueryRef = useRef(progressiveWarmupQuery)
progressiveWarmupQueryRef.current = progressiveWarmupQuery
const progressiveWarmupMatchRef = useRef(progressiveWarmupMatch)
progressiveWarmupMatchRef.current = progressiveWarmupMatch
const oneShotAfterMergeComparatorRef = useRef(oneShotAfterMergeComparator)
oneShotAfterMergeComparatorRef.current = oneShotAfterMergeComparator
const seeAllFeedEventsRef = useRef(seeAllFeedEvents) const seeAllFeedEventsRef = useRef(seeAllFeedEvents)
seeAllFeedEventsRef.current = seeAllFeedEvents seeAllFeedEventsRef.current = seeAllFeedEvents
const allowKindlessRelayExploreRef = useRef(allowKindlessRelayExplore) const allowKindlessRelayExploreRef = useRef(allowKindlessRelayExplore)
@ -828,7 +958,7 @@ const NoteList = forwardRef(
for (let i = 0; i < maxScan && out.length < target; i++) { for (let i = 0; i < maxScan && out.length < target; i++) {
const evt = timelineEventsForFilter[i] const evt = timelineEventsForFilter[i]
if (applyKindPickerInUi) { if (applyKindPickerInUi) {
if (!showKinds.includes(evt.kind)) continue if (!effectiveShowKinds.includes(evt.kind)) continue
if (evt.kind === kinds.ShortTextNote) { if (evt.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(evt) const isReply = isReplyNoteEvent(evt)
if (isReply && !showKind1Replies) continue if (isReply && !showKind1Replies) continue
@ -902,7 +1032,7 @@ const NoteList = forwardRef(
return newEvents.filter((event: Event) => { return newEvents.filter((event: Event) => {
if (applyKindPickerInUi) { if (applyKindPickerInUi) {
if (!showKinds.includes(event.kind)) return false if (!effectiveShowKinds.includes(event.kind)) return false
if (event.kind === kinds.ShortTextNote) { if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event) const isReply = isReplyNoteEvent(event)
if (isReply && !showKind1Replies) return false if (isReply && !showKind1Replies) return false
@ -926,7 +1056,7 @@ const NoteList = forwardRef(
feedFullSearchEvents, feedFullSearchEvents,
newEvents, newEvents,
shouldHideEvent, shouldHideEvent,
showKinds, effectiveShowKinds,
showKind1OPs, showKind1OPs,
showKind1Replies, showKind1Replies,
showKind1111, showKind1111,
@ -1420,20 +1550,40 @@ const NoteList = forwardRef(
/** /**
* Kindless relay REQ: when {@link showAllKinds} is true (explorer / "All Events"), keep the full batch; * Kindless relay REQ: when {@link showAllKinds} is true (explorer / "All Events"), keep the full batch;
* otherwise narrow to {@link showKinds} so the merged timeline matches {@link applyKindPickerInUi}. * otherwise narrow to effectiveShowKinds so the merged timeline matches {@link applyKindPickerInUi}.
*/ */
const narrowLiveBatch = (evs: Event[]) => { const narrowLiveBatch = (evs: Event[]) => {
if (seeAllFeedEventsRef.current) return evs if (seeAllFeedEventsRef.current) return evs
if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs
if (!withKindFilterRef.current) return evs if (!withKindFilterRef.current) return evs
return evs.filter((e) => showKinds.includes(e.kind)) return evs.filter((e) => effectiveShowKinds.includes(e.kind))
} }
if (oneShotFetch) { if (oneShotFetch) {
setHasMore(false) setHasMore(false)
try { try {
if (timelineEffectStale()) return undefined if (timelineEffectStale()) return undefined
const warmQOneShot = progressiveWarmupQueryRef.current?.trim()
if (warmQOneShot) {
setProgressiveLayersSearching(true)
kickProgressiveSearchLocalLayers({
warmQ: warmQOneShot,
isStale: () => !effectActive || timelineEffectStale(),
kindsForWarm: mergeKindsForProgressiveWarmup(
showKindsRef.current,
progressiveDocumentKindsRef.current
),
warmMatch: progressiveWarmupMatchRef.current,
afterSort: oneShotAfterMergeComparatorRef.current,
setEvents,
setLoading
})
}
if (timelineEffectStale()) {
if (warmQOneShot) setProgressiveLayersSearching(false)
return undefined
}
const firstRelayGraceResolved = const firstRelayGraceResolved =
oneShotFirstRelayGraceMs === undefined oneShotFirstRelayGraceMs === undefined
? FIRST_RELAY_RESULT_GRACE_MS ? FIRST_RELAY_RESULT_GRACE_MS
@ -1460,9 +1610,11 @@ const NoteList = forwardRef(
} }
} }
const cap = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP const cap = oneShotMergedCap ?? ONE_SHOT_MERGED_CAP
let merged = [...byId.values()] const isProgressiveLayers = !!progressiveWarmupQueryRef.current?.trim()
.sort((a, b) => b.created_at - a.created_at) let relayOnly = [...byId.values()].sort((a, b) => b.created_at - a.created_at)
.slice(0, cap) if (!isProgressiveLayers) {
relayOnly = relayOnly.slice(0, cap)
}
if ( if (
useFilterAsIs && useFilterAsIs &&
clientSideKindFilter && clientSideKindFilter &&
@ -1470,39 +1622,69 @@ const NoteList = forwardRef(
!seeAllFeedEventsRef.current && !seeAllFeedEventsRef.current &&
(!allowKindlessRelayExplore || !showAllKinds) (!allowKindlessRelayExplore || !showAllKinds)
) { ) {
merged = merged.filter((e) => showKinds.includes(e.kind)) relayOnly = relayOnly.filter((e) => effectiveShowKinds.includes(e.kind))
} }
if (sessionSnap?.length && !userPulledRefresh) { const mergeCmp = oneShotAfterMergeComparatorRef.current
merged = mergeEventBatchesById(sessionSnap, merged, oneShotMergedCap ?? ONE_SHOT_MERGED_CAP) if (isProgressiveLayers) {
setEvents((prev) => {
let next = mergeProgressiveSearchEvents(prev, relayOnly, mergeCmp)
if (sessionSnap?.length && !userPulledRefresh) {
next = mergeProgressiveSearchEvents(next, sessionSnap, mergeCmp)
}
if (mergeCmp) {
next = [...next].sort(mergeCmp)
}
lastEventsForTimelinePrefetchRef.current = next
return next
})
} else {
let merged = relayOnly
if (sessionSnap?.length && !userPulledRefresh) {
merged = mergeEventBatchesById(sessionSnap, merged, oneShotMergedCap ?? ONE_SHOT_MERGED_CAP)
}
if (oneShotDebugLabel) {
const f0 = mappedSubRequests[0]?.filter
const batchEventCounts = batches.map((b) => b.length)
const rawTotal = batchEventCounts.reduce((s, n) => s + n, 0)
logger.info(`[${oneShotDebugLabel}] one-shot fetch merged`, {
relayUrlsPerSub: mappedSubRequests.map((r) => r.urls.length),
batchEventCounts,
rawTotal,
dedupedCount: byId.size,
afterCap: merged.length,
cap,
filterAuthors: f0?.authors,
filterKinds: f0?.kinds,
filterLimit: f0?.limit,
...(rawTotal === 0
? {
emptyHint:
'All sub-batches returned 0 events: relays may not index these kinds for this author, the query may have timed out before slow relays EOSEd, or posts are kind 1 with links (this tab uses kinds 20/21/22/1222 only).'
}
: {})
})
}
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
} }
if (oneShotDebugLabel) { if (oneShotDebugLabel && isProgressiveLayers) {
const f0 = mappedSubRequests[0]?.filter const f0 = mappedSubRequests[0]?.filter
const batchEventCounts = batches.map((b) => b.length) const batchEventCounts = batches.map((b) => b.length)
const rawTotal = batchEventCounts.reduce((s, n) => s + n, 0) const rawTotal = batchEventCounts.reduce((s, n) => s + n, 0)
logger.info(`[${oneShotDebugLabel}] one-shot fetch merged`, { logger.info(`[${oneShotDebugLabel}] one-shot progressive relay merge`, {
relayUrlsPerSub: mappedSubRequests.map((r) => r.urls.length), relayUrlsPerSub: mappedSubRequests.map((r) => r.urls.length),
batchEventCounts, batchEventCounts,
rawTotal, rawTotal,
dedupedCount: byId.size, dedupedCount: byId.size,
afterCap: merged.length,
cap,
filterAuthors: f0?.authors, filterAuthors: f0?.authors,
filterKinds: f0?.kinds, filterKinds: f0?.kinds,
filterLimit: f0?.limit, filterLimit: f0?.limit
...(rawTotal === 0
? {
emptyHint:
'All sub-batches returned 0 events: relays may not index these kinds for this author, the query may have timed out before slow relays EOSEd, or posts are kind 1 with links (this tab uses kinds 20/21/22/1222 only).'
}
: {})
}) })
} }
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
feedPaintRelayPendingRef.current = true feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = { feedPaintRelayMetaRef.current = {
variant: 'one_shot_fetch', variant: 'one_shot_fetch',
mergedCount: merged.length, mergedCount: relayOnly.length,
mergedWithPriorSession: !!(sessionSnap?.length && !userPulledRefresh) mergedWithPriorSession: !!(sessionSnap?.length && !userPulledRefresh)
} }
} catch (err) { } catch (err) {
@ -1516,10 +1698,15 @@ const NoteList = forwardRef(
mergedCount: 0, mergedCount: 0,
fetchThrew: true fetchThrew: true
} }
setEvents([]) if (!progressiveWarmupQueryRef.current?.trim()) {
setEvents([])
}
} }
} finally { } finally {
if (effectActive) { if (effectActive) {
if (progressiveWarmupQueryRef.current?.trim()) {
setProgressiveLayersSearching(false)
}
feedPaintLiveRelayDoneRef.current = true feedPaintLiveRelayDoneRef.current = true
setFeedEmptyToastGateTick((n) => n + 1) setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true) setFeedTimelineEmptyUiReady(true)
@ -1563,6 +1750,27 @@ const NoteList = forwardRef(
// New REQ wave (incl. delta relays with same feed key): outcomes stay stale until this wave ends. // New REQ wave (incl. delta relays with same feed key): outcomes stay stale until this wave ends.
setFeedSubscribeRelayOutcomes([]) setFeedSubscribeRelayOutcomes([])
const warmQLive = progressiveWarmupQueryRef.current?.trim()
if (warmQLive) {
setProgressiveLayersSearching(true)
kickProgressiveSearchLocalLayers({
warmQ: warmQLive,
isStale: () => !effectActive || timelineEffectStale(),
kindsForWarm: mergeKindsForProgressiveWarmup(
showKindsRef.current,
progressiveDocumentKindsRef.current
),
warmMatch: progressiveWarmupMatchRef.current,
afterSort: oneShotAfterMergeComparatorRef.current,
setEvents,
setLoading
})
}
if (timelineEffectStale()) {
if (warmQLive) setProgressiveLayersSearching(false)
return undefined
}
timelineSubscribePromise = client.subscribeTimeline( timelineSubscribePromise = client.subscribeTimeline(
mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>, mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>,
{ {
@ -1601,19 +1809,17 @@ const NoteList = forwardRef(
} }
if (batch.length > 0) { if (batch.length > 0) {
if (narrowed.length > 0) { if (narrowed.length > 0) {
if (preserveTimelineOnSubRequestsChange) { setEvents((prev) => {
setEvents((prev) => { const next = progressiveWarmupQueryRef.current?.trim()
const next = mergeEventBatchesById(prev, narrowed, eventCap) ? mergeProgressiveSearchEvents(
lastEventsForTimelinePrefetchRef.current = next prev,
return next narrowed,
}) oneShotAfterMergeComparatorRef.current
} else { )
setEvents((prev) => { : mergeEventBatchesById(prev, narrowed, eventCap)
const next = mergeEventBatchesById(prev, narrowed, eventCap) lastEventsForTimelinePrefetchRef.current = next
lastEventsForTimelinePrefetchRef.current = next return next
return next })
})
}
// Do not wait for full EOSE across many relays — otherwise loading/skeleton stays up for 10–30s+ // Do not wait for full EOSE across many relays — otherwise loading/skeleton stays up for 10–30s+
setLoading(false) setLoading(false)
@ -1705,12 +1911,13 @@ const NoteList = forwardRef(
if (!seeAllFeedEventsRef.current && withKindFilterRef.current) { if (!seeAllFeedEventsRef.current && withKindFilterRef.current) {
const kindlessFirehose = const kindlessFirehose =
allowKindlessRelayExploreRef.current && showAllKindsRef.current allowKindlessRelayExploreRef.current && showAllKindsRef.current
if (!kindlessFirehose) { if (!kindlessFirehose) {
if (!useFilterAsIsRef.current && !showKinds.includes(event.kind)) return if (!useFilterAsIsRef.current && !effectiveShowKindsRef.current.includes(event.kind))
return
if ( if (
clientSideKindFilterRef.current && clientSideKindFilterRef.current &&
useFilterAsIsRef.current && useFilterAsIsRef.current &&
!showKinds.includes(event.kind) !effectiveShowKindsRef.current.includes(event.kind)
) )
return return
if (event.kind === kinds.ShortTextNote) { if (event.kind === kinds.ShortTextNote) {
@ -1743,6 +1950,9 @@ const NoteList = forwardRef(
onRelaySubscribeWaveComplete: (rows) => { onRelaySubscribeWaveComplete: (rows) => {
if (!effectActive) return if (!effectActive) return
setFeedSubscribeRelayOutcomes(rows) setFeedSubscribeRelayOutcomes(rows)
if (progressiveWarmupQueryRef.current?.trim()) {
setProgressiveLayersSearching(false)
}
} }
} }
) )
@ -1763,6 +1973,9 @@ const NoteList = forwardRef(
return closer return closer
} catch (_error) { } catch (_error) {
setLoading(false) setLoading(false)
if (progressiveWarmupQueryRef.current?.trim()) {
setProgressiveLayersSearching(false)
}
if (effectActive) { if (effectActive) {
feedPaintLiveRelayDoneRef.current = true feedPaintLiveRelayDoneRef.current = true
setFeedEmptyToastGateTick((n) => n + 1) setFeedEmptyToastGateTick((n) => n + 1)
@ -1784,6 +1997,7 @@ const NoteList = forwardRef(
const snapshotKeyForCleanup = sessionSnapshotIdentityKey const snapshotKeyForCleanup = sessionSnapshotIdentityKey
return () => { return () => {
effectActive = false effectActive = false
setProgressiveLayersSearching(false)
followingFeedDeltaCloserRef.current?.() followingFeedDeltaCloserRef.current?.()
followingFeedDeltaCloserRef.current = null followingFeedDeltaCloserRef.current = null
setSessionFeedSnapshot(snapshotKeyForCleanup, eventsRef.current) setSessionFeedSnapshot(snapshotKeyForCleanup, eventsRef.current)
@ -1825,7 +2039,8 @@ const NoteList = forwardRef(
showAllKinds, showAllKinds,
withKindFilter, withKindFilter,
onSingleRelayKindlessEmpty, onSingleRelayKindlessEmpty,
mapLiveSubRequestsForTimeline mapLiveSubRequestsForTimeline,
progressiveWarmupQuery
]) ])
useEffect(() => { useEffect(() => {
@ -1870,7 +2085,7 @@ const NoteList = forwardRef(
if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs
if (!withKindFilterRef.current) return evs if (!withKindFilterRef.current) return evs
return evs.filter((e) => showKindsRef.current.includes(e.kind)) return evs.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
} }
void (async () => { void (async () => {
@ -1936,11 +2151,12 @@ const NoteList = forwardRef(
const kindlessFirehose = const kindlessFirehose =
allowKindlessRelayExploreRef.current && showAllKindsRef.current allowKindlessRelayExploreRef.current && showAllKindsRef.current
if (!kindlessFirehose) { if (!kindlessFirehose) {
if (!useFilterAsIsRef.current && !showKinds.includes(event.kind)) return if (!useFilterAsIsRef.current && !effectiveShowKindsRef.current.includes(event.kind))
return
if ( if (
clientSideKindFilterRef.current && clientSideKindFilterRef.current &&
useFilterAsIsRef.current && useFilterAsIsRef.current &&
!showKinds.includes(event.kind) !effectiveShowKindsRef.current.includes(event.kind)
) )
return return
if (event.kind === kinds.ShortTextNote) { if (event.kind === kinds.ShortTextNote) {
@ -2004,7 +2220,7 @@ const NoteList = forwardRef(
clientSideKindFilter, clientSideKindFilter,
startLogin, startLogin,
pubkey, pubkey,
showKinds, effectiveShowKinds,
showKind1OPs, showKind1OPs,
showKind1Replies, showKind1Replies,
showKind1111 showKind1111
@ -2289,7 +2505,7 @@ const NoteList = forwardRef(
!seeAllFeedEventsRef.current && !seeAllFeedEventsRef.current &&
(!allowKindlessRelayExploreRef.current || !showAllKindsRef.current) (!allowKindlessRelayExploreRef.current || !showAllKindsRef.current)
let toAppend = narrowLoadMore let toAppend = narrowLoadMore
? fetchBatch.filter((e) => showKindsRef.current.includes(e.kind)) ? fetchBatch.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
: fetchBatch : fetchBatch
if ( if (
@ -2301,7 +2517,7 @@ const NoteList = forwardRef(
for (let depth = 0; depth < 8 && toAppend.length === 0; depth++) { for (let depth = 0; depth < 8 && toAppend.length === 0; depth++) {
fetchBatch = await client.loadMoreTimeline(latestTimelineKey, skipUntil, LIMIT) fetchBatch = await client.loadMoreTimeline(latestTimelineKey, skipUntil, LIMIT)
if (fetchBatch.length === 0) break if (fetchBatch.length === 0) break
toAppend = fetchBatch.filter((e) => showKindsRef.current.includes(e.kind)) toAppend = fetchBatch.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
if (toAppend.length > 0) break if (toAppend.length > 0) break
skipUntil = Math.min(...fetchBatch.map((e) => e.created_at)) - 1 skipUntil = Math.min(...fetchBatch.map((e) => e.created_at)) - 1
} }
@ -2745,6 +2961,7 @@ const NoteList = forwardRef(
const listSourceEvents = timelineEventsForFilter const listSourceEvents = timelineEventsForFilter
const feedFullSearchActive = feedFullSearchEvents !== null const feedFullSearchActive = feedFullSearchEvents !== null
const progressiveWarmupTrimmed = progressiveWarmupQuery?.trim()
const showRelaySubscribeWavePendingBanner = const showRelaySubscribeWavePendingBanner =
!oneShotFetch && !oneShotFetch &&
!feedFullSearchActive && !feedFullSearchActive &&
@ -2753,7 +2970,11 @@ const NoteList = forwardRef(
timelineKey != null && timelineKey != null &&
feedSubscribeRelayOutcomes.length === 0 && feedSubscribeRelayOutcomes.length === 0 &&
feedTimelineEmptyUiReady feedTimelineEmptyUiReady
const relayWavePendingBannerEl = showRelaySubscribeWavePendingBanner ? ( const showProgressiveLayersPendingBanner =
Boolean(progressiveWarmupTrimmed) && progressiveLayersSearching && !feedFullSearchActive
const showLookingForMoreEventsBanner =
showRelaySubscribeWavePendingBanner || showProgressiveLayersPendingBanner
const relayWavePendingBannerEl = showLookingForMoreEventsBanner ? (
<div <div
className="mb-2 rounded border border-border/40 bg-muted/15 px-3 py-1.5 text-center text-xs text-muted-foreground" className="mb-2 rounded border border-border/40 bg-muted/15 px-3 py-1.5 text-center text-xs text-muted-foreground"
role="status" role="status"

167
src/components/NoteOptions/EditOrCloneEventDialog.tsx

@ -19,7 +19,7 @@ import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle'
import AsciidocArticle from '@/components/Note/AsciidocArticle/AsciidocArticle' import AsciidocArticle from '@/components/Note/AsciidocArticle/AsciidocArticle'
import ClientTag from '@/components/ClientTag' import ClientTag from '@/components/ClientTag'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { applyImwaldAttributionTags, stripImwaldAttributionTags } from '@/lib/draft-event' import { applyImwaldAttributionTags } from '@/lib/draft-event'
import { createFakeEvent } from '@/lib/event' import { createFakeEvent } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
@ -54,6 +54,17 @@ function tagsFromRows(rows: string[][]): string[][] {
return out return out
} }
const MAX_CUSTOM_EVENT_KIND = 40000
/** Integer kind in [0, 40000], or null if invalid / empty. */
function parseEventKindInput(s: string): number | null {
const trimmed = s.trim()
if (trimmed === '') return null
const n = Number(trimmed)
if (!Number.isInteger(n) || n < 0 || n > MAX_CUSTOM_EVENT_KIND) return null
return n
}
function StaticEventPreview({ event, className }: { event: Event; className?: string }) { function StaticEventPreview({ event, className }: { event: Event; className?: string }) {
const k = event.kind const k = event.kind
const wrap = (node: ReactNode) => ( const wrap = (node: ReactNode) => (
@ -89,46 +100,67 @@ function StaticEventPreview({ event, className }: { event: Event; className?: st
export type TEditOrCloneMode = 'edit' | 'clone' export type TEditOrCloneMode = 'edit' | 'clone'
export default function EditOrCloneEventDialog({ export type EditOrCloneEventDialogProps =
open, | {
onOpenChange, open: boolean
sourceEvent, onOpenChange: (open: boolean) => void
mode mode: 'create'
}: { }
open: boolean | {
onOpenChange: (open: boolean) => void open: boolean
sourceEvent: Event onOpenChange: (open: boolean) => void
mode: TEditOrCloneMode mode: TEditOrCloneMode
}) { sourceEvent: Event
}
export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProps) {
const { open, onOpenChange, mode } = props
const isCreate = mode === 'create'
const sourceEvent = !isCreate ? props.sourceEvent : null
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
const [content, setContent] = useState(sourceEvent.content) const [content, setContent] = useState(() => sourceEvent?.content ?? '')
const [createKindInput, setCreateKindInput] = useState('1')
const [tagRows, setTagRows] = useState<string[][]>([['', '']]) const [tagRows, setTagRows] = useState<string[][]>([['', '']])
const [activeTab, setActiveTab] = useState('edit') const [activeTab, setActiveTab] = useState('edit')
const [publishing, setPublishing] = useState(false) const [publishing, setPublishing] = useState(false)
const prevOpenRef = useRef(false) const prevOpenRef = useRef(false)
const kind = sourceEvent.kind const parsedCreateKind = useMemo(
() => (isCreate ? parseEventKindInput(createKindInput) : null),
[isCreate, createKindInput]
)
const kind = isCreate ? (parsedCreateKind ?? 0) : sourceEvent!.kind
useEffect(() => { useEffect(() => {
if (open && !prevOpenRef.current) { if (open && !prevOpenRef.current) {
setContent(sourceEvent.content) if (isCreate) {
setTagRows( setCreateKindInput('1')
sourceEvent.tags?.length setContent('')
? sourceEvent.tags.map((row) => [...row]) setTagRows([['', '']])
: [['', '']] } else if (sourceEvent) {
) setContent(sourceEvent.content)
setTagRows(
sourceEvent.tags?.length
? sourceEvent.tags.map((row) => [...row])
: [['', '']]
)
}
setActiveTab('edit') setActiveTab('edit')
} }
prevOpenRef.current = open prevOpenRef.current = open
}, [open, sourceEvent]) }, [open, isCreate, sourceEvent])
const normalizedTags = useMemo(() => tagsFromRows(tagRows), [tagRows]) const normalizedTags = useMemo(() => tagsFromRows(tagRows), [tagRows])
const previewEvent = useMemo(() => { const previewEvent = useMemo(() => {
if (isCreate && parsedCreateKind === null) return null
const k = isCreate ? parsedCreateKind! : sourceEvent!.kind
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
const base: TDraftEvent = { const base: TDraftEvent = {
kind, kind: k,
content, content,
tags: normalizedTags, tags: normalizedTags,
created_at: now created_at: now
@ -137,17 +169,21 @@ export default function EditOrCloneEventDialog({
addClientTag: storage.getAddClientTag() addClientTag: storage.getAddClientTag()
}) })
return createFakeEvent({ return createFakeEvent({
kind, kind: k,
content, content,
tags: withAttribution.tags, tags: withAttribution.tags,
pubkey: pubkey ?? '', pubkey: pubkey ?? '',
created_at: now created_at: now
}) })
}, [kind, content, normalizedTags, pubkey]) }, [isCreate, parsedCreateKind, sourceEvent, content, normalizedTags, pubkey])
const buildDraftJson = useCallback(() => { const buildDraftJson = useCallback(() => {
if (isCreate && parsedCreateKind === null) {
return t('Enter a valid event kind (integer 0–40000).')
}
const k = isCreate ? parsedCreateKind! : sourceEvent!.kind
const base: TDraftEvent = { const base: TDraftEvent = {
kind, kind: k,
content, content,
tags: normalizedTags, tags: normalizedTags,
created_at: dayjs().unix() created_at: dayjs().unix()
@ -164,7 +200,7 @@ export default function EditOrCloneEventDialog({
_note: t('id and sig are assigned when you publish') _note: t('id and sig are assigned when you publish')
} }
return JSON.stringify(draft, null, 2) return JSON.stringify(draft, null, 2)
}, [pubkey, kind, content, normalizedTags, t]) }, [isCreate, parsedCreateKind, sourceEvent, pubkey, content, normalizedTags, t])
const draftJson = activeTab === 'json' ? buildDraftJson() : '' const draftJson = activeTab === 'json' ? buildDraftJson() : ''
@ -203,10 +239,18 @@ export default function EditOrCloneEventDialog({
const handlePublish = async () => { const handlePublish = async () => {
await checkLogin(async () => { await checkLogin(async () => {
if (!pubkey) return if (!pubkey) return
if (isCreate) {
const k = parseEventKindInput(createKindInput)
if (k === null) {
showPublishingError(t('Kind must be an integer from 0 to 40000.'))
return
}
}
setPublishing(true) setPublishing(true)
try { try {
const publishKind = isCreate ? parseEventKindInput(createKindInput)! : sourceEvent!.kind
const draft = { const draft = {
kind, kind: publishKind,
content, content,
tags: normalizedTags, tags: normalizedTags,
created_at: dayjs().unix() created_at: dayjs().unix()
@ -259,7 +303,11 @@ export default function EditOrCloneEventDialog({
} }
const title = const title =
mode === 'edit' ? t('Edit this event') : t('Clone or fork this event') mode === 'edit'
? t('Edit this event')
: mode === 'clone'
? t('Clone or fork this event')
: t('Create custom event')
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@ -267,7 +315,9 @@ export default function EditOrCloneEventDialog({
<DialogHeader className="shrink-0 px-6 pt-6 pb-2 pr-14"> <DialogHeader className="shrink-0 px-6 pt-6 pb-2 pr-14">
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogDescription className="sr-only"> <DialogDescription className="sr-only">
{t('Edit content and tags, then publish a new signed event.')} {isCreate
? t('Set kind, content, and tags, then publish.')
: t('Edit content and tags, then publish a new signed event.')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -284,14 +334,31 @@ export default function EditOrCloneEventDialog({
<div className="space-y-4 pb-2"> <div className="space-y-4 pb-2">
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm font-medium">{t('Event kind')}</label> <label className="text-sm font-medium">{t('Event kind')}</label>
<Input {isCreate ? (
type="number" <>
value={kind} <Input
disabled type="number"
readOnly min={0}
className="font-mono text-sm" max={MAX_CUSTOM_EVENT_KIND}
aria-readonly step={1}
/> value={createKindInput}
onChange={(e) => setCreateKindInput(e.target.value)}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{t('Integer from 0 to 40000')}
</p>
</>
) : (
<Input
type="number"
value={kind}
disabled
readOnly
className="font-mono text-sm"
aria-readonly
/>
)}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm font-medium">{t('Note content')}</label> <label className="text-sm font-medium">{t('Note content')}</label>
@ -367,12 +434,20 @@ export default function EditOrCloneEventDialog({
<TabsContent value="preview" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"> <TabsContent value="preview" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
<ScrollArea className="h-[min(50vh,420px)] pr-3"> <ScrollArea className="h-[min(50vh,420px)] pr-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
{storage.getAddClientTag() ? ( {previewEvent ? (
<div className="flex min-h-[1.125rem] items-center px-0.5"> <>
<ClientTag event={previewEvent} /> {storage.getAddClientTag() ? (
</div> <div className="flex min-h-[1.125rem] items-center px-0.5">
) : null} <ClientTag event={previewEvent} />
<StaticEventPreview event={previewEvent} /> </div>
) : null}
<StaticEventPreview event={previewEvent} />
</>
) : (
<p className="text-sm text-muted-foreground">
{t('Enter a valid event kind (integer 0–40000).')}
</p>
)}
</div> </div>
</ScrollArea> </ScrollArea>
</TabsContent> </TabsContent>
@ -391,7 +466,11 @@ export default function EditOrCloneEventDialog({
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('Cancel')} {t('Cancel')}
</Button> </Button>
<Button type="button" onClick={handlePublish} disabled={publishing || !pubkey}> <Button
type="button"
onClick={handlePublish}
disabled={publishing || !pubkey || (isCreate && parsedCreateKind === null)}
>
{publishing ? t('Loading...') : t('Publish')} {publishing ? t('Loading...') : t('Publish')}
</Button> </Button>
</DialogFooter> </DialogFooter>

2
src/components/PostEditor/HighlightEditor.tsx

@ -112,8 +112,10 @@ export default function HighlightEditor({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-sm font-medium">{t('Highlight Settings')}</div> <div className="text-sm font-medium">{t('Highlight Settings')}</div>
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
title={t('Close highlight editor')}
className="h-6 w-6" className="h-6 w-6"
onClick={() => setIsHighlight(false)} onClick={() => setIsHighlight(false)}
> >

6
src/components/PostEditor/Mentions.tsx

@ -64,8 +64,14 @@ export default function Mentions({
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button"
className="px-3" className="px-3"
variant="ghost" variant="ghost"
title={
potentialMentions.length === 0
? t('Mentions')
: `${t('Mentions')} (${mentions.length}/${potentialMentions.length})`
}
disabled={potentialMentions.length === 0} disabled={potentialMentions.length === 0}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >

3
src/components/PostEditor/PollEditor.tsx

@ -68,6 +68,7 @@ export default function PollEditor({
type="button" type="button"
variant="ghost-destructive" variant="ghost-destructive"
size="icon" size="icon"
title={t('Remove option')}
onClick={() => handleRemoveOption(index)} onClick={() => handleRemoveOption(index)}
disabled={options.length <= 2} disabled={options.length <= 2}
> >
@ -75,7 +76,7 @@ export default function PollEditor({
</Button> </Button>
</div> </div>
))} ))}
<Button type="button" variant="outline" onClick={handleAddOption}> <Button type="button" variant="outline" title={t('Add Option')} onClick={handleAddOption}>
{t('Add Option')} {t('Add Option')}
</Button> </Button>
</div> </div>

101
src/components/PostEditor/PostContent.tsx

@ -4,6 +4,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { import {
DropdownMenu, DropdownMenu,
@ -59,6 +60,7 @@ import {
X, X,
Highlighter, Highlighter,
FileText, FileText,
HelpCircle,
Quote, Quote,
StickyNote, StickyNote,
Upload, Upload,
@ -109,6 +111,7 @@ import { NeventPickerProvider } from './PostTextarea/Mention/NeventNaddrPickerDi
import { MentionAndEventToolbarButtons } from './PostTextarea/Mention/MentionAndEventToolbarButtons' import { MentionAndEventToolbarButtons } from './PostTextarea/Mention/MentionAndEventToolbarButtons'
import Uploader from './Uploader' import Uploader from './Uploader'
import HighlightEditor, { HighlightData } from './HighlightEditor' import HighlightEditor, { HighlightData } from './HighlightEditor'
import EditOrCloneEventDialog from '../NoteOptions/EditOrCloneEventDialog'
export default function PostContent({ export default function PostContent({
defaultContent = '', defaultContent = '',
@ -198,6 +201,7 @@ export default function PostContent({
{ file: File; progress: number; cancel: () => void }[] { file: File; progress: number; cancel: () => void }[]
>([]) >([])
const [showMoreOptions, setShowMoreOptions] = useState(false) const [showMoreOptions, setShowMoreOptions] = useState(false)
const [createCustomEventOpen, setCreateCustomEventOpen] = useState(false)
const [addClientTag, setAddClientTag] = useState(() => storage.getAddClientTag()) const [addClientTag, setAddClientTag] = useState(() => storage.getAddClientTag())
const [mentions, setMentions] = useState<string[]>([]) const [mentions, setMentions] = useState<string[]>([])
const [isNsfw, setIsNsfw] = useState(false) const [isNsfw, setIsNsfw] = useState(false)
@ -2078,6 +2082,7 @@ export default function PostContent({
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={threadGroupPopoverOpen} aria-expanded={threadGroupPopoverOpen}
title={t('Select group...')}
className="h-9 w-full justify-between bg-background font-normal" className="h-9 w-full justify-between bg-background font-normal"
> >
{threadSelectedGroup ? threadSelectedGroup : t('Select group...')} {threadSelectedGroup ? threadSelectedGroup : t('Select group...')}
@ -2150,6 +2155,7 @@ export default function PostContent({
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
title={threadShowReadingsPanel ? t('Hide') : t('Configure')}
onClick={() => setThreadShowReadingsPanel(!threadShowReadingsPanel)} onClick={() => setThreadShowReadingsPanel(!threadShowReadingsPanel)}
className="ml-auto" className="ml-auto"
> >
@ -2817,26 +2823,17 @@ export default function PostContent({
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button
type="button"
variant="ghost"
size="icon"
title={t('Create event with custom kind')}
onClick={() => checkLogin(() => setCreateCustomEventOpen(true))}
>
<HelpCircle className="h-4 w-4" />
</Button>
</> </>
)} )}
<GifPicker
onSelect={(gifUrl) => {
textareaRef.current?.insertText(gifUrl)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert GIF')}>
<Film className="h-4 w-4" />
</Button>
</GifPicker>
<MemePicker
onSelect={(memeUrl) => {
textareaRef.current?.insertText(memeUrl)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert meme')}>
<Laugh className="h-4 w-4" />
</Button>
</MemePicker>
</> </>
} }
/> />
@ -2968,6 +2965,7 @@ export default function PostContent({
<ImageUp /> <ImageUp />
</Button> </Button>
</Uploader> </Uploader>
<Separator orientation="vertical" className="h-6 shrink-0" />
{/* I'm not sure why, but after triggering the virtual keyboard, {/* I'm not sure why, but after triggering the virtual keyboard,
opening the emoji picker drawer causes an issue, opening the emoji picker drawer causes an issue,
the emoji I tap isn't the one that gets inserted. */} the emoji I tap isn't the one that gets inserted. */}
@ -2978,18 +2976,39 @@ export default function PostContent({
textareaRef.current?.insertEmoji(emoji) textareaRef.current?.insertEmoji(emoji)
}} }}
> >
<Button variant="ghost" size="icon"> <Button type="button" variant="ghost" size="icon" title={t('Insert emoji')}>
<Smile /> <Smile />
</Button> </Button>
</EmojiPickerDialog> </EmojiPickerDialog>
)} )}
<GifPicker
onSelect={(gifUrl) => {
textareaRef.current?.insertText(gifUrl)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert GIF')}>
<Film className="h-4 w-4" />
</Button>
</GifPicker>
<MemePicker
onSelect={(memeUrl) => {
textareaRef.current?.insertText(memeUrl)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert meme')}>
<Laugh className="h-4 w-4" />
</Button>
</MemePicker>
<Separator orientation="vertical" className="h-6 shrink-0" />
<MentionAndEventToolbarButtons <MentionAndEventToolbarButtons
insertAtCursor={(text) => textareaRef.current?.insertText(text)} insertAtCursor={(text) => textareaRef.current?.insertText(text)}
variant="ghost" variant="ghost"
/> />
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
title={t('More options')}
className={showMoreOptions ? 'bg-accent' : ''} className={showMoreOptions ? 'bg-accent' : ''}
onClick={() => setShowMoreOptions((pre) => !pre)} onClick={() => setShowMoreOptions((pre) => !pre)}
> >
@ -3005,7 +3024,9 @@ export default function PostContent({
/> />
<div className="flex gap-2 items-center max-sm:hidden"> <div className="flex gap-2 items-center max-sm:hidden">
<Button <Button
type="button"
variant="outline" variant="outline"
title={t('Clear')}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleClear() handleClear()
@ -3014,7 +3035,9 @@ export default function PostContent({
{t('Clear')} {t('Clear')}
</Button> </Button>
<Button <Button
type="button"
variant="secondary" variant="secondary"
title={t('Cancel')}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
close() close()
@ -3022,7 +3045,20 @@ export default function PostContent({
> >
{t('Cancel')} {t('Cancel')}
</Button> </Button>
<Button type="submit" disabled={!canPost} onClick={post}> <Button
type="submit"
title={
parentEvent
? t('Reply')
: isPublicMessage
? t('Send Public Message')
: isDiscussionThread
? t('Create Thread')
: t('Post')
}
disabled={!canPost}
onClick={post}
>
{posting && ( {posting && (
<Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden /> <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />
)} )}
@ -3049,8 +3085,10 @@ export default function PostContent({
/> />
<div className="flex gap-2 items-center justify-around sm:hidden"> <div className="flex gap-2 items-center justify-around sm:hidden">
<Button <Button
type="button"
className="w-full" className="w-full"
variant="outline" variant="outline"
title={t('Clear')}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleClear() handleClear()
@ -3059,8 +3097,10 @@ export default function PostContent({
{t('Clear')} {t('Clear')}
</Button> </Button>
<Button <Button
type="button"
className="w-full" className="w-full"
variant="secondary" variant="secondary"
title={t('Cancel')}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
close() close()
@ -3068,7 +3108,21 @@ export default function PostContent({
> >
{t('Cancel')} {t('Cancel')}
</Button> </Button>
<Button className="w-full" type="submit" disabled={!canPost} onClick={post}> <Button
className="w-full"
type="submit"
title={
parentEvent
? t('Reply')
: isPublicMessage
? t('Send Public Message')
: isDiscussionThread
? t('Create Thread')
: t('Post')
}
disabled={!canPost}
onClick={post}
>
{posting && ( {posting && (
<Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden /> <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />
)} )}
@ -3157,6 +3211,11 @@ export default function PostContent({
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<EditOrCloneEventDialog
open={createCustomEventOpen}
onOpenChange={setCreateCustomEventOpen}
mode="create"
/>
</NeventPickerProvider> </NeventPickerProvider>
</div> </div>
) )

6
src/components/PostEditor/PostRelaySelector.tsx

@ -431,12 +431,16 @@ export default function PostRelaySelector({
{selectableRelays.length > 0 && ( {selectableRelays.length > 0 && (
<div className="flex gap-2 mb-2"> <div className="flex gap-2 mb-2">
<button <button
type="button"
title={t('Select All')}
onClick={handleSelectAll} onClick={handleSelectAll}
className="text-xs text-muted-foreground hover:text-foreground" className="text-xs text-muted-foreground hover:text-foreground"
> >
{t('Select All')} {t('Select All')}
</button> </button>
<button <button
type="button"
title={t('Clear All')}
onClick={handleClearAll} onClick={handleClearAll}
className="text-xs text-muted-foreground hover:text-foreground" className="text-xs text-muted-foreground hover:text-foreground"
> >
@ -528,6 +532,7 @@ export default function PostRelaySelector({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
title={triggerText}
className="h-8 px-3 text-xs justify-between min-w-0 flex-1" className="h-8 px-3 text-xs justify-between min-w-0 flex-1"
> >
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
@ -564,6 +569,7 @@ export default function PostRelaySelector({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
title={triggerText}
className="h-8 px-3 text-xs justify-between min-w-0 flex-1" className="h-8 px-3 text-xs justify-between min-w-0 flex-1"
> >
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">

3
src/components/PostEditor/PostTextarea/Emoji/suggestion.ts

@ -1,3 +1,4 @@
import client from '@/services/client.service'
import customEmojiService from '@/services/custom-emoji.service' import customEmojiService from '@/services/custom-emoji.service'
import postEditor from '@/services/post-editor.service' import postEditor from '@/services/post-editor.service'
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
@ -36,7 +37,7 @@ function searchStandardEmojiShortcodes(query: string): string[] {
const suggestion = { const suggestion = {
items: async ({ query }: { query: string }) => { items: async ({ query }: { query: string }) => {
const custom = await customEmojiService.searchEmojis(query) const custom = await customEmojiService.searchEmojis(query, client.pubkey ?? null)
const customSet = new Set(custom) const customSet = new Set(custom)
const standard = searchStandardEmojiShortcodes(query).filter((s) => !customSet.has(s)) const standard = searchStandardEmojiShortcodes(query).filter((s) => !customSet.has(s))
return [...custom, ...standard].slice(0, 50) return [...custom, ...standard].slice(0, 50)

8
src/components/PostEditor/PostTextarea/index.tsx

@ -247,8 +247,12 @@ const PostTextarea = forwardRef<
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-2"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-2">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<TabsList className="w-auto justify-start"> <TabsList className="w-auto justify-start">
<TabsTrigger value="preview">{t('Preview')}</TabsTrigger> <TabsTrigger value="preview" title={t('Preview')}>
<TabsTrigger value="json">{t('Json')}</TabsTrigger> {t('Preview')}
</TabsTrigger>
<TabsTrigger value="json" title={t('Json')}>
{t('Json')}
</TabsTrigger>
</TabsList> </TabsList>
{headerActions && ( {headerActions && (
<div className="flex gap-1 items-center flex-wrap"> <div className="flex gap-1 items-center flex-wrap">

17
src/components/SearchBar/index.tsx

@ -151,9 +151,10 @@ const SearchBar = forwardRef<
...(normalizedDTag && normalizedDTag.length > 0 ? [{ type: 'dtag', search: normalizedDTag, input: search }] : []), ...(normalizedDTag && normalizedDTag.length > 0 ? [{ type: 'dtag', search: normalizedDTag, input: search }] : []),
...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []), ...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []),
...profiles.map((profile) => ({ ...profiles.map((profile) => ({
type: 'profile', type: 'profile' as const,
search: profile.npub, search: profile.npub,
input: profile.username input: profile.username,
profile
})), })),
...(profiles.length >= 5 ? [{ type: 'profiles', search }] : []) ...(profiles.length >= 5 ? [{ type: 'profiles', search }] : [])
] as TSearchParams[]) ] as TSearchParams[])
@ -180,9 +181,10 @@ const SearchBar = forwardRef<
if (option.type === 'profile') { if (option.type === 'profile') {
return ( return (
<ProfileItem <ProfileItem
key={index} key={`profile-${option.search}`}
selected={selectedIndex === index} selected={selectedIndex === index}
userId={option.search} userId={option.search}
prefetchedProfile={option.profile}
onClick={() => updateSearch(option)} onClick={() => updateSearch(option)}
/> />
) )
@ -451,10 +453,12 @@ function NoteItem({
function ProfileItem({ function ProfileItem({
userId, userId,
prefetchedProfile,
onClick, onClick,
selected selected
}: { }: {
userId: string userId: string
prefetchedProfile?: TSearchParams['profile']
onClick?: () => void onClick?: () => void
selected?: boolean selected?: boolean
}) { }) {
@ -463,7 +467,12 @@ function ProfileItem({
className={cn('px-2 hover:bg-accent rounded-md cursor-pointer', selected && 'bg-accent')} className={cn('px-2 hover:bg-accent rounded-md cursor-pointer', selected && 'bg-accent')}
onClick={onClick} onClick={onClick}
> >
<UserItem pubkey={userId} hideFollowButton className="pointer-events-none" /> <UserItem
pubkey={userId}
hideFollowButton
className="pointer-events-none"
prefetchedProfile={prefetchedProfile}
/>
</div> </div>
) )
} }

15
src/components/SearchResult/index.tsx

@ -1,4 +1,10 @@
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import {
FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS,
NIP_SEARCH_DOCUMENT_KINDS,
SEARCHABLE_RELAY_URLS
} from '@/constants'
import { compareEventsForDTagQuery } from '@/lib/dtag-search'
import { TSearchParams } from '@/types' import { TSearchParams } from '@/types'
import NormalFeed from '../NormalFeed' import NormalFeed from '../NormalFeed'
import Profile from '../Profile' import Profile from '../Profile'
@ -51,7 +57,12 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
if (searchParams.type === 'notes') { if (searchParams.type === 'notes') {
return ( return (
<NormalFeed <NormalFeed
subRequests={[{ urls: searchRelays, filter: { search: searchParams.search } }]} subRequests={[
{ urls: searchRelays, filter: { search: searchParams.search, kinds: [...NIP_SEARCH_DOCUMENT_KINDS] } }
]}
progressiveWarmupQuery={searchParams.search}
progressiveDocumentKinds={NIP_SEARCH_DOCUMENT_KINDS}
oneShotAfterMergeComparator={(a, b) => compareEventsForDTagQuery(searchParams.search, a, b)}
/> />
) )
} }

2
src/components/SuggestedEmojis/index.tsx

@ -15,7 +15,7 @@ export default function SuggestedEmojis({
onMoreButtonClick: () => void onMoreButtonClick: () => void
}) { }) {
const [suggestedEmojis, setSuggestedEmojis] = const [suggestedEmojis, setSuggestedEmojis] =
useState<(string | TEmoji)[]>(DEFAULT_SUGGESTED_EMOJIS) useState<(string | TEmoji)[]>(() => [...DEFAULT_SUGGESTED_EMOJIS])
useEffect(() => { useEffect(() => {
try { try {

6
src/components/TextareaWithMentionAutocomplete/index.tsx

@ -7,6 +7,7 @@ import {
searchNpubsForMention, searchNpubsForMention,
type PickerSearchMode type PickerSearchMode
} from '@/services/mention-event-search.service' } from '@/services/mention-event-search.service'
import { useNostr } from '@/providers/NostrProvider'
import customEmojiService from '@/services/custom-emoji.service' import customEmojiService from '@/services/custom-emoji.service'
import { searchStandardEmojiShortcodes } from '@/lib/emoji-content' import { searchStandardEmojiShortcodes } from '@/lib/emoji-content'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
@ -52,6 +53,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
const emojiSearchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const emojiSearchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const mentionQueryRef = useRef(mentionQuery) const mentionQueryRef = useRef(mentionQuery)
const neventPicker = useNeventPicker() const neventPicker = useNeventPicker()
const { pubkey } = useNostr()
mentionQueryRef.current = mentionQuery mentionQueryRef.current = mentionQuery
const [dropdownRect, setDropdownRect] = useState<{ top: number; left: number; width: number } | null>(null) const [dropdownRect, setDropdownRect] = useState<{ top: number; left: number; width: number } | null>(null)
@ -200,7 +202,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
if (emojiSearchTimeoutRef.current) clearTimeout(emojiSearchTimeoutRef.current) if (emojiSearchTimeoutRef.current) clearTimeout(emojiSearchTimeoutRef.current)
emojiSearchTimeoutRef.current = setTimeout(() => { emojiSearchTimeoutRef.current = setTimeout(() => {
Promise.all([ Promise.all([
customEmojiService.searchEmojis(q), customEmojiService.searchEmojis(q, pubkey ?? null),
Promise.resolve(searchStandardEmojiShortcodes(q, EMOJI_LIMIT)) Promise.resolve(searchStandardEmojiShortcodes(q, EMOJI_LIMIT))
]).then(([custom, standard]) => { ]).then(([custom, standard]) => {
const customSet = new Set(custom) const customSet = new Set(custom)
@ -213,7 +215,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
return () => { return () => {
if (emojiSearchTimeoutRef.current) clearTimeout(emojiSearchTimeoutRef.current) if (emojiSearchTimeoutRef.current) clearTimeout(emojiSearchTimeoutRef.current)
} }
}, [emojiQuery]) }, [emojiQuery, pubkey])
const open = (emojiOpen && emojiItems.length > 0) || (mentionOpen && mentionItems.length > 0) const open = (emojiOpen && emojiItems.length > 0) || (mentionOpen && mentionItems.length > 0)
useEffect(() => { useEffect(() => {

28
src/components/UserAvatar/index.tsx

@ -4,6 +4,7 @@ import { generateImageByPubkey, userIdToPubkey } from '@/lib/pubkey'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartProfileNavigationOptional } from '@/PageManager' import { useSmartProfileNavigationOptional } from '@/PageManager'
import type { TProfile } from '@/types'
import { useMemo, useState, useEffect, useRef, type RefObject } from 'react' import { useMemo, useState, useEffect, useRef, type RefObject } from 'react'
/** Only defer network fetches for typical profile picture URLs (not data:, blob:, etc.). */ /** Only defer network fetches for typical profile picture URLs (not data:, blob:, etc.). */
@ -75,13 +76,23 @@ const UserAvatarSizeCnMap = {
export default function UserAvatar({ export default function UserAvatar({
userId, userId,
className, className,
size = 'normal' size = 'normal',
prefetchedProfile
}: { }: {
userId: string userId: string
className?: string className?: string
size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny' size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
/** Same pubkey as userId; use avatar from search/cache until fetch completes. */
prefetchedProfile?: TProfile
}) { }) {
const { profile } = useFetchProfile(userId) const { profile: fetchedProfile } = useFetchProfile(userId)
const profile = useMemo(() => {
const idPk = userId ? userIdToPubkey(userId) : ''
if (prefetchedProfile && idPk && prefetchedProfile.pubkey === idPk) {
return fetchedProfile ?? prefetchedProfile
}
return fetchedProfile
}, [userId, prefetchedProfile, fetchedProfile])
const { navigateToProfile } = useSmartProfileNavigationOptional() const { navigateToProfile } = useSmartProfileNavigationOptional()
// Extract pubkey from userId if it's npub/nprofile format // Extract pubkey from userId if it's npub/nprofile format
@ -169,13 +180,22 @@ export default function UserAvatar({
export function SimpleUserAvatar({ export function SimpleUserAvatar({
userId, userId,
size = 'normal', size = 'normal',
className className,
prefetchedProfile
}: { }: {
userId: string userId: string
size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny' size?: 'large' | 'big' | 'semiBig' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
className?: string className?: string
prefetchedProfile?: TProfile
}) { }) {
const { profile } = useFetchProfile(userId) const { profile: fetchedProfile } = useFetchProfile(userId)
const profile = useMemo(() => {
const idPk = userId ? userIdToPubkey(userId) : ''
if (prefetchedProfile && idPk && prefetchedProfile.pubkey === idPk) {
return fetchedProfile ?? prefetchedProfile
}
return fetchedProfile
}, [userId, prefetchedProfile, fetchedProfile])
// Always generate default avatar from userId/pubkey, even if profile isn't loaded yet // Always generate default avatar from userId/pubkey, even if profile isn't loaded yet
const pubkey = useMemo(() => { const pubkey = useMemo(() => {
if (!userId) return '' if (!userId) return ''

9
src/components/UserItem/index.tsx

@ -4,22 +4,27 @@ import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username' import Username from '@/components/Username'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { TProfile } from '@/types'
export default function UserItem({ export default function UserItem({
pubkey, pubkey,
hideFollowButton, hideFollowButton,
className className,
prefetchedProfile
}: { }: {
pubkey: string pubkey: string
hideFollowButton?: boolean hideFollowButton?: boolean
className?: string className?: string
/** When the caller already loaded this profile (e.g. search index / DB), show it immediately. */
prefetchedProfile?: TProfile | null
}) { }) {
return ( return (
<div className={cn('flex gap-2 items-center h-14', className)}> <div className={cn('flex gap-2 items-center h-14', className)}>
<UserAvatar userId={pubkey} className="shrink-0" /> <UserAvatar userId={pubkey} prefetchedProfile={prefetchedProfile ?? undefined} className="shrink-0" />
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
<Username <Username
userId={pubkey} userId={pubkey}
prefetchedProfile={prefetchedProfile ?? undefined}
className="font-semibold truncate max-w-full w-fit" className="font-semibold truncate max-w-full w-fit"
skeletonClassName="h-4" skeletonClassName="h-4"
/> />

16
src/components/Username/index.tsx

@ -4,6 +4,7 @@ import { toProfile } from '@/lib/link'
import { formatPubkey, userIdToPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey' import { formatPubkey, userIdToPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartProfileNavigationOptional } from '@/PageManager' import { useSmartProfileNavigationOptional } from '@/PageManager'
import type { TProfile } from '@/types'
import { useMemo } from 'react' import { useMemo } from 'react'
export default function Username({ export default function Username({
@ -13,7 +14,8 @@ export default function Username({
skeletonClassName, skeletonClassName,
withoutSkeleton = false, withoutSkeleton = false,
style, style,
onNavigate onNavigate,
prefetchedProfile
}: { }: {
userId: string userId: string
showAt?: boolean showAt?: boolean
@ -22,8 +24,16 @@ export default function Username({
withoutSkeleton?: boolean withoutSkeleton?: boolean
style?: React.CSSProperties style?: React.CSSProperties
onNavigate?: () => void onNavigate?: () => void
prefetchedProfile?: TProfile
}) { }) {
const { profile, isFetching } = useFetchProfile(userId) const { profile: fetchedProfile, isFetching } = useFetchProfile(userId)
const profile = useMemo(() => {
const idPk = userId ? userIdToPubkey(userId) : ''
if (prefetchedProfile && idPk && prefetchedProfile.pubkey === idPk) {
return fetchedProfile ?? prefetchedProfile
}
return fetchedProfile
}, [userId, prefetchedProfile, fetchedProfile])
const { navigateToProfile } = useSmartProfileNavigationOptional() const { navigateToProfile } = useSmartProfileNavigationOptional()
// Get pubkey from userId (works even if profile isn't loaded) // Get pubkey from userId (works even if profile isn't loaded)
@ -34,7 +44,7 @@ export default function Username({
// Never block on profile fetch when we can already show npub/hex fallback (feeds batch-fetch profiles). // Never block on profile fetch when we can already show npub/hex fallback (feeds batch-fetch profiles).
const canShowWithoutProfile = Boolean(pubkey) const canShowWithoutProfile = Boolean(pubkey)
if (isFetching && !withoutSkeleton && !canShowWithoutProfile) { if (isFetching && !withoutSkeleton && !canShowWithoutProfile && !profile) {
return ( return (
<div className="py-1"> <div className="py-1">
<Skeleton className={cn('w-16', skeletonClassName)} /> <Skeleton className={cn('w-16', skeletonClassName)} />

23
src/components/YoutubeEmbeddedPlayer/index.tsx

@ -1,3 +1,4 @@
import { ensureYouTubeIframeApi } from '@/lib/youtube-iframe-api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import mediaManager from '@/services/media-manager.service' import mediaManager from '@/services/media-manager.service'
@ -37,21 +38,12 @@ export default function YoutubeEmbeddedPlayer({
useEffect(() => { useEffect(() => {
if (!videoId || !containerRef.current || (!mustLoad && !display)) return if (!videoId || !containerRef.current || (!mustLoad && !display)) return
if (!window.YT) { let cancelled = false
const script = document.createElement('script')
script.src = 'https://www.youtube.com/iframe_api'
document.body.appendChild(script)
window.onYouTubeIframeAPIReady = () => { void ensureYouTubeIframeApi().then(() => {
initPlayer() if (cancelled || !containerRef.current) return
}
} else {
initPlayer()
}
function initPlayer() {
try { try {
if (!videoId || !containerRef.current || !window.YT.Player) return if (!videoId || !window.YT?.Player) return
playerRef.current = new window.YT.Player(containerRef.current, { playerRef.current = new window.YT.Player(containerRef.current, {
videoId: videoId, videoId: videoId,
playerVars: { playerVars: {
@ -74,13 +66,14 @@ export default function YoutubeEmbeddedPlayer({
} catch (error) { } catch (error) {
logger.error('Failed to initialize YouTube player', { error }) logger.error('Failed to initialize YouTube player', { error })
setError(true) setError(true)
return
} }
} })
return () => { return () => {
cancelled = true
if (playerRef.current) { if (playerRef.current) {
playerRef.current.destroy() playerRef.current.destroy()
playerRef.current = null
} }
} }
}, [videoId, display, mustLoad]) }, [videoId, display, mustLoad])

13
src/constants.ts

@ -531,6 +531,19 @@ export function isDocumentRelayKind(kind: number): boolean {
return DOCUMENT_RELAY_KIND_SET.has(kind) return DOCUMENT_RELAY_KIND_SET.has(kind)
} }
/**
* Long-form, wiki, and publication kinds always included in NIP-50 / d-tag search REQ and progressive local warmup.
* Kind 30041 (`PUBLICATION_CONTENT`) is deprioritized in `compareEventsForDTagQuery` unless the `d` tag is an exact
* match (case-insensitive).
*/
export const NIP_SEARCH_DOCUMENT_KINDS: readonly number[] = [
kinds.LongFormArticle,
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.PUBLICATION,
ExtendedKind.PUBLICATION_CONTENT
]
export function relayFilterIncludesDocumentRelayKind(filter: Filter): boolean { export function relayFilterIncludesDocumentRelayKind(filter: Filter): boolean {
const k = filter.kinds const k = filter.kinds
if (k === undefined) return false if (k === undefined) return false

3
src/i18n/locales/de.ts

@ -764,6 +764,8 @@ export default {
'Im Gegensatz zu regulären Notizen werden Umfragen nicht weit verbreitet unterstützt und werden möglicherweise nicht in anderen Clients angezeigt.', 'Im Gegensatz zu regulären Notizen werden Umfragen nicht weit verbreitet unterstützt und werden möglicherweise nicht in anderen Clients angezeigt.',
'Option {{number}}': 'Option {{number}}', 'Option {{number}}': 'Option {{number}}',
'Add Option': 'Option hinzufügen', 'Add Option': 'Option hinzufügen',
'Remove option': 'Option entfernen',
'Close highlight editor': 'Highlight-Editor schließen',
'Allow multiple choices': 'Mehrfachauswahl erlauben', 'Allow multiple choices': 'Mehrfachauswahl erlauben',
'End Date (optional)': 'Enddatum (optional)', 'End Date (optional)': 'Enddatum (optional)',
'Clear end date': 'Enddatum löschen', 'Clear end date': 'Enddatum löschen',
@ -1044,6 +1046,7 @@ export default {
'Trending on the Default Relays': 'Trending auf den Standard-Relays', 'Trending on the Default Relays': 'Trending auf den Standard-Relays',
'Latest from your follows': 'Neuestes von deinen Follows', 'Latest from your follows': 'Neuestes von deinen Follows',
'Latest from our recommended follows': 'Neuestes von unseren empfohlenen Follows', 'Latest from our recommended follows': 'Neuestes von unseren empfohlenen Follows',
'Search page title': 'Nostr durchsuchen',
'Follows latest page title': 'Neuestes von Follows', 'Follows latest page title': 'Neuestes von Follows',
'Follows latest page description': 'Follows latest page description':
'Aktuelle Notizen von Leuten, denen du folgst (ohne Konto: unsere kuratierte Liste). Wir führen Outbox-Relays aus ihren NIP-65-Listen mit deinen Favoriten zusammen und laden in Stapeln. Zeile aufklappen für Notizen oder Profil antippen.', 'Aktuelle Notizen von Leuten, denen du folgst (ohne Konto: unsere kuratierte Liste). Wir führen Outbox-Relays aus ihren NIP-65-Listen mit deinen Favoriten zusammen und laden in Stapeln. Zeile aufklappen für Notizen oder Profil antippen.',

3
src/i18n/locales/en.ts

@ -761,6 +761,8 @@ export default {
'Unlike regular notes, polls are not widely supported and may not display on other clients.', 'Unlike regular notes, polls are not widely supported and may not display on other clients.',
'Option {{number}}': 'Option {{number}}', 'Option {{number}}': 'Option {{number}}',
'Add Option': 'Add Option', 'Add Option': 'Add Option',
'Remove option': 'Remove option',
'Close highlight editor': 'Close highlight editor',
'Allow multiple choices': 'Allow multiple choices', 'Allow multiple choices': 'Allow multiple choices',
'End Date (optional)': 'End Date (optional)', 'End Date (optional)': 'End Date (optional)',
'Clear end date': 'Clear end date', 'Clear end date': 'Clear end date',
@ -1042,6 +1044,7 @@ export default {
'Trending on the Default Relays': 'Trending on the Default Relays', 'Trending on the Default Relays': 'Trending on the Default Relays',
'Latest from your follows': 'Latest from your follows', 'Latest from your follows': 'Latest from your follows',
'Latest from our recommended follows': 'Latest from our recommended follows', 'Latest from our recommended follows': 'Latest from our recommended follows',
'Search page title': 'Search Nostr',
'Follows latest page title': 'Latest from follows', 'Follows latest page title': 'Latest from follows',
'Follows latest page description': 'Follows latest page description':
'Recent notes from accounts you follow (or a curated list when not signed in), using their outbox relays merged with your favorites. Expand a row for notes or open the profile from the row.', 'Recent notes from accounts you follow (or a curated list when not signed in), using their outbox relays merged with your favorites. Expand a row for notes or open the profile from the row.',

47
src/lib/dtag-search.ts

@ -0,0 +1,47 @@
import { ExtendedKind } from '@/constants'
import type { Event } from 'nostr-tools'
export function getDTagValue(event: Event): string | undefined {
const t = event.tags.find((x) => x[0] === 'd' && x[1])?.[1]
return t
}
/** d-tag contains needle or note content contains needle (case-insensitive). */
export function eventMatchesDTagLooseQuery(needle: string, event: Event): boolean {
const q = needle.trim().toLowerCase()
if (!q) return true
const d = getDTagValue(event)?.toLowerCase() ?? ''
if (d.includes(q)) return true
if ((event.content ?? '').toLowerCase().includes(q)) return true
return false
}
/** Sort key: exact d-tag match first, then prefix, substring, then non-d / content-only. */
export function dTagMatchRank(needle: string, dVal: string | undefined): number {
if (!dVal) return 4
const nl = needle.trim().toLowerCase()
const dl = dVal.toLowerCase()
if (dl === nl) return 0
if (dl.startsWith(nl)) return 1
if (dl.includes(nl)) return 2
return 3
}
/** For merged lists: better d-tag match first; tie-break newest first. Kind 30041 sinks unless `d` equals the needle. */
export function compareEventsForDTagQuery(needle: string, a: Event, b: Event): number {
const nl = needle.trim().toLowerCase()
const ra = dTagMatchRank(needle, getDTagValue(a))
const rb = dTagMatchRank(needle, getDTagValue(b))
if (nl.length > 0) {
const kCh = ExtendedKind.PUBLICATION_CONTENT
const aExact = getDTagValue(a)?.toLowerCase() === nl
const bExact = getDTagValue(b)?.toLowerCase() === nl
const aBottom = a.kind === kCh && !aExact
const bBottom = b.kind === kCh && !bExact
if (aBottom !== bBottom) return aBottom ? 1 : -1
}
if (ra !== rb) return ra - rb
return b.created_at - a.created_at
}

55
src/lib/youtube-iframe-api.ts

@ -0,0 +1,55 @@
/**
* Loads YouTubes iframe API exactly once. Multiple {@link YoutubeEmbeddedPlayer} instances must not each inject
* `https://www.youtube.com/iframe_api` that produced many duplicate &lt;script&gt; tags and broken callbacks.
*/
let iframeApiReadyPromise: Promise<void> | null = null
const IFRAME_API_URL = 'https://www.youtube.com/iframe_api'
function hasYtPlayer(): boolean {
return !!(window as Window & { YT?: { Player?: unknown } }).YT?.Player
}
function scriptAlreadyPresent(): boolean {
return !!document.querySelector(`script[src="${IFRAME_API_URL}"], script[src*="youtube.com/iframe_api"]`)
}
export function ensureYouTubeIframeApi(): Promise<void> {
if (typeof window === 'undefined') return Promise.resolve()
if (hasYtPlayer()) return Promise.resolve()
if (!iframeApiReadyPromise) {
iframeApiReadyPromise = new Promise<void>((resolve) => {
const tryResolve = () => {
if (hasYtPlayer()) resolve()
}
const chainReady = () => {
const previous = window.onYouTubeIframeAPIReady
window.onYouTubeIframeAPIReady = () => {
previous?.()
tryResolve()
}
}
if (scriptAlreadyPresent()) {
chainReady()
const poll = () => {
tryResolve()
if (hasYtPlayer()) return
requestAnimationFrame(poll)
}
poll()
return
}
chainReady()
const script = document.createElement('script')
script.src = IFRAME_API_URL
script.async = true
document.body.appendChild(script)
})
}
return iframeApiReadyPromise
}

30
src/pages/primary/FollowsLatestPage/index.tsx

@ -2,6 +2,7 @@ import LatestFromFollowsSection from '@/components/LatestFromFollowsSection'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout'
import { TPageRef } from '@/types' import { TPageRef } from '@/types'
import { UsersRound } from 'lucide-react'
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react' import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -27,21 +28,13 @@ const FollowsLatestPage = forwardRef<TPageRef>(function FollowsLatestPage(_, ref
<PrimaryPageLayout <PrimaryPageLayout
ref={layoutRef} ref={layoutRef}
pageName="follows-latest" pageName="follows-latest"
titlebar={null} titlebar={<FollowsLatestPageTitlebar onRefresh={bumpRefresh} />}
displayScrollToTopButton displayScrollToTopButton
> >
<div className="min-w-0 pt-4 px-4 pb-8"> <div className="min-w-0 pt-4 px-4 pb-8">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <p className="mb-4 max-w-prose text-sm text-muted-foreground leading-relaxed">
<div className="min-w-0 space-y-2"> {t('Follows latest page description')}
<h1 className="text-2xl font-bold tracking-tight">{t('Follows latest page title')}</h1> </p>
<p className="max-w-prose text-sm text-muted-foreground leading-relaxed">
{t('Follows latest page description')}
</p>
</div>
<div className="shrink-0 self-start sm:self-center">
<RefreshButton onClick={bumpRefresh} />
</div>
</div>
<LatestFromFollowsSection refreshKey={refreshKey} variant="page" /> <LatestFromFollowsSection refreshKey={refreshKey} variant="page" />
</div> </div>
</PrimaryPageLayout> </PrimaryPageLayout>
@ -50,3 +43,16 @@ const FollowsLatestPage = forwardRef<TPageRef>(function FollowsLatestPage(_, ref
FollowsLatestPage.displayName = 'FollowsLatestPage' FollowsLatestPage.displayName = 'FollowsLatestPage'
export default FollowsLatestPage export default FollowsLatestPage
function FollowsLatestPageTitlebar({ onRefresh }: { onRefresh: () => void }) {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center justify-between gap-2 pr-1">
<div className="flex items-center gap-2 pl-3">
<UsersRound className="size-5" />
<div className="app-chrome-title">{t('Follows latest page title')}</div>
</div>
<RefreshButton onClick={onRefresh} />
</div>
)
}

22
src/pages/primary/SearchPage/index.tsx

@ -6,9 +6,10 @@ import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { TPageRef, TSearchParams } from '@/types' import { TPageRef, TSearchParams } from '@/types'
import { BookOpen } from 'lucide-react' import { BookOpen, Search } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const SearchPage = forwardRef<TPageRef>((_props, ref) => { const SearchPage = forwardRef<TPageRef>((_props, ref) => {
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
@ -54,14 +55,10 @@ const SearchPage = forwardRef<TPageRef>((_props, ref) => {
<PrimaryPageLayout <PrimaryPageLayout
ref={layoutRef} ref={layoutRef}
pageName="search" pageName="search"
titlebar={null} titlebar={<SearchPageTitlebar onRefresh={bumpResults} />}
displayScrollToTopButton displayScrollToTopButton
> >
<div className="min-w-0 pt-4 px-4 pb-4"> <div className="min-w-0 pt-4 px-4 pb-4">
<div className="mb-4 flex items-center justify-between gap-2">
<div className="text-2xl font-bold">Search Nostr</div>
<RefreshButton onClick={bumpResults} />
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mb-4 relative z-40"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 mb-4 relative z-40">
<div className="flex-1 relative order-2 sm:order-1"> <div className="flex-1 relative order-2 sm:order-1">
<SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} /> <SearchBar ref={searchBarRef} onSearch={onSearch} input={input} setInput={setInput} />
@ -93,3 +90,16 @@ const SearchPage = forwardRef<TPageRef>((_props, ref) => {
}) })
SearchPage.displayName = 'SearchPage' SearchPage.displayName = 'SearchPage'
export default SearchPage export default SearchPage
function SearchPageTitlebar({ onRefresh }: { onRefresh: () => void }) {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center justify-between gap-2 pr-1">
<div className="flex items-center gap-2 pl-3">
<Search className="size-5" />
<div className="app-chrome-title">{t('Search page title')}</div>
</div>
<RefreshButton onClick={onRefresh} />
</div>
)
}

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

@ -3,13 +3,14 @@ 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, SEARCHABLE_RELAY_URLS } from '@/constants' import { isSocialKindBlockedKind, NIP_SEARCH_DOCUMENT_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { import {
augmentSubRequestsWithFavoritesFastReadAndInbox, augmentSubRequestsWithFavoritesFastReadAndInbox,
getRelayUrlsWithFavoritesFastReadAndInbox getRelayUrlsWithFavoritesFastReadAndInbox
} from '@/lib/favorites-feed-relays' } from '@/lib/favorites-feed-relays'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toProfileList } from '@/lib/link' import { toProfileList } from '@/lib/link'
import { compareEventsForDTagQuery, eventMatchesDTagLooseQuery } from '@/lib/dtag-search'
import { fetchPubkeysFromDomain, getWellKnownNip05Url } from '@/lib/nip05' import { fetchPubkeysFromDomain, getWellKnownNip05Url } from '@/lib/nip05'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
@ -191,30 +192,31 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
setSubRequests([]) setSubRequests([])
} }
} else { } else {
// D-tag search - filter events by d-tag value // D-tag browse: NIP-50 search + exact #d REQ (merged), substring match client-side, exact d-tag sorted first
setTitle(`D-Tag: ${domain}`) setTitle(`D-Tag: ${domain}`)
setData({ setData({
type: 'dtag', type: 'dtag',
dtag: domain, dtag: domain,
kinds: kinds.length > 0 ? kinds : undefined kinds: kinds.length > 0 ? kinds : undefined
}) })
// Filter by d-tag - we'll need to fetch events that have this d-tag const relayUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
// For replaceable events, the d-tag is in the 'd' tag position favoriteRelays,
const filter: any = { blockedRelays,
'#d': [domain] relayList?.read ?? [],
} readUrlOpts
if (kinds.length > 0) { )
filter.kinds = kinds const mergedReqKinds = Array.from(
} new Set([...NIP_SEARCH_DOCUMENT_KINDS, ...(kinds.length > 0 ? kinds : [])])
).sort((a, b) => a - b)
const kindFilter = { kinds: mergedReqKinds }
setSubRequests([ setSubRequests([
{ {
filter, filter: { search: domain, ...kindFilter },
urls: getRelayUrlsWithFavoritesFastReadAndInbox( urls: [...new Set([...relayUrls, ...SEARCHABLE_RELAY_URLS])]
favoriteRelays, },
blockedRelays, {
relayList?.read ?? [], filter: { '#d': [domain], ...kindFilter },
readUrlOpts urls: relayUrls
)
} }
]) ])
} }
@ -296,7 +298,22 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
</div> </div>
) )
} else if (data) { } else if (data) {
content = <NormalFeed ref={feedRef} subRequests={subRequests} /> content =
data.type === 'dtag' && data.dtag ? (
<NormalFeed
ref={feedRef}
subRequests={subRequests}
oneShotFetch
progressiveWarmupQuery={data.dtag}
progressiveWarmupMatch={(ev) => eventMatchesDTagLooseQuery(data.dtag!, ev)}
progressiveDocumentKinds={NIP_SEARCH_DOCUMENT_KINDS}
oneShotAfterMergeComparator={(a, b) => compareEventsForDTagQuery(data.dtag!, a, b)}
extraShouldHideEvent={(ev) => !eventMatchesDTagLooseQuery(data.dtag!, ev)}
oneShotMergedCap={400}
/>
) : (
<NormalFeed ref={feedRef} subRequests={subRequests} />
)
} }
const titlebarExtras = controls const titlebarExtras = controls

8
src/providers/NostrProvider/index.tsx

@ -805,8 +805,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}, [account]) }, [account])
useEffect(() => { useEffect(() => {
customEmojiService.init(userEmojiListEvent) if (!account?.pubkey) {
}, [userEmojiListEvent]) void customEmojiService.init(null, null)
return
}
void customEmojiService.init(userEmojiListEvent, account.pubkey)
}, [userEmojiListEvent, account?.pubkey])
/** /**
* If session restore temporarily fell back to read-only (`npub`) while the stored * If session restore temporarily fell back to read-only (`npub`) while the stored

18
src/services/client-events.service.ts

@ -454,19 +454,27 @@ export class EventService {
*/ */
getSessionEventsMatchingSearch(query: string, limit: number, allowedKinds?: number[]): NEvent[] { getSessionEventsMatchingSearch(query: string, limit: number, allowedKinds?: number[]): NEvent[] {
const results: NEvent[] = [] const results: NEvent[] = []
const queryLower = query.toLowerCase() const queryTrim = query.trim()
const queryLower = queryTrim.toLowerCase()
for (const [, event] of this.sessionEventCache.entries()) { for (const [, event] of this.sessionEventCache.entries()) {
if (shouldDropEventOnIngest(event)) continue if (shouldDropEventOnIngest(event)) continue
if (allowedKinds && !allowedKinds.includes(event.kind)) continue if (allowedKinds && !allowedKinds.includes(event.kind)) continue
const content = event.content.toLowerCase() if (queryTrim === '') {
if (content.includes(queryLower)) { results.push(event)
if (results.length >= limit) break
continue
}
const content = (event.content ?? '').toLowerCase()
const tagsStr = (event.tags ?? []).flat().join(' ').toLowerCase()
if (content.includes(queryLower) || tagsStr.includes(queryLower)) {
results.push(event) results.push(event)
if (results.length >= limit) break if (results.length >= limit) break
} }
} }
return results return results
} }

48
src/services/client.service.ts

@ -3364,9 +3364,51 @@ class ClientService extends EventTarget {
return this.replaceableEventService.forceRefreshProfileAndPaymentInfoCache(pubkey) return this.replaceableEventService.forceRefreshProfileAndPaymentInfoCache(pubkey)
} }
async fetchEmojiSetEvents(_pointers: string[]) { /**
// Implementation would use replaceableEventService * Resolve `a` tags (kind:pubkey:d) pointing at kind 30030 emoji packs into events.
return [] */
async fetchEmojiSetEvents(pointers: string[]): Promise<NEvent[]> {
if (!pointers?.length) return []
const out: NEvent[] = []
for (const coord of pointers) {
const parts = coord.split(':')
if (parts.length < 3) continue
const kind = parseInt(parts[0]!, 10)
const authorPk = parts[1]?.trim().toLowerCase()
if (!authorPk || Number.isNaN(kind)) continue
const d = parts.slice(2).join(':')
try {
const ev = await this.replaceableEventService.fetchReplaceableEvent(authorPk, kind, d)
if (ev) out.push(ev)
} catch {
/* ignore per-pointer failures */
}
}
return out
}
/**
* Kind 10030 (user emoji list) + 30030 (emoji packs) for an author used to populate the custom emoji picker.
*/
async fetchAuthorEmojiInventory(pubkey: string): Promise<NEvent[]> {
const pk = pubkey.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) return []
const relayList = await this.fetchRelayList(pk)
const urls = dedupeNormalizeRelayUrlsOrdered([
...relayList.write.map((u) => normalizeUrl(u) || u),
...relayList.read.map((u) => normalizeUrl(u) || u),
...relayList.httpRead.map((u) => normalizeHttpRelayUrl(u) || u),
...relayList.httpWrite.map((u) => normalizeHttpRelayUrl(u) || u),
...FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u),
...PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u)
]).filter(Boolean)
const capped = urls.slice(0, 20)
if (capped.length === 0) return []
return this.queryService.fetchEvents(capped, {
kinds: [kinds.UserEmojiList, kinds.Emojisets],
authors: [pk],
limit: 80
})
} }
/** =========== Following favorite relays =========== */ /** =========== Following favorite relays =========== */

133
src/services/custom-emoji.service.ts

@ -6,15 +6,18 @@ import { sha256 } from '@noble/hashes/sha2'
import { SkinTones } from 'emoji-picker-react' import { SkinTones } from 'emoji-picker-react'
import { getSuggested, setSuggested } from 'emoji-picker-react/src/dataUtils/suggested' import { getSuggested, setSuggested } from 'emoji-picker-react/src/dataUtils/suggested'
import FlexSearch from 'flexsearch' import FlexSearch from 'flexsearch'
import { Event } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
class CustomEmojiService { class CustomEmojiService {
static instance: CustomEmojiService static instance: CustomEmojiService
private emojiMap = new Map<string, TEmoji>() private emojiMap = new Map<string, TEmoji>()
private emojiIndex = new FlexSearch.Index({ /** Hex pubkey (lowercase) of the event that introduced each custom emoji into the index. */
private emojiAuthorById = new Map<string, string>()
private emojiIndex: FlexSearch.Index = new FlexSearch.Index({
tokenize: 'full' tokenize: 'full'
}) })
private indexUpdateListeners = new Set<() => void>()
constructor() { constructor() {
if (!CustomEmojiService.instance) { if (!CustomEmojiService.instance) {
@ -23,23 +26,94 @@ class CustomEmojiService {
return CustomEmojiService.instance return CustomEmojiService.instance
} }
async init(userEmojiListEvent: Event | null) { /** Subscribe to runs after {@link init} finishes loading emoji sets (picker can refresh custom list). */
if (!userEmojiListEvent) return subscribeIndexUpdate(fn: () => void): () => void {
this.indexUpdateListeners.add(fn)
return () => this.indexUpdateListeners.delete(fn)
}
const { emojis, emojiSetPointers } = getEmojisAndEmojiSetsFromEvent(userEmojiListEvent) private notifyIndexUpdate() {
await this.addEmojisToIndex(emojis) this.indexUpdateListeners.forEach((f) => f())
}
const emojiSetEvents = await client.fetchEmojiSetEvents(emojiSetPointers) private reset() {
await Promise.allSettled( this.emojiMap.clear()
emojiSetEvents.map(async (event) => { this.emojiAuthorById.clear()
if (!event || (event as any) instanceof Error) return this.emojiIndex = new FlexSearch.Index({ tokenize: 'full' })
}
/**
* Load NIP-30 emoji sets (kind 10030) and packs (30030) for the account.
* Merges `userEmojiListEvent` with a relay fetch so we still load when hydrate missed the event
* (same idea as aitherboards picker: fetch author emoji kinds from read relays).
*/
async init(userEmojiListEvent: Event | null, accountPubkey?: string | null) {
this.reset()
const pk = accountPubkey?.trim().toLowerCase() ?? ''
const hasPk = /^[0-9a-f]{64}$/.test(pk)
const byId = new Map<string, Event>()
if (
userEmojiListEvent &&
hasPk &&
userEmojiListEvent.pubkey.trim().toLowerCase() === pk
) {
byId.set(userEmojiListEvent.id, userEmojiListEvent)
}
if (hasPk) {
const remote = await client.fetchAuthorEmojiInventory(pk).catch(() => [] as Event[])
for (const ev of remote) {
byId.set(ev.id, ev)
}
}
const events = [...byId.values()]
if (events.length === 0) {
this.notifyIndexUpdate()
return
}
await this.addEmojisToIndex(getEmojisFromEvent(event)) const listEvents = events
.filter((e) => e.kind === kinds.UserEmojiList)
.sort((a, b) => b.created_at - a.created_at)
const latestList = listEvents[0] ?? null
const packEvents = events.filter((e) => e.kind === kinds.Emojisets)
if (latestList) {
const authorPk = latestList.pubkey.toLowerCase()
const { emojis, emojiSetPointers } = getEmojisAndEmojiSetsFromEvent(latestList)
await this.addEmojisToIndex(emojis, authorPk)
const emojiSetEvents = await client.fetchEmojiSetEvents(emojiSetPointers)
await Promise.allSettled(
emojiSetEvents.map(async (event) => {
if (!event || (event as any) instanceof Error) return
await this.addEmojisToIndex(getEmojisFromEvent(event), event.pubkey.toLowerCase())
})
)
}
await Promise.allSettled(
packEvents.map(async (pack) => {
await this.addEmojisToIndex(getEmojisFromEvent(pack), pack.pubkey.toLowerCase())
}) })
) )
this.notifyIndexUpdate()
} }
async searchEmojis(query: string = ''): Promise<string[]> { private sortEmojiIdsForViewer(ids: string[], viewerPubkeyLower: string): string[] {
if (!viewerPubkeyLower) return ids
const own: string[] = []
const rest: string[] = []
for (const id of ids) {
if (this.emojiAuthorById.get(id) === viewerPubkeyLower) own.push(id)
else rest.push(id)
}
return [...own, ...rest]
}
async searchEmojis(query: string = '', viewerPubkey?: string | null): Promise<string[]> {
const v = viewerPubkey?.toLowerCase() ?? ''
if (!query) { if (!query) {
const idSet = new Set<string>() const idSet = new Set<string>()
getSuggested() getSuggested()
@ -48,18 +122,17 @@ class CustomEmojiService {
.forEach((item) => { .forEach((item) => {
if (item && typeof item !== 'string') { if (item && typeof item !== 'string') {
const id = this.getEmojiId(item) const id = this.getEmojiId(item)
if (!idSet.has(id)) { idSet.add(id)
idSet.add(id)
}
} }
}) })
for (const key of this.emojiMap.keys()) { for (const key of this.emojiMap.keys()) {
idSet.add(key) idSet.add(key)
} }
return Array.from(idSet) return this.sortEmojiIdsForViewer(Array.from(idSet), v)
} }
const results = await this.emojiIndex.searchAsync(query) const results = await this.emojiIndex.searchAsync(query)
return results.filter((id) => typeof id === 'string') as string[] const filtered = results.filter((id) => typeof id === 'string') as string[]
return this.sortEmojiIdsForViewer(filtered, v)
} }
getEmojiById(id?: string): TEmoji | undefined { getEmojiById(id?: string): TEmoji | undefined {
@ -68,23 +141,37 @@ class CustomEmojiService {
return this.emojiMap.get(id) return this.emojiMap.get(id)
} }
getAllCustomEmojisForPicker() { getAllCustomEmojisForPicker(viewerPubkey?: string | null) {
return Array.from(this.emojiMap.values()).map((emoji) => ({ const v = viewerPubkey?.toLowerCase() ?? ''
id: `:${emoji.shortcode}:${emoji.url}`, const rows = Array.from(this.emojiMap.entries()).map(([hashId, emoji]) => ({
imgUrl: emoji.url, row: {
names: [emoji.shortcode] id: `:${emoji.shortcode}:${emoji.url}`,
imgUrl: emoji.url,
names: [emoji.shortcode] as [string]
},
author: this.emojiAuthorById.get(hashId) ?? ''
})) }))
rows.sort((a, b) => {
if (v) {
const aOwn = a.author === v ? 0 : 1
const bOwn = b.author === v ? 0 : 1
if (aOwn !== bOwn) return aOwn - bOwn
}
return a.row.names[0].localeCompare(b.row.names[0])
})
return rows.map((r) => r.row)
} }
isCustomEmojiId(shortcode: string) { isCustomEmojiId(shortcode: string) {
return this.emojiMap.has(shortcode) return this.emojiMap.has(shortcode)
} }
private async addEmojisToIndex(emojis: TEmoji[]) { private async addEmojisToIndex(emojis: TEmoji[], authorPubkeyLower: string) {
await Promise.allSettled( await Promise.allSettled(
emojis.map(async (emoji) => { emojis.map(async (emoji) => {
const id = this.getEmojiId(emoji) const id = this.getEmojiId(emoji)
this.emojiMap.set(id, emoji) this.emojiMap.set(id, emoji)
this.emojiAuthorById.set(id, authorPubkeyLower)
await this.emojiIndex.addAsync(id, emoji.shortcode) await this.emojiIndex.addAsync(id, emoji.shortcode)
}) })
) )

18
src/services/gif.service.ts

@ -45,6 +45,19 @@ export function gifShouldOfferNip94Archive(gif: GifMetadata): boolean {
return true return true
} }
/** Own GIFs/memes first, then newest first (picker grids). */
export function sortGifsForPicker(gifs: GifMetadata[], userPubkey: string | null): GifMetadata[] {
const u = userPubkey?.toLowerCase() ?? ''
return [...gifs].sort((a, b) => {
if (u) {
const aOwn = a.pubkey.toLowerCase() === u ? 1 : 0
const bOwn = b.pubkey.toLowerCase() === u ? 1 : 0
if (aOwn !== bOwn) return bOwn - aOwn
}
return b.createdAt - a.createdAt
})
}
/** Normalize a GIF URL for deduplication: strip fragment and query, lowercase. */ /** Normalize a GIF URL for deduplication: strip fragment and query, lowercase. */
function normalizeGifUrl(url: string): string { function normalizeGifUrl(url: string): string {
try { try {
@ -237,7 +250,7 @@ export async function fetchGifs(
cached.gifs.length >= MIN_GIF_CACHE_ENTRIES && cached.gifs.length >= MIN_GIF_CACHE_ENTRIES &&
Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS
) { ) {
return cached.gifs.slice(0, limit) as GifMetadata[] return sortGifsForPicker(cached.gifs as GifMetadata[], userPubkey).slice(0, limit)
} }
} }
@ -312,8 +325,7 @@ export async function fetchGifs(
} }
const gifs = Array.from(byUrl.values()).map((v) => v.gif) const gifs = Array.from(byUrl.values()).map((v) => v.gif)
gifs.sort((a, b) => b.createdAt - a.createdAt) const result = sortGifsForPicker(gifs, userPubkey).slice(0, limit)
const result = gifs.slice(0, limit)
if (result.length >= MIN_GIF_CACHE_ENTRIES && !searchQuery) { if (result.length >= MIN_GIF_CACHE_ENTRIES && !searchQuery) {
await indexedDb.setGifCache(result, Date.now()) await indexedDb.setGifCache(result, Date.now())

71
src/services/indexed-db.service.ts

@ -1170,6 +1170,77 @@ class IndexedDbService {
}) })
} }
/**
* Publication store + hot {@link StoreNames.EVENT_ARCHIVE}: events whose kind is allowed and content or any tag
* value matches the query (case-insensitive). Used to show local hits before NIP-50 relay results.
*/
async getCachedAndArchivedEventsMatchingLocalSearch(
query: string,
limit: number,
allowedKinds: number[],
options?: { archiveScanMaxMs?: number }
): Promise<Event[]> {
const fromPub = await this.getCachedEventsForSearch(query, limit, allowedKinds)
if (fromPub.length >= limit) return fromPub.slice(0, limit)
const q = query.trim().toLowerCase()
if (!q || allowedKinds.length === 0) return fromPub
const kindSet = new Set(allowedKinds)
const seen = new Set(fromPub.map((e) => e.id))
const rest: Event[] = []
const scanStart = Date.now()
const archiveScanMaxMs = options?.archiveScanMaxMs
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) {
return fromPub
}
await new Promise<void>((resolve, reject) => {
const transaction = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly')
const store = transaction.objectStore(StoreNames.EVENT_ARCHIVE)
const request = store.openCursor()
request.onsuccess = () => {
if (
archiveScanMaxMs !== undefined &&
Date.now() - scanStart >= archiveScanMaxMs
) {
transaction.commit()
resolve()
return
}
const cursor = (request as IDBRequest<IDBCursorWithValue>).result
if (!cursor || fromPub.length + rest.length >= limit) {
transaction.commit()
resolve()
return
}
const row = cursor.value as TArchivedEventRow
const ev = row?.value
if (ev && kindSet.has(ev.kind) && !seen.has(ev.id)) {
const content = (ev.content ?? '').toLowerCase()
const tagsStr = (ev.tags ?? []).flat().join(' ').toLowerCase()
if (content.includes(q) || tagsStr.includes(q)) {
seen.add(ev.id)
rest.push(ev)
}
}
cursor.continue()
}
request.onerror = (e) => {
transaction.commit()
reject(idbEventToError(e))
}
}).catch((e: unknown) => {
logger.warn('[indexedDb] getCachedAndArchivedEventsMatchingLocalSearch archive scan failed', { e })
})
return [...fromPub, ...rest].slice(0, limit)
}
async getPublicationStoreItems(storeName: string): Promise<Array<{ key: string; value: any; addedAt: number; nestedCount?: number }>> { async getPublicationStoreItems(storeName: string): Promise<Array<{ key: string; value: any; addedAt: number; nestedCount?: number }>> {
// For publication stores, only return master events with nested counts // For publication stores, only return master events with nested counts
await this.initPromise await this.initPromise

22
src/services/meme.service.ts

@ -52,6 +52,19 @@ function isStaticMemeUrl(mimeType: string | undefined, url: string): boolean {
return /\.(jpe?g|png|webp)(\?|$)/i.test(url) return /\.(jpe?g|png|webp)(\?|$)/i.test(url)
} }
/** Own templates first, then newest first (picker grid). */
export function sortMemesForPicker(memes: MemeMetadata[], userPubkey: string | null): MemeMetadata[] {
const u = userPubkey?.toLowerCase() ?? ''
return [...memes].sort((a, b) => {
if (u) {
const aOwn = a.pubkey.toLowerCase() === u ? 1 : 0
const bOwn = b.pubkey.toLowerCase() === u ? 1 : 0
if (aOwn !== bOwn) return bOwn - aOwn
}
return b.createdAt - a.createdAt
})
}
function normalizeMemeUrl(url: string): string { function normalizeMemeUrl(url: string): string {
try { try {
const withoutFragment = url.split('#')[0].trim() const withoutFragment = url.split('#')[0].trim()
@ -262,7 +275,7 @@ export async function fetchMemes(
cached.memes.length >= MIN_MEME_CACHE_ENTRIES && cached.memes.length >= MIN_MEME_CACHE_ENTRIES &&
Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS
) { ) {
return cached.memes.slice(0, limit) as MemeMetadata[] return sortMemesForPicker(cached.memes as MemeMetadata[], userPubkey).slice(0, limit)
} }
} }
@ -308,7 +321,7 @@ export async function fetchMemes(
) )
} catch (err) { } catch (err) {
if (!searchQuery && staleFallback?.length) { if (!searchQuery && staleFallback?.length) {
return staleFallback.slice(0, limit) as MemeMetadata[] return sortMemesForPicker(staleFallback as MemeMetadata[], userPubkey).slice(0, limit)
} }
throw err throw err
} }
@ -335,11 +348,10 @@ export async function fetchMemes(
} }
const memes = Array.from(byUrl.values()).map((v) => v.meme) const memes = Array.from(byUrl.values()).map((v) => v.meme)
memes.sort((a, b) => b.createdAt - a.createdAt) let result = sortMemesForPicker(memes, userPubkey).slice(0, limit)
let result = memes.slice(0, limit)
if (!searchQuery && result.length === 0 && staleFallback?.length) { if (!searchQuery && result.length === 0 && staleFallback?.length) {
result = staleFallback.slice(0, limit) result = sortMemesForPicker(staleFallback as MemeMetadata[], userPubkey).slice(0, limit)
} }
if (result.length > 0 && !searchQuery) { if (result.length > 0 && !searchQuery) {

2
src/types/index.d.ts vendored

@ -235,6 +235,8 @@ export type TSearchParams = {
type: TSearchType type: TSearchType
search: string search: string
input?: string input?: string
/** Present for profile rows from typeahead; avoids redundant fetch and shows cached avatar/name immediately. */
profile?: TProfile
} }
export type TNotificationStyle = export type TNotificationStyle =

Loading…
Cancel
Save