Browse Source

fixed personal feeds

imwald
Silberengel 5 months ago
parent
commit
3f827813bb
  1. 209
      src/components/NoteList/index.tsx
  2. 30
      src/components/Profile/ProfileFeed.tsx
  3. 14
      src/components/Profile/index.tsx
  4. 33
      src/components/SimpleNoteFeed/index.tsx

209
src/components/NoteList/index.tsx

@ -6,19 +6,17 @@ import {
isReplaceableEvent, isReplaceableEvent,
isReplyNoteEvent isReplyNoteEvent
} from '@/lib/event' } from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TFeedSubRequest } from '@/types' import { TFeedSubRequest } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools' import { Event } from 'nostr-tools'
import { decode } from 'nostr-tools/nip19'
import { import {
forwardRef, forwardRef,
useCallback, useCallback,
@ -33,8 +31,8 @@ import PullToRefresh from 'react-simple-pull-to-refresh'
import { toast } from 'sonner' import { toast } from 'sonner'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const LIMIT = 100 const LIMIT = 200
const ALGO_LIMIT = 100 const ALGO_LIMIT = 500
const SHOW_COUNT = 10 const SHOW_COUNT = 10
const NoteList = forwardRef( const NoteList = forwardRef(
@ -47,7 +45,7 @@ const NoteList = forwardRef(
hideUntrustedNotes = false, hideUntrustedNotes = false,
areAlgoRelays = false, areAlgoRelays = false,
showRelayCloseReason = false, showRelayCloseReason = false,
customHeader pinnedEventIds = []
}: { }: {
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
showKinds: number[] showKinds: number[]
@ -56,7 +54,7 @@ const NoteList = forwardRef(
hideUntrustedNotes?: boolean hideUntrustedNotes?: boolean
areAlgoRelays?: boolean areAlgoRelays?: boolean
showRelayCloseReason?: boolean showRelayCloseReason?: boolean
customHeader?: React.ReactNode pinnedEventIds?: string[]
}, },
ref ref
) => { ) => {
@ -66,7 +64,6 @@ const NoteList = forwardRef(
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { zapReplyThreshold } = useZap()
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([]) const [newEvents, setNewEvents] = useState<Event[]>([])
const [hasMore, setHasMore] = useState<boolean>(true) const [hasMore, setHasMore] = useState<boolean>(true)
@ -80,82 +77,49 @@ const NoteList = forwardRef(
const shouldHideEvent = useCallback( const shouldHideEvent = useCallback(
(evt: Event) => { (evt: Event) => {
// Check if this is a profile feed const pinnedEventHexIdSet = new Set()
const isProfileFeed = subRequests.some(req => req.filter.authors && req.filter.authors.length === 1) pinnedEventIds.forEach((id) => {
try {
if (isEventDeleted(evt)) { const { type, data } = decode(id)
logger.component('NoteList', 'Event filtered: deleted', { id: evt.id, kind: evt.kind }) if (type === 'nevent') {
return true pinnedEventHexIdSet.add(data.id)
} }
} catch {
// Special handling for zaps - check threshold, but be more lenient for profile feeds // ignore
if (evt.kind === kinds.Zap) {
const zapInfo = getZapInfoFromEvent(evt)
// For profile feeds, show all zaps from the profile owner
// For timeline feeds, filter by threshold
if (!isProfileFeed && zapInfo && zapInfo.amount < zapReplyThreshold) {
logger.component('NoteList', 'Event filtered: zap below threshold', {
id: evt.id,
amount: zapInfo.amount,
threshold: zapReplyThreshold
})
return true
} }
} else if (hideReplies && isReplyNoteEvent(evt)) { })
logger.component('NoteList', 'Event filtered: reply hidden', { id: evt.id, kind: evt.kind })
return true if (pinnedEventHexIdSet.has(evt.id)) return true
} if (isEventDeleted(evt)) return true
if (hideReplies && isReplyNoteEvent(evt)) return true
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) { if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true
logger.component('NoteList', 'Event filtered: untrusted user', { id: evt.id, pubkey: evt.pubkey.substring(0, 8) }) if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true
return true
}
if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) {
logger.component('NoteList', 'Event filtered: muted user', { id: evt.id, pubkey: evt.pubkey.substring(0, 8) })
return true
}
if ( if (
filterMutedNotes && filterMutedNotes &&
hideContentMentioningMutedUsers && hideContentMentioningMutedUsers &&
isMentioningMutedUsers(evt, mutePubkeySet) isMentioningMutedUsers(evt, mutePubkeySet)
) { ) {
logger.component('NoteList', 'Event filtered: mentions muted users', { id: evt.id, kind: evt.kind })
return true return true
} }
return false return false
}, },
[hideReplies, hideUntrustedNotes, mutePubkeySet, isEventDeleted, zapReplyThreshold, subRequests] [hideReplies, hideUntrustedNotes, mutePubkeySet, pinnedEventIds, isEventDeleted]
) )
const filteredEvents = useMemo(() => { const filteredEvents = useMemo(() => {
const idSet = new Set<string>() const idSet = new Set<string>()
const startTime = performance.now()
const filtered = events.slice(0, showCount).filter((evt) => { return events.slice(0, showCount).filter((evt) => {
if (shouldHideEvent(evt)) { if (shouldHideEvent(evt)) return false
return false
}
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)) {
logger.component('NoteList', 'Event filtered: duplicate', { id: evt.id, kind: evt.kind })
return false return false
} }
idSet.add(id) idSet.add(id)
return true return true
}) })
const endTime = performance.now()
logger.perfComponent('NoteList', 'Event filtering completed', {
totalEvents: events.length,
filteredEvents: filtered.length,
showCount,
duration: `${(endTime - startTime).toFixed(2)}ms`
})
return filtered
}, [events, showCount, shouldHideEvent]) }, [events, showCount, shouldHideEvent])
const filteredNewEvents = useMemo(() => { const filteredNewEvents = useMemo(() => {
@ -183,9 +147,6 @@ const NoteList = forwardRef(
const refresh = () => { const refresh = () => {
scrollToTop() scrollToTop()
// Clear relay connection state to force fresh connections
const relayUrls = subRequests.flatMap(req => req.urls)
relayUrls.forEach(url => client.clearRelayConnectionState(url))
setTimeout(() => { setTimeout(() => {
setRefreshCount((count) => count + 1) setRefreshCount((count) => count + 1)
}, 500) }, 500)
@ -193,94 +154,39 @@ const NoteList = forwardRef(
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), []) useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [])
useEffect(() => { useEffect(() => {
logger.component('NoteList', 'useEffect triggered', { if (!subRequests.length) return
subRequests: subRequests.length,
showKinds: showKinds.length,
refreshCount
})
if (!subRequests.length) {
logger.component('NoteList', 'No subRequests, returning early')
return
}
// Don't initialize if showKinds is empty (still loading from provider)
if (showKinds.length === 0) {
logger.component('NoteList', 'showKinds is empty, waiting for provider to initialize')
return
}
async function init() { async function init() {
logger.component('NoteList', 'Initializing feed')
setLoading(true) setLoading(true)
setEvents([]) setEvents([])
setNewEvents([]) setNewEvents([])
setHasMore(true) setHasMore(true)
if (showKinds.length === 0) { if (showKinds.length === 0) {
logger.component('NoteList', 'showKinds is empty, no events will be displayed')
setLoading(false) setLoading(false)
setHasMore(false) setHasMore(false)
return () => {} return () => {}
} }
const finalFilters = subRequests.map(({ urls, filter }) => ({
urls,
filter: {
kinds: showKinds,
...filter,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
}
}))
const { closer, timelineKey } = await client.subscribeTimeline( const { closer, timelineKey } = await client.subscribeTimeline(
finalFilters, subRequests.map(({ urls, filter }) => ({
urls,
filter: {
kinds: showKinds,
...filter,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
}
})),
{ {
onEvents: (events, eosed) => { onEvents: (events, eosed) => {
logger.component('NoteList', 'Received events from relay', {
eventsCount: events.length,
eosed,
eventKinds: [...new Set(events.map(e => e.kind))].slice(0, 5)
})
if (events.length > 0) { if (events.length > 0) {
setEvents(prevEvents => { setEvents(events)
// For profile feeds, accumulate events from all relays
// For timeline feeds, replace events
const isProfileFeed = subRequests.some(req => req.filter.authors && req.filter.authors.length === 1)
if (isProfileFeed) {
// Accumulate events, removing duplicates
const existingIds = new Set(prevEvents.map(e => e.id))
const newEvents = events.filter(e => !existingIds.has(e.id))
logger.component('NoteList', 'Profile feed - accumulating events', {
previous: prevEvents.length,
new: events.length,
unique: newEvents.length,
total: prevEvents.length + newEvents.length
})
return [...prevEvents, ...newEvents]
} else {
// Timeline feed - replace events
logger.component('NoteList', 'Timeline feed - replacing events', {
previous: prevEvents.length,
new: events.length
})
return events
}
})
// Stop loading as soon as we have events, don't wait for all relays
setLoading(false)
} }
if (areAlgoRelays) { if (areAlgoRelays) {
setHasMore(false) setHasMore(false)
} }
if (eosed) { if (eosed) {
logger.component('NoteList', 'EOSED - all relays finished', {
eventsCount: events.length,
hasMore: events.length > 0
})
setLoading(false) setLoading(false)
setHasMore(events.length > 0) setHasMore(events.length > 0)
} }
@ -299,7 +205,6 @@ const NoteList = forwardRef(
} }
}, },
onClose: (url, reason) => { onClose: (url, reason) => {
logger.component('NoteList', 'Relay connection closed', { url, reason })
if (!showRelayCloseReason) return if (!showRelayCloseReason) return
// ignore reasons from nostr-tools // ignore reasons from nostr-tools
if ( if (
@ -322,28 +227,15 @@ const NoteList = forwardRef(
needSort: !areAlgoRelays needSort: !areAlgoRelays
} }
) )
// Add a fallback timeout to prevent infinite loading
// Increased timeout to 15 seconds to handle slow relay connections
const fallbackTimeout = setTimeout(() => {
if (loading) {
setLoading(false)
logger.component('NoteList', 'Loading timeout - stopping after 15 seconds')
}
}, 15000)
setTimelineKey(timelineKey) setTimelineKey(timelineKey)
return () => { return closer
clearTimeout(fallbackTimeout)
closer?.()
}
} }
const promise = init() const promise = init()
return () => { return () => {
promise.then((closer) => closer()) promise.then((closer) => closer())
} }
}, [subRequests, refreshCount, showKinds]) }, [JSON.stringify(subRequests), refreshCount, showKinds])
useEffect(() => { useEffect(() => {
const options = { const options = {
@ -403,17 +295,8 @@ const NoteList = forwardRef(
}, 0) }, 0)
} }
logger.component('NoteList', 'Rendering with state', {
eventsCount: events.length,
filteredEventsCount: filteredEvents.length,
loading,
hasMore,
showKinds: showKinds.length
})
const list = ( const list = (
<div className="min-h-screen"> <div className="min-h-screen">
{customHeader}
{filteredEvents.map((event) => ( {filteredEvents.map((event) => (
<NoteCard <NoteCard
key={event.id} key={event.id}
@ -430,13 +313,7 @@ const NoteList = forwardRef(
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div> <div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
) : ( ) : (
<div className="flex justify-center w-full mt-2"> <div className="flex justify-center w-full mt-2">
<Button size="lg" onClick={() => { <Button size="lg" onClick={() => setRefreshCount((count) => count + 1)}>
logger.component('NoteList', 'Reload button clicked, refreshing feed')
// Clear relay connection state to force fresh connections
const relayUrls = subRequests.flatMap(req => req.urls)
relayUrls.forEach(url => client.clearRelayConnectionState(url))
setRefreshCount((count) => count + 1)
}}>
{t('reload notes')} {t('reload notes')}
</Button> </Button>
</div> </div>
@ -446,9 +323,6 @@ const NoteList = forwardRef(
return ( return (
<div> <div>
{filteredNewEvents.length > 0 && (
<NewNotesButton newEvents={filteredNewEvents} onClick={showNewEvents} />
)}
<div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" /> <div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
{supportTouch ? ( {supportTouch ? (
<PullToRefresh <PullToRefresh
@ -464,6 +338,9 @@ const NoteList = forwardRef(
list list
)} )}
<div className="h-40" /> <div className="h-40" />
{filteredNewEvents.length > 0 && (
<NewNotesButton newEvents={filteredNewEvents} onClick={showNewEvents} />
)}
</div> </div>
) )
} }

30
src/components/Profile/ProfileFeed.tsx

@ -2,6 +2,7 @@ import KindFilter from '@/components/KindFilter'
import SimpleNoteFeed from '@/components/SimpleNoteFeed' import SimpleNoteFeed from '@/components/SimpleNoteFeed'
import Tabs from '@/components/Tabs' import Tabs from '@/components/Tabs'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import logger from '@/lib/logger'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilter } from '@/providers/KindFilterProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { TNoteListMode } from '@/types' import { TNoteListMode } from '@/types'
@ -51,6 +52,7 @@ export default function ProfileFeed({
if (!myPubkey) return [] if (!myPubkey) return []
return [myPubkey, pubkey] // Show interactions between current user and profile user return [myPubkey, pubkey] // Show interactions between current user and profile user
} }
logger.component('ProfileFeed', 'getAuthorsFilter called', { listMode, pubkey, myPubkey })
return [pubkey] // Show only profile user's events return [pubkey] // Show only profile user's events
} }
@ -78,14 +80,26 @@ export default function ProfileFeed({
{listMode === 'bookmarksAndHashtags' ? ( {listMode === 'bookmarksAndHashtags' ? (
<ProfileBookmarksAndHashtags pubkey={pubkey} topSpace={topSpace} /> <ProfileBookmarksAndHashtags pubkey={pubkey} topSpace={topSpace} />
) : ( ) : (
<SimpleNoteFeed (() => {
ref={simpleNoteFeedRef} const authors = getAuthorsFilter()
authors={getAuthorsFilter()} logger.component('ProfileFeed', 'Rendering SimpleNoteFeed', {
kinds={temporaryShowKinds} listMode,
limit={100} authors,
hideReplies={shouldHideReplies} kinds: temporaryShowKinds,
filterMutedNotes={false} hideReplies: shouldHideReplies,
/> pubkey
})
return (
<SimpleNoteFeed
ref={simpleNoteFeedRef}
authors={authors}
kinds={temporaryShowKinds}
limit={100}
hideReplies={shouldHideReplies}
filterMutedNotes={false}
/>
)
})()
)} )}
</> </>
) )

14
src/components/Profile/index.tsx

@ -19,6 +19,7 @@ import client from '@/services/client.service'
import { Link, Zap } from 'lucide-react' import { Link, Zap } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger'
import NotFound from '../NotFound' import NotFound from '../NotFound'
import FollowedBy from './FollowedBy' import FollowedBy from './FollowedBy'
import ProfileFeed from './ProfileFeed' import ProfileFeed from './ProfileFeed'
@ -99,6 +100,14 @@ export default function Profile({ id }: { id?: string }) {
if (!profile) return <NotFound /> if (!profile) return <NotFound />
const { banner, username, about, avatar, pubkey, website, lightningAddress } = profile const { banner, username, about, avatar, pubkey, website, lightningAddress } = profile
logger.component('Profile', 'Profile data loaded', {
pubkey,
username,
hasProfile: !!profile,
isFetching,
id
})
return ( return (
<> <>
<div ref={topContainerRef}> <div ref={topContainerRef}>
@ -178,7 +187,10 @@ export default function Profile({ id }: { id?: string }) {
</div> </div>
</div> </div>
</div> </div>
<ProfileFeed pubkey={pubkey} topSpace={topContainerHeight + 100} /> {(() => {
logger.component('Profile', 'Rendering ProfileFeed', { pubkey, topSpace: topContainerHeight + 100, profile: !!profile, isFetching })
return <ProfileFeed pubkey={pubkey} topSpace={topContainerHeight + 100} />
})()}
</> </>
) )
} }

33
src/components/SimpleNoteFeed/index.tsx

@ -2,7 +2,6 @@ import { forwardRef, useEffect, useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { FAST_READ_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS } from '@/constants'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -33,11 +32,12 @@ const SimpleNoteFeed = forwardRef<
}, ref) => { }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { favoriteRelays } = useFavoriteRelays()
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
logger.component('SimpleNoteFeed', 'Component rendered', { authors, requestedKinds, limit, hideReplies, pubkey: !!pubkey })
// Build comprehensive relay list (same as Discussions) // Build comprehensive relay list (same as Discussions)
const buildComprehensiveRelayList = useCallback(async () => { const buildComprehensiveRelayList = useCallback(async () => {
const myRelayList = pubkey ? await client.fetchRelayList(pubkey) : { write: [], read: [] } const myRelayList = pubkey ? await client.fetchRelayList(pubkey) : { write: [], read: [] }
@ -54,17 +54,20 @@ const SimpleNoteFeed = forwardRef<
logger.debug('[SimpleNoteFeed] Using', normalizedRelays.length, 'comprehensive relays') logger.debug('[SimpleNoteFeed] Using', normalizedRelays.length, 'comprehensive relays')
return Array.from(new Set(normalizedRelays)) return Array.from(new Set(normalizedRelays))
}, [pubkey, favoriteRelays]) }, [pubkey])
// Fetch events using the same pattern as Discussions // Fetch events using the same pattern as Discussions
const fetchEvents = useCallback(async () => { const fetchEvents = useCallback(async () => {
if (isRefreshing) return if (isRefreshing) {
logger.component('SimpleNoteFeed', 'Already refreshing, skipping')
return
}
logger.component('SimpleNoteFeed', 'Starting fetch', { authors, kinds: requestedKinds, limit })
setLoading(true) setLoading(true)
setIsRefreshing(true) setIsRefreshing(true)
try { try {
logger.component('SimpleNoteFeed', 'Starting fetch', { authors, kinds: requestedKinds, limit })
// Get comprehensive relay list // Get comprehensive relay list
const allRelays = await buildComprehensiveRelayList() const allRelays = await buildComprehensiveRelayList()
logger.component('SimpleNoteFeed', 'Using relays', { count: allRelays.length }) logger.component('SimpleNoteFeed', 'Using relays', { count: allRelays.length })
@ -115,16 +118,20 @@ const SimpleNoteFeed = forwardRef<
logger.component('SimpleNoteFeed', 'Filtered events', { count: filteredEvents.length }) logger.component('SimpleNoteFeed', 'Filtered events', { count: filteredEvents.length })
setEvents(filteredEvents) setEvents(filteredEvents)
logger.component('SimpleNoteFeed', 'Set events successfully', { count: filteredEvents.length })
} catch (error) { } catch (error) {
logger.component('SimpleNoteFeed', 'Error fetching events', { error: (error as Error).message }) logger.component('SimpleNoteFeed', 'Error fetching events', { error: (error as Error).message })
// Don't clear events on error, keep what we have
} finally { } finally {
logger.component('SimpleNoteFeed', 'Setting loading states to false')
setLoading(false) setLoading(false)
setIsRefreshing(false) setIsRefreshing(false)
} }
}, [authors, requestedKinds, limit, hideReplies, buildComprehensiveRelayList, isRefreshing]) }, [authors, requestedKinds, limit, hideReplies, isRefreshing])
// Initial fetch // Initial fetch
useEffect(() => { useEffect(() => {
logger.component('SimpleNoteFeed', 'useEffect triggered for initial fetch', { authors, requestedKinds, limit, hideReplies })
fetchEvents() fetchEvents()
}, [authors, requestedKinds, limit, hideReplies]) }, [authors, requestedKinds, limit, hideReplies])
@ -138,6 +145,7 @@ const SimpleNoteFeed = forwardRef<
}, [ref, fetchEvents]) }, [ref, fetchEvents])
const handleRefresh = () => { const handleRefresh = () => {
logger.component('SimpleNoteFeed', 'handleRefresh called')
fetchEvents() fetchEvents()
} }
@ -159,17 +167,6 @@ const SimpleNoteFeed = forwardRef<
<div className="min-h-screen"> <div className="min-h-screen">
{customHeader} {customHeader}
{/* Refresh button */}
<div className="flex justify-end p-4">
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-muted/80 rounded-md disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? t('refreshing...') : t('refresh')}
</button>
</div>
{/* Events list */} {/* Events list */}
{events.length > 0 ? ( {events.length > 0 ? (

Loading…
Cancel
Save