Browse Source

Fully implement logger

imwald
Silberengel 4 months ago
parent
commit
1ca85bd660
  1. 3
      src/components/AccountManager/NostrConnectionLogin.tsx
  2. 3
      src/components/AudioPlayer/index.tsx
  3. 19
      src/components/CacheRelaysSetting/index.tsx
  4. 3
      src/components/ClientSelect/index.tsx
  5. 29
      src/components/Content/index.tsx
  6. 12
      src/components/Embedded/EmbeddedNote.tsx
  7. 3
      src/components/ErrorBoundary.tsx
  8. 3
      src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx
  9. 3
      src/components/FavoriteRelaysSetting/AddNewRelay.tsx
  10. 3
      src/components/FavoriteRelaysSetting/AddNewRelaySet.tsx
  11. 3
      src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx
  12. 5
      src/components/FavoriteRelaysSetting/RelayUrl.tsx
  13. 3
      src/components/FeedSwitcher/index.tsx
  14. 7
      src/components/Image/index.tsx
  15. 3
      src/components/KindFilter/index.tsx
  16. 9
      src/components/MailboxSetting/DiscoveredRelays.tsx
  17. 3
      src/components/MailboxSetting/SaveButton.tsx
  18. 3
      src/components/MediaErrorBoundary.tsx
  19. 17
      src/components/Note/Article/index.tsx
  20. 17
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  21. 3
      src/components/Note/Highlight/index.tsx
  22. 3
      src/components/Note/LongFormArticle/NostrNode.tsx
  23. 3
      src/components/Note/MarkdownArticle/NostrNode.tsx
  24. 5
      src/components/Note/Poll.tsx
  25. 3
      src/components/NoteOptions/RawEventDialog.tsx
  26. 2
      src/components/NoteOptions/useMenuActions.tsx
  27. 11
      src/components/NoteStats/LikeButton.tsx
  28. 3
      src/components/NoteStats/Likes.tsx
  29. 3
      src/components/NoteStats/RepostButton.tsx
  30. 3
      src/components/NoteStats/VoteButtons.tsx
  31. 3
      src/components/ParentNotePreview/index.tsx
  32. 7
      src/components/PostEditor/HighlightEditor.tsx
  33. 3
      src/components/PostEditor/Mentions.tsx
  34. 9
      src/components/PostEditor/PostContent.tsx
  35. 9
      src/components/PostEditor/PostRelaySelector.tsx
  36. 3
      src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts
  37. 3
      src/components/PostEditor/Uploader.tsx
  38. 349
      src/components/Profile/ProfileArticles.tsx
  39. 15
      src/components/Profile/ProfileBookmarksAndHashtags.tsx
  40. 323
      src/components/Profile/ProfileFeed.tsx
  41. 146
      src/components/Profile/ProfileMedia.tsx
  42. 103
      src/components/Profile/index.tsx
  43. 3
      src/components/RelayInfo/ReviewEditor.tsx
  44. 5
      src/components/SaveRelayDropdownMenu/index.tsx
  45. 133
      src/components/TrendingNotes/index.tsx
  46. 29
      src/components/UniversalContent/EnhancedContent.tsx
  47. 5
      src/components/UniversalContent/HighlightSourcePreview.tsx
  48. 3
      src/components/VersionUpdateBanner/index.tsx
  49. 3
      src/components/VideoPlayer/index.tsx
  50. 34
      src/components/WebPreview/index.tsx
  51. 3
      src/components/YoutubeEmbeddedPlayer/index.tsx
  52. 3
      src/hooks/useFetchRelayInfo.tsx
  53. 3
      src/hooks/useFetchRelayInfos.tsx
  54. 3
      src/hooks/useFetchRelayList.tsx
  55. 202
      src/hooks/useProfileTimeline.tsx
  56. 6
      src/lib/debug-utils.ts
  57. 15
      src/lib/draft-event.ts
  58. 3
      src/lib/event-metadata.ts
  59. 5
      src/lib/nip05.ts
  60. 3
      src/lib/nostr-parser.tsx
  61. 3
      src/lib/pubkey.ts
  62. 16
      src/lib/url.ts
  63. 2
      src/pages/primary/NoteListPage/index.tsx
  64. 3
      src/pages/secondary/HomePage/index.tsx
  65. 5
      src/pages/secondary/NotePage/NotFound.tsx
  66. 7
      src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx
  67. 3
      src/providers/MuteListProvider.tsx
  68. 4
      src/providers/NotificationProvider.tsx
  69. 21
      src/services/client.service.ts
  70. 11
      src/services/content-parser.service.ts
  71. 24
      src/services/indexed-db.service.ts
  72. 3
      src/services/lightning.service.ts
  73. 3
      src/services/media-manager.service.ts
  74. 5
      src/services/relay-info.service.ts
  75. 17
      src/services/relay-selection.service.ts

3
src/components/AccountManager/NostrConnectionLogin.tsx

@ -10,6 +10,7 @@ import QrScanner from 'qr-scanner'
import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import QrCode from '../QrCode' import QrCode from '../QrCode'
import logger from '@/lib/logger'
export default function NostrConnectLogin({ export default function NostrConnectLogin({
back, back,
@ -95,7 +96,7 @@ export default function NostrConnectLogin({
nostrConnectionLogin(loginDetails.privKey, loginDetails.connectionString) nostrConnectionLogin(loginDetails.privKey, loginDetails.connectionString)
.then(() => onLoginSuccess()) .then(() => onLoginSuccess())
.catch((err) => { .catch((err) => {
console.error('NostrConnectionLogin Error:', err) logger.error('NostrConnectionLogin error', { error: err })
setNostrConnectionErrMsg( setNostrConnectionErrMsg(
err.message ? `${err.message}. Please reload.` : 'Connection failed. Please reload.' err.message ? `${err.message}. Please reload.` : 'Connection failed. Please reload.'
) )

3
src/components/AudioPlayer/index.tsx

@ -6,6 +6,7 @@ import { Pause, Play } from 'lucide-react'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import ExternalLink from '../ExternalLink' import ExternalLink from '../ExternalLink'
import { MediaErrorBoundary } from '../MediaErrorBoundary' import { MediaErrorBoundary } from '../MediaErrorBoundary'
import logger from '@/lib/logger'
interface AudioPlayerProps { interface AudioPlayerProps {
src: string src: string
@ -91,7 +92,7 @@ export default function AudioPlayer({ src, className }: AudioPlayerProps) {
onError={(error) => { onError={(error) => {
// Don't log expected media errors // Don't log expected media errors
if (error.name !== 'AbortError' && !error.message.includes('play() request was interrupted')) { if (error.name !== 'AbortError' && !error.message.includes('play() request was interrupted')) {
console.warn('Audio player error:', error) logger.warn('Audio player error', error)
} }
setError(true) setError(true)
}} }}

19
src/components/CacheRelaysSetting/index.tsx

@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url' import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url'
import logger from '@/lib/logger'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { TMailboxRelay, TMailboxRelayScope } from '@/types' import { TMailboxRelay, TMailboxRelayScope } from '@/types'
import { useEffect, useState, useMemo, useRef, useCallback } from 'react' import { useEffect, useState, useMemo, useRef, useCallback } from 'react'
@ -187,7 +188,7 @@ export default function CacheRelaysSetting() {
const info = await indexedDb.getStoreInfo() const info = await indexedDb.getStoreInfo()
setCacheInfo(info) setCacheInfo(info)
} catch (error) { } catch (error) {
console.error('Failed to load cache info:', error) logger.error('Failed to load cache info', { error })
} }
} }
@ -209,7 +210,7 @@ export default function CacheRelaysSetting() {
try { try {
window.localStorage.removeItem(key) window.localStorage.removeItem(key)
} catch (e) { } catch (e) {
console.warn(`Failed to remove ${key} from localStorage:`, e) logger.warn(`Failed to remove ${key} from localStorage`, e as Error)
} }
}) })
@ -231,7 +232,7 @@ export default function CacheRelaysSetting() {
toast.success(t('Cache cleared successfully')) toast.success(t('Cache cleared successfully'))
} catch (error) { } catch (error) {
console.error('Failed to clear cache:', error) logger.error('Failed to clear cache', { error })
toast.error(t('Failed to clear cache')) toast.error(t('Failed to clear cache'))
} }
} }
@ -246,7 +247,7 @@ export default function CacheRelaysSetting() {
toast.success(t('Cache refreshed successfully')) toast.success(t('Cache refreshed successfully'))
} catch (error) { } catch (error) {
console.error('Failed to refresh cache:', error) logger.error('Failed to refresh cache', { error })
toast.error(t('Failed to refresh cache')) toast.error(t('Failed to refresh cache'))
} }
} }
@ -270,7 +271,7 @@ export default function CacheRelaysSetting() {
: await indexedDb.getStoreItems(storeName) : await indexedDb.getStoreItems(storeName)
setStoreItems(items) setStoreItems(items)
} catch (error) { } catch (error) {
console.error('Failed to load store items:', error) logger.error('Failed to load store items', { error })
toast.error(t('Failed to load store items')) toast.error(t('Failed to load store items'))
setStoreItems([]) setStoreItems([])
} finally { } finally {
@ -335,7 +336,7 @@ export default function CacheRelaysSetting() {
// Update cache info // Update cache info
loadCacheInfo() loadCacheInfo()
} catch (error) { } catch (error) {
console.error('Failed to delete item:', error) logger.error('Failed to delete item', { error })
toast.error(t('Failed to delete item')) toast.error(t('Failed to delete item'))
} }
} }
@ -354,7 +355,7 @@ export default function CacheRelaysSetting() {
loadCacheInfo() loadCacheInfo()
toast.success(t('All items deleted successfully')) toast.success(t('All items deleted successfully'))
} catch (error) { } catch (error) {
console.error('Failed to delete all items:', error) logger.error('Failed to delete all items', { error })
toast.error(t('Failed to delete all items')) toast.error(t('Failed to delete all items'))
} }
} }
@ -391,7 +392,7 @@ export default function CacheRelaysSetting() {
toast.success(t('Cleaned up {{deleted}} duplicate entries, kept {{kept}}', { deleted: result.deleted, kept: result.kept })) toast.success(t('Cleaned up {{deleted}} duplicate entries, kept {{kept}}', { deleted: result.deleted, kept: result.kept }))
} }
} catch (error) { } catch (error) {
console.error('Failed to cleanup duplicates:', error) logger.error('Failed to cleanup duplicates', { error })
if (error instanceof Error && error.message === 'Not a replaceable event store') { if (error instanceof Error && error.message === 'Not a replaceable event store') {
toast.error(t('This store does not contain replaceable events')) toast.error(t('This store does not contain replaceable events'))
} else { } else {
@ -476,7 +477,7 @@ export default function CacheRelaysSetting() {
} catch (error) { } catch (error) {
// Reset flag on error // Reset flag on error
justSavedRef.current = false justSavedRef.current = false
console.error('Failed to save cache relays:', error) logger.error('Failed to save cache relays', { error })
// Show error feedback // Show error feedback
if (error instanceof Error && (error as any).relayStatuses) { if (error instanceof Error && (error as any).relayStatuses) {
showPublishingFeedback({ showPublishingFeedback({

3
src/components/ClientSelect/index.tsx

@ -10,6 +10,7 @@ import clientService from '@/services/client.service'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { Event, kinds, nip19 } from 'nostr-tools' import { Event, kinds, nip19 } from 'nostr-tools'
import { Dispatch, SetStateAction, useMemo, useState } from 'react' import { Dispatch, SetStateAction, useMemo, useState } from 'react'
import logger from '@/lib/logger'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const clients: Record<string, { name: string; getUrl: (id: string) => string }> = { const clients: Record<string, { name: string; getUrl: (id: string) => string }> = {
@ -94,7 +95,7 @@ export default function ClientSelect({
kind = pointer.data.kind kind = pointer.data.kind
} }
} catch (error) { } catch (error) {
console.error('Failed to decode NIP-19 pointer:', error) logger.error('Failed to decode NIP-19 pointer', { error, originalNoteId })
return ['njump'] return ['njump']
} }
} }

29
src/components/Content/index.tsx

@ -28,6 +28,33 @@ import Emoji from '../Emoji'
import ImageGallery from '../ImageGallery' import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer' import MediaPlayer from '../MediaPlayer'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer' import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
import { toNote } from '@/lib/link'
const REDIRECT_REGEX = /Read (naddr1[a-z0-9]+) instead\./i
function renderRedirectText(text: string, key: number) {
const match = text.match(REDIRECT_REGEX)
if (!match) {
return text
}
const [fullMatch, naddr] = match
const [prefix, suffix] = text.split(fullMatch)
const href = toNote(naddr)
return (
<span key={`redirect-${key}`}>
{prefix}
Read{' '}
<a
className="text-primary hover:underline"
href={href}
onClick={(e) => e.stopPropagation()}
>
{naddr}
</a>{' '}
instead.{suffix}
</span>
)
}
export default function Content({ export default function Content({
event, event,
@ -168,7 +195,7 @@ export default function Content({
{nodes.map((node, index) => { {nodes.map((node, index) => {
if (node.type === 'text') { if (node.type === 'text') {
return node.data return renderRedirectText(node.data, index)
} }
// Skip image nodes - they're rendered in the carousel at the top // Skip image nodes - they're rendered in the carousel at the top
if (node.type === 'image' || node.type === 'images') { if (node.type === 'image' || node.type === 'images') {

12
src/components/Embedded/EmbeddedNote.tsx

@ -11,6 +11,7 @@ import ClientSelect from '../ClientSelect'
import MainNoteCard from '../NoteCard/MainNoteCard' import MainNoteCard from '../NoteCard/MainNoteCard'
import { Button } from '../ui/button' import { Button } from '../ui/button'
import { Search } from 'lucide-react' import { Search } from 'lucide-react'
import logger from '@/lib/logger'
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) { export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) {
const { event, isFetching } = useFetchEvent(noteId) const { event, isFetching } = useFetchEvent(noteId)
@ -32,7 +33,12 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className?
} }
}) })
.catch((error: any) => { .catch((error: any) => {
console.warn(`Retry ${retryCount + 1}/${maxRetries} failed for event:`, noteId, error) logger.warn('EmbeddedNote retry failed', {
attempt: retryCount + 1,
maxRetries,
noteId,
error
})
}) })
.finally(() => { .finally(() => {
setIsRetrying(false) setIsRetrying(false)
@ -125,7 +131,7 @@ function EmbeddedNoteNotFound({
relays = relays.map(url => normalizeUrl(url) || url) relays = relays.map(url => normalizeUrl(url) || url)
relays = Array.from(new Set(relays)) relays = Array.from(new Set(relays))
} catch (err) { } catch (err) {
console.error('Failed to parse external relays:', err) logger.error('Failed to parse external relays', { error: err, noteId })
} }
} else { } else {
extractedHexEventId = noteId extractedHexEventId = noteId
@ -167,7 +173,7 @@ function EmbeddedNoteNotFound({
onEventFound(event) onEventFound(event)
} }
} catch (error) { } catch (error) {
console.error('External relay fetch failed:', error) logger.error('External relay fetch failed', { error, noteId })
} finally { } finally {
setIsSearchingExternal(false) setIsSearchingExternal(false)
setTriedExternal(true) setTriedExternal(true)

3
src/components/ErrorBoundary.tsx

@ -4,6 +4,7 @@ import { SILBERENGEL_PUBKEY } from '@/constants'
import { MessageCircle, RotateCw } from 'lucide-react' import { MessageCircle, RotateCw } from 'lucide-react'
import React, { Component, ReactNode } from 'react' import React, { Component, ReactNode } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import logger from '@/lib/logger'
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
children: ReactNode children: ReactNode
@ -26,7 +27,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
} }
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo) logger.error('ErrorBoundary caught an error', { error, errorInfo })
} }
render() { render() {

3
src/components/FavoriteRelaysSetting/AddBlockedRelay.tsx

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
import { Button } from '../ui/button' import { Button } from '../ui/button'
import { Input } from '../ui/input' import { Input } from '../ui/input'
import { Loader2, Check } from 'lucide-react' import { Loader2, Check } from 'lucide-react'
import logger from '@/lib/logger'
export default function AddBlockedRelay() { export default function AddBlockedRelay() {
const { t } = useTranslation() const { t } = useTranslation()
@ -38,7 +39,7 @@ export default function AddBlockedRelay() {
setSuccessMsg(t('Relay blocked successfully')) setSuccessMsg(t('Relay blocked successfully'))
setTimeout(() => setSuccessMsg(''), 3000) setTimeout(() => setSuccessMsg(''), 3000)
} catch (error) { } catch (error) {
console.error('Failed to block relay:', error) logger.error('Failed to block relay', { error, relay: normalizedUrl })
setErrorMsg(t('Failed to block relay. Please try again.')) setErrorMsg(t('Failed to block relay. Please try again.'))
} finally { } finally {
setIsLoading(false) setIsLoading(false)

3
src/components/FavoriteRelaysSetting/AddNewRelay.tsx

@ -4,6 +4,7 @@ import { normalizeUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger'
export default function AddNewRelay() { export default function AddNewRelay() {
const { t } = useTranslation() const { t } = useTranslation()
@ -31,7 +32,7 @@ export default function AddNewRelay() {
await addFavoriteRelays([normalizedUrl]) await addFavoriteRelays([normalizedUrl])
setInput('') setInput('')
} catch (error) { } catch (error) {
console.error('Failed to add favorite relay:', error) logger.error('Failed to add favorite relay', { error, relay: normalizedUrl })
setErrorMsg(t('Failed to add relay. Please try again.')) setErrorMsg(t('Failed to add relay. Please try again.'))
} finally { } finally {
setIsLoading(false) setIsLoading(false)

3
src/components/FavoriteRelaysSetting/AddNewRelaySet.tsx

@ -3,6 +3,7 @@ import { Input } from '@/components/ui/input'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger'
export default function AddNewRelaySet() { export default function AddNewRelaySet() {
const { t } = useTranslation() const { t } = useTranslation()
@ -21,7 +22,7 @@ export default function AddNewRelaySet() {
await createRelaySet(newRelaySetName) await createRelaySet(newRelaySetName)
setNewRelaySetName('') setNewRelaySetName('')
} catch (error) { } catch (error) {
console.error('Failed to create relay set:', error) logger.error('Failed to create relay set', { error, name: newRelaySetName })
setErrorMsg(t('Failed to create relay set. Please try again.')) setErrorMsg(t('Failed to create relay set. Please try again.'))
} finally { } finally {
setIsLoading(false) setIsLoading(false)

3
src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx

@ -5,6 +5,7 @@ import { X, Loader2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import RelayIcon from '../RelayIcon' import RelayIcon from '../RelayIcon'
import { Button } from '../ui/button' import { Button } from '../ui/button'
import logger from '@/lib/logger'
export default function BlockedRelayItem({ relay }: { relay: string }) { export default function BlockedRelayItem({ relay }: { relay: string }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
@ -19,7 +20,7 @@ export default function BlockedRelayItem({ relay }: { relay: string }) {
try { try {
await deleteBlockedRelays([relay]) await deleteBlockedRelays([relay])
} catch (error) { } catch (error) {
console.error('Failed to unblock relay:', error) logger.error('Failed to unblock relay', { error, relay })
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }

5
src/components/FavoriteRelaysSetting/RelayUrl.tsx

@ -8,6 +8,7 @@ import { CircleX } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon' import RelayIcon from '../RelayIcon'
import logger from '@/lib/logger'
export default function RelayUrls({ relaySetId }: { relaySetId: string }) { export default function RelayUrls({ relaySetId }: { relaySetId: string }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -29,7 +30,7 @@ export default function RelayUrls({ relaySetId }: { relaySetId: string }) {
relayUrls: relaySet.relayUrls.filter((u) => u !== url) relayUrls: relaySet.relayUrls.filter((u) => u !== url)
}) })
} catch (error) { } catch (error) {
console.error('Failed to remove relay from set:', error) logger.error('Failed to remove relay from set', { error, relaySetId, url })
} }
} }
@ -54,7 +55,7 @@ export default function RelayUrls({ relaySetId }: { relaySetId: string }) {
await updateRelaySet({ ...relaySet, relayUrls: newRelayUrls }) await updateRelaySet({ ...relaySet, relayUrls: newRelayUrls })
setNewRelayUrl('') setNewRelayUrl('')
} catch (error) { } catch (error) {
console.error('Failed to update relay set:', error) logger.error('Failed to update relay set', { error, relaySetId, url: normalizedUrl })
setNewRelayUrlError(t('Failed to add relay. Please try again.')) setNewRelayUrlError(t('Failed to add relay. Please try again.'))
} finally { } finally {
setIsLoading(false) setIsLoading(false)

3
src/components/FeedSwitcher/index.tsx

@ -8,6 +8,7 @@ import { BookmarkIcon, UsersRound, Server } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon' import RelayIcon from '../RelayIcon'
import RelaySetCard from '../RelaySetCard' import RelaySetCard from '../RelaySetCard'
import logger from '@/lib/logger'
export default function FeedSwitcher({ close }: { close?: () => void }) { export default function FeedSwitcher({ close }: { close?: () => void }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -60,7 +61,7 @@ export default function FeedSwitcher({ close }: { close?: () => void }) {
<FeedSwitcherItem <FeedSwitcherItem
isActive={feedInfo.feedType === 'all-favorites'} isActive={feedInfo.feedType === 'all-favorites'}
onClick={() => { onClick={() => {
console.log('FeedSwitcher: Switching to all-favorites') logger.debug('FeedSwitcher: Switching to all-favorites')
switchFeed('all-favorites') switchFeed('all-favorites')
close?.() close?.()
}} }}

7
src/components/Image/index.tsx

@ -6,6 +6,7 @@ import { getHashFromURL } from 'blossom-client-sdk'
import { decode } from 'blurhash' import { decode } from 'blurhash'
import { ImageOff } from 'lucide-react' import { ImageOff } from 'lucide-react'
import { HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react' import { HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react'
import logger from '@/lib/logger'
export default function Image({ export default function Image({
image: { url, blurHash, pubkey, dim, alt: imetaAlt, fallback }, image: { url, blurHash, pubkey, dim, alt: imetaAlt, fallback },
@ -62,7 +63,7 @@ export default function Image({
oldImageUrl = new URL(imageUrl) oldImageUrl = new URL(imageUrl)
hash = getHashFromURL(oldImageUrl) hash = getHashFromURL(oldImageUrl)
} catch (error) { } catch (error) {
console.error('Invalid image URL:', error) logger.error('Invalid image URL', { error, imageUrl })
} }
if (!pubkey || !hash || !oldImageUrl) { if (!pubkey || !hash || !oldImageUrl) {
setIsLoading(false) setIsLoading(false)
@ -79,7 +80,7 @@ export default function Image({
try { try {
return new URL(server) return new URL(server)
} catch (error) { } catch (error) {
console.error('Invalid Blossom server URL:', server, error) logger.error('Invalid Blossom server URL', { server, error })
return undefined return undefined
} }
}) })
@ -167,7 +168,7 @@ function BlurHashCanvas({ blurHash, className = '' }: { blurHash: string; classN
try { try {
return decode(blurHash, blurHashWidth, blurHashHeight) return decode(blurHash, blurHashWidth, blurHashHeight)
} catch (error) { } catch (error) {
console.warn('Failed to decode blurhash:', error) logger.warn('Failed to decode blurhash', error as Error)
return null return null
} }
}, [blurHash]) }, [blurHash])

3
src/components/KindFilter/index.tsx

@ -17,7 +17,7 @@ const KIND_FILTER_OPTIONS = [
{ kindGroup: [kinds.Repost], label: 'Reposts' }, { kindGroup: [kinds.Repost], label: 'Reposts' },
{ kindGroup: [kinds.LongFormArticle], label: 'Articles' }, { kindGroup: [kinds.LongFormArticle], label: 'Articles' },
{ kindGroup: [ExtendedKind.PUBLICATION], label: 'Publications' }, { kindGroup: [ExtendedKind.PUBLICATION], label: 'Publications' },
{ kindGroup: [ExtendedKind.WIKI_ARTICLE], label: 'Wiki Articles' }, { kindGroup: [ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE_MARKDOWN], label: 'Wiki Articles' },
{ kindGroup: [kinds.Highlights], label: 'Highlights' }, { kindGroup: [kinds.Highlights], label: 'Highlights' },
{ kindGroup: [ExtendedKind.POLL], label: 'Polls' }, { kindGroup: [ExtendedKind.POLL], label: 'Polls' },
{ kindGroup: [ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT], label: 'Voice Posts' }, { kindGroup: [ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT], label: 'Voice Posts' },
@ -109,7 +109,6 @@ export default function KindFilter({
checked ? 'border-primary/60 bg-primary/5' : 'clickable' checked ? 'border-primary/60 bg-primary/5' : 'clickable'
)} )}
onClick={() => { onClick={() => {
console.log(checked)
if (!checked) { if (!checked) {
// add all kinds in this group // add all kinds in this group
setTemporaryShowKinds((prev) => Array.from(new Set([...prev, ...kindGroup]))) setTemporaryShowKinds((prev) => Array.from(new Set([...prev, ...kindGroup])))

9
src/components/MailboxSetting/DiscoveredRelays.tsx

@ -8,6 +8,7 @@ import { Loader2, Check, AlertCircle } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon' import RelayIcon from '../RelayIcon'
import logger from '@/lib/logger'
interface DiscoveredRelay { interface DiscoveredRelay {
url: string url: string
@ -53,7 +54,7 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd:
}) })
} }
} catch (error) { } catch (error) {
console.log('Could not fetch relays from NIP-05:', error) logger.warn('Could not fetch relays from NIP-05', error as Error)
} }
} }
@ -72,7 +73,7 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd:
} }
}) })
} catch (error) { } catch (error) {
console.log('Could not fetch relays from NIP-07 extension:', error) logger.warn('Could not fetch relays from NIP-07 extension', error as Error)
} }
} }
@ -87,7 +88,7 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd:
setDiscoveredRelays(discoveredArray) setDiscoveredRelays(discoveredArray)
} catch (error) { } catch (error) {
console.error('Error discovering relays:', error) logger.error('Error discovering relays', { error })
setErrorMsg(t('Failed to discover relays')) setErrorMsg(t('Failed to discover relays'))
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@ -131,7 +132,7 @@ export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd:
// Clear discovered relays after adding // Clear discovered relays after adding
setDiscoveredRelays([]) setDiscoveredRelays([])
} catch (error) { } catch (error) {
console.error('Failed to add relays:', error) logger.error('Failed to add relays', { error })
setErrorMsg(t('Failed to add relays')) setErrorMsg(t('Failed to add relays'))
} finally { } finally {
setIsAdding(false) setIsAdding(false)

3
src/components/MailboxSetting/SaveButton.tsx

@ -6,6 +6,7 @@ import { TMailboxRelay } from '@/types'
import { CloudUpload, Loader } from 'lucide-react' import { CloudUpload, Loader } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger'
export default function SaveButton({ export default function SaveButton({
mailboxRelays, mailboxRelays,
@ -49,7 +50,7 @@ export default function SaveButton({
showSimplePublishSuccess(t('Mailbox relays saved')) showSimplePublishSuccess(t('Mailbox relays saved'))
} }
} catch (error) { } catch (error) {
console.error('Failed to save relay list:', error) logger.error('Failed to save relay list', { error })
// Show error feedback with relay statuses if available // Show error feedback with relay statuses if available
if (error instanceof Error && (error as any).relayStatuses) { if (error instanceof Error && (error as any).relayStatuses) {
const errorRelayStatuses = (error as any).relayStatuses const errorRelayStatuses = (error as any).relayStatuses

3
src/components/MediaErrorBoundary.tsx

@ -1,5 +1,6 @@
import React, { Component, ReactNode } from 'react' import React, { Component, ReactNode } from 'react'
import { AlertTriangle } from 'lucide-react' import { AlertTriangle } from 'lucide-react'
import logger from '@/lib/logger'
interface MediaErrorBoundaryProps { interface MediaErrorBoundaryProps {
children: ReactNode children: ReactNode
@ -31,7 +32,7 @@ export class MediaErrorBoundary extends Component<MediaErrorBoundaryProps, Media
} }
// Log unexpected errors // Log unexpected errors
console.warn('Media error boundary caught error:', error, errorInfo) logger.warn('Media error boundary caught error', { error, errorInfo })
this.props.onError?.(error) this.props.onError?.(error)
} }

17
src/components/Note/Article/index.tsx

@ -248,7 +248,7 @@ export default function Article({
/> />
{/* Collapsible Article Info - only for article-type events */} {/* Collapsible Article Info - only for article-type events */}
{isArticleType && (parsedContent?.nostrLinks?.length > 0 || parsedContent?.highlightSources?.length > 0 || parsedContent?.hashtags?.length > 0) && ( {isArticleType && (parsedContent?.highlightSources?.length > 0 || parsedContent?.hashtags?.length > 0) && (
<Collapsible open={isInfoOpen} onOpenChange={setIsInfoOpen} className="mt-4"> <Collapsible open={isInfoOpen} onOpenChange={setIsInfoOpen} className="mt-4">
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between"> <Button variant="outline" className="w-full justify-between">
@ -258,21 +258,6 @@ export default function Article({
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="space-y-4 mt-2"> <CollapsibleContent className="space-y-4 mt-2">
{/* Nostr links summary */}
{parsedContent?.nostrLinks?.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-2">Nostr references:</h4>
<div className="space-y-1">
{parsedContent?.nostrLinks?.map((link, index) => (
<div key={index} className="text-sm">
<span className="font-mono text-blue-600">{link.type}:</span>{' '}
<span className="font-mono">{link.id}</span>
</div>
))}
</div>
</div>
)}
{/* Highlight sources */} {/* Highlight sources */}
{parsedContent?.highlightSources?.length > 0 && ( {parsedContent?.highlightSources?.length > 0 && (
<div className="p-4 bg-muted rounded-lg"> <div className="p-4 bg-muted rounded-lg">

17
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -425,7 +425,7 @@ export default function AsciidocArticle({
)} )}
{/* Collapsible Article Info - only for article-type events */} {/* Collapsible Article Info - only for article-type events */}
{!hideImagesAndInfo && isArticleType && (parsedContent?.nostrLinks?.length > 0 || parsedContent?.highlightSources?.length > 0 || parsedContent?.hashtags?.length > 0) && ( {!hideImagesAndInfo && isArticleType && (parsedContent?.highlightSources?.length > 0 || parsedContent?.hashtags?.length > 0) && (
<Collapsible open={isInfoOpen} onOpenChange={setIsInfoOpen} className="mt-4"> <Collapsible open={isInfoOpen} onOpenChange={setIsInfoOpen} className="mt-4">
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between"> <Button variant="outline" className="w-full justify-between">
@ -435,21 +435,6 @@ export default function AsciidocArticle({
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="space-y-4 mt-2"> <CollapsibleContent className="space-y-4 mt-2">
{/* Nostr links summary */}
{parsedContent?.nostrLinks?.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-2">Nostr references:</h4>
<div className="space-y-1">
{parsedContent?.nostrLinks?.map((link, index) => (
<div key={index} className="text-sm">
<span className="font-mono text-blue-600">{link.type}:</span>{' '}
<span className="font-mono">{link.id}</span>
</div>
))}
</div>
</div>
)}
{/* Highlight sources */} {/* Highlight sources */}
{parsedContent?.highlightSources?.length > 0 && ( {parsedContent?.highlightSources?.length > 0 && (
<div className="p-4 bg-muted rounded-lg"> <div className="p-4 bg-muted rounded-lg">

3
src/components/Note/Highlight/index.tsx

@ -1,6 +1,7 @@
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { Highlighter } from 'lucide-react' import { Highlighter } from 'lucide-react'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import logger from '@/lib/logger'
import HighlightSourcePreview from '@/components/UniversalContent/HighlightSourcePreview' import HighlightSourcePreview from '@/components/UniversalContent/HighlightSourcePreview'
export default function Highlight({ export default function Highlight({
@ -123,7 +124,7 @@ export default function Highlight({
</div> </div>
) )
} catch (error) { } catch (error) {
console.error('Highlight component error:', error) logger.error('Highlight component error', { error, eventId: event.id })
return ( return (
<div className={`relative border-l-4 border-red-500 bg-red-50/50 dark:bg-red-950/20 rounded-r-lg p-4 ${className || ''}`}> <div className={`relative border-l-4 border-red-500 bg-red-50/50 dark:bg-red-950/20 rounded-r-lg p-4 ${className || ''}`}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">

3
src/components/Note/LongFormArticle/NostrNode.tsx

@ -1,6 +1,7 @@
import { EmbeddedMention, EmbeddedNote } from '@/components/Embedded' import { EmbeddedMention, EmbeddedNote } from '@/components/Embedded'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import logger from '@/lib/logger'
interface NostrNodeProps { interface NostrNodeProps {
rawText: string rawText: string
@ -19,7 +20,7 @@ export default function NostrNode({ rawText, bech32Id }: NostrNodeProps) {
return { type: 'note', id: bech32Id } return { type: 'note', id: bech32Id }
} }
} catch (error) { } catch (error) {
console.error('Invalid bech32 ID:', bech32Id, error) logger.error('Invalid bech32 ID', { bech32Id, error })
} }
return { type: 'invalid', id: '' } return { type: 'invalid', id: '' }
}, [bech32Id]) }, [bech32Id])

3
src/components/Note/MarkdownArticle/NostrNode.tsx

@ -2,6 +2,7 @@ import { EmbeddedMention, EmbeddedNote } from '@/components/Embedded'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { ComponentProps, useMemo } from 'react' import { ComponentProps, useMemo } from 'react'
import { Components } from './types' import { Components } from './types'
import logger from '@/lib/logger'
export default function NostrNode({ rawText, bech32Id }: ComponentProps<Components['nostr']>) { export default function NostrNode({ rawText, bech32Id }: ComponentProps<Components['nostr']>) {
const { type, id } = useMemo(() => { const { type, id } = useMemo(() => {
@ -15,7 +16,7 @@ export default function NostrNode({ rawText, bech32Id }: ComponentProps<Componen
return { type: 'note', id: bech32Id } return { type: 'note', id: bech32Id }
} }
} catch (error) { } catch (error) {
console.error('Invalid bech32 ID:', bech32Id, error) logger.error('Invalid bech32 ID', { bech32Id, error })
} }
return { type: 'invalid', id: '' } return { type: 'invalid', id: '' }
}, [bech32Id]) }, [bech32Id])

5
src/components/Note/Poll.tsx

@ -13,6 +13,7 @@ import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import logger from '@/lib/logger'
export default function Poll({ event, className }: { event: Event; className?: string }) { export default function Poll({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -80,7 +81,7 @@ export default function Poll({ event, className }: { event: Event; className?: s
poll.endsAt poll.endsAt
) )
} catch (error) { } catch (error) {
console.error('Failed to fetch poll results:', error) logger.error('Failed to fetch poll results', { error, eventId: event.id })
toast.error('Failed to fetch poll results: ' + (error as Error).message) toast.error('Failed to fetch poll results: ' + (error as Error).message)
} finally { } finally {
setIsLoadingResults(false) setIsLoadingResults(false)
@ -125,7 +126,7 @@ export default function Poll({ event, className }: { event: Event; className?: s
setSelectedOptionIds([]) setSelectedOptionIds([])
pollResultsService.addPollResponse(event.id, pubkey, selectedOptionIds) pollResultsService.addPollResponse(event.id, pubkey, selectedOptionIds)
} catch (error) { } catch (error) {
console.error('Failed to vote:', error) logger.error('Failed to vote', { error, eventId: event.id })
toast.error('Failed to vote: ' + (error as Error).message) toast.error('Failed to vote: ' + (error as Error).message)
} finally { } finally {
setIsVoting(false) setIsVoting(false)

3
src/components/NoteOptions/RawEventDialog.tsx

@ -11,6 +11,7 @@ import { Event } from 'nostr-tools'
import { WrapText, Copy, Check } from 'lucide-react' import { WrapText, Copy, Check } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger'
export default function RawEventDialog({ export default function RawEventDialog({
event, event,
@ -31,7 +32,7 @@ export default function RawEventDialog({
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} catch (err) { } catch (err) {
console.error('Failed to copy:', err) logger.error('Failed to copy raw event', { error: err, eventId: event.id })
} }
} }

2
src/components/NoteOptions/useMenuActions.tsx

@ -342,7 +342,7 @@ export function useMenuActions({
relays: relays.length > 0 ? relays : undefined relays: relays.length > 0 ? relays : undefined
}) })
} catch (error) { } catch (error) {
console.error('Error generating naddr:', error) logger.error('Error generating naddr', { error })
return '' return ''
} }
}, [isArticleType, event, dTag]) }, [isArticleType, event, dTag])

11
src/components/NoteStats/LikeButton.tsx

@ -18,6 +18,7 @@ import { TEmoji } from '@/types'
import { Loader, SmilePlus } from 'lucide-react' import { Loader, SmilePlus } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import logger from '@/lib/logger'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Emoji from '../Emoji' import Emoji from '../Emoji'
import EmojiPicker from '../EmojiPicker' import EmojiPicker from '../EmojiPicker'
@ -88,7 +89,13 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
const myLastEmojiString = typeof myLastEmoji === 'string' ? myLastEmoji : typeof myLastEmoji === 'object' ? myLastEmoji.shortcode : undefined const myLastEmojiString = typeof myLastEmoji === 'string' ? myLastEmoji : typeof myLastEmoji === 'object' ? myLastEmoji.shortcode : undefined
const isTogglingOff = myLastEmojiString === emojiString const isTogglingOff = myLastEmojiString === emojiString
console.log('Toggle check:', { myLastEmoji, myLastEmojiString, emojiString, isTogglingOff, myLikes: noteStats?.likes?.filter(l => l.pubkey === pubkey) }) logger.debug('Like toggle check', {
myLastEmoji,
myLastEmojiString,
emojiString,
isTogglingOff,
myLikes: noteStats?.likes?.filter(like => like.pubkey === pubkey)
})
if (isTogglingOff) { if (isTogglingOff) {
// User wants to toggle off - find their previous reaction and delete it // User wants to toggle off - find their previous reaction and delete it
@ -117,7 +124,7 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
noteStatsService.updateNoteStatsByEvents([evt]) noteStatsService.updateNoteStatsByEvents([evt])
} }
} catch (error) { } catch (error) {
console.error('like failed', error) logger.error('Like failed', { error, eventId: event.id })
} finally { } finally {
setLiking(false) setLiking(false)
clearTimeout(timer) clearTimeout(timer)

3
src/components/NoteStats/Likes.tsx

@ -10,6 +10,7 @@ import { Loader } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import Emoji from '../Emoji' import Emoji from '../Emoji'
import logger from '@/lib/logger'
export default function Likes({ event }: { event: Event }) { export default function Likes({ event }: { event: Event }) {
const inQuietMode = shouldHideInteractions(event) const inQuietMode = shouldHideInteractions(event)
@ -58,7 +59,7 @@ export default function Likes({ event }: { event: Event }) {
const evt = await publish(reaction) const evt = await publish(reaction)
noteStatsService.updateNoteStatsByEvents([evt]) noteStatsService.updateNoteStatsByEvents([evt])
} catch (error) { } catch (error) {
console.error('like failed', error) logger.error('Like failed', { error, eventId: event.id })
} finally { } finally {
setLiking(null) setLiking(null)
clearTimeout(timer) clearTimeout(timer)

3
src/components/NoteStats/RepostButton.tsx

@ -18,6 +18,7 @@ import { Loader, PencilLine, Repeat } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger'
import PostEditor from '../PostEditor' import PostEditor from '../PostEditor'
import { formatCount } from './utils' import { formatCount } from './utils'
@ -60,7 +61,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even
const evt = await publish(repost) const evt = await publish(repost)
noteStatsService.updateNoteStatsByEvents([evt]) noteStatsService.updateNoteStatsByEvents([evt])
} catch (error) { } catch (error) {
console.error('repost failed', error) logger.error('Repost failed', { error, eventId: event.id })
} finally { } finally {
setReposting(false) setReposting(false)
clearTimeout(timer) clearTimeout(timer)

3
src/components/NoteStats/VoteButtons.tsx

@ -6,6 +6,7 @@ import { Event } from 'nostr-tools'
import { ChevronDown, ChevronUp } from 'lucide-react' import { ChevronDown, ChevronUp } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import logger from '@/lib/logger'
export default function VoteButtons({ event }: { event: Event }) { export default function VoteButtons({ event }: { event: Event }) {
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
@ -76,7 +77,7 @@ export default function VoteButtons({ event }: { event: Event }) {
noteStatsService.updateNoteStatsByEvents([evt]) noteStatsService.updateNoteStatsByEvents([evt])
} }
} catch (error) { } catch (error) {
console.error('vote failed', error) logger.error('Vote failed', { error, eventId: event.id })
} finally { } finally {
setVoting(null) setVoting(null)
clearTimeout(timer) clearTimeout(timer)

3
src/components/ParentNotePreview/index.tsx

@ -8,6 +8,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Event, nip19 } from 'nostr-tools' import { Event, nip19 } from 'nostr-tools'
import ContentPreview from '../ContentPreview' import ContentPreview from '../ContentPreview'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import logger from '@/lib/logger'
export default function ParentNotePreview({ export default function ParentNotePreview({
eventId, eventId,
@ -55,7 +56,7 @@ export default function ParentNotePreview({
setFallbackEvent(foundEvent) setFallbackEvent(foundEvent)
} }
} catch (error) { } catch (error) {
console.warn('Fallback fetch from searchable relays failed:', error) logger.warn('Fallback fetch from searchable relays failed', error as Error)
} finally { } finally {
setIsFetchingFallback(false) setIsFetchingFallback(false)
} }

7
src/components/PostEditor/HighlightEditor.tsx

@ -148,12 +148,11 @@ export default function HighlightEditor({
id="highlight-context" id="highlight-context"
value={context} value={context}
onChange={(e) => setContext(e.target.value)} onChange={(e) => setContext(e.target.value)}
placeholder={t('Enter the full text that you are highlighting from...')} placeholder={t('Paste the entire original passage that contains your highlight')}
rows={2} rows={3}
maxLength={500}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{context.length}/500 {t('characters')} {t('The main editor above should contain only the text you want to highlight. This field should contain the full quote or paragraph for context.')}
</p> </p>
</div> </div>

3
src/components/PostEditor/Mentions.tsx

@ -3,6 +3,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import logger from '@/lib/logger'
import { Check } from 'lucide-react' import { Check } from 'lucide-react'
import { Event, nip19 } from 'nostr-tools' import { Event, nip19 } from 'nostr-tools'
import { HTMLAttributes, useEffect, useState } from 'react' import { HTMLAttributes, useEffect, useState } from 'react'
@ -166,7 +167,7 @@ export async function extractMentions(content: string, parentEvent?: Event) {
} }
} }
} catch (e) { } catch (e) {
console.error(e) logger.error('Failed to decode mention', { error: e, match: m })
} }
} }

9
src/components/PostEditor/PostContent.tsx

@ -16,6 +16,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useFeed } from '@/providers/FeedProvider' import { useFeed } from '@/providers/FeedProvider'
import { useReply } from '@/providers/ReplyProvider' import { useReply } from '@/providers/ReplyProvider'
import { normalizeUrl, cleanUrl } from '@/lib/url' import { normalizeUrl, cleanUrl } from '@/lib/url'
import logger from '@/lib/logger'
import postEditorCache from '@/services/post-editor-cache.service' import postEditorCache from '@/services/post-editor-cache.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { TPollCreateData } from '@/types' import { TPollCreateData } from '@/types'
@ -148,7 +149,7 @@ export default function PostContent({
// In a real implementation, you'd also resolve @ mentions to pubkeys // In a real implementation, you'd also resolve @ mentions to pubkeys
setExtractedMentions(nostrPubkeys) setExtractedMentions(nostrPubkeys)
} catch (error) { } catch (error) {
console.error('Error extracting mentions:', error) logger.error('Error extracting mentions', { error })
setExtractedMentions([]) setExtractedMentions([])
} }
}, []) }, [])
@ -173,7 +174,7 @@ export default function PostContent({
e?.stopPropagation() e?.stopPropagation()
checkLogin(async () => { checkLogin(async () => {
if (!canPost) { if (!canPost) {
console.log('❌ Cannot post - canPost is false') logger.warn('Attempted to post while canPost is false')
return return
} }
@ -326,8 +327,8 @@ export default function PostContent({
addReplies([cleanEvent]) addReplies([cleanEvent])
close() close()
} catch (error) { } catch (error) {
console.error('Publishing error:', error) logger.error('Publishing error', { error })
console.error('Error details:', { logger.error('Publishing error details', {
name: error instanceof Error ? error.name : 'Unknown', name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : String(error), message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined stack: error instanceof Error ? error.stack : undefined

9
src/components/PostEditor/PostRelaySelector.tsx

@ -12,6 +12,7 @@ import relaySelectionService from '@/services/relay-selection.service'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet' import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
import logger from '@/lib/logger'
export default function PostRelaySelector({ export default function PostRelaySelector({
parentEvent: _parentEvent, parentEvent: _parentEvent,
@ -108,8 +109,8 @@ export default function PostRelaySelector({
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to update relay selection:', error) logger.error('Failed to update relay selection', { error })
setSelectableRelays([]) setSelectableRelays([])
if (!hasManualSelection) { if (!hasManualSelection) {
setSelectedRelayUrls([]) setSelectedRelayUrls([])
@ -167,8 +168,8 @@ export default function PostRelaySelector({
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to update relay selection:', error) logger.error('Failed to update relay selection', { error })
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }

3
src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts

@ -2,6 +2,7 @@ import mediaUpload from '@/services/media-upload.service'
import { Extension } from '@tiptap/core' import { Extension } from '@tiptap/core'
import { EditorView } from '@tiptap/pm/view' import { EditorView } from '@tiptap/pm/view'
import { Plugin, TextSelection } from 'prosemirror-state' import { Plugin, TextSelection } from 'prosemirror-state'
import logger from '@/lib/logger'
const DRAGOVER_CLASS_LIST = [ const DRAGOVER_CLASS_LIST = [
'outline-2', 'outline-2',
@ -177,7 +178,7 @@ async function uploadFiles(
} }
}) })
.catch((error) => { .catch((error) => {
console.error('Upload failed:', error) logger.error('Clipboard/drop upload failed', { error, file: file.name })
options.onUploadEnd?.(file) options.onUploadEnd?.(file)
const tr = view.state.tr const tr = view.state.tr

3
src/components/PostEditor/Uploader.tsx

@ -1,6 +1,7 @@
import mediaUpload, { UPLOAD_ABORTED_ERROR_MSG } from '@/services/media-upload.service' import mediaUpload, { UPLOAD_ABORTED_ERROR_MSG } from '@/services/media-upload.service'
import { useRef } from 'react' import { useRef } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import logger from '@/lib/logger'
export default function Uploader({ export default function Uploader({
children, children,
@ -42,7 +43,7 @@ export default function Uploader({
onUploadSuccess(result) onUploadSuccess(result)
onUploadEnd?.(file) onUploadEnd?.(file)
} catch (error) { } catch (error) {
console.error('Error uploading file', error) logger.error('Error uploading file', { error, file: file.name })
const message = (error as Error).message const message = (error as Error).message
if (message !== UPLOAD_ABORTED_ERROR_MSG) { if (message !== UPLOAD_ABORTED_ERROR_MSG) {
toast.error(`Failed to upload file: ${message}`) toast.error(`Failed to upload file: ${message}`)

349
src/components/Profile/ProfileArticles.tsx

@ -1,12 +1,9 @@
import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants' import { ExtendedKind } from '@/constants'
import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service'
import { Event, kinds } from 'nostr-tools'
import { useCallback, useEffect, useState, forwardRef, useImperativeHandle, useMemo } from 'react'
import NoteCard from '@/components/NoteCard' import NoteCard from '@/components/NoteCard'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useProfileTimeline } from '@/hooks/useProfileTimeline'
import { Event, kinds } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
interface ProfileArticlesProps { interface ProfileArticlesProps {
pubkey: string pubkey: string
@ -16,262 +13,132 @@ interface ProfileArticlesProps {
onEventsChange?: (events: Event[]) => void onEventsChange?: (events: Event[]) => void
} }
const ProfileArticles = forwardRef<{ refresh: () => void; getEvents: () => Event[] }, ProfileArticlesProps>(({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => { const ARTICLE_KINDS = [
const [events, setEvents] = useState<Event[]>([]) kinds.LongFormArticle,
const [isLoading, setIsLoading] = useState(true) ExtendedKind.WIKI_ARTICLE_MARKDOWN,
const [retryCount, setRetryCount] = useState(0) ExtendedKind.WIKI_ARTICLE,
const [isRetrying, setIsRetrying] = useState(false) ExtendedKind.PUBLICATION,
const [isRefreshing, setIsRefreshing] = useState(false) kinds.Highlights
const { favoriteRelays } = useFavoriteRelays() ]
const maxRetries = 3
const ProfileArticles = forwardRef<{ refresh: () => void; getEvents: () => Event[] }, ProfileArticlesProps>(
// Build comprehensive relay list including user's personal relays ({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => {
const buildComprehensiveRelayList = useCallback(async () => { const [isRefreshing, setIsRefreshing] = useState(false)
try {
// Get user's relay list (kind 10002) const cacheKey = useMemo(() => `${pubkey}-articles`, [pubkey])
const userRelayList = await client.fetchRelayList(pubkey)
const { events: timelineEvents, isLoading, refresh } = useProfileTimeline({
// Get all relays: user's + fast read + favorite relays pubkey,
const allRelays = [ cacheKey,
...(userRelayList.read || []), // User's read relays kinds: ARTICLE_KINDS,
...(userRelayList.write || []), // User's write relays limit: 200
...FAST_READ_RELAY_URLS, // Fast read relays })
...(favoriteRelays || []) // User's favorite relays
] useEffect(() => {
onEventsChange?.(timelineEvents)
// Normalize URLs and remove duplicates }, [timelineEvents, onEventsChange])
const normalizedRelays = allRelays
.map(url => normalizeUrl(url)) useEffect(() => {
.filter((url): url is string => !!url) if (!isLoading) {
setIsRefreshing(false)
const uniqueRelays = Array.from(new Set(normalizedRelays))
return uniqueRelays
} catch (error) {
return FAST_READ_RELAY_URLS
}
}, [pubkey, favoriteRelays])
const fetchArticles = useCallback(async (isRetry = false, isRefresh = false) => {
if (!pubkey) {
setEvents([])
setIsLoading(false)
return
}
try {
if (!isRetry && !isRefresh) {
setIsLoading(true)
setRetryCount(0)
} else if (isRetry) {
setIsRetrying(true)
} else if (isRefresh) {
setIsRefreshing(true)
}
// Build comprehensive relay list including user's personal relays
const comprehensiveRelays = await buildComprehensiveRelayList()
// Fetch longform articles (kind 30023), wiki articles (kinds 30817, 30818), publications (kind 30040), and highlights (kind 9802)
const allEvents = await client.fetchEvents(comprehensiveRelays, {
authors: [pubkey],
kinds: [kinds.LongFormArticle, 30817, 30818, 30040, kinds.Highlights], // LongFormArticle, WikiArticle (markdown), WikiArticle (asciidoc), Publication, and Highlights
limit: 100
})
const eventsToShow = allEvents
// Sort by creation time (newest first)
eventsToShow.sort((a, b) => b.created_at - a.created_at)
// If initial load returns 0 events but it's not a retry, wait and retry once
// This handles cases where relays return "too many concurrent REQS" and return empty results
if (!isRetry && !isRefresh && eventsToShow.length === 0 && retryCount === 0) {
setTimeout(() => {
setRetryCount(prev => prev + 1)
fetchArticles(true)
}, 2000) // Wait 2 seconds before retry to let relays recover
return
} }
}, [isLoading])
useImperativeHandle(
ref,
() => ({
refresh: () => {
setIsRefreshing(true)
refresh()
},
getEvents: () => timelineEvents
}),
[refresh, timelineEvents]
)
if (isRefresh) { const eventsFilteredByKind = useMemo(() => {
// For refresh, append new events and deduplicate if (kindFilter === 'all') {
setEvents(prevEvents => { return timelineEvents
const existingIds = new Set(prevEvents.map(e => e.id))
const newEvents = eventsToShow.filter(event => !existingIds.has(event.id))
const combinedEvents = [...newEvents, ...prevEvents]
// Re-sort the combined events
return combinedEvents.sort((a, b) => b.created_at - a.created_at)
})
} else {
// For initial load or retry, replace events
setEvents(eventsToShow)
} }
const kindNumber = parseInt(kindFilter, 10)
// Reset retry count on successful fetch if (Number.isNaN(kindNumber)) {
if (isRetry) { return timelineEvents
setRetryCount(0)
} }
} catch (error) { return timelineEvents.filter((event) => event.kind === kindNumber)
console.error('[ProfileArticles] Error fetching events:', error) }, [timelineEvents, kindFilter])
logger.component('ProfileArticles', 'Initialization failed', { pubkey, error: (error as Error).message, retryCount: isRetry ? retryCount + 1 : 0 })
// If this is not a retry and we haven't exceeded max retries, schedule a retry const filteredEvents = useMemo(() => {
if (!isRetry && retryCount < maxRetries) { if (!searchQuery.trim()) {
// Use shorter delays for initial retries, then exponential backoff return eventsFilteredByKind
const delay = retryCount === 0 ? 1000 : retryCount === 1 ? 2000 : 3000
setTimeout(() => {
setRetryCount(prev => prev + 1)
fetchArticles(true)
}, delay)
} else {
setEvents([])
} }
} finally { const query = searchQuery.toLowerCase()
setIsLoading(false) return eventsFilteredByKind.filter(
setIsRetrying(false) (event) =>
setIsRefreshing(false) event.content.toLowerCase().includes(query) ||
} event.tags.some((tag) => tag.length > 1 && tag[1]?.toLowerCase().includes(query))
}, [pubkey, buildComprehensiveRelayList, maxRetries]) )
}, [eventsFilteredByKind, searchQuery])
// Expose refresh function to parent component
const refresh = useCallback(() => { const getKindLabel = (kindValue: string) => {
setRetryCount(0) if (!kindValue || kindValue === 'all') return 'articles, publications, or highlights'
setIsRefreshing(true) const kindNum = parseInt(kindValue, 10)
fetchArticles(false, true) // isRetry = false, isRefresh = true if (kindNum === kinds.LongFormArticle) return 'long form articles'
}, [fetchArticles]) if (kindNum === ExtendedKind.WIKI_ARTICLE_MARKDOWN) return 'wiki articles (markdown)'
if (kindNum === ExtendedKind.WIKI_ARTICLE) return 'wiki articles (asciidoc)'
useImperativeHandle(ref, () => ({ if (kindNum === ExtendedKind.PUBLICATION) return 'publications'
refresh, if (kindNum === kinds.Highlights) return 'highlights'
getEvents: () => events return 'items'
}), [refresh, events])
// Notify parent of events changes
useEffect(() => {
if (onEventsChange) {
onEventsChange(events)
} }
}, [events, onEventsChange])
// Filter events based on search query and kind filter
const filteredEvents = useMemo(() => {
let filtered = events
// Filter by kind first if (!pubkey) {
if (kindFilter && kindFilter !== 'all') { return (
const kindFilterNum = parseInt(kindFilter) <div className="flex justify-center items-center py-8">
if (!isNaN(kindFilterNum)) { <div className="text-sm text-muted-foreground">No profile selected</div>
filtered = filtered.filter(event => event.kind === kindFilterNum) </div>
} )
} }
// Then filter by search query if (isLoading && timelineEvents.length === 0) {
if (searchQuery.trim()) { return (
const query = searchQuery.toLowerCase() <div className="space-y-2">
filtered = filtered.filter(event => {Array.from({ length: 3 }).map((_, i) => (
event.content.toLowerCase().includes(query) || <Skeleton key={i} className="h-32 w-full" />
event.tags.some(tag => ))}
tag.length > 1 && tag[1]?.toLowerCase().includes(query) </div>
)
) )
} }
return filtered if (!filteredEvents.length && !isLoading) {
}, [events, searchQuery, kindFilter]) return (
<div className="flex justify-center items-center py-8">
// Separate effect for initial fetch only with a small delay <div className="text-sm text-muted-foreground">
// Separate effect for initial fetch only - delay slightly to avoid race conditions with other tabs {searchQuery.trim()
useEffect(() => { ? `No ${getKindLabel(kindFilter)} match your search`
if (pubkey) { : `No ${getKindLabel(kindFilter)} found`}
// Small delay to stagger initial fetches across tabs and allow relay list cache to populate </div>
const timeoutId = setTimeout(() => { </div>
fetchArticles() )
}, 150) // 150ms delay (slightly longer than posts) to allow previous fetches to populate cache
return () => clearTimeout(timeoutId)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pubkey]) // Only depend on pubkey - fetchArticles is stable from useCallback
if (isLoading || isRetrying) {
return ( return (
<div className="space-y-2"> <div style={{ marginTop: topSpace || 0 }}>
{isRetrying && retryCount > 0 && ( {isRefreshing && (
<div className="text-center py-2 text-sm text-muted-foreground"> <div className="px-4 py-2 text-sm text-green-500 text-center">🔄 Refreshing articles...</div>
Retrying... ({retryCount}/{maxRetries}) )}
{(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{filteredEvents.length} of {eventsFilteredByKind.length} {getKindLabel(kindFilter)}
</div> </div>
)} )}
{Array.from({ length: 3 }).map((_, i) => ( <div className="space-y-2">
<Skeleton key={i} className="h-32 w-full" /> {filteredEvents.map((event) => (
))} <NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
</div> ))}
)
}
if (!pubkey) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">No profile selected</div>
</div>
)
}
if (events.length === 0) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">No articles, publications, or highlights found</div>
</div>
)
}
// Get kind label for display
const getKindLabel = (kindValue: string) => {
if (!kindValue || kindValue === 'all') return 'articles, publications, or highlights'
const kindNum = parseInt(kindValue)
if (kindNum === kinds.LongFormArticle) return 'long form articles'
if (kindNum === ExtendedKind.WIKI_ARTICLE_MARKDOWN) return 'wiki articles (markdown)'
if (kindNum === ExtendedKind.WIKI_ARTICLE) return 'wiki articles (asciidoc)'
if (kindNum === ExtendedKind.PUBLICATION) return 'publications'
if (kindNum === kinds.Highlights) return 'highlights'
return 'items'
}
if (filteredEvents.length === 0 && (searchQuery.trim() || (kindFilter && kindFilter !== 'all'))) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">
{searchQuery.trim()
? `No ${getKindLabel(kindFilter)} match your search`
: `No ${getKindLabel(kindFilter)} found`}
</div> </div>
</div> </div>
) )
} }
)
return (
<div style={{ marginTop: topSpace || 0 }}>
{isRefreshing && (
<div className="px-4 py-2 text-sm text-green-500 text-center">
🔄 Refreshing articles...
</div>
)}
{(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{filteredEvents.length} of {events.length} {getKindLabel(kindFilter)}
</div>
)}
<div className="space-y-2">
{filteredEvents.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={false}
/>
))}
</div>
</div>
)
})
ProfileArticles.displayName = 'ProfileArticles' ProfileArticles.displayName = 'ProfileArticles'

15
src/components/Profile/ProfileBookmarksAndHashtags.tsx

@ -182,7 +182,10 @@ const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, {
// If this is not a retry and we haven't exceeded max retries, schedule a retry // If this is not a retry and we haven't exceeded max retries, schedule a retry
if (!isRetry && retryCountBookmarks < maxRetries) { if (!isRetry && retryCountBookmarks < maxRetries) {
console.log('[ProfileBookmarksAndHashtags] Scheduling bookmark retry', retryCountBookmarks + 1, 'of', maxRetries) logger.debug('[ProfileBookmarksAndHashtags] Scheduling bookmark retry', {
attempt: retryCountBookmarks + 1,
maxRetries
})
// Use shorter delays for initial retries, then exponential backoff // Use shorter delays for initial retries, then exponential backoff
const delay = retryCountBookmarks === 0 ? 1000 : retryCountBookmarks === 1 ? 2000 : 3000 const delay = retryCountBookmarks === 0 ? 1000 : retryCountBookmarks === 1 ? 2000 : 3000
setTimeout(() => { setTimeout(() => {
@ -280,7 +283,10 @@ const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, {
// If this is not a retry and we haven't exceeded max retries, schedule a retry // If this is not a retry and we haven't exceeded max retries, schedule a retry
if (!isRetry && retryCountHashtags < maxRetries) { if (!isRetry && retryCountHashtags < maxRetries) {
console.log('[ProfileBookmarksAndHashtags] Scheduling hashtag retry', retryCountHashtags + 1, 'of', maxRetries) logger.debug('[ProfileBookmarksAndHashtags] Scheduling hashtag retry', {
attempt: retryCountHashtags + 1,
maxRetries
})
// Use shorter delays for initial retries, then exponential backoff // Use shorter delays for initial retries, then exponential backoff
const delay = retryCountHashtags === 0 ? 1000 : retryCountHashtags === 1 ? 2000 : 3000 const delay = retryCountHashtags === 0 ? 1000 : retryCountHashtags === 1 ? 2000 : 3000
setTimeout(() => { setTimeout(() => {
@ -421,7 +427,10 @@ const ProfileBookmarksAndHashtags = forwardRef<{ refresh: () => void }, {
// If this is not a retry and we haven't exceeded max retries, schedule a retry // If this is not a retry and we haven't exceeded max retries, schedule a retry
if (!isRetry && retryCountPins < maxRetries) { if (!isRetry && retryCountPins < maxRetries) {
console.log('[ProfileBookmarksAndHashtags] Scheduling pin retry', retryCountPins + 1, 'of', maxRetries) logger.debug('[ProfileBookmarksAndHashtags] Scheduling pin retry', {
attempt: retryCountPins + 1,
maxRetries
})
// Use shorter delays for initial retries, then exponential backoff // Use shorter delays for initial retries, then exponential backoff
const delay = retryCountPins === 0 ? 1000 : retryCountPins === 1 ? 2000 : 3000 const delay = retryCountPins === 0 ? 1000 : retryCountPins === 1 ? 2000 : 3000
setTimeout(() => { setTimeout(() => {

323
src/components/Profile/ProfileFeed.tsx

@ -1,239 +1,148 @@
import { FAST_READ_RELAY_URLS } from '@/constants' import { ExtendedKind } from '@/constants'
import logger from '@/lib/logger' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service'
import { Event } from 'nostr-tools'
import { useCallback, useEffect, useState, forwardRef, useImperativeHandle, useMemo } from 'react'
import NoteCard from '@/components/NoteCard' import NoteCard from '@/components/NoteCard'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { kinds, Event } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import { useProfileTimeline } from '@/hooks/useProfileTimeline'
import { useZap } from '@/providers/ZapProvider'
interface ProfileFeedProps { interface ProfileFeedProps {
pubkey: string pubkey: string
topSpace?: number topSpace?: number
searchQuery?: string searchQuery?: string
kindFilter?: string
onEventsChange?: (events: Event[]) => void
} }
const ProfileFeed = forwardRef<{ refresh: () => void }, ProfileFeedProps>(({ pubkey, topSpace, searchQuery = '' }, ref) => { const POST_KIND_LIST = [
const [events, setEvents] = useState<Event[]>([]) kinds.ShortTextNote,
const [isLoading, setIsLoading] = useState(true) kinds.Repost,
const [retryCount, setRetryCount] = useState(0) ExtendedKind.COMMENT,
const [isRetrying, setIsRetrying] = useState(false) ExtendedKind.DISCUSSION,
const [isRefreshing, setIsRefreshing] = useState(false) ExtendedKind.POLL,
const { favoriteRelays } = useFavoriteRelays() ExtendedKind.ZAP_RECEIPT
const maxRetries = 3 ]
// Build comprehensive relay list including user's personal relays const ProfileFeed = forwardRef<{ refresh: () => void }, ProfileFeedProps>(
const buildComprehensiveRelayList = useCallback(async () => { ({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => {
try { const { zapReplyThreshold } = useZap()
// Get user's relay list (kind 10002) const [isRefreshing, setIsRefreshing] = useState(false)
const userRelayList = await client.fetchRelayList(pubkey)
const filterPredicate = useMemo(
// Get all relays: user's + fast read + favorite relays () => (event: Event) => {
const allRelays = [ if (event.kind === ExtendedKind.ZAP_RECEIPT) {
...(userRelayList.read || []), // User's read relays const zapInfo = getZapInfoFromEvent(event)
...(userRelayList.write || []), // User's write relays if (!zapInfo?.amount || zapInfo.amount < zapReplyThreshold) {
...FAST_READ_RELAY_URLS, // Fast read relays return false
...(favoriteRelays || []) // User's favorite relays }
] }
return true
// Normalize URLs and remove duplicates },
const normalizedRelays = allRelays [zapReplyThreshold]
.map(url => normalizeUrl(url)) )
.filter((url): url is string => !!url)
const uniqueRelays = Array.from(new Set(normalizedRelays))
return uniqueRelays
} catch (error) {
return FAST_READ_RELAY_URLS
}
}, [pubkey, favoriteRelays])
const fetchPosts = useCallback(async (isRetry = false, isRefresh = false) => { const cacheKey = useMemo(() => `${pubkey}-posts-${zapReplyThreshold}`, [pubkey, zapReplyThreshold])
if (!pubkey) {
setEvents([])
setIsLoading(false)
return
}
try { const { events: timelineEvents, isLoading, refresh } = useProfileTimeline({
if (!isRetry && !isRefresh) { pubkey,
setIsLoading(true) cacheKey,
setRetryCount(0) kinds: POST_KIND_LIST,
} else if (isRetry) { limit: 200,
setIsRetrying(true) filterPredicate
} else if (isRefresh) { })
setIsRefreshing(true)
}
// Build comprehensive relay list including user's personal relays useEffect(() => {
const comprehensiveRelays = await buildComprehensiveRelayList() onEventsChange?.(timelineEvents)
}, [timelineEvents, onEventsChange])
// Now try to fetch text notes specifically
const allEvents = await client.fetchEvents(comprehensiveRelays, {
authors: [pubkey],
kinds: [1], // Text notes only
limit: 100
})
const eventsToShow = allEvents
// Sort by creation time (newest first)
eventsToShow.sort((a, b) => b.created_at - a.created_at)
// If initial load returns 0 events but it's not a retry, wait and retry once
// This handles cases where relays return "too many concurrent REQS" and return empty results
if (!isRetry && !isRefresh && eventsToShow.length === 0 && retryCount === 0) {
setTimeout(() => {
setRetryCount(prev => prev + 1)
fetchPosts(true)
}, 2000) // Wait 2 seconds before retry to let relays recover
return
}
if (isRefresh) { useEffect(() => {
// For refresh, append new events and deduplicate if (!isLoading) {
setEvents(prevEvents => { setIsRefreshing(false)
const existingIds = new Set(prevEvents.map(e => e.id))
const newEvents = eventsToShow.filter(event => !existingIds.has(event.id))
const combinedEvents = [...newEvents, ...prevEvents]
// Re-sort the combined events
return combinedEvents.sort((a, b) => b.created_at - a.created_at)
})
} else {
// For initial load or retry, replace events
setEvents(eventsToShow)
} }
}, [isLoading])
useImperativeHandle(
ref,
() => ({
refresh: () => {
setIsRefreshing(true)
refresh()
}
}),
[refresh]
)
// Reset retry count on successful fetch const eventsFilteredByKind = useMemo(() => {
if (isRetry) { if (kindFilter === 'all') {
setRetryCount(0) return timelineEvents
}
const kindNumber = parseInt(kindFilter, 10)
if (Number.isNaN(kindNumber)) {
return timelineEvents
} }
} catch (error) { return timelineEvents.filter((event) => event.kind === kindNumber)
console.error('[ProfileFeed] Error fetching events:', error) }, [timelineEvents, kindFilter])
logger.component('ProfileFeed', 'Initialization failed', { pubkey, error: (error as Error).message, retryCount: isRetry ? retryCount + 1 : 0 })
const filteredEvents = useMemo(() => {
// If this is not a retry and we haven't exceeded max retries, schedule a retry if (!searchQuery.trim()) {
if (!isRetry && retryCount < maxRetries) { return eventsFilteredByKind
// Use shorter delays for initial retries, then exponential backoff
const delay = retryCount === 0 ? 1000 : retryCount === 1 ? 2000 : 3000
setTimeout(() => {
setRetryCount(prev => prev + 1)
fetchPosts(true)
}, delay)
} else {
setEvents([])
} }
} finally { const query = searchQuery.toLowerCase()
setIsLoading(false) return eventsFilteredByKind.filter(
setIsRetrying(false) (event) =>
setIsRefreshing(false) event.content.toLowerCase().includes(query) ||
event.tags.some((tag) => tag.length > 1 && tag[1]?.toLowerCase().includes(query))
)
}, [eventsFilteredByKind, searchQuery])
if (!pubkey) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">No profile selected</div>
</div>
)
} }
}, [pubkey, buildComprehensiveRelayList, maxRetries])
if (isLoading && timelineEvents.length === 0) {
// Expose refresh function to parent component return (
const refresh = useCallback(() => { <div className="space-y-2">
setRetryCount(0) {Array.from({ length: 3 }).map((_, i) => (
setIsRefreshing(true) <Skeleton key={i} className="h-32 w-full" />
fetchPosts(false, true) // isRetry = false, isRefresh = true ))}
}, [fetchPosts]) </div>
)
useImperativeHandle(ref, () => ({
refresh
}), [refresh])
// Filter events based on search query
const filteredEvents = useMemo(() => {
if (!searchQuery.trim()) {
return events
} }
const query = searchQuery.toLowerCase() if (!filteredEvents.length && !isLoading) {
return events.filter(event => return (
event.content.toLowerCase().includes(query) || <div className="flex justify-center items-center py-8">
event.tags.some(tag => <div className="text-sm text-muted-foreground">
tag.length > 1 && tag[1]?.toLowerCase().includes(query) {searchQuery.trim() ? 'No posts match your search' : 'No posts found'}
</div>
</div>
) )
)
}, [events, searchQuery])
// Separate effect for initial fetch only - delay slightly to avoid race conditions with other tabs
useEffect(() => {
if (pubkey) {
// Small delay to stagger initial fetches across tabs and allow relay list cache to populate
const timeoutId = setTimeout(() => {
fetchPosts()
}, 100) // 100ms delay to allow previous fetches to populate cache
return () => clearTimeout(timeoutId)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pubkey]) // Only depend on pubkey - fetchPosts is stable from useCallback
if (isLoading || isRetrying) {
return ( return (
<div className="space-y-2"> <div style={{ marginTop: topSpace || 0 }}>
{isRetrying && retryCount > 0 && ( {isRefreshing && (
<div className="text-center py-2 text-sm text-muted-foreground"> <div className="px-4 py-2 text-sm text-green-500 text-center">🔄 Refreshing posts...</div>
Retrying... ({retryCount}/{maxRetries}) )}
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{filteredEvents.length} of {eventsFilteredByKind.length} posts
</div> </div>
)} )}
{Array.from({ length: 3 }).map((_, i) => ( <div className="space-y-2">
<Skeleton key={i} className="h-32 w-full" /> {filteredEvents.map((event) => (
))} <NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
</div> ))}
) </div>
}
if (!pubkey) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">No profile selected</div>
</div>
)
}
if (events.length === 0) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">No posts found</div>
</div>
)
}
if (filteredEvents.length === 0 && searchQuery.trim()) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">No posts match your search</div>
</div> </div>
) )
} }
)
return (
<div style={{ marginTop: topSpace || 0 }}>
{isRefreshing && (
<div className="px-4 py-2 text-sm text-green-500 text-center">
🔄 Refreshing posts...
</div>
)}
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{filteredEvents.length} of {events.length} posts
</div>
)}
<div className="space-y-2">
{filteredEvents.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={false}
/>
))}
</div>
</div>
)
})
ProfileFeed.displayName = 'ProfileFeed' ProfileFeed.displayName = 'ProfileFeed'

146
src/components/Profile/ProfileMedia.tsx

@ -0,0 +1,146 @@
import NoteCard from '@/components/NoteCard'
import { Skeleton } from '@/components/ui/skeleton'
import { useProfileTimeline } from '@/hooks/useProfileTimeline'
import { Event } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import { ExtendedKind } from '@/constants'
interface ProfileMediaProps {
pubkey: string
topSpace?: number
searchQuery?: string
kindFilter?: string
onEventsChange?: (events: Event[]) => void
}
const MEDIA_KIND_LIST = [
ExtendedKind.PICTURE,
ExtendedKind.VIDEO,
ExtendedKind.SHORT_VIDEO,
ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT
]
const ProfileMedia = forwardRef<{ refresh: () => void; getEvents: () => Event[] }, ProfileMediaProps>(
({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => {
const [isRefreshing, setIsRefreshing] = useState(false)
const cacheKey = useMemo(() => `${pubkey}-media`, [pubkey])
const { events: timelineEvents, isLoading, refresh } = useProfileTimeline({
pubkey,
cacheKey,
kinds: MEDIA_KIND_LIST,
limit: 200
})
useEffect(() => {
onEventsChange?.(timelineEvents)
}, [timelineEvents, onEventsChange])
useEffect(() => {
if (!isLoading) {
setIsRefreshing(false)
}
}, [isLoading])
useImperativeHandle(
ref,
() => ({
refresh: () => {
setIsRefreshing(true)
refresh()
},
getEvents: () => timelineEvents
}),
[refresh, timelineEvents]
)
const eventsFilteredByKind = useMemo(() => {
if (kindFilter === 'all') {
return timelineEvents
}
const kindNumber = parseInt(kindFilter, 10)
if (Number.isNaN(kindNumber)) {
return timelineEvents
}
return timelineEvents.filter((event) => event.kind === kindNumber)
}, [timelineEvents, kindFilter])
const filteredEvents = useMemo(() => {
if (!searchQuery.trim()) {
return eventsFilteredByKind
}
const query = searchQuery.toLowerCase()
return eventsFilteredByKind.filter(
(event) =>
event.content.toLowerCase().includes(query) ||
event.tags.some((tag) => tag.length > 1 && tag[1]?.toLowerCase().includes(query))
)
}, [eventsFilteredByKind, searchQuery])
const getKindLabel = (kindValue: string) => {
if (!kindValue || kindValue === 'all') return 'media items'
const kindNum = parseInt(kindValue, 10)
if (kindNum === ExtendedKind.PICTURE) return 'photos'
if (kindNum === ExtendedKind.VIDEO) return 'videos'
if (kindNum === ExtendedKind.SHORT_VIDEO) return 'short videos'
if (kindNum === ExtendedKind.VOICE) return 'voice posts'
if (kindNum === ExtendedKind.VOICE_COMMENT) return 'voice comments'
return 'media'
}
if (!pubkey) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">No profile selected</div>
</div>
)
}
if (isLoading && timelineEvents.length === 0) {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
)
}
if (!filteredEvents.length && !isLoading) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">
{searchQuery.trim()
? `No ${getKindLabel(kindFilter)} match your search`
: `No ${getKindLabel(kindFilter)} found`}
</div>
</div>
)
}
return (
<div style={{ marginTop: topSpace || 0 }}>
{isRefreshing && (
<div className="px-4 py-2 text-sm text-green-500 text-center">🔄 Refreshing media...</div>
)}
{(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{filteredEvents.length} of {eventsFilteredByKind.length} {getKindLabel(kindFilter)}
</div>
)}
<div className="space-y-2">
{filteredEvents.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
))}
</div>
</div>
)
}
)
ProfileMedia.displayName = 'ProfileMedia'
export default ProfileMedia

103
src/components/Profile/index.tsx

@ -30,7 +30,7 @@ import { toNoteList } from '@/lib/link'
import { parseAdvancedSearch } from '@/lib/search-parser' import { parseAdvancedSearch } from '@/lib/search-parser'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { FileText, Link, Zap } from 'lucide-react' import { FileText, Link, Zap, Film } from 'lucide-react'
import { useEffect, useMemo, useState, useRef } from 'react' import { useEffect, useMemo, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -42,8 +42,9 @@ import ProfileBookmarksAndHashtags from './ProfileBookmarksAndHashtags'
import SmartFollowings from './SmartFollowings' import SmartFollowings from './SmartFollowings'
import SmartMuteLink from './SmartMuteLink' import SmartMuteLink from './SmartMuteLink'
import SmartRelays from './SmartRelays' import SmartRelays from './SmartRelays'
import ProfileMedia from './ProfileMedia'
type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' | 'media'
export default function Profile({ id }: { id?: string }) { export default function Profile({ id }: { id?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -53,6 +54,8 @@ export default function Profile({ id }: { id?: string }) {
const [activeTab, setActiveTab] = useState<ProfileTabValue>('posts') const [activeTab, setActiveTab] = useState<ProfileTabValue>('posts')
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [articleKindFilter, setArticleKindFilter] = useState<string>('all') const [articleKindFilter, setArticleKindFilter] = useState<string>('all')
const [postKindFilter, setPostKindFilter] = useState<string>('all')
const [mediaKindFilter, setMediaKindFilter] = useState<string>('all')
// Handle search in articles tab - parse advanced search parameters // Handle search in articles tab - parse advanced search parameters
const handleArticleSearch = (query: string) => { const handleArticleSearch = (query: string) => {
@ -140,7 +143,10 @@ export default function Profile({ id }: { id?: string }) {
const profileFeedRef = useRef<{ refresh: () => void }>(null) const profileFeedRef = useRef<{ refresh: () => void }>(null)
const profileBookmarksRef = useRef<{ refresh: () => void }>(null) const profileBookmarksRef = useRef<{ refresh: () => void }>(null)
const profileArticlesRef = useRef<{ refresh: () => void; getEvents: () => Event[] }>(null) const profileArticlesRef = useRef<{ refresh: () => void; getEvents: () => Event[] }>(null)
const profileMediaRef = useRef<{ refresh: () => void; getEvents: () => Event[] }>(null)
const [articleEvents, setArticleEvents] = useState<Event[]>([]) const [articleEvents, setArticleEvents] = useState<Event[]>([])
const [postEvents, setPostEvents] = useState<Event[]>([])
const [mediaEvents, setMediaEvents] = useState<Event[]>([])
const isFollowingYou = useMemo(() => { const isFollowingYou = useMemo(() => {
// This will be handled by the FollowedBy component // This will be handled by the FollowedBy component
@ -158,6 +164,8 @@ export default function Profile({ id }: { id?: string }) {
profileFeedRef.current?.refresh() profileFeedRef.current?.refresh()
} else if (activeTab === 'articles') { } else if (activeTab === 'articles') {
profileArticlesRef.current?.refresh() profileArticlesRef.current?.refresh()
} else if (activeTab === 'media') {
profileMediaRef.current?.refresh()
} else { } else {
profileBookmarksRef.current?.refresh() profileBookmarksRef.current?.refresh()
} }
@ -173,6 +181,10 @@ export default function Profile({ id }: { id?: string }) {
value: 'articles', value: 'articles',
label: 'Articles' label: 'Articles'
}, },
{
value: 'media',
label: 'Media'
},
{ {
value: 'pins', value: 'pins',
label: 'Pins' label: 'Pins'
@ -317,23 +329,51 @@ export default function Profile({ id }: { id?: string }) {
<div className="flex items-center gap-2 pr-2 px-1"> <div className="flex items-center gap-2 pr-2 px-1">
<ProfileSearchBar <ProfileSearchBar
onSearch={activeTab === 'articles' ? handleArticleSearch : setSearchQuery} onSearch={activeTab === 'articles' ? handleArticleSearch : setSearchQuery}
placeholder={`Search ${activeTab}...`} placeholder={`Search ${
activeTab === 'posts' ? 'posts' : activeTab === 'media' ? 'media' : activeTab
}...`}
className="w-64" className="w-64"
/> />
{activeTab === 'posts' && (() => {
const allCount = postEvents.length
const noteCount = postEvents.filter((event) => event.kind === kinds.ShortTextNote).length
const repostCount = postEvents.filter((event) => event.kind === kinds.Repost).length
const commentCount = postEvents.filter((event) => event.kind === ExtendedKind.COMMENT).length
const discussionCount = postEvents.filter((event) => event.kind === ExtendedKind.DISCUSSION).length
const pollCount = postEvents.filter((event) => event.kind === ExtendedKind.POLL).length
const superzapCount = postEvents.filter((event) => event.kind === ExtendedKind.ZAP_RECEIPT).length
return (
<Select value={postKindFilter} onValueChange={setPostKindFilter}>
<SelectTrigger className="w-48">
<FileText className="h-4 w-4 mr-2 shrink-0" />
<SelectValue placeholder="Filter posts" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Posts ({allCount})</SelectItem>
<SelectItem value={String(kinds.ShortTextNote)}>Notes ({noteCount})</SelectItem>
<SelectItem value={String(kinds.Repost)}>Reposts ({repostCount})</SelectItem>
<SelectItem value={String(ExtendedKind.COMMENT)}>Comments ({commentCount})</SelectItem>
<SelectItem value={String(ExtendedKind.DISCUSSION)}>Discussions ({discussionCount})</SelectItem>
<SelectItem value={String(ExtendedKind.POLL)}>Polls ({pollCount})</SelectItem>
<SelectItem value={String(ExtendedKind.ZAP_RECEIPT)}>Superzaps ({superzapCount})</SelectItem>
</SelectContent>
</Select>
)
})()}
{activeTab === 'articles' && (() => { {activeTab === 'articles' && (() => {
// Calculate counts for each kind
const allCount = articleEvents.length const allCount = articleEvents.length
const longFormCount = articleEvents.filter(e => e.kind === kinds.LongFormArticle).length const longFormCount = articleEvents.filter((e) => e.kind === kinds.LongFormArticle).length
const wikiMarkdownCount = articleEvents.filter(e => e.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN).length const wikiMarkdownCount = articleEvents.filter((e) => e.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN).length
const wikiAsciiDocCount = articleEvents.filter(e => e.kind === ExtendedKind.WIKI_ARTICLE).length const wikiAsciiDocCount = articleEvents.filter((e) => e.kind === ExtendedKind.WIKI_ARTICLE).length
const publicationCount = articleEvents.filter(e => e.kind === ExtendedKind.PUBLICATION).length const publicationCount = articleEvents.filter((e) => e.kind === ExtendedKind.PUBLICATION).length
const highlightsCount = articleEvents.filter(e => e.kind === kinds.Highlights).length const highlightsCount = articleEvents.filter((e) => e.kind === kinds.Highlights).length
return ( return (
<Select value={articleKindFilter} onValueChange={setArticleKindFilter}> <Select value={articleKindFilter} onValueChange={setArticleKindFilter}>
<SelectTrigger className="w-48"> <SelectTrigger className="w-48">
<FileText className="h-4 w-4 mr-2 shrink-0" /> <FileText className="h-4 w-4 mr-2 shrink-0" />
<SelectValue placeholder="Filter by type" /> <SelectValue placeholder="Filter articles" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Types ({allCount})</SelectItem> <SelectItem value="all">All Types ({allCount})</SelectItem>
@ -346,11 +386,32 @@ export default function Profile({ id }: { id?: string }) {
</Select> </Select>
) )
})()} })()}
<RetroRefreshButton {activeTab === 'media' && (() => {
onClick={handleRefresh} const allCount = mediaEvents.length
size="sm" const pictureCount = mediaEvents.filter((event) => event.kind === ExtendedKind.PICTURE).length
className="flex-shrink-0" const videoCount = mediaEvents.filter((event) => event.kind === ExtendedKind.VIDEO).length
/> const shortVideoCount = mediaEvents.filter((event) => event.kind === ExtendedKind.SHORT_VIDEO).length
const voiceCount = mediaEvents.filter((event) => event.kind === ExtendedKind.VOICE).length
const voiceCommentCount = mediaEvents.filter((event) => event.kind === ExtendedKind.VOICE_COMMENT).length
return (
<Select value={mediaKindFilter} onValueChange={setMediaKindFilter}>
<SelectTrigger className="w-52">
<Film className="h-4 w-4 mr-2 shrink-0" />
<SelectValue placeholder="Filter media" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Media ({allCount})</SelectItem>
<SelectItem value={String(ExtendedKind.PICTURE)}>Photos ({pictureCount})</SelectItem>
<SelectItem value={String(ExtendedKind.VIDEO)}>Videos ({videoCount})</SelectItem>
<SelectItem value={String(ExtendedKind.SHORT_VIDEO)}>Short Videos ({shortVideoCount})</SelectItem>
<SelectItem value={String(ExtendedKind.VOICE)}>Voice Posts ({voiceCount})</SelectItem>
<SelectItem value={String(ExtendedKind.VOICE_COMMENT)}>Voice Comments ({voiceCommentCount})</SelectItem>
</SelectContent>
</Select>
)
})()}
<RetroRefreshButton onClick={handleRefresh} size="sm" className="flex-shrink-0" />
</div> </div>
</div> </div>
{activeTab === 'posts' && ( {activeTab === 'posts' && (
@ -359,6 +420,8 @@ export default function Profile({ id }: { id?: string }) {
pubkey={pubkey} pubkey={pubkey}
topSpace={0} topSpace={0}
searchQuery={searchQuery} searchQuery={searchQuery}
kindFilter={postKindFilter}
onEventsChange={setPostEvents}
/> />
)} )}
{activeTab === 'articles' && ( {activeTab === 'articles' && (
@ -371,6 +434,16 @@ export default function Profile({ id }: { id?: string }) {
onEventsChange={setArticleEvents} onEventsChange={setArticleEvents}
/> />
)} )}
{activeTab === 'media' && (
<ProfileMedia
ref={profileMediaRef}
pubkey={pubkey}
topSpace={0}
searchQuery={searchQuery}
kindFilter={mediaKindFilter}
onEventsChange={setMediaEvents}
/>
)}
{(activeTab === 'pins' || activeTab === 'bookmarks' || activeTab === 'interests') && ( {(activeTab === 'pins' || activeTab === 'bookmarks' || activeTab === 'interests') && (
<ProfileBookmarksAndHashtags <ProfileBookmarksAndHashtags
ref={profileBookmarksRef} ref={profileBookmarksRef}

3
src/components/RelayInfo/ReviewEditor.tsx

@ -7,6 +7,7 @@ import { NostrEvent } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import logger from '@/lib/logger'
export default function ReviewEditor({ export default function ReviewEditor({
relayUrl, relayUrl,
@ -37,7 +38,7 @@ export default function ReviewEditor({
} else if (error instanceof Error) { } else if (error instanceof Error) {
toast.error(`${t('Failed to review')}: ${error.message}`) toast.error(`${t('Failed to review')}: ${error.message}`)
} }
console.error(error) logger.error('Failed to submit relay review', { error, relayUrl })
return return
} finally { } finally {
setSubmitting(false) setSubmitting(false)

5
src/components/SaveRelayDropdownMenu/index.tsx

@ -24,6 +24,7 @@ import { Ban, Check, FolderPlus, Loader2, Plus, Star } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import DrawerMenuItem from '../DrawerMenuItem' import DrawerMenuItem from '../DrawerMenuItem'
import logger from '@/lib/logger'
export default function SaveRelayDropdownMenu({ export default function SaveRelayDropdownMenu({
urls, urls,
@ -130,7 +131,7 @@ function RelayItem({ urls }: { urls: string[] }) {
await addFavoriteRelays(urls) await addFavoriteRelays(urls)
} }
} catch (error) { } catch (error) {
console.error('Failed to toggle favorite relay:', error) logger.error('Failed to toggle favorite relay', { error, url })
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@ -255,7 +256,7 @@ function BlockRelayItem({ urls }: { urls: string[] }) {
await addBlockedRelays(urls) await addBlockedRelays(urls)
} }
} catch (error) { } catch (error) {
console.error('Failed to toggle blocked relay:', error) logger.error('Failed to toggle blocked relay', { error, url })
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }

133
src/components/TrendingNotes/index.tsx

@ -27,7 +27,7 @@ let cachedCustomEvents: {
// Flag to prevent concurrent initialization // Flag to prevent concurrent initialization
let isInitializing = false let isInitializing = false
type TrendingTab = 'relays' | 'hashtags' type TrendingTab = 'nostr' | 'relays' | 'hashtags'
type SortOrder = 'newest' | 'oldest' | 'most-popular' | 'least-popular' type SortOrder = 'newest' | 'oldest' | 'most-popular' | 'least-popular'
type HashtagFilter = 'popular' type HashtagFilter = 'popular'
@ -38,10 +38,10 @@ export default function TrendingNotes() {
const { pubkey, relayList } = useNostr() const { pubkey, relayList } = useNostr()
const { favoriteRelays } = useFavoriteRelays() const { favoriteRelays } = useFavoriteRelays()
const { zapReplyThreshold } = useZap() const { zapReplyThreshold } = useZap()
const [trendingNotes] = useState<NostrEvent[]>([]) const [nostrEvents, setNostrEvents] = useState<NostrEvent[]>([])
const [nostrLoading, setNostrLoading] = useState(false)
const [showCount, setShowCount] = useState(10) const [showCount, setShowCount] = useState(10)
const [loading] = useState(true) const [activeTab, setActiveTab] = useState<TrendingTab>('nostr')
const [activeTab, setActiveTab] = useState<TrendingTab>('relays')
const [sortOrder, setSortOrder] = useState<SortOrder>('most-popular') const [sortOrder, setSortOrder] = useState<SortOrder>('most-popular')
const [hashtagFilter] = useState<HashtagFilter>('popular') const [hashtagFilter] = useState<HashtagFilter>('popular')
const [selectedHashtag, setSelectedHashtag] = useState<string | null>(null) const [selectedHashtag, setSelectedHashtag] = useState<string | null>(null)
@ -50,6 +50,25 @@ export default function TrendingNotes() {
const [cacheLoading, setCacheLoading] = useState(false) const [cacheLoading, setCacheLoading] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
// Load Nostr.band trending feed when tab is active
useEffect(() => {
const loadTrending = async () => {
try {
setNostrLoading(true)
const events = await client.fetchTrendingNotes()
setNostrEvents(events)
} catch (error) {
logger.warn('Failed to load nostr.band trending notes', error as Error)
} finally {
setNostrLoading(false)
}
}
if (activeTab === 'nostr' && nostrEvents.length === 0 && !nostrLoading) {
loadTrending()
}
}, [activeTab, nostrEvents.length, nostrLoading])
// Debug: Track cacheEvents changes // Debug: Track cacheEvents changes
useEffect(() => { useEffect(() => {
logger.debug('[TrendingNotes] cacheEvents state changed:', cacheEvents.length, 'events') logger.debug('[TrendingNotes] cacheEvents state changed:', cacheEvents.length, 'events')
@ -65,10 +84,10 @@ export default function TrendingNotes() {
// Calculate popular hashtags from cache events (all events from relays) // Calculate popular hashtags from cache events (all events from relays)
const calculatePopularHashtags = useMemo(() => { const calculatePopularHashtags = useMemo(() => {
logger.debug('[TrendingNotes] calculatePopularHashtags - cacheEvents.length:', cacheEvents.length, 'trendingNotes.length:', trendingNotes.length) logger.debug('[TrendingNotes] calculatePopularHashtags - cacheEvents.length:', cacheEvents.length, 'nostrEvents.length:', nostrEvents.length)
// Use cache events if available, otherwise fallback to trending notes // Use cache events if available, otherwise fallback to trending notes
const eventsToAnalyze = cacheEvents.length > 0 ? cacheEvents : trendingNotes const eventsToAnalyze = cacheEvents.length > 0 ? cacheEvents : nostrEvents
if (eventsToAnalyze.length === 0) { if (eventsToAnalyze.length === 0) {
return [] return []
@ -112,7 +131,7 @@ export default function TrendingNotes() {
logger.debug('[TrendingNotes] calculatePopularHashtags - eventsWithHashtags:', eventsWithHashtags) logger.debug('[TrendingNotes] calculatePopularHashtags - eventsWithHashtags:', eventsWithHashtags)
return result return result
}, [cacheEvents, trendingNotes, activeTab, hashtagFilter, pubkey]) // Use cacheEvents and trendingNotes as dependencies }, [cacheEvents, nostrEvents, activeTab, hashtagFilter, pubkey])
// Get relays based on user login status // Get relays based on user login status
const getRelays = useMemo(() => { const getRelays = useMemo(() => {
@ -148,13 +167,13 @@ export default function TrendingNotes() {
setPopularHashtags(calculatePopularHashtags) setPopularHashtags(calculatePopularHashtags)
}, [calculatePopularHashtags]) }, [calculatePopularHashtags])
// Fallback: populate cacheEvents from trendingNotes if cache is empty // Fallback: populate cacheEvents from nostrEvents if cache is empty
useEffect(() => { useEffect(() => {
if (activeTab === 'hashtags' && cacheEvents.length === 0 && trendingNotes.length > 0) { if (activeTab === 'hashtags' && cacheEvents.length === 0 && nostrEvents.length > 0) {
logger.debug('[TrendingNotes] Fallback: populating cacheEvents from trendingNotes') logger.debug('[TrendingNotes] Fallback: populating cacheEvents from nostrEvents')
setCacheEvents(trendingNotes) setCacheEvents(nostrEvents)
} }
}, [activeTab, cacheEvents.length, trendingNotes]) }, [activeTab, cacheEvents.length, nostrEvents])
// Initialize cache only once on mount // Initialize cache only once on mount
@ -367,23 +386,9 @@ export default function TrendingNotes() {
}, []) // Only run once on mount to prevent infinite loop }, []) // Only run once on mount to prevent infinite loop
const filteredEvents = useMemo(() => { const relaysFilteredEvents = useMemo(() => {
const idSet = new Set<string>() const idSet = new Set<string>()
const sourceEvents = cacheEvents.length > 0 ? cacheEvents : nostrEvents
// Use appropriate data source based on tab and filter
let sourceEvents: NostrEvent[] = []
if (activeTab === 'relays') {
// "on your relays" tab: use cache events from user's relays
sourceEvents = cacheEvents
logger.debug('[TrendingNotes] Relays tab - cacheEvents.length:', cacheEvents.length, 'cacheLoading:', cacheLoading)
} else if (activeTab === 'hashtags') {
// Hashtags tab: use cache events for hashtag analysis
sourceEvents = cacheEvents.length > 0 ? cacheEvents : trendingNotes
logger.debug('[TrendingNotes] Hashtags tab - using ALL events from cache')
logger.debug('[TrendingNotes] Hashtags tab - cacheEvents.length:', cacheEvents.length, 'trendingNotes.length:', trendingNotes.length)
}
const filtered = sourceEvents.filter((evt) => { const filtered = sourceEvents.filter((evt) => {
if (isEventDeleted(evt)) return false if (isEventDeleted(evt)) return false
@ -407,11 +412,6 @@ export default function TrendingNotes() {
if (!allHashtags.includes(selectedHashtag.toLowerCase())) return false if (!allHashtags.includes(selectedHashtag.toLowerCase())) return false
} }
} }
} else if (activeTab === 'relays') {
// For "on your relays" tab, we'll show all events (they're already from user's relays)
// This is the default behavior, so no additional filtering needed
}
const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id
if (idSet.has(id)) { if (idSet.has(id)) {
return false return false
@ -466,7 +466,26 @@ export default function TrendingNotes() {
}) })
return filtered.slice(0, showCount) return filtered.slice(0, showCount)
}, [trendingNotes, hideUntrustedNotes, showCount, isEventDeleted, isUserTrusted, activeTab, hashtagFilter, selectedHashtag, sortOrder, zapReplyThreshold, cacheEvents]) }, [
cacheEvents,
nostrEvents,
hideUntrustedNotes,
showCount,
isEventDeleted,
isUserTrusted,
activeTab,
hashtagFilter,
selectedHashtag,
sortOrder,
zapReplyThreshold
])
const filteredEvents = useMemo(() => {
if (activeTab === 'nostr') {
return nostrEvents.slice(0, showCount)
}
return relaysFilteredEvents
}, [activeTab, nostrEvents, showCount, relaysFilteredEvents])
@ -491,7 +510,12 @@ export default function TrendingNotes() {
useEffect(() => { useEffect(() => {
if (showCount >= trendingNotes.length) return const totalLength =
activeTab === 'nostr'
? nostrEvents.length
: cacheEvents.length
if (showCount >= totalLength) return
const options = { const options = {
root: null, root: null,
@ -516,7 +540,7 @@ export default function TrendingNotes() {
observerInstance.unobserve(currentBottomRef) observerInstance.unobserve(currentBottomRef)
} }
} }
}, [loading, trendingNotes, showCount]) }, [activeTab, cacheEvents.length, nostrEvents.length, showCount, cacheLoading, nostrLoading])
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
@ -527,6 +551,16 @@ export default function TrendingNotes() {
<div className="flex items-center gap-2 px-4 pb-2"> <div className="flex items-center gap-2 px-4 pb-2">
<span className="text-sm font-medium text-muted-foreground">Trending:</span> <span className="text-sm font-medium text-muted-foreground">Trending:</span>
<div className="flex gap-1"> <div className="flex gap-1">
<button
onClick={() => setActiveTab('nostr')}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
activeTab === 'nostr'
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
}`}
>
on Nostr
</button>
<button <button
onClick={() => setActiveTab('relays')} onClick={() => setActiveTab('relays')}
className={`px-3 py-1 text-sm rounded-md transition-colors ${ className={`px-3 py-1 text-sm rounded-md transition-colors ${
@ -631,6 +665,12 @@ export default function TrendingNotes() {
)} )}
</div> </div>
{/* Show loading message for nostr tab */}
{activeTab === 'nostr' && nostrLoading && nostrEvents.length === 0 && (
<div className="text-center text-sm text-muted-foreground mt-8">
Loading trending notes from nostr.band...
</div>
)}
{/* Show loading message for relays tab when cache is loading */} {/* Show loading message for relays tab when cache is loading */}
{activeTab === 'relays' && cacheLoading && cacheEvents.length === 0 && ( {activeTab === 'relays' && cacheLoading && cacheEvents.length === 0 && (
<div className="text-center text-sm text-muted-foreground mt-8"> <div className="text-center text-sm text-muted-foreground mt-8">
@ -642,18 +682,15 @@ export default function TrendingNotes() {
<NoteCard key={event.id} className="w-full" event={event} /> <NoteCard key={event.id} className="w-full" event={event} />
))} ))}
{(() => { {(() => {
// Determine the current data source length based on active tab const currentDataLength =
const currentDataLength = activeTab === 'relays' || activeTab === 'hashtags' ? cacheEvents.length : activeTab === 'nostr'
trendingNotes.length ? nostrEvents.length
: cacheEvents.length
// Show loading if:
// 1. General loading state is true const shouldShowLoading =
// 2. For relays/hashtags tabs, if cache is loading (activeTab === 'nostr' && nostrLoading) ||
// 3. If we haven't reached the end of available data ((activeTab === 'relays' || activeTab === 'hashtags') && cacheLoading) ||
const shouldShowLoading = loading || showCount < currentDataLength
(activeTab === 'relays' && cacheLoading) ||
(activeTab === 'hashtags' && cacheLoading) ||
showCount < currentDataLength
if (shouldShowLoading) { if (shouldShowLoading) {
return ( return (

29
src/components/UniversalContent/EnhancedContent.tsx

@ -34,6 +34,33 @@ import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer' import MediaPlayer from '../MediaPlayer'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer' import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
import ParsedContent from './ParsedContent' import ParsedContent from './ParsedContent'
import { toNote } from '@/lib/link'
const REDIRECT_REGEX = /Read (naddr1[a-z0-9]+) instead\./i
function renderRedirectText(text: string, key: number) {
const match = text.match(REDIRECT_REGEX)
if (!match) {
return text
}
const [fullMatch, naddr] = match
const [prefix, suffix] = text.split(fullMatch)
const href = toNote(naddr)
return (
<span key={`redirect-${key}`}>
{prefix}
Read{' '}
<a
className="text-primary hover:underline"
href={href}
onClick={(e) => e.stopPropagation()}
>
{naddr}
</a>{' '}
instead.{suffix}
</span>
)
}
export default function EnhancedContent({ export default function EnhancedContent({
event, event,
@ -195,7 +222,7 @@ export default function EnhancedContent({
{nodes.map((node, index) => { {nodes.map((node, index) => {
if (node.type === 'text') { if (node.type === 'text') {
return node.data return renderRedirectText(node.data, index)
} }
// Skip image nodes - they're rendered in the carousel at the top // Skip image nodes - they're rendered in the carousel at the top
if (node.type === 'image' || node.type === 'images') { if (node.type === 'image' || node.type === 'images') {

5
src/components/UniversalContent/HighlightSourcePreview.tsx

@ -4,6 +4,7 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import logger from '@/lib/logger'
import WebPreview from '../WebPreview' import WebPreview from '../WebPreview'
import { EmbeddedNote } from '../Embedded/EmbeddedNote' import { EmbeddedNote } from '../Embedded/EmbeddedNote'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
@ -39,7 +40,7 @@ export default function HighlightSourcePreview({ source, className }: HighlightS
) )
} }
} catch (error) { } catch (error) {
console.warn('Failed to decode nostr event:', error) logger.warn('Failed to decode nostr event', error as Error)
} }
// If decoding failed, show as Alexandria link // If decoding failed, show as Alexandria link
@ -70,7 +71,7 @@ export default function HighlightSourcePreview({ source, className }: HighlightS
) )
} }
} catch (error) { } catch (error) {
console.warn('Failed to decode nostr addressable event:', error) logger.warn('Failed to decode nostr addressable event', error as Error)
} }
// If decoding failed, show as Alexandria link // If decoding failed, show as Alexandria link

3
src/components/VersionUpdateBanner/index.tsx

@ -2,6 +2,7 @@ import { Button } from '@/components/ui/button'
import { RefreshCw, X } from 'lucide-react' import { RefreshCw, X } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger'
export default function VersionUpdateBanner() { export default function VersionUpdateBanner() {
const { t } = useTranslation() const { t } = useTranslation()
@ -64,7 +65,7 @@ export default function VersionUpdateBanner() {
} }
} }
} catch (error) { } catch (error) {
console.error('Error checking for updates:', error) logger.error('Error checking for updates', { error })
} }
} }

3
src/components/VideoPlayer/index.tsx

@ -4,6 +4,7 @@ import mediaManager from '@/services/media-manager.service'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import ExternalLink from '../ExternalLink' import ExternalLink from '../ExternalLink'
import { MediaErrorBoundary } from '../MediaErrorBoundary' import { MediaErrorBoundary } from '../MediaErrorBoundary'
import logger from '@/lib/logger'
export default function VideoPlayer({ src, className }: { src: string; className?: string }) { export default function VideoPlayer({ src, className }: { src: string; className?: string }) {
const { autoplay } = useContentPolicy() const { autoplay } = useContentPolicy()
@ -51,7 +52,7 @@ export default function VideoPlayer({ src, className }: { src: string; className
onError={(error) => { onError={(error) => {
// Don't log expected media errors // Don't log expected media errors
if (error.name !== 'AbortError' && !error.message.includes('play() request was interrupted')) { if (error.name !== 'AbortError' && !error.message.includes('play() request was interrupted')) {
console.warn('Video player error:', error) logger.warn('Video player error', error)
} }
setError(true) setError(true)
}} }}

34
src/components/WebPreview/index.tsx

@ -11,6 +11,7 @@ import { nip19, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import Image from '../Image' import Image from '../Image'
import Username from '../Username' import Username from '../Username'
import { cleanUrl } from '@/lib/url'
// Helper function to get event type name // Helper function to get event type name
function getEventTypeName(kind: number): string { function getEventTypeName(kind: number): string {
@ -79,26 +80,29 @@ export default function WebPreview({ url, className }: { url: string; className?
const { autoLoadMedia } = useContentPolicy() const { autoLoadMedia } = useContentPolicy()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { title, description, image } = useFetchWebMetadata(url) const cleanedUrl = useMemo(() => cleanUrl(url), [url])
const { title, description, image } = useFetchWebMetadata(cleanedUrl)
const hostname = useMemo(() => { const hostname = useMemo(() => {
try { try {
return new URL(url).hostname return new URL(cleanedUrl).hostname
} catch { } catch {
return '' return ''
} }
}, [url]) }, [cleanedUrl])
const isInternalJumbleLink = useMemo(() => hostname === 'jumble.imwald.eu', [hostname])
// Extract nostr identifier from URL // Extract nostr identifier from URL
const nostrIdentifier = useMemo(() => { const nostrIdentifier = useMemo(() => {
const naddrMatch = url.match(/(naddr1[a-z0-9]+)/i) const naddrMatch = cleanedUrl.match(/(naddr1[a-z0-9]+)/i)
const neventMatch = url.match(/(nevent1[a-z0-9]+)/i) const neventMatch = cleanedUrl.match(/(nevent1[a-z0-9]+)/i)
const noteMatch = url.match(/(note1[a-z0-9]{58})/i) const noteMatch = cleanedUrl.match(/(note1[a-z0-9]{58})/i)
const npubMatch = url.match(/(npub1[a-z0-9]{58})/i) const npubMatch = cleanedUrl.match(/(npub1[a-z0-9]{58})/i)
const nprofileMatch = url.match(/(nprofile1[a-z0-9]+)/i) const nprofileMatch = cleanedUrl.match(/(nprofile1[a-z0-9]+)/i)
return naddrMatch?.[1] || neventMatch?.[1] || noteMatch?.[1] || npubMatch?.[1] || nprofileMatch?.[1] || null return naddrMatch?.[1] || neventMatch?.[1] || noteMatch?.[1] || npubMatch?.[1] || nprofileMatch?.[1] || null
}, [url]) }, [cleanedUrl])
// Determine nostr type // Determine nostr type
const nostrType = useMemo(() => { const nostrType = useMemo(() => {
@ -132,7 +136,7 @@ export default function WebPreview({ url, className }: { url: string; className?
} }
// Check if we have any opengraph data (title, description, or image) // Check if we have any opengraph data (title, description, or image)
const hasOpengraphData = title || description || image const hasOpengraphData = !isInternalJumbleLink && (title || description || image)
// If no opengraph metadata available, show enhanced fallback link card // If no opengraph metadata available, show enhanced fallback link card
if (!hasOpengraphData) { if (!hasOpengraphData) {
@ -149,7 +153,7 @@ export default function WebPreview({ url, className }: { url: string; className?
className={cn('p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3', className)} className={cn('p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3', className)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
window.open(url, '_blank') window.open(cleanedUrl, '_blank')
}} }}
> >
{eventImage && fetchedEvent && ( {eventImage && fetchedEvent && (
@ -202,7 +206,7 @@ export default function WebPreview({ url, className }: { url: string; className?
className={cn('p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3', className)} className={cn('p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3', className)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
window.open(url, '_blank') window.open(cleanedUrl, '_blank')
}} }}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@ -229,7 +233,7 @@ export default function WebPreview({ url, className }: { url: string; className?
className={cn('p-2 clickable flex w-full border rounded-lg overflow-hidden', className)} className={cn('p-2 clickable flex w-full border rounded-lg overflow-hidden', className)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
window.open(url, '_blank') window.open(cleanedUrl, '_blank')
}} }}
> >
<div className="flex-1 w-0 flex items-center gap-2"> <div className="flex-1 w-0 flex items-center gap-2">
@ -249,7 +253,7 @@ export default function WebPreview({ url, className }: { url: string; className?
className="rounded-lg border mt-2 overflow-hidden" className="rounded-lg border mt-2 overflow-hidden"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
window.open(url, '_blank') window.open(cleanedUrl, '_blank')
}} }}
> >
<Image image={{ url: image }} className="w-full max-w-[400px] h-44 rounded-none" hideIfError /> <Image image={{ url: image }} className="w-full max-w-[400px] h-44 rounded-none" hideIfError />
@ -271,7 +275,7 @@ export default function WebPreview({ url, className }: { url: string; className?
className={cn('p-2 clickable flex w-full border rounded-lg overflow-hidden gap-2', className)} className={cn('p-2 clickable flex w-full border rounded-lg overflow-hidden gap-2', className)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
window.open(url, '_blank') window.open(cleanedUrl, '_blank')
}} }}
> >
{image && ( {image && (

3
src/components/YoutubeEmbeddedPlayer/index.tsx

@ -5,6 +5,7 @@ import { YouTubePlayer } from '@/types/youtube'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ExternalLink from '../ExternalLink' import ExternalLink from '../ExternalLink'
import logger from '@/lib/logger'
export default function YoutubeEmbeddedPlayer({ export default function YoutubeEmbeddedPlayer({
url, url,
@ -70,7 +71,7 @@ export default function YoutubeEmbeddedPlayer({
} }
}) })
} catch (error) { } catch (error) {
console.error('Failed to initialize YouTube player:', error) logger.error('Failed to initialize YouTube player', { error })
setError(true) setError(true)
return return
} }

3
src/hooks/useFetchRelayInfo.tsx

@ -1,6 +1,7 @@
import relayInfoService from '@/services/relay-info.service' import relayInfoService from '@/services/relay-info.service'
import { TRelayInfo } from '@/types' import { TRelayInfo } from '@/types'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import logger from '@/lib/logger'
export function useFetchRelayInfo(url?: string) { export function useFetchRelayInfo(url?: string) {
const [isFetching, setIsFetching] = useState(true) const [isFetching, setIsFetching] = useState(true)
@ -17,7 +18,7 @@ export function useFetchRelayInfo(url?: string) {
const relayInfo = await relayInfoService.getRelayInfo(url) const relayInfo = await relayInfoService.getRelayInfo(url)
setRelayInfo(relayInfo) setRelayInfo(relayInfo)
} catch (err) { } catch (err) {
console.error(err) logger.error('Failed to fetch relay info', { error: err, url })
} finally { } finally {
clearTimeout(timer) clearTimeout(timer)
setIsFetching(false) setIsFetching(false)

3
src/hooks/useFetchRelayInfos.tsx

@ -2,6 +2,7 @@ import { checkAlgoRelay } from '@/lib/relay'
import relayInfoService from '@/services/relay-info.service' import relayInfoService from '@/services/relay-info.service'
import { TRelayInfo } from '@/types' import { TRelayInfo } from '@/types'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import logger from '@/lib/logger'
export function useFetchRelayInfos(urls: string[]) { export function useFetchRelayInfos(urls: string[]) {
const [isFetching, setIsFetching] = useState(true) const [isFetching, setIsFetching] = useState(true)
@ -33,7 +34,7 @@ export function useFetchRelayInfos(urls: string[]) {
.map((relayInfo) => relayInfo.url) .map((relayInfo) => relayInfo.url)
) )
} catch (err) { } catch (err) {
console.error(err) logger.error('Failed to fetch relay infos', { error: err, urls })
} finally { } finally {
clearTimeout(timer) clearTimeout(timer)
setIsFetching(false) setIsFetching(false)

3
src/hooks/useFetchRelayList.tsx

@ -1,6 +1,7 @@
import client from '@/services/client.service' import client from '@/services/client.service'
import { TRelayList } from '@/types' import { TRelayList } from '@/types'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import logger from '@/lib/logger'
export function useFetchRelayList(pubkey?: string | null) { export function useFetchRelayList(pubkey?: string | null) {
const [relayList, setRelayList] = useState<TRelayList>({ const [relayList, setRelayList] = useState<TRelayList>({
@ -21,7 +22,7 @@ export function useFetchRelayList(pubkey?: string | null) {
const relayList = await client.fetchRelayList(pubkey) const relayList = await client.fetchRelayList(pubkey)
setRelayList(relayList) setRelayList(relayList)
} catch (err) { } catch (err) {
console.error(err) logger.error('Failed to fetch relay list', { error: err, pubkey })
} finally { } finally {
setIsFetching(false) setIsFetching(false)
} }

202
src/hooks/useProfileTimeline.tsx

@ -0,0 +1,202 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { Event } from 'nostr-tools'
import client from '@/services/client.service'
import { FAST_READ_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url'
type ProfileTimelineCacheEntry = {
events: Event[]
lastUpdated: number
}
const timelineCache = new Map<string, ProfileTimelineCacheEntry>()
const relayGroupCache = new Map<string, string[][]>()
type UseProfileTimelineOptions = {
pubkey: string
cacheKey: string
kinds: number[]
limit?: number
filterPredicate?: (event: Event) => boolean
}
type UseProfileTimelineResult = {
events: Event[]
isLoading: boolean
refresh: () => void
}
async function getRelayGroups(pubkey: string): Promise<string[][]> {
const cached = relayGroupCache.get(pubkey)
if (cached) {
return cached
}
const [relayList, favoriteRelays] = await Promise.all([
client.fetchRelayList(pubkey).catch(() => ({ read: [], write: [] })),
client.fetchFavoriteRelays(pubkey).catch(() => [])
])
const groups: string[][] = []
const normalizeList = (urls?: string[]) =>
Array.from(
new Set(
(urls || [])
.map((url) => normalizeUrl(url))
.filter((value): value is string => !!value)
)
)
const readRelays = normalizeList(relayList.read)
if (readRelays.length) {
groups.push(readRelays)
}
const writeRelays = normalizeList(relayList.write)
if (writeRelays.length) {
groups.push(writeRelays)
}
const favoriteRelayList = normalizeList(favoriteRelays)
if (favoriteRelayList.length) {
groups.push(favoriteRelayList)
}
const fastReadRelays = normalizeList(FAST_READ_RELAY_URLS)
if (fastReadRelays.length) {
groups.push(fastReadRelays)
}
if (!groups.length) {
relayGroupCache.set(pubkey, [fastReadRelays])
return [fastReadRelays]
}
relayGroupCache.set(pubkey, groups)
return groups
}
function postProcessEvents(
rawEvents: Event[],
filterPredicate: ((event: Event) => boolean) | undefined,
limit: number
) {
const dedupMap = new Map<string, Event>()
rawEvents.forEach((evt) => {
if (!dedupMap.has(evt.id)) {
dedupMap.set(evt.id, evt)
}
})
let events = Array.from(dedupMap.values())
if (filterPredicate) {
events = events.filter(filterPredicate)
}
events.sort((a, b) => b.created_at - a.created_at)
return events.slice(0, limit)
}
export function useProfileTimeline({
pubkey,
cacheKey,
kinds,
limit = 200,
filterPredicate
}: UseProfileTimelineOptions): UseProfileTimelineResult {
const cachedEntry = useMemo(() => timelineCache.get(cacheKey), [cacheKey])
const [events, setEvents] = useState<Event[]>(cachedEntry?.events ?? [])
const [isLoading, setIsLoading] = useState(!cachedEntry)
const [refreshToken, setRefreshToken] = useState(0)
const subscriptionRef = useRef<() => void>(() => {})
useEffect(() => {
let cancelled = false
const refreshIndex = refreshToken
const subscribe = async () => {
setIsLoading(!timelineCache.has(cacheKey))
try {
const relayGroups = await getRelayGroups(pubkey)
if (cancelled) {
return
}
const subRequests = relayGroups
.map((urls) => ({
urls,
filter: {
authors: [pubkey],
kinds,
limit
} as any
}))
.filter((request) => request.urls.length)
if (!subRequests.length) {
updateCache([])
setIsLoading(false)
return
}
const { closer } = await client.subscribeTimeline(
subRequests,
{
onEvents: (fetchedEvents) => {
if (cancelled) return
const processed = postProcessEvents(fetchedEvents as Event[], filterPredicate, limit)
timelineCache.set(cacheKey, {
events: processed,
lastUpdated: Date.now()
})
setEvents(processed)
setIsLoading(false)
},
onNew: (evt) => {
if (cancelled) return
setEvents((prevEvents) => {
const combined = [evt as Event, ...prevEvents]
const processed = postProcessEvents(combined, filterPredicate, limit)
timelineCache.set(cacheKey, {
events: processed,
lastUpdated: Date.now()
})
return processed
})
}
},
{ needSort: true }
)
subscriptionRef.current = () => closer()
} catch (error) {
if (!cancelled) {
setIsLoading(false)
}
}
}
subscribe()
return () => {
cancelled = true
subscriptionRef.current()
subscriptionRef.current = () => {}
}
}, [pubkey, cacheKey, JSON.stringify(kinds), limit, filterPredicate, refreshToken])
const refresh = useCallback(() => {
subscriptionRef.current()
subscriptionRef.current = () => {}
timelineCache.delete(cacheKey)
setIsLoading(true)
setRefreshToken((token) => token + 1)
}, [])
return {
events,
isLoading,
refresh
}
}

6
src/lib/debug-utils.ts

@ -22,17 +22,17 @@ interface DebugUtils {
const debugUtils: DebugUtils = { const debugUtils: DebugUtils = {
enable: () => { enable: () => {
logger.setDebugMode(true) logger.setDebugMode(true)
console.log('🔧 Jumble debug logging enabled') logger.info('🔧 Jumble debug logging enabled')
}, },
disable: () => { disable: () => {
logger.setDebugMode(false) logger.setDebugMode(false)
console.log('🔧 Jumble debug logging disabled') logger.info('🔧 Jumble debug logging disabled')
}, },
status: () => { status: () => {
const enabled = logger.isDebugEnabled() const enabled = logger.isDebugEnabled()
console.log(`🔧 Jumble debug status: ${enabled ? 'ENABLED' : 'DISABLED'}`) logger.info(`🔧 Jumble debug status: ${enabled ? 'ENABLED' : 'DISABLED'}`)
return { enabled, level: enabled ? 'debug' : 'info' } return { enabled, level: enabled ? 'debug' : 'info' }
}, },

15
src/lib/draft-event.ts

@ -4,6 +4,7 @@ import customEmojiService from '@/services/custom-emoji.service'
import mediaUpload from '@/services/media-upload.service' import mediaUpload from '@/services/media-upload.service'
import { prefixNostrAddresses } from '@/lib/nostr-address' import { prefixNostrAddresses } from '@/lib/nostr-address'
import { normalizeHashtag } from '@/lib/discussion-topics' import { normalizeHashtag } from '@/lib/discussion-topics'
import logger from '@/lib/logger'
import { import {
TDraftEvent, TDraftEvent,
TEmoji, TEmoji,
@ -731,7 +732,7 @@ async function extractRelatedEventIds(content: string, parentEvent?: Event) {
) )
} }
} catch (e) { } catch (e) {
console.error(e) logger.error('Failed to decode quoted nostr reference', { error: e, reference: m })
} }
} }
@ -798,7 +799,7 @@ async function extractCommentMentions(content: string, parentEvent: Event) {
) )
} }
} catch (e) { } catch (e) {
console.error(e) logger.error('Failed to decode quoted nostr reference', { error: e, reference: m })
} }
} }
@ -1033,7 +1034,7 @@ export async function createHighlightDraftEvent(
} }
} }
} catch (err) { } catch (err) {
console.error('Failed to decode naddr:', err) logger.error('Failed to decode naddr', { error: err, reference: tag })
} }
} else if (sourceValue.startsWith('nevent')) { } else if (sourceValue.startsWith('nevent')) {
// Handle nevent // Handle nevent
@ -1055,7 +1056,7 @@ export async function createHighlightDraftEvent(
} }
} }
} catch (err) { } catch (err) {
console.error('Failed to decode nevent:', err) logger.error('Failed to decode nevent', { error: err, reference: tag })
} }
} else if (sourceValue.startsWith('note')) { } else if (sourceValue.startsWith('note')) {
// Handle note1... (bech32 encoded event ID) // Handle note1... (bech32 encoded event ID)
@ -1072,7 +1073,7 @@ export async function createHighlightDraftEvent(
} }
} }
} catch (err) { } catch (err) {
console.error('Failed to decode note:', err) logger.error('Failed to decode note', { error: err, reference: tag })
} }
} else { } else {
// Regular hex event ID // Regular hex event ID
@ -1089,8 +1090,8 @@ export async function createHighlightDraftEvent(
} }
// Add context tag if provided (the full text/quote that the highlight is from) // Add context tag if provided (the full text/quote that the highlight is from)
if (context && context.trim()) { if (context && context.length) {
tags.push(['context', context.trim()]) tags.push(['context', context])
} }
// Add description tag if provided (user's explanation/comment) // Add description tag if provided (user's explanation/comment)

3
src/lib/event-metadata.ts

@ -8,6 +8,7 @@ import { formatPubkey, pubkeyToNpub } from './pubkey'
import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from './tag' import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from './tag'
import { isWebsocketUrl, normalizeHttpUrl, normalizeUrl } from './url' import { isWebsocketUrl, normalizeHttpUrl, normalizeUrl } from './url'
import { isTorBrowser } from './utils' import { isTorBrowser } from './utils'
import logger from '@/lib/logger'
export function getRelayListFromEvent(event?: Event | null, blockedRelays?: string[]) { export function getRelayListFromEvent(event?: Event | null, blockedRelays?: string[]) {
if (!event) { if (!event) {
@ -77,7 +78,7 @@ export function getProfileFromEvent(event: Event) {
created_at: event.created_at created_at: event.created_at
} }
} catch (err) { } catch (err) {
console.error(event.content, err) logger.error('Failed to parse event metadata', { error: err, content: event.content })
return { return {
pubkey: event.pubkey, pubkey: event.pubkey,
npub: pubkeyToNpub(event.pubkey) ?? '', npub: pubkeyToNpub(event.pubkey) ?? '',

5
src/lib/nip05.ts

@ -1,5 +1,6 @@
import { LRUCache } from 'lru-cache' import { LRUCache } from 'lru-cache'
import { isValidPubkey } from './pubkey' import { isValidPubkey } from './pubkey'
import logger from '@/lib/logger'
type TVerifyNip05Result = { type TVerifyNip05Result = {
isVerified: boolean isVerified: boolean
@ -68,7 +69,7 @@ export async function fetchPubkeysFromDomain(domain: string): Promise<string[]>
return true return true
}) as string[] }) as string[]
} catch (error) { } catch (error) {
console.error('Error fetching pubkeys from domain:', error) logger.error('Error fetching pubkeys from domain', { error, nip05Domain })
return [] return []
} }
} }
@ -85,7 +86,7 @@ export async function getRelaysFromNip07Extension(): Promise<string[]> {
return Object.keys(relaysObj || {}) return Object.keys(relaysObj || {})
} }
} catch (error) { } catch (error) {
console.log('NIP-07 extension does not support getRelays():', error) logger.warn('NIP-07 extension does not support getRelays()', error as Error)
} }
return [] return []
} }

3
src/lib/nostr-parser.tsx

@ -10,6 +10,7 @@ import { cleanUrl, isImage, isMedia } from '@/lib/url'
import { getImetaInfosFromEvent } from '@/lib/event' import { getImetaInfosFromEvent } from '@/lib/event'
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import logger from '@/lib/logger'
export interface ParsedNostrContent { export interface ParsedNostrContent {
elements: Array<{ elements: Array<{
@ -373,7 +374,7 @@ function getNostrType(bech32Id: string): 'npub' | 'nprofile' | 'nevent' | 'naddr
return type as 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note' return type as 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note'
} }
} catch (error) { } catch (error) {
console.error('Invalid bech32 ID:', bech32Id, error) logger.error('Invalid bech32 ID', { bech32Id, error })
} }
return null return null
} }

3
src/lib/pubkey.ts

@ -1,5 +1,6 @@
import { LRUCache } from 'lru-cache' import { LRUCache } from 'lru-cache'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import logger from '@/lib/logger'
export function formatPubkey(pubkey: string) { export function formatPubkey(pubkey: string) {
const npub = pubkeyToNpub(pubkey) const npub = pubkeyToNpub(pubkey)
@ -48,7 +49,7 @@ export function userIdToPubkey(userId: string) {
return data.pubkey return data.pubkey
} }
} catch (error) { } catch (error) {
console.error('Error decoding userId:', userId, 'error:', error) logger.error('Error decoding userId', { userId, error })
} }
} }
return userId return userId

16
src/lib/url.ts

@ -1,3 +1,5 @@
import logger from '@/lib/logger'
export function isWebsocketUrl(url: string): boolean { export function isWebsocketUrl(url: string): boolean {
return /^wss?:\/\/.+$/.test(url) return /^wss?:\/\/.+$/.test(url)
} }
@ -23,7 +25,7 @@ export function normalizeUrl(url: string): string {
// Block URLs with query params or hash fragments (these are likely not relays) // Block URLs with query params or hash fragments (these are likely not relays)
if (hasQueryParams || hasHashFragment) { if (hasQueryParams || hasHashFragment) {
console.warn('Skipping URL with query/hash (not a relay):', url) logger.warn('Skipping URL with query/hash (not a relay)', { url })
return '' return ''
} }
@ -37,7 +39,7 @@ export function normalizeUrl(url: string): string {
// After protocol normalization, validate it's actually a websocket URL // After protocol normalization, validate it's actually a websocket URL
if (!isWebsocketUrl(p.toString())) { if (!isWebsocketUrl(p.toString())) {
console.warn('Skipping non-websocket URL:', url) logger.warn('Skipping non-websocket URL', { url })
return '' return ''
} }
@ -56,13 +58,13 @@ export function normalizeUrl(url: string): string {
// Final validation: ensure we have a proper websocket URL // Final validation: ensure we have a proper websocket URL
const finalUrl = p.toString() const finalUrl = p.toString()
if (!isWebsocketUrl(finalUrl)) { if (!isWebsocketUrl(finalUrl)) {
console.warn('Normalization resulted in invalid websocket URL:', finalUrl) logger.warn('Normalization resulted in invalid websocket URL', { url: finalUrl })
return '' return ''
} }
return finalUrl return finalUrl
} catch { } catch (error) {
console.error('Invalid URL:', url) logger.error('Invalid URL', { error, url })
return '' return ''
} }
} }
@ -87,8 +89,8 @@ export function normalizeHttpUrl(url: string): string {
p.searchParams.sort() p.searchParams.sort()
p.hash = '' p.hash = ''
return p.toString() return p.toString()
} catch { } catch (error) {
console.error('Invalid URL:', url) logger.error('Invalid URL', { error, url })
return '' return ''
} }
} }

2
src/pages/primary/NoteListPage/index.tsx

@ -144,7 +144,7 @@ function NoteListPageTitlebar({
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
console.log('Im Wald clicked, clearing overlay') logger.debug('Im Wald button clicked, clearing overlay')
setPrimaryNoteView(null) setPrimaryNoteView(null)
}} }}
> >

3
src/pages/secondary/HomePage/index.tsx

@ -9,6 +9,7 @@ import { TRelayInfo } from '@/types'
import { ArrowRight, Server } from 'lucide-react' import { ArrowRight, Server } from 'lucide-react'
import { forwardRef, useEffect, useState } from 'react' import { forwardRef, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger'
const HomePage = forwardRef(({ index }: { index?: number }, ref) => { const HomePage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
@ -23,7 +24,7 @@ const HomePage = forwardRef(({ index }: { index?: number }, ref) => {
const relays = await relayInfoService.getRelayInfos(RECOMMENDED_RELAYS) const relays = await relayInfoService.getRelayInfos(RECOMMENDED_RELAYS)
setRecommendedRelayInfos(relays.filter(Boolean) as TRelayInfo[]) setRecommendedRelayInfos(relays.filter(Boolean) as TRelayInfo[])
} catch (error) { } catch (error) {
console.error('Failed to fetch recommended relays:', error) logger.error('Failed to fetch recommended relays', { error })
} }
} }
init() init()

5
src/pages/secondary/NotePage/NotFound.tsx

@ -7,6 +7,7 @@ import { AlertCircle, Search } from 'lucide-react'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger'
export default function NotFound({ export default function NotFound({
bech32Id, bech32Id,
@ -55,7 +56,7 @@ export default function NotFound({
externalRelays = externalRelays.map(url => normalizeUrl(url) || url) externalRelays = externalRelays.map(url => normalizeUrl(url) || url)
externalRelays = Array.from(new Set(externalRelays)) externalRelays = Array.from(new Set(externalRelays))
} catch (err) { } catch (err) {
console.error('Failed to parse external relays:', err) logger.error('Failed to parse external relays', { error: err, bech32Id })
} }
} else { } else {
extractedHexEventId = bech32Id extractedHexEventId = bech32Id
@ -100,7 +101,7 @@ export default function NotFound({
onEventFound(event) onEventFound(event)
} }
} catch (error) { } catch (error) {
console.error('External relay fetch failed:', error) logger.error('External relay fetch failed', { error, bech32Id, hexEventId })
} finally { } finally {
setIsSearchingExternal(false) setIsSearchingExternal(false)
setTriedExternal(true) setTriedExternal(true)

7
src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx

@ -13,6 +13,7 @@ import { AlertCircle, ArrowUpToLine, Loader, X } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger'
export default function BlossomServerListSetting() { export default function BlossomServerListSetting() {
const { t } = useTranslation() const { t } = useTranslation()
@ -48,7 +49,7 @@ export default function BlossomServerListSetting() {
setBlossomServerListEvent(newEvent) setBlossomServerListEvent(newEvent)
setUrl('') setUrl('')
} catch (error) { } catch (error) {
console.error('Failed to add Blossom URL:', error) logger.error('Failed to add Blossom URL', { error, url })
} finally { } finally {
setAdding(false) setAdding(false)
} }
@ -72,7 +73,7 @@ export default function BlossomServerListSetting() {
await client.updateBlossomServerListEventCache(newEvent) await client.updateBlossomServerListEventCache(newEvent)
setBlossomServerListEvent(newEvent) setBlossomServerListEvent(newEvent)
} catch (error) { } catch (error) {
console.error('Failed to remove Blossom URL:', error) logger.error('Failed to remove Blossom URL', { error, url: serverUrls[idx] })
} finally { } finally {
setRemovingIndex(-1) setRemovingIndex(-1)
} }
@ -88,7 +89,7 @@ export default function BlossomServerListSetting() {
await client.updateBlossomServerListEventCache(newEvent) await client.updateBlossomServerListEventCache(newEvent)
setBlossomServerListEvent(newEvent) setBlossomServerListEvent(newEvent)
} catch (error) { } catch (error) {
console.error('Failed to move Blossom URL to top:', error) logger.error('Failed to move Blossom URL to top', { error, url: serverUrls[idx] })
} finally { } finally {
setMovingIndex(-1) setMovingIndex(-1)
} }

3
src/providers/MuteListProvider.tsx

@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { z } from 'zod' import { z } from 'zod'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
import logger from '@/lib/logger'
type TMuteListContext = { type TMuteListContext = {
mutePubkeySet: Set<string> mutePubkeySet: Set<string>
@ -68,7 +69,7 @@ export function MuteListProvider({ children }: { children: React.ReactNode }) {
await indexedDb.putMuteDecryptedTags(muteListEvent.id, privateTags) await indexedDb.putMuteDecryptedTags(muteListEvent.id, privateTags)
return privateTags return privateTags
} catch (error) { } catch (error) {
console.error('Failed to decrypt mute list content', error) logger.error('Failed to decrypt mute list content', { error, eventId: muteListEvent.id })
return [] return []
} }
} }

4
src/providers/NotificationProvider.tsx

@ -231,7 +231,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
if (isMountedRef.current) { if (isMountedRef.current) {
setTimeout(() => { setTimeout(() => {
if (isMountedRef.current) { if (isMountedRef.current) {
console.log('[NotificationProvider] Reconnecting after close...') logger.info('[NotificationProvider] Reconnecting after close...')
subscribe() subscribe()
} }
}, 15_000) // Increased from 5s to 15s }, 15_000) // Increased from 5s to 15s
@ -243,7 +243,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
subCloserRef.current = subCloser subCloserRef.current = subCloser
return subCloser return subCloser
} catch (error) { } catch (error) {
console.error('Subscription error:', error) logger.error('Subscription error', { error })
// Retry on error if still mounted // Retry on error if still mounted
if (isMountedRef.current) { if (isMountedRef.current) {

21
src/services/client.service.ts

@ -1023,6 +1023,27 @@ class ClientService extends EventTarget {
} }
} }
async fetchFavoriteRelays(pubkey: string): Promise<string[]> {
try {
const favoriteRelaysEvent = await this.fetchReplaceableEvent(pubkey, ExtendedKind.FAVORITE_RELAYS)
if (!favoriteRelaysEvent) return []
const relays: string[] = []
favoriteRelaysEvent.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'relay' && tagValue) {
const normalized = normalizeUrl(tagValue)
if (normalized) {
relays.push(normalized)
}
}
})
return Array.from(new Set(relays))
} catch {
return []
}
}
/** /**
* Build initial relay list for fetching events * Build initial relay list for fetching events
* Priority: FAST_READ_RELAY_URLS, user's favorite relays (10012), user's relay list read relays (10002) including cache relays (10432) * Priority: FAST_READ_RELAY_URLS, user's favorite relays (10012), user's relay list read relays (10002) including cache relays (10432)

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

@ -8,6 +8,7 @@ import { Event, kinds, nip19 } from 'nostr-tools'
import { getImetaInfosFromEvent } from '@/lib/event' import { getImetaInfosFromEvent } from '@/lib/event'
import { URL_REGEX, ExtendedKind } from '@/constants' import { URL_REGEX, ExtendedKind } from '@/constants'
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
import logger from '@/lib/logger'
export interface ParsedContent { export interface ParsedContent {
html: string html: string
@ -45,7 +46,7 @@ class ContentParserService {
this.isAsciidoctorLoaded = true this.isAsciidoctorLoaded = true
return this.asciidoctor return this.asciidoctor
} catch (error) { } catch (error) {
console.warn('Failed to load AsciiDoctor:', error) logger.warn('Failed to load AsciiDoctor', error as Error)
return null return null
} }
} }
@ -91,7 +92,7 @@ class ContentParserService {
const asciidocContent = this.convertToAsciidoc(content, markupType) const asciidocContent = this.convertToAsciidoc(content, markupType)
html = await this.parseAsciidoc(asciidocContent, { enableMath, enableSyntaxHighlighting }) html = await this.parseAsciidoc(asciidocContent, { enableMath, enableSyntaxHighlighting })
} catch (error) { } catch (error) {
console.error('Content parsing error:', error) logger.error('Content parsing error', { error })
// Fallback to plain text // Fallback to plain text
html = this.parsePlainText(content) html = this.parsePlainText(content)
} }
@ -178,7 +179,7 @@ class ContentParserService {
// Debug: log the AsciiDoc HTML output for troubleshooting // Debug: log the AsciiDoc HTML output for troubleshooting
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
console.log('AsciiDoc HTML output:', htmlString.substring(0, 1000) + '...') logger.debug('AsciiDoc HTML output preview', { preview: htmlString.substring(0, 1000) + '...' })
} }
// Process wikilinks in the HTML output // Process wikilinks in the HTML output
@ -196,7 +197,7 @@ class ContentParserService {
// Hide any raw AsciiDoc ToC text that might appear in the content // Hide any raw AsciiDoc ToC text that might appear in the content
return this.hideRawTocText(styledHtml) return this.hideRawTocText(styledHtml)
} catch (error) { } catch (error) {
console.error('AsciiDoc parsing error:', error) logger.error('AsciiDoc parsing error', { error })
return this.parsePlainText(content) return this.parsePlainText(content)
} }
} }
@ -244,7 +245,7 @@ class ContentParserService {
// Debug: log the converted AsciiDoc for troubleshooting // Debug: log the converted AsciiDoc for troubleshooting
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
console.log('Converted AsciiDoc:', result) logger.debug('Converted AsciiDoc', result)
} }
return result return result

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

@ -3,6 +3,7 @@ import { tagNameEquals } from '@/lib/tag'
import { TRelayInfo } from '@/types' import { TRelayInfo } from '@/types'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { isReplaceableEvent } from '@/lib/event' import { isReplaceableEvent } from '@/lib/event'
import logger from '@/lib/logger'
type TValue<T = any> = { type TValue<T = any> = {
key: string key: string
@ -193,7 +194,7 @@ class IndexedDbService {
} }
// Check if the store exists before trying to access it // Check if the store exists before trying to access it
if (!this.db.objectStoreNames.contains(storeName)) { if (!this.db.objectStoreNames.contains(storeName)) {
console.warn(`Store ${storeName} not found in database. Cannot save event.`) logger.warn(`Store ${storeName} not found in database. Cannot save event.`)
// Return the event anyway (don't reject) - caching is optional // Return the event anyway (don't reject) - caching is optional
return resolve(cleanEvent) return resolve(cleanEvent)
} }
@ -243,7 +244,7 @@ class IndexedDbService {
} }
// Check if the store exists before trying to access it // Check if the store exists before trying to access it
if (!this.db.objectStoreNames.contains(storeName)) { if (!this.db.objectStoreNames.contains(storeName)) {
console.warn(`Store ${storeName} not found in database. Returning null.`) logger.warn(`Store ${storeName} not found in database. Returning null.`)
return resolve(null) return resolve(null)
} }
const transaction = this.db.transaction(storeName, 'readonly') const transaction = this.db.transaction(storeName, 'readonly')
@ -569,7 +570,7 @@ class IndexedDbService {
return reject('database not initialized') return reject('database not initialized')
} }
if (!this.db.objectStoreNames.contains(storeName)) { if (!this.db.objectStoreNames.contains(storeName)) {
console.warn(`Store ${storeName} not found in database. Cannot save event.`) logger.warn(`Store ${storeName} not found in database. Cannot save event.`)
return resolve(cleanEvent) return resolve(cleanEvent)
} }
const transaction = this.db.transaction(storeName, 'readwrite') const transaction = this.db.transaction(storeName, 'readwrite')
@ -630,7 +631,7 @@ class IndexedDbService {
return reject('database not initialized') return reject('database not initialized')
} }
if (!this.db.objectStoreNames.contains(storeName)) { if (!this.db.objectStoreNames.contains(storeName)) {
console.warn(`Store ${storeName} not found in database. Cannot save event.`) logger.warn(`Store ${storeName} not found in database. Cannot save event.`)
return resolve(event) return resolve(event)
} }
const transaction = this.db.transaction(storeName, 'readwrite') const transaction = this.db.transaction(storeName, 'readwrite')
@ -1002,7 +1003,7 @@ class IndexedDbService {
} }
} catch (error) { } catch (error) {
// If we can't generate a replaceable key, skip this item // If we can't generate a replaceable key, skip this item
console.warn('Failed to get replaceable key for item:', item.key, error) logger.warn('Failed to get replaceable key for item', { key: item.key, error })
invalidItemsCount++ invalidItemsCount++
continue continue
} }
@ -1015,7 +1016,11 @@ class IndexedDbService {
if (keysToDelete.length === 0) { if (keysToDelete.length === 0) {
// No duplicates found, but verify counts match // No duplicates found, but verify counts match
if (totalProcessed + invalidItemsCount !== allItems.length) { if (totalProcessed + invalidItemsCount !== allItems.length) {
console.warn(`Count mismatch: total items=${allItems.length}, processed=${totalProcessed}, invalid=${invalidItemsCount}`) logger.warn('Count mismatch while cleaning up replaceable events', {
totalItems: allItems.length,
processed: totalProcessed,
invalid: invalidItemsCount
})
} }
return Promise.resolve({ deleted: 0, kept: actualKept }) return Promise.resolve({ deleted: 0, kept: actualKept })
} }
@ -1037,7 +1042,12 @@ class IndexedDbService {
const actualKept = eventMap.size const actualKept = eventMap.size
const totalProcessed = actualKept + deletedCount const totalProcessed = actualKept + deletedCount
if (totalProcessed + invalidItemsCount !== allItems.length) { if (totalProcessed + invalidItemsCount !== allItems.length) {
console.warn(`Count mismatch after deletion: total items=${allItems.length}, kept=${actualKept}, deleted=${deletedCount}, invalid=${invalidItemsCount}`) logger.warn('Count mismatch after deletion', {
totalItems: allItems.length,
kept: actualKept,
deleted: deletedCount,
invalid: invalidItemsCount
})
} }
resolve({ deleted: deletedCount, kept: actualKept }) resolve({ deleted: deletedCount, kept: actualKept })
} }

3
src/services/lightning.service.ts

@ -11,6 +11,7 @@ import { SubCloser } from 'nostr-tools/abstract-pool'
import { makeZapRequest } from 'nostr-tools/nip57' import { makeZapRequest } from 'nostr-tools/nip57'
import { utf8Decoder } from 'nostr-tools/utils' import { utf8Decoder } from 'nostr-tools/utils'
import client from './client.service' import client from './client.service'
import logger from '@/lib/logger'
export type TRecentSupporter = { pubkey: string; amount: number; comment?: string } export type TRecentSupporter = { pubkey: string; amount: number; comment?: string }
@ -232,7 +233,7 @@ class LightningService {
} }
} }
} catch (err) { } catch (err) {
console.error(err) logger.error('Failed to resolve LNURL from profile', { error: err, profile })
} }
return null return null

3
src/services/media-manager.service.ts

@ -1,4 +1,5 @@
import { YouTubePlayer } from '@/types/youtube' import { YouTubePlayer } from '@/types/youtube'
import logger from '@/lib/logger'
type Media = HTMLMediaElement | YouTubePlayer type Media = HTMLMediaElement | YouTubePlayer
@ -59,7 +60,7 @@ class MediaManagerService {
return return
} }
// Log other unexpected errors // Log other unexpected errors
console.error('Error playing media:', error) logger.error('Error playing media', { error })
this.currentMedia = null this.currentMedia = null
}) })
} }

5
src/services/relay-info.service.ts

@ -3,6 +3,7 @@ import indexDb from '@/services/indexed-db.service'
import { TAwesomeRelayCollection, TRelayInfo } from '@/types' import { TAwesomeRelayCollection, TRelayInfo } from '@/types'
import DataLoader from 'dataloader' import DataLoader from 'dataloader'
import FlexSearch from 'flexsearch' import FlexSearch from 'flexsearch'
import logger from '@/lib/logger'
class RelayInfoService { class RelayInfoService {
static instance: RelayInfoService static instance: RelayInfoService
@ -102,7 +103,7 @@ class RelayInfoService {
const data = (await res.json()) as { collections: TAwesomeRelayCollection[] } const data = (await res.json()) as { collections: TAwesomeRelayCollection[] }
return data.collections return data.collections
} catch (error) { } catch (error) {
console.error('Error fetching awesome relay collections:', error) logger.error('Error fetching awesome relay collections', { error })
return [] return []
} }
})() })()
@ -132,7 +133,7 @@ class RelayInfoService {
private async fetchRelayNip11(url: string) { private async fetchRelayNip11(url: string) {
try { try {
console.log('Fetching NIP-11 for', url) logger.debug('Fetching NIP-11 metadata', { url })
const res = await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), { const res = await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), {
headers: { Accept: 'application/nostr+json' } headers: { Accept: 'application/nostr+json' }
}) })

17
src/services/relay-selection.service.ts

@ -4,6 +4,7 @@ import { FAST_WRITE_RELAY_URLS } from '@/constants'
import client from '@/services/client.service' import client from '@/services/client.service'
import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url' import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url'
import { TRelaySet } from '@/types' import { TRelaySet } from '@/types'
import logger from '@/lib/logger'
export interface RelaySelectionContext { export interface RelaySelectionContext {
// User's own relays // User's own relays
@ -87,7 +88,7 @@ class RelaySelectionService {
selectableRelays.add(normalized) selectableRelays.add(normalized)
} else { } else {
// If normalization fails or returns empty (invalid URL), skip it // If normalization fails or returns empty (invalid URL), skip it
console.warn('Skipping invalid relay URL:', url) logger.warn('Skipping invalid relay URL', { url })
} }
} }
@ -174,7 +175,7 @@ class RelaySelectionService {
// Filter out local relays from other users // Filter out local relays from other users
return this.filterLocalRelaysFromOthers(userRelays) return this.filterLocalRelaysFromOthers(userRelays)
} catch (error) { } catch (error) {
console.warn(`Failed to fetch relay list for ${pubkey}:`, error) logger.warn('Failed to fetch relay list', { pubkey, error })
return [] return []
} }
}) })
@ -184,7 +185,7 @@ class RelaySelectionService {
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to get contextual relays:', error) logger.error('Failed to get contextual relays', { error, relaySets })
} }
return Array.from(contextualRelays) return Array.from(contextualRelays)
@ -258,7 +259,7 @@ class RelaySelectionService {
// Filter out local relays from other users // Filter out local relays from other users
return this.filterLocalRelaysFromOthers(userRelays) return this.filterLocalRelaysFromOthers(userRelays)
} catch (error) { } catch (error) {
console.warn(`Failed to fetch relay list for ${pubkey}:`, error) logger.warn('Failed to fetch relay list', { pubkey, error })
return [] return []
} }
}) })
@ -325,7 +326,7 @@ class RelaySelectionService {
// Filter out local relays from other users // Filter out local relays from other users
return this.filterLocalRelaysFromOthers(userRelays) return this.filterLocalRelaysFromOthers(userRelays)
} catch (error) { } catch (error) {
console.warn(`Failed to fetch relay list for ${pubkey}:`, error) logger.warn('Failed to fetch relay list', { pubkey, error })
return [] return []
} }
}) })
@ -355,11 +356,11 @@ class RelaySelectionService {
}) })
} }
} catch (error) { } catch (error) {
console.warn(`Failed to fetch relay list for ${parentEvent.pubkey}:`, error) logger.warn('Failed to fetch relay list for parent event', { parentPubkey: parentEvent.pubkey, error })
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to get public message relays:', error) logger.error('Failed to get public message relays', { error, parentEvent: context.parentEvent?.id })
} }
return Array.from(relays) return Array.from(relays)
@ -439,7 +440,7 @@ class RelaySelectionService {
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to decode nostr address:', error) logger.error('Failed to decode nostr address', { error, tag })
} }
} }
} }

Loading…
Cancel
Save