Browse Source

reduce repo size some more

imwald
Silberengel 3 weeks ago
parent
commit
3f66a0e6ca
  1. 4
      src/components/PublishSuccessSubtleIndicator/index.tsx
  2. 2
      src/contexts/suppress-embedded-note-context.tsx
  3. 1
      src/hooks/index.tsx
  4. 48
      src/hooks/useFetchRelayInfos.tsx
  5. 135
      src/hooks/useProfileBadges.tsx
  6. 122
      src/hooks/useProfileFollowPacks.tsx
  7. 275
      src/hooks/useProfileInteractions.tsx
  8. 4
      src/i18n/index.ts
  9. 2
      src/layouts/SecondaryPageLayout/index.tsx
  10. 2
      src/lib/compress-upload-media.ts
  11. 14
      src/lib/content-parser.ts
  12. 1
      src/lib/debug-utils.ts
  13. 8
      src/lib/discussion-thread-composer.ts
  14. 154
      src/lib/discussion-topics.ts
  15. 13
      src/lib/discussion-votes.ts
  16. 2
      src/lib/document-meta.ts
  17. 43
      src/lib/draft-event.ts
  18. 4
      src/lib/dtag-search.ts
  19. 30
      src/lib/emoji-content.ts
  20. 2
      src/lib/error-suppression.ts
  21. 4
      src/lib/event-filtering.ts
  22. 4
      src/lib/event-ingest-filter.ts
  23. 93
      src/lib/event.ts
  24. 4
      src/lib/favorites-feed-relays.ts
  25. 2
      src/lib/fetch-with-timeout.ts
  26. 2
      src/lib/follow-outbox-aggregate-relays.ts
  27. 2
      src/lib/follow-set-spell.ts
  28. 14
      src/lib/git-republic-event.ts
  29. 158
      src/lib/image-extraction.ts
  30. 6
      src/lib/index-relay-http.ts
  31. 7
      src/lib/like-reaction-emojis.ts
  32. 4
      src/lib/link.ts
  33. 10
      src/lib/live-activities.ts
  34. 4
      src/lib/nip84-highlight-display.ts
  35. 36
      src/lib/nostr-address.ts
  36. 11
      src/lib/nostr-build.ts
  37. 6
      src/lib/payto.ts
  38. 15
      src/lib/private-relays.ts
  39. 4
      src/lib/profile-accordion-fetch.ts
  40. 43
      src/lib/profile-accordion-session-cache.ts
  41. 5
      src/lib/publication-rendered-events.ts
  42. 2
      src/lib/publishing-feedback.tsx
  43. 4
      src/lib/react-remove-scroll-body-cleanup.ts
  44. 2
      src/lib/recently-used-emojis.ts
  45. 108
      src/lib/relay-list-builder.ts
  46. 7
      src/lib/relay-pulse-nip05.ts
  47. 2
      src/lib/relay-url-priority.ts
  48. 4
      src/lib/relay.ts
  49. 1
      src/services/content-parser.service.ts
  50. 18
      src/services/spell.service.ts

4
src/components/PublishSuccessSubtleIndicator/index.tsx

@ -5,8 +5,8 @@ import { useEffect, useRef, useState } from 'react' @@ -5,8 +5,8 @@ import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
/**
* When publish success toasts are off, {@link emitPublishSuccessSubtle} shows this instead:
* small green check + label, bottom-right, auto-dismiss.
* When publish success toasts are off, `publishing-feedback` dispatches {@link PUBLISH_SUCCESS_SUBTLE_EVENT}
* so we show a small green check + label, bottom-right, auto-dismiss.
*/
export default function PublishSuccessSubtleIndicator() {
const { t } = useTranslation()

2
src/contexts/suppress-embedded-note-context.tsx

@ -6,7 +6,7 @@ export type SuppressEmbeddedNoteValue = { @@ -6,7 +6,7 @@ export type SuppressEmbeddedNoteValue = {
}
/** When set, EmbeddedNote should not render notes whose id/coordinate matches (avoids redundancy when viewing "quotes of this note"). */
export const SuppressEmbeddedNoteContext = createContext<SuppressEmbeddedNoteValue | undefined>(undefined)
const SuppressEmbeddedNoteContext = createContext<SuppressEmbeddedNoteValue | undefined>(undefined)
export function useSuppressEmbeddedNoteId(): SuppressEmbeddedNoteValue | undefined {
return useContext(SuppressEmbeddedNoteContext)

1
src/hooks/index.tsx

@ -5,7 +5,6 @@ export * from './useFetchFollowings' @@ -5,7 +5,6 @@ export * from './useFetchFollowings'
export * from './useFetchNip05'
export * from './useFetchProfile'
export * from './useFetchRelayInfo'
export * from './useFetchRelayInfos'
export * from './useFetchRelayList'
export * from './useSearchProfiles'
export * from './useMediaExtraction'

48
src/hooks/useFetchRelayInfos.tsx

@ -1,48 +0,0 @@ @@ -1,48 +0,0 @@
import { checkAlgoRelay } from '@/lib/relay'
import relayInfoService from '@/services/relay-info.service'
import { TRelayInfo } from '@/types'
import { useEffect, useState } from 'react'
import logger from '@/lib/logger'
export function useFetchRelayInfos(urls: string[]) {
const [isFetching, setIsFetching] = useState(true)
const [relayInfos, setRelayInfos] = useState<(TRelayInfo | undefined)[]>([])
const [areAlgoRelays, setAreAlgoRelays] = useState(false)
const [searchableRelayUrls, setSearchableRelayUrls] = useState<string[]>([])
const urlsString = JSON.stringify(urls)
useEffect(() => {
const fetchRelayInfos = async () => {
setIsFetching(true)
if (urls.length === 0) {
return setIsFetching(false)
}
const timer = setTimeout(() => {
setIsFetching(false)
}, 5000)
try {
const relayInfos = await relayInfoService.getRelayInfos(urls)
setRelayInfos(relayInfos)
setAreAlgoRelays(relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo)))
setSearchableRelayUrls(
relayInfos
.map((relayInfo, index) => ({
url: urls[index],
searchable: relayInfo?.supported_nips?.includes(50)
}))
.filter((relayInfo) => relayInfo.searchable)
.map((relayInfo) => relayInfo.url)
)
} catch (err) {
logger.error('Failed to fetch relay infos', { error: err, urls })
} finally {
clearTimeout(timer)
setIsFetching(false)
}
}
fetchRelayInfos()
}, [urlsString])
return { relayInfos, isFetching, areAlgoRelays, searchableRelayUrls }
}

135
src/hooks/useProfileBadges.tsx

@ -5,19 +5,9 @@ import { @@ -5,19 +5,9 @@ import {
fetchNip58BadgeDefinition,
mergeNip58BadgeRelayPool
} from '@/lib/fetch-badge-nip58'
import {
profileAccordionGetCachedBadges,
profileAccordionGetCachedRelayUrls,
profileAccordionRelayUrlsKey,
profileAccordionSetBadges
} from '@/lib/profile-accordion-session-cache'
import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Event } from 'nostr-tools'
import { tagNameEquals } from '@/lib/tag'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { buildProfileRelayUrls } from '@/lib/profile-relay-urls'
export type TProfileBadge = {
/** Badge definition coordinate (e.g. "30009:alice:bravery") */
@ -49,14 +39,7 @@ function parseATag(aTag: string): { kind: number; pubkey: string; d: string } | @@ -49,14 +39,7 @@ function parseATag(aTag: string): { kind: number; pubkey: string; d: string } |
return { kind, pubkey: pk.toLowerCase(), d }
}
/** True when we should re-resolve the badge definition (missing media but coordinate looks like kind 30009). */
function badgeNeedsDefinitionMedia(b: TProfileBadge): boolean {
if (b.thumb || b.image) return false
const parsed = parseATag(b.a)
return !!(parsed && parsed.kind === ExtendedKind.BADGE_DEFINITION)
}
export function mergeProfileBadgesByAwardId(seed: TProfileBadge[], fresh: TProfileBadge[]): TProfileBadge[] {
function mergeProfileBadgesByAwardId(seed: TProfileBadge[], fresh: TProfileBadge[]): TProfileBadge[] {
const m = new Map<string, TProfileBadge>()
for (const b of seed) m.set(b.awardId, b)
for (const b of fresh) m.set(b.awardId, b)
@ -89,123 +72,9 @@ export async function enrichBadgesFromIndexedDb(badges: TProfileBadge[]): Promis @@ -89,123 +72,9 @@ export async function enrichBadgesFromIndexedDb(badges: TProfileBadge[]): Promis
)
}
/** NIP-58: Fetches profile badges (kind 30008) and resolves badge definitions (kind 30009). */
/** Pass relayUrls to share with other profile fetches. */
export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[]) {
const { blockedRelays } = useFavoriteRelays()
const blockedRelaysRef = useRef(blockedRelays)
blockedRelaysRef.current = blockedRelays
const relayUrlsRef = useRef(relayUrls)
relayUrlsRef.current = relayUrls
const blockedRelaysKey = profileAccordionRelayUrlsKey(blockedRelays)
const relayUrlsKey = profileAccordionRelayUrlsKey(relayUrls ?? [])
const [badges, setBadges] = useState<TProfileBadge[]>([])
const [loading, setLoading] = useState(false)
const fetchIdRef = useRef(0)
const fetchBadges = useCallback(async (force = false) => {
const myFetchId = (fetchIdRef.current += 1)
if (!pubkey) {
if (myFetchId === fetchIdRef.current) {
setBadges([])
setLoading(false)
}
return
}
const relayUrlsLatest = relayUrlsRef.current
let urls =
relayUrlsLatest && relayUrlsLatest.length > 0
? relayUrlsLatest
: profileAccordionGetCachedRelayUrls(pubkey) ?? []
if (force || urls.length === 0) {
urls = await buildProfileRelayUrls(pubkey, blockedRelaysRef.current)
}
const relayKey = profileAccordionRelayUrlsKey(urls)
const seedBadges = profileAccordionGetCachedBadges(pubkey, relayKey)
let deferLoading = !!(force && seedBadges?.length)
if (!force) {
const cached = seedBadges
if (cached?.length) {
if (cached.some(badgeNeedsDefinitionMedia)) {
const enriched = await enrichBadgesFromIndexedDb(cached)
if (!enriched.some(badgeNeedsDefinitionMedia)) {
if (myFetchId !== fetchIdRef.current) return
setBadges(enriched)
profileAccordionSetBadges(pubkey, relayKey, enriched)
setLoading(false)
return
}
deferLoading = false
// Session cache was incomplete and IndexedDB has no definitions — fetch from network below.
} else {
if (myFetchId !== fetchIdRef.current) return
setBadges(cached)
setLoading(false)
return
}
}
}
if (force && seedBadges?.length && myFetchId === fetchIdRef.current) {
setBadges(seedBadges)
}
if (myFetchId !== fetchIdRef.current) return
if (!deferLoading) {
setLoading(true)
}
try {
const events = await queryService.fetchEvents(
urls,
{ authors: [pubkey], kinds: [ExtendedKind.PROFILE_BADGES], '#d': ['profile_badges'] },
{ eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false }
)
const profileBadgesEvent = events.sort((a, b) => b.created_at - a.created_at)[0]
if (!profileBadgesEvent || myFetchId !== fetchIdRef.current) {
if (myFetchId === fetchIdRef.current && !seedBadges?.length) setBadges([])
return
}
const merged = await resolveProfileBadgeList(
profileBadgesEvent,
urls,
blockedRelaysRef.current,
seedBadges
)
if (myFetchId !== fetchIdRef.current) return
setBadges(merged)
profileAccordionSetBadges(pubkey, relayKey, merged)
} catch {
if (myFetchId !== fetchIdRef.current) return
if (!seedBadges?.length) setBadges([])
} finally {
if (myFetchId === fetchIdRef.current) setLoading(false)
}
}, [pubkey, blockedRelaysKey, relayUrlsKey])
const refresh = useCallback(() => {
void fetchBadges(true)
}, [pubkey, fetchBadges])
useEffect(() => {
void fetchBadges(false)
}, [fetchBadges])
return { badges, loading, refresh }
}
/**
* Resolves NIP-58 badge definitions/awards for the newest kind-30008 `profile_badges` event.
* Shared by {@link useProfileBadges} and profile accordion bundle fetch.
* Used by profile accordion bundle fetch.
*/
export async function resolveProfileBadgeList(
profileBadgesEvent: Event | undefined,

122
src/hooks/useProfileFollowPacks.tsx

@ -1,128 +1,6 @@ @@ -1,128 +1,6 @@
import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants'
import {
profileAccordionGetCachedFollowPacks,
profileAccordionGetCachedRelayUrls,
profileAccordionRelayUrlsKey,
profileAccordionSetFollowPacks
} from '@/lib/profile-accordion-session-cache'
import { replaceableEventDedupeKey } from '@/lib/event'
import { queryService } from '@/services/client.service'
import { Event } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { buildProfileRelayUrls } from '@/lib/profile-relay-urls'
export type TProfileFollowPack = {
event: Event
title: string
}
function getPackTitle(event: Event): string {
const titleTag = event.tags.find((tag) => tag[0] === 'title' || tag[0] === 'name')
return titleTag?.[1] || 'Follow Pack'
}
/** Fetches follow packs (kind 39089) that contain this pubkey in #p tags. */
export function useProfileFollowPacks(
pubkey: string | undefined,
relayUrls?: string[]
) {
const { blockedRelays } = useFavoriteRelays()
const blockedRelaysRef = useRef(blockedRelays)
blockedRelaysRef.current = blockedRelays
const relayUrlsRef = useRef(relayUrls)
relayUrlsRef.current = relayUrls
const blockedRelaysKey = profileAccordionRelayUrlsKey(blockedRelays)
const relayUrlsKey = profileAccordionRelayUrlsKey(relayUrls ?? [])
const [packs, setPacks] = useState<TProfileFollowPack[]>([])
const [loading, setLoading] = useState(false)
const fetchIdRef = useRef(0)
const fetchPacks = useCallback(async (force = false) => {
const myFetchId = (fetchIdRef.current += 1)
if (!pubkey) {
if (myFetchId === fetchIdRef.current) {
setPacks([])
setLoading(false)
}
return
}
const relayUrlsLatest = relayUrlsRef.current
let urls =
relayUrlsLatest && relayUrlsLatest.length > 0
? relayUrlsLatest
: profileAccordionGetCachedRelayUrls(pubkey) ?? []
if (force || urls.length === 0) {
urls = await buildProfileRelayUrls(pubkey, blockedRelaysRef.current)
}
const queryUrls = urls.length > 0 ? urls : [...FAST_READ_RELAY_URLS]
const relayKey = profileAccordionRelayUrlsKey(queryUrls)
if (!force) {
const cached = profileAccordionGetCachedFollowPacks(pubkey, relayKey)
if (cached) {
if (myFetchId !== fetchIdRef.current) return
setPacks(cached)
setLoading(false)
return
}
}
const seed = profileAccordionGetCachedFollowPacks(pubkey, relayKey)
if (seed?.length && myFetchId === fetchIdRef.current) {
setPacks(seed)
}
if (myFetchId !== fetchIdRef.current) return
if (!seed?.length) {
setLoading(true)
}
try {
const events = await queryService.fetchEvents(
queryUrls,
[{ '#p': [pubkey], kinds: [ExtendedKind.FOLLOW_PACK], limit: 50 }],
{ eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false }
)
if (myFetchId !== fetchIdRef.current) return
const network: TProfileFollowPack[] = events.map((evt) => ({
event: evt,
title: getPackTitle(evt)
}))
const byDedupeKey = new Map<string, TProfileFollowPack>()
const put = (p: TProfileFollowPack) => {
const k = replaceableEventDedupeKey(p.event)
const prev = byDedupeKey.get(k)
if (!prev || p.event.created_at > prev.event.created_at) {
byDedupeKey.set(k, p)
}
}
for (const p of seed ?? []) put(p)
for (const p of network) put(p)
const merged = [...byDedupeKey.values()].sort((a, b) => b.event.created_at - a.event.created_at)
setPacks(merged)
profileAccordionSetFollowPacks(pubkey, relayKey, merged)
} catch {
if (myFetchId !== fetchIdRef.current) return
if (!seed?.length) setPacks([])
} finally {
if (myFetchId === fetchIdRef.current) setLoading(false)
}
}, [pubkey, blockedRelaysKey, relayUrlsKey])
const refresh = useCallback(() => {
void fetchPacks(true)
}, [pubkey, fetchPacks])
useEffect(() => {
void fetchPacks(false)
}, [fetchPacks])
return { packs, loading, refresh }
}

275
src/hooks/useProfileInteractions.tsx

@ -1,18 +1,3 @@ @@ -1,18 +1,3 @@
import { ExtendedKind } from '@/constants'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { queryService, replaceableEventService } from '@/services/client.service'
import { hexPubkeysEqual } from '@/lib/pubkey'
import { Event, Filter, kinds } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
profileAccordionGetCachedInteractions,
profileAccordionGetCachedRelayUrls,
profileAccordionRelayUrlsKey,
profileAccordionSetInteractions
} from '@/lib/profile-accordion-session-cache'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { buildProfileRelayUrls } from '@/lib/profile-relay-urls'
export type TProfileZap = {
pr: string
pubkey: string
@ -20,263 +5,3 @@ export type TProfileZap = { @@ -20,263 +5,3 @@ export type TProfileZap = {
created_at: number
comment?: string
}
const NOTE_IDS_FOR_COMMENTS = 50
/** Fetches zaps, reactions (likes on the kind-0 profile metadata event only), and comments (on the user's notes + profile). */
/** Uses profile owner's outboxes + PROFILE_FETCH_RELAY_URLS. Pass relayUrls to share with other profile fetches. */
export function useProfileInteractions(pubkey: string | undefined, relayUrls?: string[]) {
const { blockedRelays } = useFavoriteRelays()
const blockedRelaysRef = useRef(blockedRelays)
blockedRelaysRef.current = blockedRelays
const relayUrlsRef = useRef(relayUrls)
relayUrlsRef.current = relayUrls
const blockedRelaysKey = profileAccordionRelayUrlsKey(blockedRelays)
const relayUrlsKey = profileAccordionRelayUrlsKey(relayUrls ?? [])
const [zaps, setZaps] = useState<TProfileZap[]>([])
const [reactions, setReactions] = useState<Event[]>([])
const [comments, setComments] = useState<Event[]>([])
const [loading, setLoading] = useState(false)
const fetchIdRef = useRef(0)
const fetchAll = useCallback(async (force = false) => {
const myFetchId = (fetchIdRef.current += 1)
if (!pubkey) {
if (myFetchId === fetchIdRef.current) {
setZaps([])
setReactions([])
setComments([])
setLoading(false)
}
return
}
const relayUrlsLatest = relayUrlsRef.current
let urls =
relayUrlsLatest && relayUrlsLatest.length > 0
? relayUrlsLatest
: profileAccordionGetCachedRelayUrls(pubkey) ?? []
if (force || urls.length === 0) {
urls = await buildProfileRelayUrls(pubkey, blockedRelaysRef.current)
}
const relayKey = profileAccordionRelayUrlsKey(urls)
if (!force) {
const cached = profileAccordionGetCachedInteractions(pubkey, relayKey)
if (cached) {
if (myFetchId !== fetchIdRef.current) return
setZaps([...cached.zaps].sort((a, b) => b.amount - a.amount))
setReactions([...cached.reactions].sort((a, b) => b.created_at - a.created_at))
setComments([...cached.comments].sort((a, b) => b.created_at - a.created_at))
setLoading(false)
return
}
}
const seed = profileAccordionGetCachedInteractions(pubkey, relayKey)
if (seed && myFetchId === fetchIdRef.current) {
setZaps([...seed.zaps].sort((a, b) => b.amount - a.amount))
setReactions([...seed.reactions].sort((a, b) => b.created_at - a.created_at))
setComments([...seed.comments].sort((a, b) => b.created_at - a.created_at))
}
if (myFetchId !== fetchIdRef.current) return
const hasVisibleSeed =
!!seed &&
(seed.zaps.length > 0 || seed.reactions.length > 0 || seed.comments.length > 0)
if (!hasVisibleSeed) {
setLoading(true)
}
try {
const profileMetaPromise = replaceableEventService.fetchReplaceableEvent(
pubkey,
kinds.Metadata,
undefined,
urls
)
const collectedZaps: TProfileZap[] = seed ? [...seed.zaps] : []
const reactionsByPubkey = new Map<string, Event>() // one reaction per npub, newest kept (profile event only)
if (seed) {
for (const e of seed.reactions) {
reactionsByPubkey.set(e.pubkey, e)
}
}
const collectedComments: Event[] = seed ? [...seed.comments] : []
const seenZaps = new Set(collectedZaps.map((z) => z.pr))
const seenProfileReactionEventIds = new Set<string>()
if (seed) {
for (const e of seed.reactions) seenProfileReactionEventIds.add(e.id)
}
const seenCommentIds = new Set(collectedComments.map((c) => c.id))
let noteIds: string[] = []
// Phase 1: zaps + profile's recent notes (for comments on those notes)
const phase1Filters: Filter[] = [
{ '#p': [pubkey], kinds: [kinds.Zap], limit: 100 },
{ authors: [pubkey], kinds: [kinds.ShortTextNote], limit: NOTE_IDS_FOR_COMMENTS }
]
const flushZaps = () => {
if (myFetchId !== fetchIdRef.current) return
const sorted = [...collectedZaps].sort((a, b) => b.amount - a.amount)
setZaps(sorted)
}
await queryService.fetchEvents(urls, phase1Filters, {
eoseTimeout: 2000,
globalTimeout: 15000,
firstRelayResultGraceMs: false,
onevent: (evt) => {
if (evt.kind === kinds.Zap) {
const info = getZapInfoFromEvent(evt)
if (!info || !hexPubkeysEqual(info.recipientPubkey ?? '', pubkey) || !info.amount || info.amount <= 0) return
const sender = info.senderPubkey ?? evt.pubkey
if (hexPubkeysEqual(sender, pubkey)) return // skip self-zaps (likely tests)
if (seenZaps.has(evt.id)) return
seenZaps.add(evt.id)
collectedZaps.push({
pr: evt.id,
pubkey: sender,
amount: info.amount,
created_at: evt.created_at,
comment: info.comment
})
flushZaps() // render incrementally as events arrive from slow relays
} else if (evt.kind === kinds.ShortTextNote) {
noteIds.push(evt.id)
}
}
})
noteIds = [...new Set(noteIds)].slice(0, NOTE_IDS_FOR_COMMENTS)
if (myFetchId !== fetchIdRef.current) return
const profileMetaEvent = await profileMetaPromise
if (myFetchId !== fetchIdRef.current) return
const profileReactionATags = new Set([`0:${pubkey}:`, `0:${pubkey}:profile`])
const reactionTargetsKind0Profile = (evt: Event): boolean => {
if (evt.kind !== kinds.Reaction) return false
const aHit = evt.tags.some((t) => t[0] === 'a' && t[1] && profileReactionATags.has(t[1]))
if (aHit) return true
const pid = profileMetaEvent?.id
if (!pid) return false
return evt.tags.some(
(t) => t[0] === 'e' && t[1] && hexPubkeysEqual(t[1], pid)
)
}
const flushReactions = () => {
if (myFetchId !== fetchIdRef.current) return
setReactions(Array.from(reactionsByPubkey.values()).sort((a, b) => b.created_at - a.created_at))
}
const flushComments = () => {
if (myFetchId !== fetchIdRef.current) return
setComments([...collectedComments].sort((a, b) => b.created_at - a.created_at))
}
const ingestProfileReaction = (evt: Event) => {
if (!reactionTargetsKind0Profile(evt)) return
if (hexPubkeysEqual(evt.pubkey, pubkey)) return
if (seenProfileReactionEventIds.has(evt.id)) return
seenProfileReactionEventIds.add(evt.id)
const existing = reactionsByPubkey.get(evt.pubkey)
if (!existing || evt.created_at > existing.created_at) {
reactionsByPubkey.set(evt.pubkey, evt)
}
flushReactions()
}
const ingestComment = (evt: Event) => {
if (hexPubkeysEqual(evt.pubkey, pubkey)) return
if (seenCommentIds.has(evt.id)) return
seenCommentIds.add(evt.id)
collectedComments.push(evt)
flushComments()
}
const phase2CommentOpts = {
eoseTimeout: 2000,
globalTimeout: 15000,
firstRelayResultGraceMs: false as const,
onevent: (evt: Event) => {
if (evt.kind === ExtendedKind.COMMENT) {
ingestComment(evt)
}
}
}
// Phase 2a: comments on profile's notes (#e) only
if (noteIds.length > 0) {
await queryService.fetchEvents(urls, [{
'#e': noteIds,
kinds: [ExtendedKind.COMMENT],
limit: 50
}], phase2CommentOpts)
}
// Phase 2b: comments ON the profile itself (kind 0) - use #a (required), p is optional
const profileAddrs = [`0:${pubkey}:`, `0:${pubkey}:profile`]
await queryService.fetchEvents(urls, [{
'#a': profileAddrs,
kinds: [ExtendedKind.COMMENT],
limit: 50
}], phase2CommentOpts)
// Phase 2c: reactions (likes) on the kind-0 profile metadata event only (#e + event id, and/or #a coordinates)
const profileReactionFilters: Filter[] = []
if (profileMetaEvent?.id) {
profileReactionFilters.push({ '#e': [profileMetaEvent.id], kinds: [kinds.Reaction], limit: 80 })
}
profileReactionFilters.push({ '#a': [...profileReactionATags], kinds: [kinds.Reaction], limit: 80 })
await queryService.fetchEvents(urls, profileReactionFilters, {
eoseTimeout: 2000,
globalTimeout: 15000,
firstRelayResultGraceMs: false,
onevent: (evt: Event) => {
if (evt.kind === kinds.Reaction) {
ingestProfileReaction(evt)
}
}
})
if (myFetchId !== fetchIdRef.current) return
collectedZaps.sort((a, b) => b.amount - a.amount)
const collectedReactions = Array.from(reactionsByPubkey.values()).sort((a, b) => b.created_at - a.created_at)
collectedComments.sort((a, b) => b.created_at - a.created_at)
setZaps(collectedZaps)
setReactions(collectedReactions)
setComments(collectedComments)
profileAccordionSetInteractions(pubkey, relayKey, {
zaps: collectedZaps,
reactions: collectedReactions,
comments: collectedComments
})
} catch {
if (myFetchId !== fetchIdRef.current) return
} finally {
if (myFetchId === fetchIdRef.current) setLoading(false)
}
}, [pubkey, blockedRelaysKey, relayUrlsKey])
const refresh = useCallback(() => {
/** Keep session cache so refresh merges new relays/events onto what is already shown */
void fetchAll(true)
}, [pubkey, fetchAll])
useEffect(() => {
void fetchAll(false)
}, [fetchAll])
return { zaps, reactions, comments, loading, refresh }
}
/** @deprecated Use useProfileInteractions instead. Returns zaps only for compatibility. */
export function useProfileZaps(pubkey: string | undefined) {
const result = useProfileInteractions(pubkey)
return { zaps: result.zaps, loading: result.loading, refresh: result.refresh }
}

4
src/i18n/index.ts

@ -28,7 +28,7 @@ export type TLanguage = keyof typeof LANGUAGE_META @@ -28,7 +28,7 @@ export type TLanguage = keyof typeof LANGUAGE_META
export const LocalizedLanguageNames: { [key in TLanguage]: string } = { ...LANGUAGE_META }
export const supportedLanguages = Object.keys(LANGUAGE_META) as TLanguage[]
const supportedLanguages = Object.keys(LANGUAGE_META) as TLanguage[]
const localeModules = import.meta.glob<{ default: Resource }>('./locales/*.ts')
@ -40,7 +40,7 @@ function normalizeToSupported(lng: string): TLanguage { @@ -40,7 +40,7 @@ function normalizeToSupported(lng: string): TLanguage {
return supportedLanguages.find((s) => lng.startsWith(s)) ?? 'en'
}
export async function ensureLocaleLoaded(code: TLanguage): Promise<void> {
async function ensureLocaleLoaded(code: TLanguage): Promise<void> {
if (code === 'en') return
if (i18n.hasResourceBundle(code, 'translation')) return
const load = localeModules[localePath(code)]

2
src/layouts/SecondaryPageLayout/index.tsx

@ -145,7 +145,7 @@ const SecondaryPageLayout = forwardRef( @@ -145,7 +145,7 @@ const SecondaryPageLayout = forwardRef(
SecondaryPageLayout.displayName = 'SecondaryPageLayout'
export default SecondaryPageLayout
export function SecondaryPageTitlebar({
function SecondaryPageTitlebar({
title,
controls,
hideBackButton = false,

2
src/lib/compress-upload-media.ts

@ -556,7 +556,7 @@ export type CompressMediaOptions = { @@ -556,7 +556,7 @@ export type CompressMediaOptions = {
}
/** Default cap for raster image uploads (profile pics and inline media). */
export const DEFAULT_IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
const DEFAULT_IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
/**
* Compress media before upload. Non-media types are returned unchanged.

14
src/lib/content-parser.ts

@ -8,8 +8,7 @@ import { @@ -8,8 +8,7 @@ import {
import {
EMBEDDED_EVENT_REGEX,
EMBEDDED_MENTION_REGEX,
EMOJI_SHORT_CODE_REGEX,
LEGACY_PROFILE_BECH32_REGEX
EMOJI_SHORT_CODE_REGEX
} from '@/lib/content-patterns'
import { PAYTO_URI_REGEX } from '@/lib/payto'
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug'
@ -59,12 +58,7 @@ export const EmbeddedMentionParser: TContentParser = { @@ -59,12 +58,7 @@ export const EmbeddedMentionParser: TContentParser = {
regex: EMBEDDED_MENTION_REGEX
}
export const EmbeddedLegacyMentionParser: TContentParser = {
type: 'legacy-mention',
regex: LEGACY_PROFILE_BECH32_REGEX
}
export const EmbeddedEventParser: TContentParser = {
const EmbeddedEventParser: TContentParser = {
type: 'event',
regex: EMBEDDED_EVENT_REGEX
}
@ -74,12 +68,12 @@ export const EmbeddedWebsocketUrlParser: TContentParser = { @@ -74,12 +68,12 @@ export const EmbeddedWebsocketUrlParser: TContentParser = {
regex: WS_URL_REGEX
}
export const EmbeddedEmojiParser: TContentParser = {
const EmbeddedEmojiParser: TContentParser = {
type: 'emoji',
regex: EMOJI_SHORT_CODE_REGEX
}
export const EmbeddedLNInvoiceParser: TContentParser = {
const EmbeddedLNInvoiceParser: TContentParser = {
type: 'invoice',
regex: LN_INVOICE_REGEX
}

1
src/lib/debug-utils.ts

@ -59,4 +59,3 @@ if (import.meta.env.DEV) { @@ -59,4 +59,3 @@ if (import.meta.env.DEV) {
;(window as any).jumbleDebug = debugUtils
}
export default debugUtils

8
src/lib/discussion-thread-composer.ts

@ -35,20 +35,20 @@ export type TDiscussionDynamicTopics = { @@ -35,20 +35,20 @@ export type TDiscussionDynamicTopics = {
}[]
}
export type TTopicRow = { id: string; label: string; icon: LucideIcon }
type TTopicRow = { id: string; label: string; icon: LucideIcon }
type TopicListEntry = { id: string; label: string }
export function extractImagesFromContent(content: string): string[] {
function extractImagesFromContent(content: string): string[] {
const imageRegex = /(https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?)/gi
return content.match(imageRegex) || []
}
export function generateImetaTagsFromUrls(imageUrls: string[]): string[][] {
function generateImetaTagsFromUrls(imageUrls: string[]): string[][] {
return imageUrls.map((url) => ['imeta', 'url', url])
}
export function buildDiscussionNsfwTag(): string[] {
function buildDiscussionNsfwTag(): string[] {
return ['content-warning', '']
}

154
src/lib/discussion-topics.ts

@ -84,151 +84,10 @@ export function extractHashtagsFromContent(content: string): string[] { @@ -84,151 +84,10 @@ export function extractHashtagsFromContent(content: string): string[] {
return hashtags
}
/**
* Extract t-tags from event tags
*/
export function extractTTagsFromEvent(event: NostrEvent): string[] {
return event.tags
.filter(tag => tag[0] === 't' && tag[1])
.map(tag => normalizeTopic(tag[1]))
}
/**
* Extract all topics (both hashtags and t-tags) from an event
*/
export function extractAllTopics(event: NostrEvent): string[] {
const hashtags = extractHashtagsFromContent(event.content)
const tTags = extractTTagsFromEvent(event)
// Combine and deduplicate
const allTopics = [...new Set([...hashtags, ...tTags])]
return allTopics
}
/**
* Group threads by their primary topic and collect subtopic statistics
*/
export interface TopicAnalysis {
primaryTopic: string
subtopics: Map<string, Set<string>> // subtopic -> set of npubs
threads: NostrEvent[]
}
export function analyzeThreadTopics(
threads: NostrEvent[],
availableTopicIds: string[]
): Map<string, TopicAnalysis> {
const topicMap = new Map<string, TopicAnalysis>()
for (const thread of threads) {
const allTopics = extractAllTopics(thread)
// Find the primary topic (first match from available topics)
let primaryTopic = 'general'
for (const topic of allTopics) {
if (availableTopicIds.includes(topic)) {
primaryTopic = topic
break
}
}
// Get or create topic analysis
if (!topicMap.has(primaryTopic)) {
topicMap.set(primaryTopic, {
primaryTopic,
subtopics: new Map(),
threads: []
})
}
const analysis = topicMap.get(primaryTopic)!
analysis.threads.push(thread)
// Track subtopics (all topics except the primary one and 'all'/'all-topics')
// For 'general' topic, include all other topics as subtopics
// Special case: Always include 'readings' as a subtopic for literature threads
const subtopics = allTopics.filter(
t => t !== primaryTopic && t !== 'all' && t !== 'all-topics'
)
// Special handling for literature threads with 'readings' hashtag
if (primaryTopic === 'literature' && allTopics.includes('readings')) {
// Ensure 'readings' is included as a subtopic
if (!subtopics.includes('readings')) {
subtopics.push('readings')
}
}
for (const subtopic of subtopics) {
if (!analysis.subtopics.has(subtopic)) {
analysis.subtopics.set(subtopic, new Set())
}
analysis.subtopics.get(subtopic)!.add(thread.pubkey)
}
}
return topicMap
}
/**
* Get dynamic subtopics for a given main topic
* Returns subtopics that have been used by more than minNpubs unique npubs
*/
export function getDynamicSubtopics(
analysis: TopicAnalysis | undefined,
minNpubs: number = 3
): string[] {
if (!analysis) return []
const subtopics: string[] = []
for (const [subtopic, npubs] of analysis.subtopics.entries()) {
if (npubs.size >= minNpubs) {
subtopics.push(subtopic)
}
}
// Sort alphabetically
return subtopics.sort()
}
/**
* Check if a thread matches a specific subtopic
*/
export function threadMatchesSubtopic(
thread: NostrEvent,
subtopic: string
): boolean {
const allTopics = extractAllTopics(thread)
return allTopics.includes(subtopic)
}
/**
* Get the categorized topic for a thread
*/
export function getCategorizedTopic(
thread: NostrEvent,
availableTopicIds: string[]
): string {
const allTopics = extractAllTopics(thread)
// Find the first matching topic from available topics
for (const topic of allTopics) {
if (availableTopicIds.includes(topic)) {
return topic
}
}
return 'general'
}
/**
* Extract h-tag (group ID) from event tags
*/
export function extractHTagFromEvent(event: NostrEvent): string | null {
function extractHTagFromEvent(event: NostrEvent): string | null {
const hTag = event.tags.find(tag => tag[0] === 'h' && tag[1])
return hTag ? hTag[1] : null
}
@ -237,7 +96,7 @@ export function extractHTagFromEvent(event: NostrEvent): string | null { @@ -237,7 +96,7 @@ export function extractHTagFromEvent(event: NostrEvent): string | null {
* Parse group identifier from h-tag and relay sources
* Supports both "relay'group-id" format and bare group IDs
*/
export function parseGroupIdentifier(
function parseGroupIdentifier(
hTag: string,
relaySources: string[]
): { groupId: string; groupRelay: string | null; fullIdentifier: string } {
@ -262,17 +121,10 @@ export function parseGroupIdentifier( @@ -262,17 +121,10 @@ export function parseGroupIdentifier(
}
}
/**
* Check if a discussion belongs to a group
*/
export function isGroupDiscussion(event: NostrEvent): boolean {
return extractHTagFromEvent(event) !== null
}
/**
* Build display name for a group
*/
export function buildGroupDisplayName(
function buildGroupDisplayName(
groupId: string,
groupRelay: string | null
): string {

13
src/lib/discussion-votes.ts

@ -1,9 +1,9 @@ @@ -1,9 +1,9 @@
import type { TEmoji } from '@/types'
/** Canonical reaction `content` for discussion upvotes (kind 7). */
export const DISCUSSION_UPVOTE = '+'
const DISCUSSION_UPVOTE = '+'
/** Canonical reaction `content` for discussion downvotes (kind 7). */
export const DISCUSSION_DOWNVOTE = '-'
const DISCUSSION_DOWNVOTE = '-'
/** Shown in discussion UIs; legacy reaction `content` used the same characters. */
export const DISCUSSION_UPVOTE_DISPLAY = '⬆'
@ -42,15 +42,6 @@ export function isDiscussionVoteEmoji(emoji: TEmoji | string | undefined | null) @@ -42,15 +42,6 @@ export function isDiscussionVoteEmoji(emoji: TEmoji | string | undefined | null)
return isDiscussionUpvoteEmoji(emoji) || isDiscussionDownvoteEmoji(emoji)
}
/** Group legacy arrow reactions with +/- for one pill per direction. */
export function canonicalDiscussionVoteKey(
emoji: TEmoji | string | undefined | null
): typeof DISCUSSION_UPVOTE | typeof DISCUSSION_DOWNVOTE | null {
if (isDiscussionUpvoteEmoji(emoji)) return DISCUSSION_UPVOTE
if (isDiscussionDownvoteEmoji(emoji)) return DISCUSSION_DOWNVOTE
return null
}
export const DISCUSSION_VOTE_EMOJIS = [DISCUSSION_UPVOTE, DISCUSSION_DOWNVOTE] as const
/** Same vote direction, including legacy ⬆/⬇ vs +/-. */

2
src/lib/document-meta.ts

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
export const SITE_NAME = 'Imwald'
export const SITE_TAGLINE =
const SITE_TAGLINE =
'A user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery.'
export function getSiteOrigin(): string {

43
src/lib/draft-event.ts

@ -1471,7 +1471,7 @@ const IMWALD_ATTRIBUTION_ALT_TEXT = 'This event was published by https://jumble. @@ -1471,7 +1471,7 @@ const IMWALD_ATTRIBUTION_ALT_TEXT = 'This event was published by https://jumble.
* True for `alt` tags that are *our* app attribution (current or legacy Jumble/Imwald wording).
* Does not match arbitrary user `alt` text unless it clearly points at this app.
*/
export function isImwaldAppAttributionAltTag(tag: string[]): boolean {
function isImwaldAppAttributionAltTag(tag: string[]): boolean {
if (!Array.isArray(tag) || tag[0] !== 'alt' || tag.length < 2) return false
const raw = tag[1]
if (typeof raw !== 'string') return false
@ -2440,44 +2440,3 @@ export function createCitationPromptDraftEvent( @@ -2440,44 +2440,3 @@ export function createCitationPromptDraftEvent(
created_at: dayjs().unix()
}
}
/** Git Republic release (kind 1642); mirrors `releases-service` tag layout. */
export function createGitReleaseDraftEvent(
content: string,
options: {
repoOwnerPubkey: string
repoId: string
tagName: string
tagHash: string
title?: string
downloadUrl?: string
isDraft?: boolean
isPrerelease?: boolean
}
): TDraftEvent {
const repoAddress = `${ExtendedKind.GIT_REPO_ANNOUNCEMENT}:${options.repoOwnerPubkey}:${options.repoId}`
const tags: string[][] = [
['a', repoAddress],
['p', options.repoOwnerPubkey],
['tag', options.tagName],
['r', options.tagHash, '', 'tag']
]
if (options.title) {
tags.push(['title', options.title])
}
if (options.downloadUrl) {
tags.push(['r', options.downloadUrl, '', 'download'])
}
if (options.isDraft) {
tags.push(['draft', 'true'])
}
if (options.isPrerelease) {
tags.push(['prerelease', 'true'])
}
return {
kind: ExtendedKind.GIT_RELEASE,
content,
tags,
created_at: dayjs().unix()
}
}

4
src/lib/dtag-search.ts

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { ExtendedKind } from '@/constants'
import type { Event } from 'nostr-tools'
export function getDTagValue(event: Event): string | undefined {
function getDTagValue(event: Event): string | undefined {
const t = event.tags.find((x) => x[0] === 'd' && x[1])?.[1]
return t
}
@ -45,7 +45,7 @@ export function eventMatchesDTagLooseQuery(needle: string, event: Event): boolea @@ -45,7 +45,7 @@ export function eventMatchesDTagLooseQuery(needle: string, event: Event): boolea
}
/** Sort key: exact d-tag match first, then prefix, substring, then non-d / content-only. */
export function dTagMatchRank(needle: string, dVal: string | undefined): number {
function dTagMatchRank(needle: string, dVal: string | undefined): number {
if (!dVal) return 4
const nl = needle.trim().toLowerCase()
const dl = dVal.toLowerCase()

30
src/lib/emoji-content.ts

@ -1,36 +1,6 @@ @@ -1,36 +1,6 @@
import { EMOJI_SHORT_CODE_REGEX } from '@/lib/content-patterns'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
const STANDARD_EMOJI_LIMIT = 20
/**
* Returns standard emoji shortcodes matching the query (for autocomplete).
*/
export function searchStandardEmojiShortcodes(query: string, limit = STANDARD_EMOJI_LIMIT): string[] {
const q = query.toLowerCase().trim()
if (!q) return []
const seen = new Set<string>()
const out: string[] = []
for (const item of emojis) {
const shortcodes = item.shortcodes ?? []
const tags = item.tags ?? []
const name = item.name ?? ''
const match =
shortcodes.some((s) => String(s).toLowerCase().includes(q)) ||
tags.some((t) => String(t).toLowerCase().includes(q)) ||
name.toLowerCase().includes(q)
if (match) {
const shortcode = shortcodes[0] ?? name
if (shortcode && !seen.has(shortcode)) {
seen.add(shortcode)
out.push(shortcode)
if (out.length >= limit) break
}
}
}
return out
}
/**
* Replaces standard (non-custom) :shortcode: in content with their Unicode emoji
* so they render correctly in all content fields (preview, feed, note page, etc.).

2
src/lib/error-suppression.ts

@ -6,7 +6,7 @@ @@ -6,7 +6,7 @@
// Track suppressed errors to avoid spam
const suppressedErrors = new Set<string>()
export function suppressExpectedErrors() {
function suppressExpectedErrors() {
// Override console.error to filter out expected errors
const originalConsoleError = console.error

4
src/lib/event-filtering.ts

@ -5,7 +5,7 @@ import storage from '@/services/local-storage.service' @@ -5,7 +5,7 @@ import storage from '@/services/local-storage.service'
/**
* Check if an event has expired based on its expiration tag
*/
export function isEventExpired(event: Event): boolean {
function isEventExpired(event: Event): boolean {
const expirationTag = event.tags.find(tag => tag[0] === 'expiration')
if (!expirationTag || !expirationTag[1]) {
return false
@ -22,7 +22,7 @@ export function isEventExpired(event: Event): boolean { @@ -22,7 +22,7 @@ export function isEventExpired(event: Event): boolean {
/**
* Check if an event is in quiet mode based on its quiet tag
*/
export function isEventInQuietMode(event: Event): boolean {
function isEventInQuietMode(event: Event): boolean {
const quietTag = event.tags.find(tag => tag[0] === 'quiet')
if (!quietTag || !quietTag[1]) {
return false

4
src/lib/event-ingest-filter.ts

@ -7,7 +7,7 @@ import { kinds } from 'nostr-tools' @@ -7,7 +7,7 @@ import { kinds } from 'nostr-tools'
* Detects **kind-1 note** spam where `content` is a stringified JSON **object** (game/app payloads, etc.)
* instead of human-readable text. Scoped to {@link kinds.ShortTextNote} only.
*/
export function isStringifiedJsonObjectContentNostrEvent(
function isStringifiedJsonObjectContentNostrEvent(
event: Pick<NEvent, 'kind' | 'content'>
): boolean {
if (event.kind !== kinds.ShortTextNote) return false
@ -26,7 +26,7 @@ export function isStringifiedJsonObjectContentNostrEvent( @@ -26,7 +26,7 @@ export function isStringifiedJsonObjectContentNostrEvent(
* Kind-31987 noise: missing `d` (relay URL). Rating formats differ across clients; do not drop at ingest
* (feeds and cards already treat unknown ratings as zero stars).
*/
export function isIncompleteRelayReviewIngest(event: NEvent): boolean {
function isIncompleteRelayReviewIngest(event: NEvent): boolean {
if (event.kind !== ExtendedKind.RELAY_REVIEW) return false
return !getRelayUrlFromRelayReviewEvent(event)
}

93
src/lib/event.ts

@ -107,10 +107,6 @@ export function isReplaceableEvent(kind: number) { @@ -107,10 +107,6 @@ export function isReplaceableEvent(kind: number) {
)
}
export function isPictureEvent(event: Event) {
return event.kind === ExtendedKind.PICTURE
}
export function isProtectedEvent(event: Event) {
return event.tags.some(([tagName]) => tagName === '-')
}
@ -320,49 +316,6 @@ export function resolveDeclaredThreadRootEventHex(startHexId: string): string { @@ -320,49 +316,6 @@ export function resolveDeclaredThreadRootEventHex(startHexId: string): string {
return cur
}
/** True if event references target as root, parent, or quoted (#q, #a) — used to hide redundant preview when showing quotes of current note. */
export function eventReferencesEventId(
event: Event | undefined,
targetHexIdOrEvent: string | Event
): boolean {
if (!event) return false
const targetEvent = typeof targetHexIdOrEvent === 'object' ? targetHexIdOrEvent : undefined
const targetHexId =
typeof targetHexIdOrEvent === 'string'
? targetHexIdOrEvent.toLowerCase()
: targetHexIdOrEvent.id?.toLowerCase()
const targetCoordinate =
targetEvent && isReplaceableEvent(targetEvent.kind)
? getReplaceableCoordinateFromEvent(targetEvent)
: undefined
const qRef = getQuotedReferenceFromQTags(event)
if (targetHexId) {
const rootId = getRootETag(event)?.[1]?.toLowerCase()
if (rootId === targetHexId) return true
const parentId = getParentETag(event)?.[1]?.toLowerCase()
if (parentId === targetHexId) return true
if (qRef?.hexId === targetHexId) return true
const eTags = event.tags.filter((t) => t[0] === 'e' || t[0] === 'E')
if (eTags.some((t) => t[1]?.toLowerCase() === targetHexId)) return true
}
if (targetCoordinate) {
const targetCoordNorm = normalizeReplaceableCoordinateString(targetCoordinate)
const aTags = event.tags.filter((t) => t[0] === 'a' || t[0] === 'A')
if (aTags.some((t) => normalizeReplaceableCoordinateString(t[1] ?? '') === targetCoordNorm)) return true
if (
qRef?.coordinate &&
normalizeReplaceableCoordinateString(qRef.coordinate) === targetCoordNorm
) {
return true
}
}
return false
}
export function getRootBech32Id(event?: Event) {
const eTag = getRootETag(event)
if (!eTag) {
@ -396,7 +349,7 @@ export function replaceableEventDedupeKey(event: Event): string { @@ -396,7 +349,7 @@ export function replaceableEventDedupeKey(event: Event): string {
}
/** Normalize `kind:pubkey:d` for comparisons (lowercase pubkey; preserve d). */
export function normalizeReplaceableCoordinateString(coord: string): string {
function normalizeReplaceableCoordinateString(coord: string): string {
const m = /^(\d+):([0-9a-f]{64}):(.*)$/i.exec(coord.trim())
if (!m) return coord.trim().toLowerCase()
return getReplaceableCoordinate(Number(m[1]), m[2].toLowerCase(), m[3])
@ -411,7 +364,7 @@ function stripNostrUriScheme(s: string): string { @@ -411,7 +364,7 @@ function stripNostrUriScheme(s: string): string {
/**
* NIP-10 / NIP-18: `q` tag value is `<event-id>` or `<event-address>` (coordinate), or NIP-19 bech32.
*/
export function parseQTagReferenceValue(
function parseQTagReferenceValue(
raw: string | undefined | null
): { hexId?: string; coordinate?: string } | undefined {
if (raw == null) return undefined
@ -476,13 +429,6 @@ export function getQuotedEventHexIdFromQTags(event: Event): string | undefined { @@ -476,13 +429,6 @@ export function getQuotedEventHexIdFromQTags(event: Event): string | undefined {
return getQuotedReferenceFromQTags(event)?.hexId
}
/** Kind 1 whose `q` points at this hex id (legacy helper). */
export function kind1QuotesEventHexId(event: Event, hexId: string): boolean {
if (event.kind !== kinds.ShortTextNote) return false
const ref = getQuotedReferenceFromQTags(event)
return !!ref?.hexId && ref.hexId === hexId.trim().toLowerCase()
}
/** Kind 1 quote-of-root: match `q` hex and/or replaceable coordinate (and bech32 decoding). */
export function kind1QuotesThreadRoot(
event: Event,
@ -549,7 +495,7 @@ export function getImetaInfosFromEvent(event: Event) { @@ -549,7 +495,7 @@ export function getImetaInfosFromEvent(event: Event) {
return imeta
}
export function getEmbeddedNoteBech32Ids(event: Event) {
function getEmbeddedNoteBech32Ids(event: Event) {
const cache = EVENT_EMBEDDED_NOTES_CACHE.get(event.id)
if (cache) return cache
@ -619,7 +565,7 @@ export function collectEmbeddedEventPrefetchTargets(event: Event): { @@ -619,7 +565,7 @@ export function collectEmbeddedEventPrefetchTargets(event: Event): {
}
}
export function getEmbeddedPubkeys(event: Event) {
function getEmbeddedPubkeys(event: Event) {
const cache = EVENT_EMBEDDED_PUBKEYS_CACHE.get(event.id)
if (cache) return cache
@ -731,37 +677,6 @@ export function compareEvents(a: Event, b: Event): number { @@ -731,37 +677,6 @@ export function compareEvents(a: Event, b: Event): number {
return 0
}
// Returns the event that should be retained when comparing two events
export function getRetainedEvent(a: Event, b: Event): Event {
if (compareEvents(a, b) > 0) {
return a
}
return b
}
/**
* Collapse replaceable/addressable events to one per NIP-01 coordinate (`kind:pubkey` or `kind:pubkey:d`),
* keeping the newest (`created_at`, then lexicographically smallest `id` on ties).
* Non-replaceable events are keyed by `id` only.
*/
export function dedupeToLatestPerReplaceableCoordinate(events: Event[]): Event[] {
const byKey = new Map<string, Event>()
for (const e of events) {
if (!isReplaceableEvent(e.kind)) {
byKey.set(e.id, e)
continue
}
const coord = getReplaceableCoordinateFromEvent(e)
const existing = byKey.get(coord)
if (!existing) {
byKey.set(coord, e)
continue
}
byKey.set(coord, getRetainedEvent(e, existing))
}
return [...byKey.values()]
}
/** External article URL from `i` / `I` tags (e.g. kind 1111 comments on web content). */
export function getHttpUrlFromITags(event: Event): string | undefined {
const lower = event.tags.find((t) => t[0] === 'i')?.[1]?.trim()

4
src/lib/favorites-feed-relays.ts

@ -97,7 +97,7 @@ export function buildAuthorInboxOutboxRelayUrls( @@ -97,7 +97,7 @@ export function buildAuthorInboxOutboxRelayUrls(
* Profile pins + Medien: author NIP-65 tier (pass from {@link buildAuthorInboxOutboxRelayUrls}), then
* {@link READ_ONLY_RELAY_URLS}, then {@link FAST_READ_RELAY_URLS}; dedupe, blocked-stripped, capped.
*/
export const PROFILE_AUGMENTED_READ_MAX_RELAYS = 16
const PROFILE_AUGMENTED_READ_MAX_RELAYS = 16
export function buildProfileAugmentedReadRelayUrls(
authorRelayUrls: string[],
@ -159,7 +159,7 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox( @@ -159,7 +159,7 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox(
* Profile page pins + feed: viewed author's NIP-65 read + write (REQ tier 1), then logged-in user's favorites,
* then fast-read defaults from constants, deduped and blocked-stripped, capped at this count.
*/
export const PROFILE_PAGE_FEED_MAX_RELAYS = 6
const PROFILE_PAGE_FEED_MAX_RELAYS = 6
export const PROFILE_PAGE_PINS_RESOLVE_LIMIT = 10

2
src/lib/fetch-with-timeout.ts

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/** Default cap for HTTP fetches so tabs cannot hang indefinitely on bad networks or servers. */
export const DEFAULT_FETCH_TIMEOUT_MS = 30_000
const DEFAULT_FETCH_TIMEOUT_MS = 30_000
/**
* `fetch` with a wall-clock timeout. Honors an optional caller `signal` (abort propagates both ways).

2
src/lib/follow-outbox-aggregate-relays.ts

@ -8,7 +8,7 @@ import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority' @@ -8,7 +8,7 @@ import { relayUrlsLocalsFirst } from '@/lib/relay-url-priority'
import type { TRelayList } from '@/types'
/** First N NIP-65 `write` (outbox) URLs per followed pubkey, follow-list order; locals first per author. */
export const FOLLOW_OUTBOX_AGGREGATE_PER_AUTHOR = 2
const FOLLOW_OUTBOX_AGGREGATE_PER_AUTHOR = 2
/** Plain `ws://` relays are almost always someone else's LAN; the client cannot use them for third-party reads. */
function isNonPublicWsRelayUrl(normalizedUrl: string): boolean {

2
src/lib/follow-set-spell.ts

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { tagNameEquals } from '@/lib/tag'
import type { Event } from 'nostr-tools'
export const FOLLOW_SET_SPELL_PREFIX = 'followset:' as const
const FOLLOW_SET_SPELL_PREFIX = 'followset:' as const
export function isFollowSetSpellId(s: string): boolean {
return s.startsWith(FOLLOW_SET_SPELL_PREFIX)

14
src/lib/git-republic-event.ts

@ -31,20 +31,6 @@ export function getGitRepublicRepoContext(event: Event): GitRepublicRepoContext @@ -31,20 +31,6 @@ export function getGitRepublicRepoContext(event: Event): GitRepublicRepoContext
return { ownerHex: parts[1], repoId: parts[2] }
}
/** Accepts hex pubkey or `npub…` for Git Republic repo owner fields in forms. */
export function parseRepoOwnerPubkeyInput(input: string): string | null {
const t = input.trim()
if (!t) return null
if (/^[0-9a-fA-F]{64}$/.test(t)) return t.toLowerCase()
try {
const dec = nip19.decode(t)
if (dec.type === 'npub') return dec.data as string
} catch {
return null
}
return null
}
export function gitRepublicRepoWebUrl(ctx: GitRepublicRepoContext): string | null {
try {
const npub = nip19.npubEncode(ctx.ownerHex)

158
src/lib/image-extraction.ts

@ -1,121 +1,3 @@ @@ -1,121 +1,3 @@
import { Event } from 'nostr-tools'
import { TImetaInfo } from '@/types'
import { getImetaInfosFromEvent } from '@/lib/event'
/**
* Extract and normalize all images from an event
* This includes images from:
* - imeta tags
* - content (markdown images, HTML img tags, etc.)
* - metadata (title image, etc.)
*/
export function extractAllImagesFromEvent(event: Event): TImetaInfo[] {
const images: TImetaInfo[] = []
const seenUrls = new Set<string>()
// Helper function to add media if not already seen
const addMedia = (url: string, pubkey: string = event.pubkey) => {
if (!url || seenUrls.has(url)) return
// Normalize URL
const normalizedUrl = normalizeImageUrl(url)
if (!normalizedUrl) return
// Check if it's media (image or video)
const isVideo = isVideoUrl(normalizedUrl)
const isImage = isImageUrl(normalizedUrl)
if (!isImage && !isVideo) return
images.push({
url: normalizedUrl,
pubkey,
m: isVideo ? 'video/*' : 'image/*'
})
seenUrls.add(normalizedUrl)
}
// 1. Extract from imeta tags
const imetaMedia = getImetaInfosFromEvent(event)
imetaMedia.forEach((item: TImetaInfo) => {
if (item.m?.startsWith('image/') || item.m?.startsWith('video/')) {
addMedia(item.url, item.pubkey)
}
})
// 2. Extract from content - markdown images
const markdownImageRegex = /!\[.*?\]\((.*?)\)/g
let match
while ((match = markdownImageRegex.exec(event.content)) !== null) {
addMedia(match[1])
}
// 3. Extract from content - HTML img tags
const htmlImgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi
while ((match = htmlImgRegex.exec(event.content)) !== null) {
addMedia(match[1])
}
// 4. Extract from content - HTML video tags
const htmlVideoRegex = /<video[^>]+src=["']([^"']+)["'][^>]*>/gi
while ((match = htmlVideoRegex.exec(event.content)) !== null) {
addMedia(match[1])
}
// 5. Extract from content - AsciiDoc images
const asciidocImageRegex = /image::([^\s\[]+)(?:\[.*?\])?/g
while ((match = asciidocImageRegex.exec(event.content)) !== null) {
addMedia(match[1])
}
// 6. Extract from metadata
const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1])
if (imageTag?.[1]) {
addMedia(imageTag[1])
}
// 7. Extract from content - general URL patterns that look like media
const mediaUrlRegex =
/https?:\/\/[^\s<>"']+\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff|ico|mp4|webm|ogg|avi|mov|wmv|flv|mkv|mka|3gp|3g2|ogv)(?:\?[^\s<>"']*)?/gi
while ((match = mediaUrlRegex.exec(event.content)) !== null) {
addMedia(match[0])
}
return images
}
/**
* Normalize image URL
*/
function normalizeImageUrl(url: string): string | null {
if (!url) return null
// Remove common tracking parameters
const cleanUrl = url
.replace(/[?&](utm_[^&]*)/g, '')
.replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '')
.replace(/[?&]w=\d+/g, '')
.replace(/[?&]h=\d+/g, '')
.replace(/[?&]q=\d+/g, '')
.replace(/[?&]f=\w+/g, '')
.replace(/[?&]auto=\w+/g, '')
.replace(/[?&]format=\w+/g, '')
.replace(/[?&]fit=\w+/g, '')
.replace(/[?&]crop=\w+/g, '')
.replace(/[?&]&+/g, '&')
.replace(/[?&]$/, '')
.replace(/\?$/, '')
// Ensure it's a valid URL
try {
new URL(cleanUrl)
return cleanUrl
} catch {
return null
}
}
/**
* Check if URL is likely an image (extension or known image host).
*/
@ -140,50 +22,14 @@ export function isImageUrl(url: string): boolean { @@ -140,50 +22,14 @@ export function isImageUrl(url: string): boolean {
'placehold.it'
]
// Check file extension
if (imageExtensions.test(url)) {
return true
}
// Check known image domains
try {
const urlObj = new URL(url)
return imageDomains.some(domain =>
urlObj.hostname === domain || urlObj.hostname.endsWith('.' + domain)
)
} catch {
return false
}
}
/**
* Check if URL is likely a video
*/
function isVideoUrl(url: string): boolean {
const videoExtensions = /\.(mp4|webm|ogg|avi|mov|wmv|flv|mkv|m4v|3gp|3g2|ogv)(\?.*)?$/i
const videoDomains = [
'youtube.com',
'youtu.be',
'vimeo.com',
'dailymotion.com',
'twitch.tv',
'streamable.com',
'gfycat.com',
'redgifs.com',
'cdn.discordapp.com',
'media.discordapp.net'
]
// Check file extension
if (videoExtensions.test(url)) {
return true
}
// Check known video domains
try {
const urlObj = new URL(url)
return videoDomains.some(domain =>
urlObj.hostname === domain || urlObj.hostname.endsWith('.' + domain)
return imageDomains.some(
(domain) => urlObj.hostname === domain || urlObj.hostname.endsWith('.' + domain)
)
} catch {
return false

6
src/lib/index-relay-http.ts

@ -16,16 +16,16 @@ function trimSlash(base: string): string { @@ -16,16 +16,16 @@ function trimSlash(base: string): string {
return base.replace(/\/+$/, '')
}
export function indexRelayFilterUrl(baseUrl: string): string {
function indexRelayFilterUrl(baseUrl: string): string {
return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/events/filter`
}
export function indexRelayPublishUrl(baseUrl: string): string {
function indexRelayPublishUrl(baseUrl: string): string {
return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/events`
}
/** Map a Nostr filter to gc_index_relay POST body (requires `limit` 1–100; strips unsupported keys). */
export function nostrFilterToIndexRelayBody(f: Filter): Record<string, unknown> {
function nostrFilterToIndexRelayBody(f: Filter): Record<string, unknown> {
const body: Record<string, unknown> = {}
const lim = f.limit
const capped = lim == null || lim < 1 ? 100 : Math.min(100, lim)

7
src/lib/like-reaction-emojis.ts

@ -1,8 +1,5 @@ @@ -1,8 +1,5 @@
/**
* Single source for the quick-like emoji row used by the EmojiPicker / LikeButton
* reactions row. Also re-exported as EMOJI_PICKER_REACTIONS for LikeButton.
* Single source for the quick-like emoji row used by the EmojiPicker / LikeButton.
* EmojiPicker re-exports this list as EMOJI_PICKER_REACTIONS for LikeButton.
*/
export const DEFAULT_SUGGESTED_EMOJIS = ['❤', '👍', '🔥', '😂', '😢', '🫂', '🚀'] as const
/** Emoji characters for the reactions row in the like-button picker. */
export const EMOJI_PICKER_REACTIONS: readonly string[] = DEFAULT_SUGGESTED_EMOJIS

4
src/lib/link.ts

@ -2,7 +2,6 @@ import { Event, nip19 } from 'nostr-tools' @@ -2,7 +2,6 @@ import { Event, nip19 } from 'nostr-tools'
import { getNoteBech32Id } from './event'
import { TSearchParams } from '@/types'
export const toHome = () => '/'
export const toNote = (eventOrId: Event | string) => {
if (typeof eventOrId === 'string') return `/notes/${eventOrId}`
const nevent = getNoteBech32Id(eventOrId)
@ -62,7 +61,6 @@ export const toSearch = (params?: TSearchParams) => { @@ -62,7 +61,6 @@ export const toSearch = (params?: TSearchParams) => {
}
return `/search?${query.toString()}`
}
export const toSettings = () => '/settings'
export const toRelaySettings = (tag?: 'mailbox' | 'favorite-relays') => {
return '/settings/relays' + (tag ? '#' + tag : '')
}
@ -83,10 +81,8 @@ export const toBookmarksList = () => '/bookmarks' @@ -83,10 +81,8 @@ export const toBookmarksList = () => '/bookmarks'
export const toPinsList = () => '/pins'
export const toInterestsList = () => '/interests'
export const toSpells = () => '/spells'
export const toChachiChat = (relay: string, d: string) => {
return `https://chachi.chat/${relay.replace(/^wss?:\/\//, '').replace(/\/$/, '')}/${d}`
}
export const toNjump = (id: string) => `https://njump.me/${id}`
export const toAlexandria = (id: string) => `https://next-alexandria.gitcitadel.eu/events?id=${encodeURIComponent(id)}`

10
src/lib/live-activities.ts

@ -25,7 +25,7 @@ const CORNYCHAT_LABEL_NAMESPACE = 'com.cornychat' @@ -25,7 +25,7 @@ const CORNYCHAT_LABEL_NAMESPACE = 'com.cornychat'
const EMPTY_PARENT_MAP = new Map<string, Event>()
/** Max extra REQ filters when resolving 30312 parents for 30313 meetings (relay limits). */
export const LIVE_ACTIVITIES_MAX_PARENT_FETCH = 32
const LIVE_ACTIVITIES_MAX_PARENT_FETCH = 32
export type LiveActivitiesFetchEventsFn = (
urls: string[],
@ -36,7 +36,7 @@ export type LiveActivitiesFetchEventsFn = ( @@ -36,7 +36,7 @@ export type LiveActivitiesFetchEventsFn = (
/** NIP-53 live streaming (30311), meeting space (30312), meeting (30313). */
export const LIVE_ACTIVITY_KINDS = [30311, 30312, 30313] as const
export const LIVE_ACTIVITIES_MAX_ITEMS = 10
const LIVE_ACTIVITIES_MAX_ITEMS = 10
export const LIVE_ACTIVITIES_SLIDE_INTERVAL_MS = 30_000
@ -342,7 +342,7 @@ function isActiveLiveActivityStatus(ev: Event): boolean { @@ -342,7 +342,7 @@ function isActiveLiveActivityStatus(ev: Event): boolean {
}
/** Parse NIP-33 address `kind:hex64pubkey:d` (used in `a` tags and dedupe keys). */
export function parseNip33Address(ref: string): { kind: number; pubkey: string; d: string } | null {
function parseNip33Address(ref: string): { kind: number; pubkey: string; d: string } | null {
const m = /^(\d+):([0-9a-f]{64}):(.+)$/i.exec(ref.trim())
if (!m) return null
const kind = Number(m[1])
@ -351,7 +351,7 @@ export function parseNip33Address(ref: string): { kind: number; pubkey: string; @@ -351,7 +351,7 @@ export function parseNip33Address(ref: string): { kind: number; pubkey: string;
}
/** Parent meeting space (30312) address from a 30313 event’s `a` tag, if any. */
export function firstParent30312Address(ev: Event): string | null {
function firstParent30312Address(ev: Event): string | null {
for (const t of ev.tags) {
if (t[0] !== 'a' || !t[1]) continue
const p = parseNip33Address(t[1])
@ -394,7 +394,7 @@ function dedupeLatestForLiveTicker(events: Event[]): Map<string, Event> { @@ -394,7 +394,7 @@ function dedupeLatestForLiveTicker(events: Event[]): Map<string, Event> {
}
/** Latest 30312 space event per address from an event list (no network). */
export function parent30312MapFromEvents(events: Event[]): Map<string, Event> {
function parent30312MapFromEvents(events: Event[]): Map<string, Event> {
const m = new Map<string, Event>()
for (const ev of events) {
if (ev.kind !== 30312) continue

4
src/lib/nip84-highlight-display.ts

@ -8,7 +8,7 @@ import type { Event } from 'nostr-tools' @@ -8,7 +8,7 @@ import type { Event } from 'nostr-tools'
* - `["textquoteselector", prefix, suffix]` (3 items)
* - `["textquoteselector", "-", prefix, suffix]` leading "-" = empty slot (Hypothesis-style)
*/
export function parseTextQuoteSelectorParts(tag: readonly string[]): { prefix: string; suffix: string } {
function parseTextQuoteSelectorParts(tag: readonly string[]): { prefix: string; suffix: string } {
if (tag.length < 2 || tag[0] !== 'textquoteselector') {
return { prefix: '', suffix: '' }
}
@ -28,7 +28,7 @@ export function parseTextQuoteSelectorParts(tag: readonly string[]): { prefix: s @@ -28,7 +28,7 @@ export function parseTextQuoteSelectorParts(tag: readonly string[]): { prefix: s
}
/** `["textpositionselector", start, end]` — character offsets into a full document string. */
export function parseTextPositionSelector(tag: readonly string[]): { start: number; end: number } | null {
function parseTextPositionSelector(tag: readonly string[]): { start: number; end: number } | null {
if (tag.length < 3 || tag[0] !== 'textpositionselector') return null
const start = parseInt(tag[1] ?? '', 10)
const end = parseInt(tag[2] ?? '', 10)

36
src/lib/nostr-address.ts

@ -48,39 +48,3 @@ export function prefixNostrAddresses(content: string): string { @@ -48,39 +48,3 @@ export function prefixNostrAddresses(content: string): string {
return `nostr:${match}`
})
}
/**
* Checks if a string contains nostr addresses that need prefixing
* @param content - The content to check
* @returns True if the content contains unprefixed nostr addresses
*/
export function containsUnprefixedNostrAddresses(content: string): boolean {
return NOSTR_ADDRESS_REGEX.test(content)
}
/**
* Extracts all nostr addresses from content (both prefixed and unprefixed)
* @param content - The content to extract addresses from
* @returns Array of nostr addresses found
*/
export function extractNostrAddresses(content: string): string[] {
// Reset regex state
NOSTR_ADDRESS_REGEX.lastIndex = 0
const addresses: string[] = []
let match
while ((match = NOSTR_ADDRESS_REGEX.exec(content)) !== null) {
addresses.push(match[0])
}
// Also check for already prefixed addresses
const prefixedRegex = /\bnostr:(npub|nprofile|note|nevent|naddr|nrelay)1[a-z0-9]+/gi
prefixedRegex.lastIndex = 0
while ((match = prefixedRegex.exec(content)) !== null) {
addresses.push(match[0])
}
return addresses
}

11
src/lib/nostr-build.ts

@ -11,17 +11,6 @@ import { isVideo } from './url' @@ -11,17 +11,6 @@ import { isVideo } from './url'
const I_NOSTR_BUILD = 'i.nostr.build'
/** Returns true when a URL is hosted on any nostr.build domain. */
export function isNostrBuildUrl(url: string): boolean {
const u = (url ?? '').trim()
if (!u) return false
try {
return new URL(u).hostname.endsWith('nostr.build')
} catch {
return false
}
}
/**
* True when we may rewrite `url` to i.nostr.builds `/thumb/…` variant.
* Only **i.nostr.build** serves generated thumbs; cdn.nostr.build does not.

6
src/lib/payto.ts

@ -83,7 +83,7 @@ export const PAYTO_KNOWN_TYPES: Record< @@ -83,7 +83,7 @@ export const PAYTO_KNOWN_TYPES: Record<
* Short labels accepted after payto:// that map to a canonical type.
* e.g. payto://BTC/..., payto://LBTC/..., payto://DOGE/... are recognized as bitcoin, lightning, dogecoin.
*/
export const PAYTO_TYPE_ALIASES: Record<string, string> = {
const PAYTO_TYPE_ALIASES: Record<string, string> = {
btc: 'bitcoin',
lbtc: 'lightning',
doge: 'dogecoin',
@ -107,7 +107,7 @@ export function getPaytoIconChar(type: string): string | null { @@ -107,7 +107,7 @@ export function getPaytoIconChar(type: string): string | null {
}
/** Logo filename in /payto_logos/ for types that have an asset. Any image format works: .svg, .gif, .jpg, .png, .webp, etc. */
export const PAYTO_LOGO_FILES: Record<string, string> = {
const PAYTO_LOGO_FILES: Record<string, string> = {
ethereum: 'ethereum-eth-logo.svg',
monero: 'Monero.png',
litecoin: 'Litecoin.png',
@ -138,7 +138,7 @@ export const PAYTO_LOGO_FILES: Record<string, string> = { @@ -138,7 +138,7 @@ export const PAYTO_LOGO_FILES: Record<string, string> = {
}
/** Profile/page URL template for types that have a web profile. Use {authority} as placeholder. Null = no direct link. */
export const PAYTO_PROFILE_URL_TEMPLATES: Record<string, string> = {
const PAYTO_PROFILE_URL_TEMPLATES: Record<string, string> = {
paypal: 'https://paypal.me/{authority}',
venmo: 'https://venmo.com/{authority}',
revolut: 'https://revolut.me/{authority}',

15
src/lib/private-relays.ts

@ -55,21 +55,6 @@ export async function getPrivateRelayUrls(pubkey: string): Promise<string[]> { @@ -55,21 +55,6 @@ export async function getPrivateRelayUrls(pubkey: string): Promise<string[]> {
return Array.from(new Set(relayUrls))
}
/**
* Check if user has cache relays set
* @param pubkey - User's public key
* @returns Promise<boolean> - true if user has at least one cache relay
*/
export async function hasCacheRelays(pubkey: string): Promise<boolean> {
const cacheRelayEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)
if (cacheRelayEvent) {
// Check if cache relay event has any relays
const hasRelays = cacheRelayEvent.tags.some(tag => tag[0] === 'relay' && tag[1])
return hasRelays
}
return false
}
/**
* Get cache relay URLs only
* @param pubkey - User's public key

4
src/lib/profile-accordion-fetch.ts

@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
* profile_badges list), then separate batches for comments on notes, comments on profile (#a), and
* profile reactions (#e + #a); badge NIP-58 resolution and reports run after. `onPartial` fires as
* relays return events (coalesced per microtask). Session cache writes stay at completion only.
* Ordering matches {@link useProfileInteractions}.
* Ordering matches the former standalone profile-interactions hook (removed; logic lives here).
*/
import { ExtendedKind } from '@/constants'
@ -230,7 +230,7 @@ export async function fetchProfileAccordionBundle(args: { @@ -230,7 +230,7 @@ export async function fetchProfileAccordionBundle(args: {
}
// Keep phase 1 free of #a reaction/comment: many relays handle those poorly when batched with
// zaps/notes/badges. Match {@link useProfileInteractions} — dedicated REQ(s) for profile comments
// zaps/notes/badges. Same ordering as interactions hook — dedicated REQ(s) for profile comments
// and reactions after we have note ids + kind-0 id.
const phase1Filters: Filter[] = [
{ '#p': [pubkey], kinds: [kinds.Zap], limit: 100 },

43
src/lib/profile-accordion-session-cache.ts

@ -129,46 +129,3 @@ export function profileAccordionSetReports( @@ -129,46 +129,3 @@ export function profileAccordionSetReports(
e.reportsByViewer[viewerPubkey] = reports
}
export type ProfileAccordionCacheSlice =
| 'relayUrls'
| 'interactions'
| 'badges'
| 'followPacks'
| 'reports'
| 'all'
export function profileAccordionInvalidate(pubkey: string, slice: ProfileAccordionCacheSlice = 'all'): void {
if (slice === 'all') {
store.delete(pubkey)
return
}
const e = store.get(pubkey)
if (!e) return
switch (slice) {
case 'relayUrls':
delete e.relayUrls
delete e.relayUrlsKey
delete e.interactions
delete e.interactionsRelayKey
delete e.badges
delete e.badgesRelayKey
delete e.followPacks
delete e.followPacksRelayKey
break
case 'interactions':
delete e.interactions
delete e.interactionsRelayKey
break
case 'badges':
delete e.badges
delete e.badgesRelayKey
break
case 'followPacks':
delete e.followPacks
delete e.followPacksRelayKey
break
case 'reports':
delete e.reportsByViewer
break
}
}

5
src/lib/publication-rendered-events.ts

@ -32,11 +32,6 @@ export function getRenderedPublicationEventsVersion(): number { @@ -32,11 +32,6 @@ export function getRenderedPublicationEventsVersion(): number {
return renderedVersion
}
export function getRenderedPublicationEvents(publicationId: string): Event[] {
const pubId = normId(publicationId)
return [...(renderedByPublication.get(pubId)?.values() ?? [])]
}
/**
* Deep collection for nested 30040 publications that were rendered in this session.
*/

2
src/lib/publishing-feedback.tsx

@ -8,7 +8,7 @@ export type PublishSuccessSubtleDetail = { message?: string } @@ -8,7 +8,7 @@ export type PublishSuccessSubtleDetail = { message?: string }
export const PUBLISH_SUCCESS_SUBTLE_EVENT = 'jumble:publishSuccessSubtle'
export function emitPublishSuccessSubtle(message?: string): void {
function emitPublishSuccessSubtle(message?: string): void {
if (typeof window === 'undefined') return
window.dispatchEvent(
new CustomEvent<PublishSuccessSubtleDetail>(PUBLISH_SUCCESS_SUBTLE_EVENT, {

4
src/lib/react-remove-scroll-body-cleanup.ts

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
* that class is still present, the UI can paint on top but ignore all clicks (notably after closing
* our Zap dialog from a secondary pane / sheet).
*/
export function stripReactRemoveScrollBodyLocks(): void {
function stripReactRemoveScrollBodyLocks(): void {
if (typeof document === 'undefined') return
const body = document.body
const toRemove: string[] = []
@ -18,7 +18,7 @@ export function stripReactRemoveScrollBodyLocks(): void { @@ -18,7 +18,7 @@ export function stripReactRemoveScrollBodyLocks(): void {
}
/** Slightly longer than Radix dialog exit animation (`duration-200` in our `DialogContent`). */
export const MS_AFTER_RADIX_DIALOG_FOR_EXTERNAL_MODAL = 280
const MS_AFTER_RADIX_DIALOG_FOR_EXTERNAL_MODAL = 280
/**
* Call `closeOuterModel` (e.g. close Zap `Dialog`), wait for scroll-lock cleanup when applicable,

2
src/lib/recently-used-emojis.ts

@ -5,7 +5,7 @@ const MAX_ENTRIES = 18 @@ -5,7 +5,7 @@ const MAX_ENTRIES = 18
type StoredEmoji = string | { shortcode: string; url: string }
export function getRecentlyUsedEmojis(): (string | TEmoji)[] {
function getRecentlyUsedEmojis(): (string | TEmoji)[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []

108
src/lib/relay-list-builder.ts

@ -33,7 +33,7 @@ function dedupeNormalizedRelayUrls(urls: string[]): string[] { @@ -33,7 +33,7 @@ function dedupeNormalizedRelayUrls(urls: string[]): string[] {
* Relays to bootstrap Explore replaceable fetches (e.g. kind 10012 batch) before NIP-65 resolves.
* PROFILE_FETCH + FAST_READ.
*/
export function exploreDiscoveryBootstrapRelayUrls(): string[] {
function exploreDiscoveryBootstrapRelayUrls(): string[] {
return dedupeNormalizedRelayUrls([...PROFILE_FETCH_RELAY_URLS, ...FAST_READ_RELAY_URLS])
}
@ -428,109 +428,3 @@ export async function buildReplyReadRelayList( @@ -428,109 +428,3 @@ export async function buildReplyReadRelayList(
blockedRelays
})
}
/**
* Build relay list for writing replies/comments
* WRITE to: OP author's outboxes + OP author's inboxes + reply-to author's inboxes + user's outboxes + local relay
*/
export async function buildReplyWriteRelayList(
opAuthorPubkey: string | undefined,
replyToAuthorPubkey: string | undefined,
userPubkey: string | undefined,
blockedRelays: string[] = []
): Promise<string[]> {
const relayUrls = new Set<string>()
const normalizedBlocked = new Set(
(blockedRelays || []).map(url => {
const normalized = normalizeUrl(url) || url
return normalized.toLowerCase()
}).filter((url): url is string => !!url)
)
const addRelay = (url: string | undefined) => {
if (!url) return
if (isHttpRelayUrl(url)) return
const normalized = normalizeAnyRelayUrl(url)
if (!normalized) return
// Filter blocked (case-insensitive comparison)
if (normalizedBlocked.has(normalized.toLowerCase())) return
relayUrls.add(normalized)
}
// OP author's outboxes
if (opAuthorPubkey) {
try {
// Add timeout to prevent hanging - 2 seconds max
const relayListPromise = client.fetchRelayList(opAuthorPubkey)
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => resolve(null), 2000)
})
const opRelayList = await Promise.race([relayListPromise, timeoutPromise])
if (opRelayList) {
const opOutboxes = [
...(opRelayList.write || []).slice(0, 10)
]
opOutboxes.forEach(addRelay)
const opInboxes = [
...(opRelayList.read || []).slice(0, 10)
]
opInboxes.forEach(addRelay)
}
} catch (error) {
logger.debug('[RelayListBuilder] Failed to fetch OP author relay list', { error })
}
}
// Reply-to author's inboxes
if (replyToAuthorPubkey && replyToAuthorPubkey !== opAuthorPubkey) {
try {
// Add timeout to prevent hanging - 2 seconds max
const relayListPromise = client.fetchRelayList(replyToAuthorPubkey)
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => resolve(null), 2000)
})
const replyToRelayList = await Promise.race([relayListPromise, timeoutPromise])
if (replyToRelayList) {
const replyToInboxes = [
...(replyToRelayList.read || []).slice(0, 10)
]
replyToInboxes.forEach(addRelay)
}
} catch (error) {
logger.debug('[RelayListBuilder] Failed to fetch reply-to author relay list', { error })
}
}
// User's outboxes
if (userPubkey) {
try {
// Add timeout to prevent hanging - 2 seconds max
const relayListPromise = client.fetchRelayList(userPubkey)
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => resolve(null), 2000)
})
const userRelayList = await Promise.race([relayListPromise, timeoutPromise])
if (userRelayList) {
const userOutboxes = [
...(userRelayList.write || []).slice(0, 10)
]
userOutboxes.forEach(addRelay)
}
// User's local relay (kind 10432)
const localRelays = await getCacheRelayUrls(userPubkey)
localRelays.forEach(addRelay)
} catch (error) {
logger.debug('[RelayListBuilder] Failed to fetch user relay list', { error })
}
}
// Fast write relays as fallback
FAST_WRITE_RELAY_URLS.forEach(addRelay)
return Array.from(relayUrls)
}

7
src/lib/relay-pulse-nip05.ts

@ -27,10 +27,3 @@ export function collectAggregatedNip05sFromKind0(event: Event): string[] { @@ -27,10 +27,3 @@ export function collectAggregatedNip05sFromKind0(event: Event): string[] {
}
return [...set]
}
export function truncateAbout(about: string | undefined, maxLen: number): string {
if (!about) return ''
const t = about.trim()
if (t.length <= maxLen) return t
return `${t.slice(0, maxLen)}`
}

2
src/lib/relay-url-priority.ts

@ -164,7 +164,7 @@ export function buildPrioritizedReadRelayUrls(opts: { @@ -164,7 +164,7 @@ export function buildPrioritizedReadRelayUrls(opts: {
/**
* Ordered layers for publish / write (before merge, blocked strip, kind-1 strip, cap).
*/
export function buildWriteRelayPriorityLayers(opts: {
function buildWriteRelayPriorityLayers(opts: {
userWriteRelays: string[]
authorReadRelays?: string[]
favoriteRelays?: string[]

4
src/lib/relay.ts

@ -3,7 +3,3 @@ import { TRelayInfo } from '@/types' @@ -3,7 +3,3 @@ import { TRelayInfo } from '@/types'
export function checkAlgoRelay(relayInfo: TRelayInfo | undefined) {
return relayInfo?.software === 'https://github.com/bitvora/algo-relay' // hardcode for now
}
export function checkSearchRelay(relayInfo: TRelayInfo | undefined) {
return relayInfo?.supported_nips?.includes(50)
}

1
src/services/content-parser.service.ts

@ -1150,4 +1150,3 @@ class ContentParserService { @@ -1150,4 +1150,3 @@ class ContentParserService {
// Export singleton instance
export const contentParserService = new ContentParserService()
export default contentParserService

18
src/services/spell.service.ts

@ -24,7 +24,7 @@ const RELATIVE_UNIT_SECONDS: Record<string, number> = { @@ -24,7 +24,7 @@ const RELATIVE_UNIT_SECONDS: Record<string, number> = {
* Resolve relative time to Unix timestamp.
* "now" -> current time; "7d" -> now - 7*86400; "1704067200" -> 1704067200.
*/
export function resolveRelativeTime(value: string): number {
function resolveRelativeTime(value: string): number {
const trimmed = (value || '').trim()
if (trimmed === 'now' || trimmed === '') {
return Math.floor(Date.now() / 1000)
@ -63,7 +63,7 @@ export const SPELL_CATALOG_SYNC_LIMIT = 200 @@ -63,7 +63,7 @@ export const SPELL_CATALOG_SYNC_LIMIT = 200
export const SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS = 600
/** Max distinct pubkeys in one catalog REQ (relay compatibility). Your pubkey is always first. */
export const SPELL_CATALOG_MAX_AUTHORS = 400
const SPELL_CATALOG_MAX_AUTHORS = 400
/**
* If no relay sends EOSE, stop showing the catalog sync state and close the sub after this long.
@ -145,15 +145,6 @@ export function getRelaysForSpell( @@ -145,15 +145,6 @@ export function getRelaysForSpell(
return dedupeRelayUrls(primary)
}
/** Spell lists at least one relay URL in its `relays` tag. */
export function spellHasExplicitRelays(spell: Event): boolean {
const relayTag = spell.tags.find(tagNameEquals('relays'))
if (!relayTag || relayTag.length < 2) return false
return relayTag
.slice(1)
.some((u) => typeof u === 'string' && (u.startsWith('wss://') || u.startsWith('ws://')))
}
/**
* Resolve authors: replace $me with pubkey and $contacts with contacts array.
*/
@ -248,11 +239,6 @@ export function spellEventToFilter(spell: Event, ctx: SpellExecutionContext): Fi @@ -248,11 +239,6 @@ export function spellEventToFilter(spell: Event, ctx: SpellExecutionContext): Fi
return filter
}
/** Spell uses COUNT: run filter against relays and show a numeric result (not a feed). */
export function spellIsCount(spell: Event): boolean {
return spell.tags.find(tagNameEquals('cmd'))?.[1] === 'COUNT'
}
/**
* Get display name for a spell (from "name" tag or content).
*/

Loading…
Cancel
Save