31 changed files with 21 additions and 4305 deletions
@ -1,56 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { Input } from '@/components/ui/input' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { Loader } from 'lucide-react' |
|
||||||
import { useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
export default function BunkerLogin({ |
|
||||||
back, |
|
||||||
onLoginSuccess |
|
||||||
}: { |
|
||||||
back: () => void |
|
||||||
onLoginSuccess: () => void |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { bunkerLogin } = useNostr() |
|
||||||
const [pending, setPending] = useState(false) |
|
||||||
const [bunkerInput, setBunkerInput] = useState('') |
|
||||||
const [errMsg, setErrMsg] = useState<string | null>(null) |
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
|
||||||
setBunkerInput(e.target.value) |
|
||||||
setErrMsg(null) |
|
||||||
} |
|
||||||
|
|
||||||
const handleLogin = () => { |
|
||||||
if (bunkerInput === '') return |
|
||||||
|
|
||||||
setPending(true) |
|
||||||
bunkerLogin(bunkerInput) |
|
||||||
.then(() => onLoginSuccess()) |
|
||||||
.catch((err) => setErrMsg(err.message)) |
|
||||||
.finally(() => setPending(false)) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<> |
|
||||||
<div className="space-y-1"> |
|
||||||
<Input |
|
||||||
placeholder="bunker://..." |
|
||||||
value={bunkerInput} |
|
||||||
onChange={handleInputChange} |
|
||||||
className={errMsg ? 'border-destructive' : ''} |
|
||||||
/> |
|
||||||
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>} |
|
||||||
</div> |
|
||||||
<Button onClick={handleLogin} disabled={pending}> |
|
||||||
<Loader className={pending ? 'animate-spin' : 'hidden'} /> |
|
||||||
{t('Login')} |
|
||||||
</Button> |
|
||||||
<Button variant="secondary" onClick={back}> |
|
||||||
{t('Back')} |
|
||||||
</Button> |
|
||||||
</> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,43 +0,0 @@ |
|||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' |
|
||||||
import { Skeleton } from '@/components/ui/skeleton' |
|
||||||
import { generateImageByPubkey } from '@/lib/pubkey' |
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
import { usePrimaryPage } from '@/PageManager' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { UserRound } from 'lucide-react' |
|
||||||
import { useMemo } from 'react' |
|
||||||
import BottomNavigationBarItem from './BottomNavigationBarItem' |
|
||||||
|
|
||||||
export default function AccountButton() { |
|
||||||
const { navigate, current, display } = usePrimaryPage() |
|
||||||
const { pubkey, profile } = useNostr() |
|
||||||
const defaultAvatar = useMemo( |
|
||||||
() => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''), |
|
||||||
[profile] |
|
||||||
) |
|
||||||
const active = useMemo(() => current === 'profile' && display, [display, current]) |
|
||||||
|
|
||||||
return ( |
|
||||||
<BottomNavigationBarItem |
|
||||||
onClick={() => { |
|
||||||
navigate(pubkey ? 'profile' : 'me') |
|
||||||
}} |
|
||||||
active={active} |
|
||||||
> |
|
||||||
{pubkey ? ( |
|
||||||
profile ? ( |
|
||||||
<Avatar className={cn('w-7 h-7', active ? 'ring-primary ring-1' : '')}> |
|
||||||
<AvatarImage src={profile.avatar} className="object-cover object-center" /> |
|
||||||
<AvatarFallback> |
|
||||||
<img src={defaultAvatar} /> |
|
||||||
</AvatarFallback> |
|
||||||
</Avatar> |
|
||||||
) : ( |
|
||||||
<Skeleton className={cn('w-7 h-7 rounded-full', active ? 'ring-primary ring-1' : '')} /> |
|
||||||
) |
|
||||||
) : ( |
|
||||||
<UserRound /> |
|
||||||
)} |
|
||||||
</BottomNavigationBarItem> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,143 +0,0 @@ |
|||||||
import { toRelaySettings } from '@/lib/link' |
|
||||||
import { simplifyUrl } from '@/lib/url' |
|
||||||
import { SecondaryPageLink } from '@/PageManager' |
|
||||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
||||||
import { useFeed } from '@/providers/FeedProvider' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { BookmarkIcon, UsersRound, Server } from 'lucide-react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import RelayIcon from '../RelayIcon' |
|
||||||
import RelaySetCard from '../RelaySetCard' |
|
||||||
import logger from '@/lib/logger' |
|
||||||
|
|
||||||
export default function FeedSwitcher({ close }: { close?: () => void }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { pubkey } = useNostr() |
|
||||||
const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays() |
|
||||||
const { feedInfo, switchFeed } = useFeed() |
|
||||||
|
|
||||||
// Filter out blocked relays for display
|
|
||||||
const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay)) |
|
||||||
|
|
||||||
// Feed rows: aggregate favorites → following → bookmarks (see FAUX_SPELL_ORDER for spell picker order).
|
|
||||||
return ( |
|
||||||
<div className="space-y-2"> |
|
||||||
{visibleRelays.length > 0 && ( |
|
||||||
<FeedSwitcherItem |
|
||||||
isActive={feedInfo.feedType === 'all-favorites'} |
|
||||||
onClick={() => { |
|
||||||
logger.debug('FeedSwitcher: Switching to all-favorites') |
|
||||||
switchFeed('all-favorites') |
|
||||||
close?.() |
|
||||||
}} |
|
||||||
> |
|
||||||
<div className="flex gap-2 items-center"> |
|
||||||
<div className="flex justify-center items-center w-6 h-6 shrink-0"> |
|
||||||
<Server className="size-4" /> |
|
||||||
</div> |
|
||||||
<div>{t('All favorite relays')}</div> |
|
||||||
</div> |
|
||||||
</FeedSwitcherItem> |
|
||||||
)} |
|
||||||
|
|
||||||
{pubkey && ( |
|
||||||
<FeedSwitcherItem |
|
||||||
isActive={feedInfo.feedType === 'following'} |
|
||||||
onClick={() => { |
|
||||||
if (!pubkey) return |
|
||||||
switchFeed('following', { pubkey }) |
|
||||||
close?.() |
|
||||||
}} |
|
||||||
> |
|
||||||
<div className="flex gap-2 items-center"> |
|
||||||
<div className="flex justify-center items-center w-6 h-6 shrink-0"> |
|
||||||
<UsersRound className="size-4" /> |
|
||||||
</div> |
|
||||||
<div>{t('Following')}</div> |
|
||||||
</div> |
|
||||||
</FeedSwitcherItem> |
|
||||||
)} |
|
||||||
|
|
||||||
{pubkey && ( |
|
||||||
<FeedSwitcherItem |
|
||||||
isActive={feedInfo.feedType === 'bookmarks'} |
|
||||||
onClick={() => { |
|
||||||
if (!pubkey) return |
|
||||||
switchFeed('bookmarks', { pubkey }) |
|
||||||
close?.() |
|
||||||
}} |
|
||||||
> |
|
||||||
<div className="flex gap-2 items-center"> |
|
||||||
<div className="flex justify-center items-center w-6 h-6 shrink-0"> |
|
||||||
<BookmarkIcon className="size-4" /> |
|
||||||
</div> |
|
||||||
<div>{t('Bookmarks')}</div> |
|
||||||
</div> |
|
||||||
</FeedSwitcherItem> |
|
||||||
)} |
|
||||||
|
|
||||||
<div className="flex justify-end items-center text-sm"> |
|
||||||
<SecondaryPageLink |
|
||||||
to={toRelaySettings()} |
|
||||||
className="text-primary font-semibold" |
|
||||||
onClick={() => close?.()} |
|
||||||
> |
|
||||||
{t('edit')} |
|
||||||
</SecondaryPageLink> |
|
||||||
</div> |
|
||||||
{relaySets |
|
||||||
.filter((set) => set.relayUrls.length > 0) |
|
||||||
.map((set) => ( |
|
||||||
<RelaySetCard |
|
||||||
key={set.id} |
|
||||||
relaySet={set} |
|
||||||
select={feedInfo.feedType === 'relays' && set.id === feedInfo.id} |
|
||||||
onSelectChange={(select) => { |
|
||||||
if (!select) return |
|
||||||
switchFeed('relays', { activeRelaySetId: set.id }) |
|
||||||
close?.() |
|
||||||
}} |
|
||||||
/> |
|
||||||
))} |
|
||||||
{visibleRelays.map((relay) => ( |
|
||||||
<FeedSwitcherItem |
|
||||||
key={relay} |
|
||||||
isActive={feedInfo.feedType === 'relay' && feedInfo.id === relay} |
|
||||||
onClick={() => { |
|
||||||
switchFeed('relay', { relay }) |
|
||||||
close?.() |
|
||||||
}} |
|
||||||
> |
|
||||||
<div className="flex gap-2 items-center w-full"> |
|
||||||
<RelayIcon url={relay} /> |
|
||||||
<div className="flex-1 w-0 truncate">{simplifyUrl(relay)}</div> |
|
||||||
</div> |
|
||||||
</FeedSwitcherItem> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function FeedSwitcherItem({ |
|
||||||
children, |
|
||||||
isActive, |
|
||||||
onClick, |
|
||||||
controls |
|
||||||
}: { |
|
||||||
children: React.ReactNode |
|
||||||
isActive: boolean |
|
||||||
onClick: () => void |
|
||||||
controls?: React.ReactNode |
|
||||||
}) { |
|
||||||
return ( |
|
||||||
<div |
|
||||||
className={`w-full border rounded-lg p-4 ${isActive ? 'border-primary bg-primary/5' : 'clickable'}`} |
|
||||||
onClick={onClick} |
|
||||||
> |
|
||||||
<div className="flex justify-between items-center"> |
|
||||||
<div className="font-semibold flex-1">{children}</div> |
|
||||||
{controls} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,25 +0,0 @@ |
|||||||
import { MessageCircle } from 'lucide-react' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import Notification from './Notification' |
|
||||||
|
|
||||||
export function DiscussionNotification({ notification }: { notification: Event }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
|
|
||||||
// Get the topic from t-tags
|
|
||||||
const topicTags = notification.tags.filter(tag => tag[0] === 't' && tag[1]) |
|
||||||
const topics = topicTags.map(tag => tag[1]) |
|
||||||
const topicString = topics.length > 0 ? topics.join(', ') : t('general') |
|
||||||
|
|
||||||
return ( |
|
||||||
<Notification |
|
||||||
sender={notification.pubkey} |
|
||||||
sentAt={notification.created_at} |
|
||||||
description={t('started a discussion in {{topic}}', { topic: topicString })} |
|
||||||
icon={<MessageCircle className="w-4 h-4 text-primary" />} |
|
||||||
targetEvent={notification} |
|
||||||
showStats={false} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,59 +0,0 @@ |
|||||||
import ParentNotePreview from '@/components/ParentNotePreview' |
|
||||||
import { NOTIFICATION_LIST_STYLE } from '@/constants' |
|
||||||
import { getEmbeddedPubkeys, getParentBech32Id } from '@/lib/event' |
|
||||||
import { toNote } from '@/lib/link' |
|
||||||
import { useSmartNoteNavigation } from '@/PageManager' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider' |
|
||||||
import { AtSign, MessageCircle, Quote } from 'lucide-react' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { useMemo } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import Notification from './Notification' |
|
||||||
|
|
||||||
export function MentionNotification({ notification }: { notification: Event }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { navigateToNote } = useSmartNoteNavigation() |
|
||||||
const { pubkey } = useNostr() |
|
||||||
const { notificationListStyle } = useUserPreferences() |
|
||||||
const isMention = useMemo(() => { |
|
||||||
if (!pubkey) return false |
|
||||||
const mentions = getEmbeddedPubkeys(notification) |
|
||||||
return mentions.includes(pubkey) |
|
||||||
}, [pubkey, notification]) |
|
||||||
const parentEventId = useMemo(() => getParentBech32Id(notification), [notification]) |
|
||||||
|
|
||||||
return ( |
|
||||||
<Notification |
|
||||||
icon={ |
|
||||||
isMention ? ( |
|
||||||
<AtSign size={24} className="text-pink-400" /> |
|
||||||
) : parentEventId ? ( |
|
||||||
<MessageCircle size={24} className="text-blue-400" /> |
|
||||||
) : ( |
|
||||||
<Quote size={24} className="text-green-400" /> |
|
||||||
) |
|
||||||
} |
|
||||||
sender={notification.pubkey} |
|
||||||
sentAt={notification.created_at} |
|
||||||
targetEvent={notification} |
|
||||||
middle={ |
|
||||||
notificationListStyle === NOTIFICATION_LIST_STYLE.DETAILED && |
|
||||||
parentEventId && ( |
|
||||||
<ParentNotePreview |
|
||||||
eventId={parentEventId} |
|
||||||
className="" |
|
||||||
onClick={(e) => { |
|
||||||
e.stopPropagation() |
|
||||||
navigateToNote(toNote(parentEventId)) |
|
||||||
}} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
description={ |
|
||||||
isMention ? t('mentioned you in a note') : parentEventId ? '' : t('quoted your note') |
|
||||||
} |
|
||||||
showStats |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,147 +0,0 @@ |
|||||||
import ContentPreview from '@/components/ContentPreview' |
|
||||||
import { FormattedTimestamp } from '@/components/FormattedTimestamp' |
|
||||||
import NoteStats from '@/components/NoteStats' |
|
||||||
import { Skeleton } from '@/components/ui/skeleton' |
|
||||||
import UserAvatar from '@/components/UserAvatar' |
|
||||||
import Username from '@/components/Username' |
|
||||||
import { NOTIFICATION_LIST_STYLE } from '@/constants' |
|
||||||
import { toNote, toProfile } from '@/lib/link' |
|
||||||
import client from '@/services/client.service' |
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider' |
|
||||||
import { NostrEvent } from 'nostr-tools' |
|
||||||
|
|
||||||
export default function Notification({ |
|
||||||
icon, |
|
||||||
sender, |
|
||||||
sentAt, |
|
||||||
description, |
|
||||||
middle = null, |
|
||||||
targetEvent, |
|
||||||
showStats = false, |
|
||||||
rightAction = null |
|
||||||
}: { |
|
||||||
icon: React.ReactNode |
|
||||||
sender: string |
|
||||||
sentAt: number |
|
||||||
description: string |
|
||||||
middle?: React.ReactNode |
|
||||||
targetEvent?: NostrEvent |
|
||||||
showStats?: boolean |
|
||||||
rightAction?: React.ReactNode |
|
||||||
}) { |
|
||||||
const { navigateToNote } = useSmartNoteNavigation() |
|
||||||
const { push } = useSecondaryPage() |
|
||||||
const { pubkey } = useNostr() |
|
||||||
const { notificationListStyle } = useUserPreferences() |
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => { |
|
||||||
const target = e.target as HTMLElement |
|
||||||
if (target.closest('button') || target.closest('[role="button"]') || target.closest('a')) { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
if (target.closest('[data-note-stats]')) { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
const hasOpenModal = document.querySelector('[data-radix-dialog-content][data-state="open"]') |
|
||||||
if (hasOpenModal) { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
if (targetEvent) { |
|
||||||
client.addEventToCache(targetEvent) |
|
||||||
navigateToNote(toNote(targetEvent.id), targetEvent) |
|
||||||
} else if (pubkey) { |
|
||||||
push(toProfile(pubkey)) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT) { |
|
||||||
return ( |
|
||||||
<div |
|
||||||
className="flex items-center justify-between cursor-pointer py-2 px-4" |
|
||||||
onClick={handleClick} |
|
||||||
> |
|
||||||
<div className="flex gap-2 items-center flex-1 w-0"> |
|
||||||
<UserAvatar userId={sender} size="small" /> |
|
||||||
{icon} |
|
||||||
{middle} |
|
||||||
{targetEvent && ( |
|
||||||
<ContentPreview className="truncate flex-1 w-0 text-muted-foreground" event={targetEvent} /> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
<div className="text-muted-foreground shrink-0"> |
|
||||||
<FormattedTimestamp timestamp={sentAt} short /> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div |
|
||||||
className="clickable flex items-start gap-2 cursor-pointer py-2 px-4 border-b" |
|
||||||
onClick={handleClick} |
|
||||||
> |
|
||||||
<div className="flex gap-2 items-center mt-1.5"> |
|
||||||
{icon} |
|
||||||
<UserAvatar userId={sender} size="medium" /> |
|
||||||
</div> |
|
||||||
<div className="flex-1 w-0"> |
|
||||||
<div className="flex items-center justify-between gap-1"> |
|
||||||
<div className="flex gap-1 items-center"> |
|
||||||
<Username |
|
||||||
userId={sender} |
|
||||||
className="flex-1 max-w-fit truncate font-semibold" |
|
||||||
skeletonClassName="h-4" |
|
||||||
/> |
|
||||||
<div className="shrink-0 text-muted-foreground text-sm">{description}</div> |
|
||||||
</div> |
|
||||||
<div className="flex items-center gap-1 shrink-0">{rightAction}</div> |
|
||||||
</div> |
|
||||||
{middle} |
|
||||||
{targetEvent && ( |
|
||||||
<ContentPreview className={cn('line-clamp-2 text-muted-foreground')} event={targetEvent} /> |
|
||||||
)} |
|
||||||
<FormattedTimestamp timestamp={sentAt} className="shrink-0 text-muted-foreground text-sm" /> |
|
||||||
{showStats && targetEvent && <NoteStats event={targetEvent} className="mt-1" />} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
export function NotificationSkeleton() { |
|
||||||
const { notificationListStyle } = useUserPreferences() |
|
||||||
|
|
||||||
if (notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT) { |
|
||||||
return ( |
|
||||||
<div className="flex gap-2 items-center h-11 py-2 px-4"> |
|
||||||
<Skeleton className="w-7 h-7 rounded-full" /> |
|
||||||
<Skeleton className="h-6 flex-1 w-0" /> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="flex items-start gap-2 cursor-pointer py-2 px-4"> |
|
||||||
<div className="flex gap-2 items-center mt-1.5"> |
|
||||||
<Skeleton className="w-6 h-6" /> |
|
||||||
<Skeleton className="w-9 h-9 rounded-full" /> |
|
||||||
</div> |
|
||||||
<div className="flex-1 w-0"> |
|
||||||
<div className="py-1"> |
|
||||||
<Skeleton className="w-16 h-4" /> |
|
||||||
</div> |
|
||||||
<div className="py-1"> |
|
||||||
<Skeleton className="w-full h-4" /> |
|
||||||
</div> |
|
||||||
<div className="py-1"> |
|
||||||
<Skeleton className="w-12 h-4" /> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,30 +0,0 @@ |
|||||||
import { useFetchEvent } from '@/hooks' |
|
||||||
import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag' |
|
||||||
import { Vote } from 'lucide-react' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { useMemo } from 'react' |
|
||||||
import Notification from './Notification' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
export function PollResponseNotification({ notification }: { notification: Event }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const eventId = useMemo(() => { |
|
||||||
const eTag = notification.tags.find(tagNameEquals('e')) |
|
||||||
return eTag ? generateBech32IdFromETag(eTag) : undefined |
|
||||||
}, [notification]) |
|
||||||
const { event: pollEvent } = useFetchEvent(eventId) |
|
||||||
|
|
||||||
if (!pollEvent) { |
|
||||||
return null |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<Notification |
|
||||||
icon={<Vote size={24} className="text-violet-400" />} |
|
||||||
sender={notification.pubkey} |
|
||||||
sentAt={notification.created_at} |
|
||||||
targetEvent={pollEvent} |
|
||||||
description={t('voted in your poll')} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,48 +0,0 @@ |
|||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { MessageCircle } from 'lucide-react' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { useMemo } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import Notification from './Notification' |
|
||||||
|
|
||||||
export function PublicMessageNotification({ notification }: { notification: Event }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { pubkey } = useNostr() |
|
||||||
|
|
||||||
const isRecipient = useMemo(() => { |
|
||||||
if (!pubkey) return false |
|
||||||
// Check if current user is in the 'p' tags (recipients)
|
|
||||||
return notification.tags.some((tag) => tag[0] === 'p' && tag[1] === pubkey) |
|
||||||
}, [pubkey, notification]) |
|
||||||
|
|
||||||
// Get list of recipients for display
|
|
||||||
const recipients = useMemo(() => { |
|
||||||
return notification.tags |
|
||||||
.filter((tag) => tag[0] === 'p') |
|
||||||
.map((tag) => tag[1]) |
|
||||||
.slice(0, 3) // Show first 3 recipients
|
|
||||||
}, [notification.tags]) |
|
||||||
|
|
||||||
const description = useMemo(() => { |
|
||||||
if (isRecipient) { |
|
||||||
if (recipients.length > 1) { |
|
||||||
return t('sent you a public message (along with {{count}} others)', {
|
|
||||||
count: recipients.length - 1
|
|
||||||
}) |
|
||||||
} |
|
||||||
return t('sent you a public message') |
|
||||||
} |
|
||||||
return t('sent a public message') |
|
||||||
}, [isRecipient, recipients.length, t]) |
|
||||||
|
|
||||||
return ( |
|
||||||
<Notification |
|
||||||
icon={<MessageCircle size={24} className="text-purple-400" />} |
|
||||||
sender={notification.pubkey} |
|
||||||
sentAt={notification.created_at} |
|
||||||
targetEvent={notification} |
|
||||||
description={description} |
|
||||||
showStats |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,60 +0,0 @@ |
|||||||
import Image from '@/components/Image' |
|
||||||
import { useFetchEvent } from '@/hooks' |
|
||||||
import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { Heart } from 'lucide-react' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { useMemo } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import Notification from './Notification' |
|
||||||
|
|
||||||
export function ReactionNotification({ notification }: { notification: Event }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { pubkey } = useNostr() |
|
||||||
const eventId = useMemo(() => { |
|
||||||
const aTag = notification.tags.findLast(tagNameEquals('a')) |
|
||||||
if (aTag) { |
|
||||||
return generateBech32IdFromATag(aTag) |
|
||||||
} |
|
||||||
const eTag = notification.tags.findLast(tagNameEquals('e')) |
|
||||||
return eTag ? generateBech32IdFromETag(eTag) : undefined |
|
||||||
}, [notification, pubkey]) |
|
||||||
const { event } = useFetchEvent(eventId) |
|
||||||
const reaction = useMemo(() => { |
|
||||||
if (!notification.content || notification.content === '+') { |
|
||||||
return <Heart size={24} className="text-red-400" /> |
|
||||||
} |
|
||||||
|
|
||||||
const emojiName = /^:([^:]+):$/.exec(notification.content)?.[1] |
|
||||||
if (emojiName) { |
|
||||||
const emojiTag = notification.tags.find((tag) => tag[0] === 'emoji' && tag[1] === emojiName) |
|
||||||
const emojiUrl = emojiTag?.[2] |
|
||||||
if (emojiUrl) { |
|
||||||
return ( |
|
||||||
<Image |
|
||||||
image={{ url: emojiUrl, pubkey: notification.pubkey }} |
|
||||||
alt={emojiName} |
|
||||||
className="w-6 h-6" |
|
||||||
classNames={{ errorPlaceholder: 'bg-transparent' }} |
|
||||||
errorPlaceholder={<Heart size={24} className="text-red-400" />} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
return notification.content |
|
||||||
}, [notification]) |
|
||||||
|
|
||||||
if (!event || !eventId) { |
|
||||||
return null |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<Notification |
|
||||||
icon={<div className="text-xl min-w-6 text-center">{reaction}</div>} |
|
||||||
sender={notification.pubkey} |
|
||||||
sentAt={notification.created_at} |
|
||||||
targetEvent={event} |
|
||||||
description={t('reacted to your note')} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,32 +0,0 @@ |
|||||||
import client from '@/services/client.service' |
|
||||||
import { Repeat } from 'lucide-react' |
|
||||||
import { Event, validateEvent } from 'nostr-tools' |
|
||||||
import { useMemo } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import Notification from './Notification' |
|
||||||
|
|
||||||
export function RepostNotification({ notification }: { notification: Event }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const event = useMemo(() => { |
|
||||||
try { |
|
||||||
const event = JSON.parse(notification.content) as Event |
|
||||||
const isValid = validateEvent(event) |
|
||||||
if (!isValid) return null |
|
||||||
client.addEventToCache(event) |
|
||||||
return event |
|
||||||
} catch { |
|
||||||
return null |
|
||||||
} |
|
||||||
}, [notification.content]) |
|
||||||
if (!event) return null |
|
||||||
|
|
||||||
return ( |
|
||||||
<Notification |
|
||||||
icon={<Repeat size={24} className="text-green-400" />} |
|
||||||
sender={notification.pubkey} |
|
||||||
sentAt={notification.created_at} |
|
||||||
targetEvent={event} |
|
||||||
description={t('boosted your note')} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,34 +0,0 @@ |
|||||||
import { useFetchEvent } from '@/hooks' |
|
||||||
import { getZapInfoFromEvent } from '@/lib/event-metadata' |
|
||||||
import { formatAmount } from '@/lib/lightning' |
|
||||||
import { Zap } from 'lucide-react' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { useMemo } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import Notification from './Notification' |
|
||||||
|
|
||||||
export function ZapNotification({ notification }: { notification: Event }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { senderPubkey, eventId, amount, comment } = useMemo( |
|
||||||
() => getZapInfoFromEvent(notification) ?? ({} as any), |
|
||||||
[notification] |
|
||||||
) |
|
||||||
const { event } = useFetchEvent(eventId) |
|
||||||
|
|
||||||
if (!senderPubkey || !amount) return null |
|
||||||
|
|
||||||
return ( |
|
||||||
<Notification |
|
||||||
icon={<Zap size={24} className="text-yellow-400 shrink-0" />} |
|
||||||
sender={senderPubkey} |
|
||||||
sentAt={notification.created_at} |
|
||||||
targetEvent={event} |
|
||||||
middle={ |
|
||||||
<div className="font-semibold text-yellow-400 truncate"> |
|
||||||
{formatAmount(amount)} {t('sats')} {comment} |
|
||||||
</div> |
|
||||||
} |
|
||||||
description={event ? t('zapped your note') : t('zapped you')} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,68 +0,0 @@ |
|||||||
import { ExtendedKind } from '@/constants' |
|
||||||
import { notificationFilter } from '@/lib/notification' |
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider' |
|
||||||
import { useMuteList } from '@/providers/MuteListProvider' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider' |
|
||||||
import { Event, kinds } from 'nostr-tools' |
|
||||||
import { useMemo } from 'react' |
|
||||||
import { DiscussionNotification } from './DiscussionNotification' |
|
||||||
import { MentionNotification } from './MentionNotification' |
|
||||||
import { PollResponseNotification } from './PollResponseNotification' |
|
||||||
import { PublicMessageNotification } from './PublicMessageNotification' |
|
||||||
import { ReactionNotification } from './ReactionNotification' |
|
||||||
import { RepostNotification } from './RepostNotification' |
|
||||||
import { ZapNotification } from './ZapNotification' |
|
||||||
|
|
||||||
export function NotificationItem({ notification }: { notification: Event }) { |
|
||||||
const { pubkey } = useNostr() |
|
||||||
const { mutePubkeySet } = useMuteList() |
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy() |
|
||||||
const { hideUntrustedNotifications, isUserTrusted } = useUserTrust() |
|
||||||
const canShow = useMemo(() => { |
|
||||||
const result = notificationFilter(notification, { |
|
||||||
pubkey, |
|
||||||
mutePubkeySet, |
|
||||||
hideContentMentioningMutedUsers, |
|
||||||
hideUntrustedNotifications, |
|
||||||
isUserTrusted |
|
||||||
}) |
|
||||||
|
|
||||||
return result |
|
||||||
}, [ |
|
||||||
notification, |
|
||||||
mutePubkeySet, |
|
||||||
hideContentMentioningMutedUsers, |
|
||||||
hideUntrustedNotifications, |
|
||||||
isUserTrusted |
|
||||||
]) |
|
||||||
if (!canShow) return null |
|
||||||
|
|
||||||
if (notification.kind === 11) { |
|
||||||
return <DiscussionNotification notification={notification} /> |
|
||||||
} |
|
||||||
if (notification.kind === kinds.Reaction) { |
|
||||||
return <ReactionNotification notification={notification} /> |
|
||||||
} |
|
||||||
if (notification.kind === ExtendedKind.PUBLIC_MESSAGE) { |
|
||||||
return <PublicMessageNotification notification={notification} /> |
|
||||||
} |
|
||||||
if ( |
|
||||||
notification.kind === kinds.ShortTextNote || |
|
||||||
notification.kind === ExtendedKind.COMMENT || |
|
||||||
notification.kind === ExtendedKind.VOICE_COMMENT || |
|
||||||
notification.kind === ExtendedKind.POLL |
|
||||||
) { |
|
||||||
return <MentionNotification notification={notification} /> |
|
||||||
} |
|
||||||
if (notification.kind === kinds.Repost) { |
|
||||||
return <RepostNotification notification={notification} /> |
|
||||||
} |
|
||||||
if (notification.kind === kinds.Zap) { |
|
||||||
return <ZapNotification notification={notification} /> |
|
||||||
} |
|
||||||
if (notification.kind === ExtendedKind.POLL_RESPONSE) { |
|
||||||
return <PollResponseNotification notification={notification} /> |
|
||||||
} |
|
||||||
return null |
|
||||||
} |
|
||||||
@ -1,438 +0,0 @@ |
|||||||
import { ExtendedKind, NOTIFICATION_LIST_STYLE, FAST_READ_RELAY_URLS } from '@/constants' |
|
||||||
import { compareEvents } from '@/lib/event' |
|
||||||
import logger from '@/lib/logger' |
|
||||||
import { usePrimaryPage } from '@/PageManager' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider' |
|
||||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
||||||
import client from '@/services/client.service' |
|
||||||
import noteStatsService from '@/services/note-stats.service' |
|
||||||
import { TNotificationType } from '@/types' |
|
||||||
import dayjs from 'dayjs' |
|
||||||
import { NostrEvent, kinds, matchFilter } from 'nostr-tools' |
|
||||||
import { |
|
||||||
forwardRef, |
|
||||||
useCallback, |
|
||||||
useEffect, |
|
||||||
useImperativeHandle, |
|
||||||
useMemo, |
|
||||||
useRef, |
|
||||||
useState |
|
||||||
} from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import PullToRefresh from 'react-simple-pull-to-refresh' |
|
||||||
import { NotificationItem } from './NotificationItem' |
|
||||||
import { NotificationSkeleton } from './NotificationItem/Notification' |
|
||||||
import { isTouchDevice } from '@/lib/utils' |
|
||||||
const LIMIT = 500 // Increased from 100 to load more notifications per request
|
|
||||||
const SHOW_COUNT = 50 // Increased from 30 to show more notifications at once
|
|
||||||
|
|
||||||
const NotificationList = forwardRef( |
|
||||||
( |
|
||||||
{ |
|
||||||
notificationType |
|
||||||
}: { |
|
||||||
notificationType: TNotificationType |
|
||||||
}, |
|
||||||
ref |
|
||||||
) => { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { display } = usePrimaryPage() |
|
||||||
const active = display |
|
||||||
const { pubkey, relayList } = useNostr() |
|
||||||
const { notificationListStyle } = useUserPreferences() |
|
||||||
const { favoriteRelays } = useFavoriteRelays() |
|
||||||
const [refreshCount, setRefreshCount] = useState(0) |
|
||||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined) |
|
||||||
const [loading, setLoading] = useState(true) |
|
||||||
const [notifications, setNotifications] = useState<NostrEvent[]>([]) |
|
||||||
const [visibleNotifications, setVisibleNotifications] = useState<NostrEvent[]>([]) |
|
||||||
const [showCount, setShowCount] = useState(SHOW_COUNT) |
|
||||||
const [until, setUntil] = useState<number | undefined>(dayjs().unix()) |
|
||||||
const supportTouch = useMemo(() => isTouchDevice(), []) |
|
||||||
const topRef = useRef<HTMLDivElement | null>(null) |
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null) |
|
||||||
const consecutiveEmptyRef = useRef(0) // Track consecutive empty results to prevent premature stopping
|
|
||||||
const filterKinds = useMemo(() => { |
|
||||||
switch (notificationType) { |
|
||||||
case 'mentions': |
|
||||||
return [ |
|
||||||
kinds.ShortTextNote, |
|
||||||
ExtendedKind.COMMENT, |
|
||||||
ExtendedKind.VOICE_COMMENT, |
|
||||||
ExtendedKind.POLL, |
|
||||||
ExtendedKind.PUBLIC_MESSAGE, |
|
||||||
11 // Discussion threads
|
|
||||||
] |
|
||||||
case 'reactions': |
|
||||||
return [kinds.Reaction, kinds.Repost, ExtendedKind.POLL_RESPONSE] |
|
||||||
case 'zaps': |
|
||||||
return [kinds.Zap] |
|
||||||
default: |
|
||||||
return [ |
|
||||||
kinds.ShortTextNote, |
|
||||||
kinds.Repost, |
|
||||||
kinds.Reaction, |
|
||||||
kinds.Zap, |
|
||||||
ExtendedKind.COMMENT, |
|
||||||
ExtendedKind.POLL_RESPONSE, |
|
||||||
ExtendedKind.VOICE_COMMENT, |
|
||||||
ExtendedKind.POLL, |
|
||||||
ExtendedKind.PUBLIC_MESSAGE, |
|
||||||
11 // Discussion threads
|
|
||||||
] |
|
||||||
} |
|
||||||
}, [notificationType]) |
|
||||||
useImperativeHandle( |
|
||||||
ref, |
|
||||||
() => ({ |
|
||||||
refresh: () => { |
|
||||||
if (loading) return |
|
||||||
setRefreshCount((count) => count + 1) |
|
||||||
} |
|
||||||
}), |
|
||||||
[loading] |
|
||||||
) |
|
||||||
|
|
||||||
// Reset visible count when tab changes (parent owns tab state)
|
|
||||||
useEffect(() => { |
|
||||||
setShowCount(SHOW_COUNT) |
|
||||||
}, [notificationType]) |
|
||||||
|
|
||||||
// Batch stats updates to avoid calling updateNoteStatsByEvents for every single event
|
|
||||||
const pendingStatsEventsRef = useRef<NostrEvent[]>([]) |
|
||||||
const statsBatchTimeoutRef = useRef<NodeJS.Timeout | null>(null) |
|
||||||
|
|
||||||
const flushStatsBatch = useCallback(() => { |
|
||||||
if (pendingStatsEventsRef.current.length > 0) { |
|
||||||
noteStatsService.updateNoteStatsByEvents(pendingStatsEventsRef.current) |
|
||||||
pendingStatsEventsRef.current = [] |
|
||||||
} |
|
||||||
if (statsBatchTimeoutRef.current) { |
|
||||||
clearTimeout(statsBatchTimeoutRef.current) |
|
||||||
statsBatchTimeoutRef.current = null |
|
||||||
} |
|
||||||
}, []) |
|
||||||
|
|
||||||
const handleNewEvent = useCallback( |
|
||||||
(event: NostrEvent) => { |
|
||||||
if (event.pubkey === pubkey) return |
|
||||||
setNotifications((oldEvents) => { |
|
||||||
// Check if event already exists
|
|
||||||
const existingIndex = oldEvents.findIndex((oldEvent) => oldEvent.id === event.id) |
|
||||||
if (existingIndex !== -1) { |
|
||||||
return oldEvents // Already exists, don't update
|
|
||||||
} |
|
||||||
|
|
||||||
const index = oldEvents.findIndex((oldEvent) => compareEvents(oldEvent, event) <= 0) |
|
||||||
|
|
||||||
// Batch stats updates instead of calling for each event
|
|
||||||
pendingStatsEventsRef.current.push(event) |
|
||||||
if (!statsBatchTimeoutRef.current) { |
|
||||||
statsBatchTimeoutRef.current = setTimeout(flushStatsBatch, 500) // Batch every 500ms
|
|
||||||
} |
|
||||||
|
|
||||||
if (index === -1) { |
|
||||||
return [...oldEvents, event] |
|
||||||
} |
|
||||||
return [...oldEvents.slice(0, index), event, ...oldEvents.slice(index)] |
|
||||||
}) |
|
||||||
}, |
|
||||||
[pubkey, flushStatsBatch] |
|
||||||
) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (!pubkey) { |
|
||||||
setUntil(undefined) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
const init = async () => { |
|
||||||
setLoading(true) |
|
||||||
setNotifications([]) |
|
||||||
setShowCount(SHOW_COUNT) |
|
||||||
// Use proper fallback hierarchy: user's read/inbox relays → favorite relays → fast read relays
|
|
||||||
const userRelayList = relayList || { read: [], write: [] } |
|
||||||
const userReadRelays = userRelayList.read || [] |
|
||||||
const userFavoriteRelays = favoriteRelays || [] |
|
||||||
|
|
||||||
// Build relay list with proper fallback hierarchy
|
|
||||||
let primaryRelays: string[] = [] |
|
||||||
|
|
||||||
if (userReadRelays.length > 0) { |
|
||||||
// Priority 1: User's read/inbox relays (kind 10002)
|
|
||||||
primaryRelays = userReadRelays.slice(0, 5) |
|
||||||
logger.component('NotificationList', 'Using user read relays', {
|
|
||||||
count: primaryRelays.length,
|
|
||||||
relays: primaryRelays.slice(0, 3) // Show first 3 for brevity
|
|
||||||
}) |
|
||||||
} else if (userFavoriteRelays.length > 0) { |
|
||||||
// Priority 2: User's favorite relays (kind 10012)
|
|
||||||
primaryRelays = userFavoriteRelays.slice(0, 5) |
|
||||||
logger.component('NotificationList', 'Using user favorite relays', {
|
|
||||||
count: primaryRelays.length,
|
|
||||||
relays: primaryRelays.slice(0, 3) // Show first 3 for brevity
|
|
||||||
}) |
|
||||||
} else { |
|
||||||
// Priority 3: Fast read relays (reliable defaults)
|
|
||||||
primaryRelays = FAST_READ_RELAY_URLS.slice(0, 5) |
|
||||||
logger.component('NotificationList', 'Using fast read relays fallback', {
|
|
||||||
count: primaryRelays.length,
|
|
||||||
relays: primaryRelays.slice(0, 3) // Show first 3 for brevity
|
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
// Create a single optimized subscription for all notification types
|
|
||||||
const subscriptions = [{ |
|
||||||
urls: primaryRelays, |
|
||||||
filter: { |
|
||||||
kinds: filterKinds, |
|
||||||
limit: LIMIT, |
|
||||||
'#p': [pubkey] // Always filter for mentions to the current user
|
|
||||||
} |
|
||||||
}] |
|
||||||
|
|
||||||
const { closer, timelineKey } = await client.subscribeTimeline( |
|
||||||
subscriptions, |
|
||||||
{ |
|
||||||
onEvents: (events, eosed) => { |
|
||||||
if (events.length > 0) { |
|
||||||
setNotifications(events.filter((event) => event.pubkey !== pubkey)) |
|
||||||
} |
|
||||||
if (eosed) { |
|
||||||
setLoading(false) |
|
||||||
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined) |
|
||||||
// Batch stats update for initial load - only process events that don't have stats yet
|
|
||||||
// This avoids redundant processing since updateNoteStatsByEvents is idempotent but still expensive
|
|
||||||
if (events.length > 0) { |
|
||||||
noteStatsService.updateNoteStatsByEvents(events) |
|
||||||
} |
|
||||||
} |
|
||||||
}, |
|
||||||
onNew: (event) => { |
|
||||||
handleNewEvent(event) |
|
||||||
} |
|
||||||
}, |
|
||||||
{ |
|
||||||
useCache: false // Notifications should always fetch fresh from relays, not use cache
|
|
||||||
} |
|
||||||
) |
|
||||||
setTimelineKey(timelineKey) |
|
||||||
return closer |
|
||||||
} |
|
||||||
|
|
||||||
const promise = init() |
|
||||||
return () => { |
|
||||||
promise.then((closer) => closer?.()) |
|
||||||
// Clean up stats batch timeout on unmount
|
|
||||||
if (statsBatchTimeoutRef.current) { |
|
||||||
clearTimeout(statsBatchTimeoutRef.current) |
|
||||||
statsBatchTimeoutRef.current = null |
|
||||||
} |
|
||||||
flushStatsBatch() // Flush any pending stats updates
|
|
||||||
consecutiveEmptyRef.current = 0 // Reset counter on refresh
|
|
||||||
} |
|
||||||
}, [pubkey, refreshCount, filterKinds, relayList, favoriteRelays, flushStatsBatch]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (!active || !pubkey) return |
|
||||||
|
|
||||||
const handler = (data: Event) => { |
|
||||||
const customEvent = data as CustomEvent<NostrEvent> |
|
||||||
const evt = customEvent.detail |
|
||||||
if ( |
|
||||||
matchFilter( |
|
||||||
{ |
|
||||||
kinds: filterKinds, |
|
||||||
'#p': [pubkey] |
|
||||||
}, |
|
||||||
evt |
|
||||||
) |
|
||||||
) { |
|
||||||
handleNewEvent(evt) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
client.addEventListener('newEvent', handler) |
|
||||||
return () => { |
|
||||||
client.removeEventListener('newEvent', handler) |
|
||||||
} |
|
||||||
}, [pubkey, active, filterKinds, handleNewEvent]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
setVisibleNotifications(notifications.slice(0, showCount)) |
|
||||||
}, [notifications, showCount]) |
|
||||||
|
|
||||||
// Use refs to avoid infinite loops from dependency changes
|
|
||||||
const notificationsRef = useRef(notifications) |
|
||||||
const showCountRef = useRef(showCount) |
|
||||||
const loadingRef = useRef(loading) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
notificationsRef.current = notifications |
|
||||||
}, [notifications]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
showCountRef.current = showCount |
|
||||||
}, [showCount]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
loadingRef.current = loading |
|
||||||
}, [loading]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const options = { |
|
||||||
root: null, |
|
||||||
rootMargin: '10px', |
|
||||||
threshold: 1 |
|
||||||
} |
|
||||||
|
|
||||||
const loadMore = async () => { |
|
||||||
// Use refs to avoid dependency on notifications/showCount/loading
|
|
||||||
const currentNotifications = notificationsRef.current |
|
||||||
const currentShowCount = showCountRef.current |
|
||||||
const currentLoading = loadingRef.current |
|
||||||
|
|
||||||
if (currentShowCount < currentNotifications.length) { |
|
||||||
// Show more aggressively: increase by SHOW_COUNT, but also check if we should show even more
|
|
||||||
const remaining = currentNotifications.length - currentShowCount |
|
||||||
const increment = Math.min(SHOW_COUNT * 2, remaining) // Show up to 2x SHOW_COUNT if available
|
|
||||||
setShowCount((count) => count + increment) |
|
||||||
// Only preload more if we have plenty cached (more than 3/4 of LIMIT)
|
|
||||||
// BUT: Always try to load more if we have very few notifications (might be due to filtering)
|
|
||||||
if (currentNotifications.length - currentShowCount > LIMIT * 0.75 && currentNotifications.length >= 50) { |
|
||||||
return |
|
||||||
} |
|
||||||
// If we have very few notifications, always try to load more (might be aggressive filtering)
|
|
||||||
if (currentNotifications.length < 50) { |
|
||||||
// Continue to loadMore below even if we have cached notifications
|
|
||||||
// This ensures we keep loading when filtering is aggressive
|
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (!pubkey || !timelineKey || !until || currentLoading) return |
|
||||||
setLoading(true) |
|
||||||
try { |
|
||||||
const newNotifications = await client.loadMoreTimeline(timelineKey, until, LIMIT) |
|
||||||
// CRITICAL FIX: Don't stop immediately on empty results - might be temporary relay issues
|
|
||||||
// Only stop if we've tried many times with no results
|
|
||||||
if (newNotifications.length === 0) { |
|
||||||
// Check if timeline has more cached refs that we haven't loaded yet
|
|
||||||
const hasMoreCached = client.hasMoreTimelineEvents?.(timelineKey, until) ?? false |
|
||||||
if (hasMoreCached) { |
|
||||||
// There are more cached notifications, keep trying
|
|
||||||
consecutiveEmptyRef.current = 0 // Reset counter when we have cached events
|
|
||||||
setLoading(false) |
|
||||||
// Retry after a short delay to allow IndexedDB to catch up
|
|
||||||
setTimeout(() => { |
|
||||||
if (until) { |
|
||||||
loadMore() |
|
||||||
} |
|
||||||
}, 300) |
|
||||||
return |
|
||||||
} |
|
||||||
// No cached notifications and network returned empty
|
|
||||||
// Be patient - don't stop too early, especially when we have few notifications
|
|
||||||
consecutiveEmptyRef.current += 1 |
|
||||||
// Only stop after MANY consecutive empty results (similar to NoteList)
|
|
||||||
if (consecutiveEmptyRef.current >= 20) { |
|
||||||
// After 20 consecutive empty results, assume we've reached the end
|
|
||||||
setUntil(undefined) |
|
||||||
setLoading(false) |
|
||||||
return |
|
||||||
} |
|
||||||
// Otherwise, keep trying on next scroll
|
|
||||||
setLoading(false) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
// Reset consecutive empty counter on success
|
|
||||||
consecutiveEmptyRef.current = 0 |
|
||||||
|
|
||||||
if (newNotifications.length > 0) { |
|
||||||
setNotifications((oldNotifications) => [ |
|
||||||
...oldNotifications, |
|
||||||
...newNotifications.filter((event) => event.pubkey !== pubkey) |
|
||||||
]) |
|
||||||
} |
|
||||||
|
|
||||||
setUntil(newNotifications[newNotifications.length - 1].created_at - 1) |
|
||||||
} catch (error) { |
|
||||||
// On error, don't stop immediately - might be temporary network issue
|
|
||||||
logger.error('[NotificationList] Error loading more notifications', { error }) |
|
||||||
consecutiveEmptyRef.current += 1 |
|
||||||
// Only stop after MANY consecutive errors - be very patient with network issues
|
|
||||||
if (consecutiveEmptyRef.current >= 25) { |
|
||||||
setUntil(undefined) |
|
||||||
} |
|
||||||
} finally { |
|
||||||
setLoading(false) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const observerInstance = new IntersectionObserver((entries) => { |
|
||||||
if (entries[0].isIntersecting) { |
|
||||||
loadMore() |
|
||||||
} |
|
||||||
}, options) |
|
||||||
|
|
||||||
const currentBottomRef = bottomRef.current |
|
||||||
|
|
||||||
if (currentBottomRef) { |
|
||||||
observerInstance.observe(currentBottomRef) |
|
||||||
} |
|
||||||
|
|
||||||
return () => { |
|
||||||
if (observerInstance && currentBottomRef) { |
|
||||||
observerInstance.unobserve(currentBottomRef) |
|
||||||
} |
|
||||||
} |
|
||||||
}, [pubkey, timelineKey, until]) // Removed notifications, showCount, loading to prevent infinite loops
|
|
||||||
|
|
||||||
const refresh = () => { |
|
||||||
topRef.current?.scrollIntoView({ behavior: 'instant', block: 'start' }) |
|
||||||
consecutiveEmptyRef.current = 0 // Reset counter on refresh
|
|
||||||
setTimeout(() => { |
|
||||||
setRefreshCount((count) => count + 1) |
|
||||||
}, 500) |
|
||||||
} |
|
||||||
|
|
||||||
const list = ( |
|
||||||
<div className={notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT ? 'pt-2' : ''}> |
|
||||||
{visibleNotifications.map((notification) => ( |
|
||||||
<NotificationItem key={notification.id} notification={notification} /> |
|
||||||
))} |
|
||||||
<div className="text-center text-sm text-muted-foreground"> |
|
||||||
{until || loading ? ( |
|
||||||
<div ref={bottomRef}> |
|
||||||
<NotificationSkeleton /> |
|
||||||
</div> |
|
||||||
) : ( |
|
||||||
t('no more notifications') |
|
||||||
)} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
|
|
||||||
return ( |
|
||||||
<div> |
|
||||||
<div ref={topRef} /> |
|
||||||
{supportTouch ? ( |
|
||||||
<PullToRefresh |
|
||||||
onRefresh={async () => { |
|
||||||
refresh() |
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000)) |
|
||||||
}} |
|
||||||
pullingContent="" |
|
||||||
> |
|
||||||
{list} |
|
||||||
</PullToRefresh> |
|
||||||
) : ( |
|
||||||
list |
|
||||||
)} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
) |
|
||||||
NotificationList.displayName = 'NotificationList' |
|
||||||
export default NotificationList |
|
||||||
@ -1,37 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
|
||||||
import storage from '@/services/local-storage.service' |
|
||||||
import { PanelLeft, PanelsLeftRight } from 'lucide-react' |
|
||||||
import { useState } from 'react' |
|
||||||
|
|
||||||
export default function PaneModeToggle() { |
|
||||||
const { isSmallScreen } = useScreenSize() |
|
||||||
const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode()) |
|
||||||
|
|
||||||
// Hide on mobile
|
|
||||||
if (isSmallScreen) return null |
|
||||||
|
|
||||||
const toggleMode = () => { |
|
||||||
const newMode = panelMode === 'single' ? 'double' : 'single' |
|
||||||
setPanelMode(newMode) |
|
||||||
storage.setPanelMode(newMode) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<Button |
|
||||||
variant="ghost" |
|
||||||
className="flex shadow-none items-center transition-colors duration-500 bg-transparent w-12 h-12 xl:w-full xl:h-auto p-3 m-0 xl:py-2 xl:px-3 rounded-lg xl:justify-start gap-4 text-lg font-semibold [&_svg]:size-full xl:[&_svg]:size-4" |
|
||||||
title={panelMode === 'single' ? 'Switch to double-pane mode' : 'Switch to single-pane mode'} |
|
||||||
onClick={toggleMode} |
|
||||||
> |
|
||||||
{panelMode === 'single' ? ( |
|
||||||
<PanelLeft strokeWidth={3} /> |
|
||||||
) : ( |
|
||||||
<PanelsLeftRight strokeWidth={3} /> |
|
||||||
)} |
|
||||||
<div className="max-xl:hidden"> |
|
||||||
{panelMode === 'single' ? 'Single-pane' : 'Double-pane'} |
|
||||||
</div> |
|
||||||
</Button> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,48 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { useFetchProfile } from '@/hooks' |
|
||||||
import { toProfile } from '@/lib/link' |
|
||||||
import { useSmartProfileNavigation } from '@/PageManager' |
|
||||||
import { UserRound } from 'lucide-react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import FollowButton from '../FollowButton' |
|
||||||
import Nip05 from '../Nip05' |
|
||||||
import ProfileAbout from '../ProfileAbout' |
|
||||||
import { SimpleUserAvatar } from '../UserAvatar' |
|
||||||
|
|
||||||
export default function ProfileCard({ pubkey }: { pubkey: string }) { |
|
||||||
const { profile } = useFetchProfile(pubkey) |
|
||||||
const { username, about } = profile || {} |
|
||||||
const { navigateToProfile } = useSmartProfileNavigation() |
|
||||||
const { t } = useTranslation() |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="w-full flex flex-col gap-2 not-prose"> |
|
||||||
<div className="flex space-x-2 w-full items-start justify-between"> |
|
||||||
<SimpleUserAvatar userId={pubkey} className="w-12 h-12" /> |
|
||||||
<FollowButton pubkey={pubkey} /> |
|
||||||
</div> |
|
||||||
<div> |
|
||||||
<div className="text-lg font-semibold truncate">{username}</div> |
|
||||||
<Nip05 pubkey={pubkey} /> |
|
||||||
</div> |
|
||||||
{about && ( |
|
||||||
<ProfileAbout |
|
||||||
about={about} |
|
||||||
className="text-sm text-wrap break-words w-full overflow-hidden text-ellipsis line-clamp-6" |
|
||||||
/> |
|
||||||
)} |
|
||||||
<Button |
|
||||||
variant="outline" |
|
||||||
size="sm" |
|
||||||
className="w-full mt-2" |
|
||||||
onClick={(e) => { |
|
||||||
e.stopPropagation() |
|
||||||
navigateToProfile(toProfile(pubkey)) |
|
||||||
}} |
|
||||||
> |
|
||||||
<UserRound className="w-4 h-4 mr-2" /> |
|
||||||
{t('View full profile')} |
|
||||||
</Button> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,205 +0,0 @@ |
|||||||
import { forwardRef, useEffect, useState, useCallback } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import { RefreshCw } from 'lucide-react' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { normalizeUrl } from '@/lib/url' |
|
||||||
import { FAST_READ_RELAY_URLS, FIRST_RELAY_RESULT_GRACE_MS } from '@/constants' |
|
||||||
import client from '@/services/client.service' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { kinds } from 'nostr-tools' |
|
||||||
import logger from '@/lib/logger' |
|
||||||
import NoteCard from '@/components/NoteCard' |
|
||||||
|
|
||||||
type TSimpleNoteFeedProps = { |
|
||||||
authors?: string[] |
|
||||||
kinds?: number[] |
|
||||||
limit?: number |
|
||||||
hideReplies?: boolean |
|
||||||
filterMutedNotes?: boolean |
|
||||||
customHeader?: React.ReactNode |
|
||||||
} |
|
||||||
|
|
||||||
const SimpleNoteFeed = forwardRef< |
|
||||||
{ refresh: () => void }, |
|
||||||
TSimpleNoteFeedProps |
|
||||||
>(({ |
|
||||||
authors = [], |
|
||||||
kinds: requestedKinds = [kinds.ShortTextNote, kinds.Repost, kinds.Highlights, kinds.LongFormArticle], |
|
||||||
limit = 100, |
|
||||||
hideReplies = false, |
|
||||||
filterMutedNotes = false, |
|
||||||
customHeader |
|
||||||
}, ref) => { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { pubkey } = useNostr() |
|
||||||
const [events, setEvents] = useState<Event[]>([]) |
|
||||||
const [loading, setLoading] = useState(true) |
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false) |
|
||||||
|
|
||||||
logger.component('SimpleNoteFeed', 'Component rendered', { authors, requestedKinds, limit, hideReplies, pubkey: !!pubkey }) |
|
||||||
|
|
||||||
// Build comprehensive relay list (same as Discussions)
|
|
||||||
const buildComprehensiveRelayList = useCallback(async () => { |
|
||||||
const myRelayList = pubkey ? await client.fetchRelayList(pubkey) : { write: [], read: [] } |
|
||||||
const allRelays = [ |
|
||||||
...(myRelayList.read || []), // User's inboxes (kind 10002)
|
|
||||||
...(myRelayList.write || []), // User's outboxes (kind 10002)
|
|
||||||
...FAST_READ_RELAY_URLS, // Fast read relays
|
|
||||||
] |
|
||||||
|
|
||||||
// Normalize and deduplicate relay URLs
|
|
||||||
const normalizedRelays = allRelays |
|
||||||
.map(url => normalizeUrl(url)) |
|
||||||
.filter((url): url is string => !!url) |
|
||||||
|
|
||||||
logger.debug('[SimpleNoteFeed] Using', normalizedRelays.length, 'comprehensive relays') |
|
||||||
return Array.from(new Set(normalizedRelays)) |
|
||||||
}, [pubkey]) |
|
||||||
|
|
||||||
// Fetch events using the same pattern as Discussions
|
|
||||||
const fetchEvents = useCallback(async () => { |
|
||||||
if (isRefreshing) { |
|
||||||
logger.component('SimpleNoteFeed', 'Already refreshing, skipping') |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
logger.component('SimpleNoteFeed', 'Starting fetch', { authors, kinds: requestedKinds, limit }) |
|
||||||
setLoading(true) |
|
||||||
setIsRefreshing(true) |
|
||||||
|
|
||||||
try { |
|
||||||
// Get comprehensive relay list
|
|
||||||
const allRelays = await buildComprehensiveRelayList() |
|
||||||
logger.component('SimpleNoteFeed', 'Using relays', { count: allRelays.length }) |
|
||||||
|
|
||||||
// Build filter
|
|
||||||
const filter: any = { |
|
||||||
kinds: requestedKinds, |
|
||||||
limit |
|
||||||
} |
|
||||||
|
|
||||||
if (authors.length > 0) { |
|
||||||
filter.authors = authors |
|
||||||
} |
|
||||||
|
|
||||||
logger.component('SimpleNoteFeed', 'Using filter', filter) |
|
||||||
|
|
||||||
// Fetch events
|
|
||||||
logger.component('SimpleNoteFeed', 'Calling client.fetchEvents') |
|
||||||
const { queryService } = await import('@/services/client.service') |
|
||||||
const fetchedEvents = await queryService.fetchEvents(allRelays, [filter], { |
|
||||||
firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS |
|
||||||
}) |
|
||||||
|
|
||||||
logger.component('SimpleNoteFeed', 'Fetched events', { count: fetchedEvents.length }) |
|
||||||
|
|
||||||
// Deduplicate events by ID (same event might come from different relays)
|
|
||||||
const seenIds = new Set<string>() |
|
||||||
const uniqueEvents = fetchedEvents.filter(event => { |
|
||||||
if (seenIds.has(event.id)) { |
|
||||||
return false |
|
||||||
} |
|
||||||
seenIds.add(event.id) |
|
||||||
return true |
|
||||||
}) |
|
||||||
|
|
||||||
logger.component('SimpleNoteFeed', 'Deduplicated events', { count: uniqueEvents.length }) |
|
||||||
|
|
||||||
// Filter events (basic filtering)
|
|
||||||
const filteredEvents = uniqueEvents.filter(event => { |
|
||||||
// Skip deleted events
|
|
||||||
if (event.content === '') return false |
|
||||||
|
|
||||||
// Skip replies if hideReplies is true
|
|
||||||
if (hideReplies && event.tags.some(tag => tag[0] === 'e' && tag[1])) { |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
return true |
|
||||||
}) |
|
||||||
|
|
||||||
logger.component('SimpleNoteFeed', 'Filtered events', { count: filteredEvents.length }) |
|
||||||
|
|
||||||
setEvents(filteredEvents) |
|
||||||
logger.component('SimpleNoteFeed', 'Set events successfully', { count: filteredEvents.length }) |
|
||||||
} catch (error) { |
|
||||||
logger.component('SimpleNoteFeed', 'Error fetching events', { error: (error as Error).message }) |
|
||||||
// Don't clear events on error, keep what we have
|
|
||||||
} finally { |
|
||||||
logger.component('SimpleNoteFeed', 'Setting loading states to false') |
|
||||||
setLoading(false) |
|
||||||
setIsRefreshing(false) |
|
||||||
} |
|
||||||
}, [authors, requestedKinds, limit, hideReplies, isRefreshing]) |
|
||||||
|
|
||||||
// Initial fetch
|
|
||||||
useEffect(() => { |
|
||||||
logger.component('SimpleNoteFeed', 'useEffect triggered for initial fetch', { authors, requestedKinds, limit, hideReplies }) |
|
||||||
fetchEvents() |
|
||||||
}, [authors, requestedKinds, limit, hideReplies]) |
|
||||||
|
|
||||||
// Expose refresh method
|
|
||||||
useEffect(() => { |
|
||||||
if (ref && typeof ref === 'object') { |
|
||||||
ref.current = { |
|
||||||
refresh: fetchEvents |
|
||||||
} |
|
||||||
} |
|
||||||
}, [ref, fetchEvents]) |
|
||||||
|
|
||||||
const handleRefresh = () => { |
|
||||||
logger.component('SimpleNoteFeed', 'handleRefresh called') |
|
||||||
fetchEvents() |
|
||||||
} |
|
||||||
|
|
||||||
if (loading && events.length === 0) { |
|
||||||
return ( |
|
||||||
<div className="min-h-screen"> |
|
||||||
{customHeader} |
|
||||||
<div className="flex items-center justify-center p-8"> |
|
||||||
<div className="text-center"> |
|
||||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4" /> |
|
||||||
<p className="text-muted-foreground">{t('loading...')}</p> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="min-h-screen"> |
|
||||||
{customHeader} |
|
||||||
|
|
||||||
|
|
||||||
{/* Events list */} |
|
||||||
{events.length > 0 ? ( |
|
||||||
<div className="space-y-4"> |
|
||||||
{events.map((event) => ( |
|
||||||
<NoteCard |
|
||||||
key={event.id} |
|
||||||
className="w-full" |
|
||||||
event={event} |
|
||||||
filterMutedNotes={filterMutedNotes} |
|
||||||
/> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
) : ( |
|
||||||
<div className="flex justify-center w-full mt-8"> |
|
||||||
<div className="text-center"> |
|
||||||
<p className="text-muted-foreground mb-4">{t('no notes found')}</p> |
|
||||||
<button |
|
||||||
onClick={handleRefresh} |
|
||||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90" |
|
||||||
> |
|
||||||
{t('reload notes')} |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
) |
|
||||||
}) |
|
||||||
|
|
||||||
SimpleNoteFeed.displayName = 'SimpleNoteFeed' |
|
||||||
|
|
||||||
export default SimpleNoteFeed |
|
||||||
@ -1,162 +0,0 @@ |
|||||||
import { type DialogProps } from '@radix-ui/react-dialog' |
|
||||||
import { Command as CommandPrimitive } from 'cmdk' |
|
||||||
import { Search } from 'lucide-react' |
|
||||||
import * as React from 'react' |
|
||||||
|
|
||||||
import { |
|
||||||
Dialog, |
|
||||||
DialogContent, |
|
||||||
DialogDescription, |
|
||||||
DialogHeader, |
|
||||||
DialogTitle |
|
||||||
} from '@/components/ui/dialog' |
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area' |
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
|
|
||||||
const Command = React.forwardRef< |
|
||||||
React.ElementRef<typeof CommandPrimitive>, |
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive> |
|
||||||
>(({ className, ...props }, ref) => ( |
|
||||||
<CommandPrimitive |
|
||||||
ref={ref} |
|
||||||
className={cn( |
|
||||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', |
|
||||||
className |
|
||||||
)} |
|
||||||
{...props} |
|
||||||
/> |
|
||||||
)) |
|
||||||
Command.displayName = CommandPrimitive.displayName |
|
||||||
|
|
||||||
const CommandDialog = ({ |
|
||||||
children, |
|
||||||
classNames, |
|
||||||
...props |
|
||||||
}: DialogProps & { classNames?: { content?: string } }) => { |
|
||||||
return ( |
|
||||||
<Dialog {...props}> |
|
||||||
<DialogContent |
|
||||||
className={cn( |
|
||||||
'overflow-hidden p-0 shadow-lg top-4 translate-y-0 data-[state=closed]:slide-out-to-top-4 data-[state=open]:slide-in-from-top-4', |
|
||||||
classNames?.content |
|
||||||
)} |
|
||||||
> |
|
||||||
<DialogHeader className="sr-only"> |
|
||||||
<DialogTitle>Command Menu</DialogTitle> |
|
||||||
<DialogDescription>Search and select a command</DialogDescription> |
|
||||||
</DialogHeader> |
|
||||||
<Command |
|
||||||
shouldFilter={false} |
|
||||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5" |
|
||||||
> |
|
||||||
{children} |
|
||||||
</Command> |
|
||||||
</DialogContent> |
|
||||||
</Dialog> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
const CommandInput = React.forwardRef< |
|
||||||
React.ElementRef<typeof CommandPrimitive.Input>, |
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> |
|
||||||
>(({ className, ...props }, ref) => ( |
|
||||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper=""> |
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> |
|
||||||
<CommandPrimitive.Input |
|
||||||
ref={ref} |
|
||||||
className={cn( |
|
||||||
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 pr-6', |
|
||||||
className |
|
||||||
)} |
|
||||||
{...props} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
)) |
|
||||||
|
|
||||||
CommandInput.displayName = CommandPrimitive.Input.displayName |
|
||||||
|
|
||||||
const CommandList = React.forwardRef< |
|
||||||
React.ElementRef<typeof CommandPrimitive.List>, |
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> & { scrollAreaClassName?: string } |
|
||||||
>(({ className, scrollAreaClassName, ...props }, ref) => ( |
|
||||||
<ScrollArea className={scrollAreaClassName}> |
|
||||||
<CommandPrimitive.List ref={ref} className={cn('overflow-x-hidden', className)} {...props} /> |
|
||||||
</ScrollArea> |
|
||||||
)) |
|
||||||
|
|
||||||
CommandList.displayName = CommandPrimitive.List.displayName |
|
||||||
|
|
||||||
const CommandEmpty = React.forwardRef< |
|
||||||
React.ElementRef<typeof CommandPrimitive.Empty>, |
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> |
|
||||||
>((props, ref) => ( |
|
||||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} /> |
|
||||||
)) |
|
||||||
|
|
||||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName |
|
||||||
|
|
||||||
const CommandGroup = React.forwardRef< |
|
||||||
React.ElementRef<typeof CommandPrimitive.Group>, |
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> |
|
||||||
>(({ className, ...props }, ref) => ( |
|
||||||
<CommandPrimitive.Group |
|
||||||
ref={ref} |
|
||||||
className={cn( |
|
||||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground', |
|
||||||
className |
|
||||||
)} |
|
||||||
{...props} |
|
||||||
/> |
|
||||||
)) |
|
||||||
|
|
||||||
CommandGroup.displayName = CommandPrimitive.Group.displayName |
|
||||||
|
|
||||||
const CommandSeparator = React.forwardRef< |
|
||||||
React.ElementRef<typeof CommandPrimitive.Separator>, |
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> |
|
||||||
>(({ className, ...props }, ref) => ( |
|
||||||
<CommandPrimitive.Separator |
|
||||||
ref={ref} |
|
||||||
className={cn('-mx-1 h-px bg-border', className)} |
|
||||||
{...props} |
|
||||||
/> |
|
||||||
)) |
|
||||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName |
|
||||||
|
|
||||||
const CommandItem = React.forwardRef< |
|
||||||
React.ElementRef<typeof CommandPrimitive.Item>, |
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> |
|
||||||
>(({ className, ...props }, ref) => ( |
|
||||||
<CommandPrimitive.Item |
|
||||||
ref={ref} |
|
||||||
className={cn( |
|
||||||
'relative flex cursor-pointer gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', |
|
||||||
className |
|
||||||
)} |
|
||||||
{...props} |
|
||||||
/> |
|
||||||
)) |
|
||||||
|
|
||||||
CommandItem.displayName = CommandPrimitive.Item.displayName |
|
||||||
|
|
||||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => { |
|
||||||
return ( |
|
||||||
<span |
|
||||||
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} |
|
||||||
{...props} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
CommandShortcut.displayName = 'CommandShortcut' |
|
||||||
|
|
||||||
export { |
|
||||||
Command, |
|
||||||
CommandDialog, |
|
||||||
CommandEmpty, |
|
||||||
CommandGroup, |
|
||||||
CommandInput, |
|
||||||
CommandItem, |
|
||||||
CommandList, |
|
||||||
CommandSeparator, |
|
||||||
CommandShortcut |
|
||||||
} |
|
||||||
@ -1,154 +0,0 @@ |
|||||||
/** |
|
||||||
* React hook for content parsing |
|
||||||
*/ |
|
||||||
|
|
||||||
import { useState, useEffect } from 'react' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { contentParserService, ParsedContent, ParseOptions } from '@/services/content-parser.service' |
|
||||||
|
|
||||||
export interface UseContentParserOptions extends ParseOptions { |
|
||||||
autoParse?: boolean |
|
||||||
} |
|
||||||
|
|
||||||
export interface UseContentParserReturn { |
|
||||||
parsedContent: ParsedContent | null |
|
||||||
isLoading: boolean |
|
||||||
error: Error | null |
|
||||||
parse: () => Promise<void> |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Hook for parsing content with automatic detection and processing |
|
||||||
*/ |
|
||||||
export function useContentParser( |
|
||||||
content: string, |
|
||||||
options: UseContentParserOptions = {} |
|
||||||
): UseContentParserReturn { |
|
||||||
const { autoParse = true, ...parseOptions } = options |
|
||||||
const [parsedContent, setParsedContent] = useState<ParsedContent | null>(null) |
|
||||||
const [isLoading, setIsLoading] = useState(false) |
|
||||||
const [error, setError] = useState<Error | null>(null) |
|
||||||
|
|
||||||
const parse = async () => { |
|
||||||
if (!content.trim()) { |
|
||||||
setParsedContent(null) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
setIsLoading(true) |
|
||||||
setError(null) |
|
||||||
const result = await contentParserService.parseContent(content, parseOptions) |
|
||||||
setParsedContent(result) |
|
||||||
} catch (err) { |
|
||||||
setError(err instanceof Error ? err : new Error('Unknown parsing error')) |
|
||||||
setParsedContent(null) |
|
||||||
} finally { |
|
||||||
setIsLoading(false) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (autoParse) { |
|
||||||
parse() |
|
||||||
} |
|
||||||
}, [content, autoParse, JSON.stringify(parseOptions)]) |
|
||||||
|
|
||||||
return { |
|
||||||
parsedContent, |
|
||||||
isLoading, |
|
||||||
error, |
|
||||||
parse |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Hook for parsing Nostr event fields |
|
||||||
*/ |
|
||||||
export function useEventFieldParser( |
|
||||||
event: Event, |
|
||||||
field: 'content' | 'title' | 'summary' | 'description', |
|
||||||
options: Omit<UseContentParserOptions, 'eventKind' | 'field'> = {} |
|
||||||
): UseContentParserReturn { |
|
||||||
const [parsedContent, setParsedContent] = useState<ParsedContent | null>(null) |
|
||||||
const [isLoading, setIsLoading] = useState(false) |
|
||||||
const [error, setError] = useState<Error | null>(null) |
|
||||||
|
|
||||||
const { autoParse = true, ...parseOptions } = options |
|
||||||
|
|
||||||
const parse = async () => { |
|
||||||
try { |
|
||||||
setIsLoading(true) |
|
||||||
setError(null) |
|
||||||
const result = await contentParserService.parseEventField(event, field, parseOptions) |
|
||||||
setParsedContent(result) |
|
||||||
} catch (err) { |
|
||||||
setError(err instanceof Error ? err : new Error('Unknown parsing error')) |
|
||||||
setParsedContent(null) |
|
||||||
} finally { |
|
||||||
setIsLoading(false) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (autoParse) { |
|
||||||
parse() |
|
||||||
} |
|
||||||
}, [event.id, field, autoParse, JSON.stringify(parseOptions)]) |
|
||||||
|
|
||||||
return { |
|
||||||
parsedContent, |
|
||||||
isLoading, |
|
||||||
error, |
|
||||||
parse |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Hook for parsing multiple event fields at once |
|
||||||
*/ |
|
||||||
export function useEventFieldsParser( |
|
||||||
event: Event, |
|
||||||
fields: Array<'content' | 'title' | 'summary' | 'description'>, |
|
||||||
options: Omit<UseContentParserOptions, 'eventKind' | 'field'> = {} |
|
||||||
) { |
|
||||||
const [parsedFields, setParsedFields] = useState<Record<string, ParsedContent | null>>({}) |
|
||||||
const [isLoading, setIsLoading] = useState(false) |
|
||||||
const [error, setError] = useState<Error | null>(null) |
|
||||||
|
|
||||||
const { autoParse = true, ...parseOptions } = options |
|
||||||
|
|
||||||
const parse = async () => { |
|
||||||
try { |
|
||||||
setIsLoading(true) |
|
||||||
setError(null) |
|
||||||
|
|
||||||
const results: Record<string, ParsedContent | null> = {} |
|
||||||
|
|
||||||
for (const field of fields) { |
|
||||||
const result = await contentParserService.parseEventField(event, field, parseOptions) |
|
||||||
results[field] = result |
|
||||||
} |
|
||||||
|
|
||||||
setParsedFields(results) |
|
||||||
} catch (err) { |
|
||||||
setError(err instanceof Error ? err : new Error('Unknown parsing error')) |
|
||||||
setParsedFields({}) |
|
||||||
} finally { |
|
||||||
setIsLoading(false) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (autoParse) { |
|
||||||
parse() |
|
||||||
} |
|
||||||
}, [event.id, JSON.stringify(fields), autoParse, JSON.stringify(parseOptions)]) |
|
||||||
|
|
||||||
return { |
|
||||||
parsedFields, |
|
||||||
isLoading, |
|
||||||
error, |
|
||||||
parse |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,64 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { Badge } from '@/components/ui/badge' |
|
||||||
import { X } from 'lucide-react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
interface SubtopicFilterProps { |
|
||||||
subtopics: string[] |
|
||||||
selectedSubtopic: string | null |
|
||||||
onSubtopicChange: (subtopic: string | null) => void |
|
||||||
} |
|
||||||
|
|
||||||
export default function SubtopicFilter({
|
|
||||||
subtopics,
|
|
||||||
selectedSubtopic,
|
|
||||||
onSubtopicChange
|
|
||||||
}: SubtopicFilterProps) { |
|
||||||
const { t } = useTranslation() |
|
||||||
|
|
||||||
if (subtopics.length === 0) return null |
|
||||||
|
|
||||||
const formatSubtopicLabel = (subtopic: string): string => { |
|
||||||
return subtopic |
|
||||||
.split('-') |
|
||||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1)) |
|
||||||
.join(' ') |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="flex gap-2 flex-wrap items-center"> |
|
||||||
<span className="text-sm text-muted-foreground">{t('Filter by')}:</span> |
|
||||||
<Badge |
|
||||||
variant={selectedSubtopic === null ? 'default' : 'outline'} |
|
||||||
className="cursor-pointer" |
|
||||||
onClick={() => onSubtopicChange(null)} |
|
||||||
> |
|
||||||
{t('All')} |
|
||||||
</Badge> |
|
||||||
{subtopics.map(subtopic => ( |
|
||||||
<Badge |
|
||||||
key={subtopic} |
|
||||||
variant={selectedSubtopic === subtopic ? 'default' : 'outline'} |
|
||||||
className="cursor-pointer flex items-center gap-1" |
|
||||||
onClick={() => onSubtopicChange(subtopic)} |
|
||||||
> |
|
||||||
{formatSubtopicLabel(subtopic)} |
|
||||||
{selectedSubtopic === subtopic && ( |
|
||||||
<Button |
|
||||||
variant="ghost" |
|
||||||
size="icon" |
|
||||||
className="h-3 w-3 p-0 hover:bg-transparent" |
|
||||||
onClick={(e) => { |
|
||||||
e.stopPropagation() |
|
||||||
onSubtopicChange(null) |
|
||||||
}} |
|
||||||
> |
|
||||||
<X className="h-2 w-2" /> |
|
||||||
</Button> |
|
||||||
)} |
|
||||||
</Badge> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,206 +0,0 @@ |
|||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card' |
|
||||||
import { Badge } from '@/components/ui/badge' |
|
||||||
import { Clock, Hash, Users } from 'lucide-react' |
|
||||||
import { NostrEvent } from 'nostr-tools' |
|
||||||
import dayjs from 'dayjs' |
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime' |
|
||||||
|
|
||||||
dayjs.extend(relativeTime) |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
import { DISCUSSION_TOPICS } from './discussionTopics' |
|
||||||
import Username from '@/components/Username' |
|
||||||
import UserAvatar from '@/components/UserAvatar' |
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
|
||||||
import { extractAllTopics, extractGroupInfo } from '@/lib/discussion-topics' |
|
||||||
import { removeEmojis } from '@/lib/utils' |
|
||||||
|
|
||||||
interface ThreadCardProps { |
|
||||||
thread: NostrEvent |
|
||||||
onThreadClick: () => void |
|
||||||
className?: string |
|
||||||
lastCommentTime?: number |
|
||||||
lastVoteTime?: number |
|
||||||
upVotes?: number |
|
||||||
downVotes?: number |
|
||||||
} |
|
||||||
|
|
||||||
export default function ThreadCard({
|
|
||||||
thread,
|
|
||||||
onThreadClick,
|
|
||||||
className, |
|
||||||
lastCommentTime = 0, |
|
||||||
lastVoteTime = 0, |
|
||||||
upVotes = 0, |
|
||||||
downVotes = 0 |
|
||||||
}: ThreadCardProps) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { isSmallScreen } = useScreenSize() |
|
||||||
|
|
||||||
// Extract title from tags and remove emojis
|
|
||||||
const titleTag = thread.tags.find(tag => tag[0] === 'title' && tag[1]) |
|
||||||
const rawTitle = titleTag?.[1] || t('Untitled') |
|
||||||
const title = removeEmojis(rawTitle) || t('Untitled') |
|
||||||
|
|
||||||
// Get topic info
|
|
||||||
const topicTag = thread.tags.find(tag => tag[0] === 't' && tag[1]) |
|
||||||
const topic = topicTag?.[1] || 'general' |
|
||||||
const topicInfo = DISCUSSION_TOPICS.find(t => t.id === topic) || {
|
|
||||||
id: topic,
|
|
||||||
label: topic,
|
|
||||||
icon: Hash |
|
||||||
} |
|
||||||
|
|
||||||
// Extract group information
|
|
||||||
const groupInfo = extractGroupInfo(thread, ['unknown']) |
|
||||||
|
|
||||||
// Get all topics from this thread
|
|
||||||
const allTopics = extractAllTopics(thread) |
|
||||||
|
|
||||||
// Format creation time (fromNow() includes suffix e.g. "3 hours ago")
|
|
||||||
const timeAgo = dayjs.unix(thread.created_at).fromNow() |
|
||||||
|
|
||||||
// Format last activity times
|
|
||||||
const formatLastActivity = (timestamp: number) => { |
|
||||||
if (timestamp === 0) return null |
|
||||||
return dayjs.unix(timestamp).fromNow() |
|
||||||
} |
|
||||||
|
|
||||||
const lastCommentAgo = formatLastActivity(lastCommentTime) |
|
||||||
const lastVoteAgo = formatLastActivity(lastVoteTime) |
|
||||||
|
|
||||||
// Vote counts are no longer displayed, keeping variables for potential future use
|
|
||||||
|
|
||||||
// Get content preview - remove emojis first, then truncate
|
|
||||||
const contentWithoutEmojis = removeEmojis(thread.content) |
|
||||||
const contentPreview = contentWithoutEmojis.length > 250
|
|
||||||
? contentWithoutEmojis.substring(0, 250) + '...' |
|
||||||
: contentWithoutEmojis |
|
||||||
|
|
||||||
|
|
||||||
return ( |
|
||||||
<Card
|
|
||||||
className={cn( |
|
||||||
'clickable hover:shadow-md transition-shadow cursor-pointer', |
|
||||||
className |
|
||||||
)} |
|
||||||
onClick={onThreadClick} |
|
||||||
> |
|
||||||
<CardHeader className="pb-3"> |
|
||||||
{isSmallScreen ? ( |
|
||||||
<div className="space-y-3"> |
|
||||||
<div className="flex items-start gap-3"> |
|
||||||
<div className="flex flex-col items-center gap-1"> |
|
||||||
<div className="text-green-600 font-semibold text-sm">+{upVotes || 0}</div> |
|
||||||
<div className="text-red-600 font-semibold text-sm">-{downVotes || 0}</div> |
|
||||||
</div> |
|
||||||
<div className="flex-1 min-w-0"> |
|
||||||
<h3 className="font-semibold text-lg leading-tight line-clamp-2 mb-2 break-words"> |
|
||||||
{title} |
|
||||||
</h3> |
|
||||||
<div className="flex items-center flex-wrap gap-2 text-sm text-muted-foreground mb-2"> |
|
||||||
<div className="flex items-center gap-1"> |
|
||||||
<topicInfo.icon className="w-4 h-4" /> |
|
||||||
<span className="text-xs">{topicInfo.id}</span> |
|
||||||
</div> |
|
||||||
{allTopics.slice(0, 3).map(topic => ( |
|
||||||
<Badge key={topic} variant="outline" className="text-xs"> |
|
||||||
<Hash className="w-3 h-3 mr-1" /> |
|
||||||
{topic.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')} |
|
||||||
</Badge> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
{groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && ( |
|
||||||
<div className="mb-2"> |
|
||||||
<Badge variant="outline" className="text-xs"> |
|
||||||
<Users className="w-3 h-3 mr-1" /> |
|
||||||
{groupInfo.groupDisplayName} |
|
||||||
</Badge> |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
<div className="flex flex-col items-end gap-2"> |
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground"> |
|
||||||
<UserAvatar userId={thread.pubkey} size="xSmall" /> |
|
||||||
<Username
|
|
||||||
userId={thread.pubkey}
|
|
||||||
className="truncate font-medium" |
|
||||||
skeletonClassName="h-4 w-20" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground"> |
|
||||||
<Clock className="w-3 h-3" /> |
|
||||||
<span>{timeAgo}</span> |
|
||||||
</div> |
|
||||||
|
|
||||||
{/* Last updated */} |
|
||||||
<div className="text-xs text-muted-foreground"> |
|
||||||
{t('last updated')}: {lastCommentAgo || lastVoteAgo || timeAgo} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) : ( |
|
||||||
<div className="flex items-start justify-between gap-3"> |
|
||||||
<div className="flex items-start gap-3 flex-1 min-w-0"> |
|
||||||
<div className="flex flex-col items-center gap-1"> |
|
||||||
<div className="text-green-600 font-semibold text-sm">+{upVotes || 0}</div> |
|
||||||
<div className="text-red-600 font-semibold text-sm">-{downVotes || 0}</div> |
|
||||||
</div> |
|
||||||
<div className="flex-1 min-w-0"> |
|
||||||
<div className="flex items-center gap-2 mb-2"> |
|
||||||
<h3 className="font-semibold text-lg leading-tight line-clamp-2 break-words"> |
|
||||||
{title} |
|
||||||
</h3> |
|
||||||
</div> |
|
||||||
<div className="flex items-center flex-wrap gap-2 text-sm text-muted-foreground mb-2"> |
|
||||||
<Badge variant="secondary" className="text-xs"> |
|
||||||
<topicInfo.icon className="w-3 h-3 mr-1" /> |
|
||||||
{topicInfo.label} |
|
||||||
</Badge> |
|
||||||
{allTopics.slice(0, 3).map(topic => ( |
|
||||||
<Badge key={topic} variant="outline" className="text-xs"> |
|
||||||
<Hash className="w-3 h-3 mr-1" /> |
|
||||||
{topic.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')} |
|
||||||
</Badge> |
|
||||||
))} |
|
||||||
<div className="flex items-center gap-1"> |
|
||||||
<Clock className="w-3 h-3" /> |
|
||||||
{timeAgo} |
|
||||||
</div> |
|
||||||
|
|
||||||
{/* Last updated */} |
|
||||||
<div className="text-xs text-muted-foreground"> |
|
||||||
{t('last updated')}: {lastCommentAgo || lastVoteAgo || timeAgo} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
{groupInfo.isGroupDiscussion && groupInfo.groupDisplayName && ( |
|
||||||
<div className="mb-2"> |
|
||||||
<Badge variant="outline" className="text-xs"> |
|
||||||
<Users className="w-3 h-3 mr-1" /> |
|
||||||
{groupInfo.groupDisplayName} |
|
||||||
</Badge> |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground shrink-0"> |
|
||||||
<UserAvatar userId={thread.pubkey} size="xSmall" /> |
|
||||||
<Username
|
|
||||||
userId={thread.pubkey}
|
|
||||||
className="truncate font-medium" |
|
||||||
skeletonClassName="h-4 w-20" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</CardHeader> |
|
||||||
|
|
||||||
<CardContent className="pt-0"> |
|
||||||
<div className="text-sm text-muted-foreground leading-relaxed break-words overflow-hidden"> |
|
||||||
{contentPreview} |
|
||||||
</div> |
|
||||||
</CardContent> |
|
||||||
</Card> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,47 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' |
|
||||||
import { ChevronDown, Clock, TrendingUp, ArrowUpDown, Zap } from 'lucide-react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
export type SortOption = 'newest' | 'oldest' | 'top' | 'controversial' | 'most-zapped' |
|
||||||
|
|
||||||
export default function ThreadSort({ selectedSort, onSortChange }: { selectedSort: SortOption; onSortChange: (sort: SortOption) => void }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
|
|
||||||
const sortOptions = [ |
|
||||||
{ id: 'newest' as SortOption, label: t('Newest'), icon: Clock }, |
|
||||||
{ id: 'oldest' as SortOption, label: t('Oldest'), icon: Clock }, |
|
||||||
{ id: 'top' as SortOption, label: t('Top'), icon: TrendingUp }, |
|
||||||
{ id: 'controversial' as SortOption, label: t('Controversial'), icon: ArrowUpDown }, |
|
||||||
{ id: 'most-zapped' as SortOption, label: t('Most Zapped'), icon: Zap }, |
|
||||||
] |
|
||||||
|
|
||||||
const selectedOption = sortOptions.find(option => option.id === selectedSort) || sortOptions[0] |
|
||||||
|
|
||||||
return ( |
|
||||||
<DropdownMenu> |
|
||||||
<DropdownMenuTrigger asChild> |
|
||||||
<Button variant="outline" className="flex items-center gap-2 h-8"> |
|
||||||
<selectedOption.icon className="w-4 h-4" /> |
|
||||||
<span className="text-sm">{selectedOption.label}</span> |
|
||||||
<ChevronDown className="w-4 h-4" /> |
|
||||||
</Button> |
|
||||||
</DropdownMenuTrigger> |
|
||||||
<DropdownMenuContent align="start"> |
|
||||||
{sortOptions.map(option => ( |
|
||||||
<DropdownMenuItem |
|
||||||
key={option.id} |
|
||||||
onClick={() => onSortChange(option.id)} |
|
||||||
className="flex items-center gap-2" |
|
||||||
> |
|
||||||
<option.icon className="w-4 h-4" /> |
|
||||||
<span>{option.label}</span> |
|
||||||
{option.id === selectedSort && ( |
|
||||||
<span className="ml-auto text-primary">✓</span> |
|
||||||
)} |
|
||||||
</DropdownMenuItem> |
|
||||||
))} |
|
||||||
</DropdownMenuContent> |
|
||||||
</DropdownMenu> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,120 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' |
|
||||||
import { ChevronDown, Grid3X3, Users } from 'lucide-react' |
|
||||||
import { NostrEvent } from 'nostr-tools' |
|
||||||
import { useMemo } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
interface Topic { |
|
||||||
id: string |
|
||||||
label: string |
|
||||||
icon: any |
|
||||||
} |
|
||||||
|
|
||||||
interface TopicFilterProps { |
|
||||||
topics: Topic[] |
|
||||||
selectedTopic: string |
|
||||||
onTopicChange: (topicId: string) => void |
|
||||||
threads: NostrEvent[] |
|
||||||
replies: NostrEvent[] |
|
||||||
} |
|
||||||
|
|
||||||
export default function TopicFilter({ topics, selectedTopic, onTopicChange, threads, replies }: TopicFilterProps) { |
|
||||||
const { t } = useTranslation() |
|
||||||
|
|
||||||
// Sort topics by activity (most recent kind 11 or kind 1111 events first)
|
|
||||||
const sortedTopics = useMemo(() => { |
|
||||||
const allEvents = [...threads, ...replies] |
|
||||||
|
|
||||||
return [...topics].sort((a, b) => { |
|
||||||
// Find the most recent event for each topic
|
|
||||||
const getMostRecentEvent = (topicId: string) => { |
|
||||||
return allEvents |
|
||||||
.filter(event => { |
|
||||||
const topicTag = event.tags.find(tag => tag[0] === 't' && tag[1] === topicId) |
|
||||||
return topicTag !== undefined |
|
||||||
}) |
|
||||||
.sort((a, b) => b.created_at - a.created_at)[0] |
|
||||||
} |
|
||||||
|
|
||||||
const mostRecentA = getMostRecentEvent(a.id) |
|
||||||
const mostRecentB = getMostRecentEvent(b.id) |
|
||||||
|
|
||||||
// If one has events and the other doesn't, prioritize the one with events
|
|
||||||
if (mostRecentA && !mostRecentB) return -1 |
|
||||||
if (!mostRecentA && mostRecentB) return 1 |
|
||||||
if (!mostRecentA && !mostRecentB) return 0 // Both have no events, keep original order
|
|
||||||
|
|
||||||
// Sort by creation time (most recent first)
|
|
||||||
return mostRecentB!.created_at - mostRecentA!.created_at |
|
||||||
}) |
|
||||||
}, [topics, threads, replies]) |
|
||||||
|
|
||||||
// Create all topics option
|
|
||||||
const allTopicsOption = { id: 'all', label: t('All Topics'), icon: Grid3X3 } |
|
||||||
|
|
||||||
// Create groups option if there are group discussions
|
|
||||||
const hasGroupDiscussions = threads.some(thread =>
|
|
||||||
thread.tags.some(tag => tag[0] === 'h' && tag[1]) |
|
||||||
) |
|
||||||
const groupsOption = hasGroupDiscussions ? { id: 'groups', label: t('Groups'), icon: Users } : null |
|
||||||
|
|
||||||
const selectedTopicInfo = selectedTopic === 'all'
|
|
||||||
? allTopicsOption
|
|
||||||
: selectedTopic === 'groups' && groupsOption |
|
||||||
? groupsOption |
|
||||||
: sortedTopics.find(topic => topic.id === selectedTopic) || sortedTopics[0] |
|
||||||
|
|
||||||
return ( |
|
||||||
<DropdownMenu> |
|
||||||
<DropdownMenuTrigger asChild> |
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center gap-2 h-10 px-3 min-w-44" |
|
||||||
> |
|
||||||
<span className="flex-1 text-left">{selectedTopicInfo.label}</span> |
|
||||||
<ChevronDown className="w-4 h-4" /> |
|
||||||
</Button> |
|
||||||
</DropdownMenuTrigger> |
|
||||||
<DropdownMenuContent align="start" className="w-72"> |
|
||||||
<DropdownMenuItem |
|
||||||
key="all" |
|
||||||
onClick={() => onTopicChange('all')} |
|
||||||
className="flex items-center gap-2" |
|
||||||
> |
|
||||||
<Grid3X3 className="w-4 h-4" /> |
|
||||||
<span>{t('All Topics')}</span> |
|
||||||
{selectedTopic === 'all' && ( |
|
||||||
<span className="ml-auto text-primary">✓</span> |
|
||||||
)} |
|
||||||
</DropdownMenuItem> |
|
||||||
{groupsOption && ( |
|
||||||
<DropdownMenuItem |
|
||||||
key="groups" |
|
||||||
onClick={() => onTopicChange('groups')} |
|
||||||
className="flex items-center gap-2" |
|
||||||
> |
|
||||||
<Users className="w-4 h-4" /> |
|
||||||
<span>{t('Groups')}</span> |
|
||||||
{selectedTopic === 'groups' && ( |
|
||||||
<span className="ml-auto text-primary">✓</span> |
|
||||||
)} |
|
||||||
</DropdownMenuItem> |
|
||||||
)} |
|
||||||
{sortedTopics.map(topic => ( |
|
||||||
<DropdownMenuItem |
|
||||||
key={topic.id} |
|
||||||
onClick={() => onTopicChange(topic.id)} |
|
||||||
className="flex items-center gap-2" |
|
||||||
> |
|
||||||
<topic.icon className="w-4 h-4" /> |
|
||||||
<span>{topic.label}</span> |
|
||||||
{topic.id === selectedTopic && ( |
|
||||||
<span className="ml-auto text-primary">✓</span> |
|
||||||
)} |
|
||||||
</DropdownMenuItem> |
|
||||||
))} |
|
||||||
</DropdownMenuContent> |
|
||||||
</DropdownMenu> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,67 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' |
|
||||||
import { ChevronDown, List, Grid3X3 } from 'lucide-react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
interface ViewToggleProps { |
|
||||||
viewMode: 'flat' | 'grouped' |
|
||||||
onViewModeChange: (mode: 'flat' | 'grouped') => void |
|
||||||
disabled?: boolean |
|
||||||
} |
|
||||||
|
|
||||||
export default function ViewToggle({ viewMode, onViewModeChange, disabled = false }: ViewToggleProps) { |
|
||||||
const { t } = useTranslation() |
|
||||||
|
|
||||||
const viewOptions = [ |
|
||||||
{
|
|
||||||
id: 'flat' as const,
|
|
||||||
label: t('Flat View'),
|
|
||||||
icon: List, |
|
||||||
description: t('Show all discussions in a single list') |
|
||||||
}, |
|
||||||
{
|
|
||||||
id: 'grouped' as const,
|
|
||||||
label: t('Grouped View'),
|
|
||||||
icon: Grid3X3, |
|
||||||
description: t('Group discussions by topic') |
|
||||||
} |
|
||||||
] |
|
||||||
|
|
||||||
const selectedOption = viewOptions.find(option => option.id === viewMode) || viewOptions[0] |
|
||||||
|
|
||||||
return ( |
|
||||||
<DropdownMenu> |
|
||||||
<DropdownMenuTrigger asChild> |
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center gap-2 h-10 px-3 min-w-32" |
|
||||||
disabled={disabled} |
|
||||||
> |
|
||||||
<selectedOption.icon className="w-4 h-4" /> |
|
||||||
<span className="flex-1 text-left">{selectedOption.label}</span> |
|
||||||
<ChevronDown className="w-4 h-4" /> |
|
||||||
</Button> |
|
||||||
</DropdownMenuTrigger> |
|
||||||
<DropdownMenuContent align="start" className="w-64"> |
|
||||||
{viewOptions.map(option => ( |
|
||||||
<DropdownMenuItem |
|
||||||
key={option.id} |
|
||||||
onClick={() => onViewModeChange(option.id)} |
|
||||||
className="flex items-start gap-3 p-3" |
|
||||||
> |
|
||||||
<option.icon className="w-4 h-4 mt-0.5" /> |
|
||||||
<div className="flex-1"> |
|
||||||
<div className="font-medium">{option.label}</div> |
|
||||||
<div className="text-xs text-muted-foreground mt-1"> |
|
||||||
{option.description} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
{option.id === viewMode && ( |
|
||||||
<span className="text-primary">✓</span> |
|
||||||
)} |
|
||||||
</DropdownMenuItem> |
|
||||||
))} |
|
||||||
</DropdownMenuContent> |
|
||||||
</DropdownMenu> |
|
||||||
) |
|
||||||
} |
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,322 +0,0 @@ |
|||||||
import { ExtendedKind } from '@/constants' |
|
||||||
import { kinds } from 'nostr-tools' |
|
||||||
import type { Event as NEvent } from 'nostr-tools' |
|
||||||
import logger from '@/lib/logger' |
|
||||||
import indexedDb from './indexed-db.service' |
|
||||||
import { getProfileFromEvent } from '@/lib/event-metadata' |
|
||||||
import type { TProfile, TRelayList } from '@/types' |
|
||||||
import { getRelayListFromEvent } from '@/lib/event-metadata' |
|
||||||
|
|
||||||
/** Cache TTLs in milliseconds */ |
|
||||||
const CACHE_TTLS = { |
|
||||||
PROFILE: 30 * 60 * 1000, // 30 minutes
|
|
||||||
PAYMENT_INFO: 5 * 60 * 1000, // 5 minutes
|
|
||||||
RELAY_LIST: 15 * 60 * 1000, // 15 minutes
|
|
||||||
FOLLOW_LIST: 60 * 60 * 1000, // 1 hour
|
|
||||||
MUTE_LIST: 60 * 60 * 1000, // 1 hour
|
|
||||||
OTHER_REPLACEABLE: 60 * 60 * 1000 // 1 hour
|
|
||||||
} as const |
|
||||||
|
|
||||||
/** Cache refresh thresholds - refresh if older than this */ |
|
||||||
const REFRESH_THRESHOLDS = { |
|
||||||
PROFILE: 15 * 60 * 1000, // 15 minutes
|
|
||||||
PAYMENT_INFO: 2 * 60 * 1000, // 2 minutes
|
|
||||||
RELAY_LIST: 10 * 60 * 1000, // 10 minutes
|
|
||||||
FOLLOW_LIST: 30 * 60 * 1000, // 30 minutes
|
|
||||||
MUTE_LIST: 30 * 60 * 1000, // 30 minutes
|
|
||||||
OTHER_REPLACEABLE: 30 * 60 * 1000 // 30 minutes
|
|
||||||
} as const |
|
||||||
|
|
||||||
interface CacheWarmupConfig { |
|
||||||
/** Pubkeys to warm up profiles for */ |
|
||||||
profilePubkeys?: string[] |
|
||||||
/** Pubkeys to warm up relay lists for */ |
|
||||||
relayListPubkeys?: string[] |
|
||||||
/** Whether to warm up follow lists */ |
|
||||||
warmupFollowLists?: boolean |
|
||||||
/** Whether to warm up mute lists */ |
|
||||||
warmupMuteLists?: boolean |
|
||||||
} |
|
||||||
|
|
||||||
class ClientCacheService { |
|
||||||
private static instance: ClientCacheService |
|
||||||
private refreshQueue = new Set<string>() // pubkey:kind strings
|
|
||||||
private warmingUp = false |
|
||||||
private refreshIntervalId: ReturnType<typeof setInterval> | null = null |
|
||||||
|
|
||||||
static getInstance(): ClientCacheService { |
|
||||||
if (!ClientCacheService.instance) { |
|
||||||
ClientCacheService.instance = new ClientCacheService() |
|
||||||
} |
|
||||||
return ClientCacheService.instance |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Check if a cached replaceable event is stale and needs refresh |
|
||||||
*/ |
|
||||||
isStale(_pubkey: string, kind: number, cachedAt?: number): boolean { |
|
||||||
if (!cachedAt) return true |
|
||||||
|
|
||||||
const threshold = this.getRefreshThreshold(kind) |
|
||||||
return Date.now() - cachedAt > threshold |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get refresh threshold for a kind |
|
||||||
*/ |
|
||||||
private getRefreshThreshold(kind: number): number { |
|
||||||
if (kind === kinds.Metadata) return REFRESH_THRESHOLDS.PROFILE |
|
||||||
if (kind === ExtendedKind.PAYMENT_INFO) return REFRESH_THRESHOLDS.PAYMENT_INFO |
|
||||||
if (kind === kinds.RelayList) return REFRESH_THRESHOLDS.RELAY_LIST |
|
||||||
if (kind === kinds.Contacts) return REFRESH_THRESHOLDS.FOLLOW_LIST |
|
||||||
if (kind === kinds.Mutelist) return REFRESH_THRESHOLDS.MUTE_LIST |
|
||||||
return REFRESH_THRESHOLDS.OTHER_REPLACEABLE |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get cache TTL for a kind |
|
||||||
*/ |
|
||||||
private getCacheTTL(kind: number): number { |
|
||||||
if (kind === kinds.Metadata) return CACHE_TTLS.PROFILE |
|
||||||
if (kind === ExtendedKind.PAYMENT_INFO) return CACHE_TTLS.PAYMENT_INFO |
|
||||||
if (kind === kinds.RelayList) return CACHE_TTLS.RELAY_LIST |
|
||||||
if (kind === kinds.Contacts) return CACHE_TTLS.FOLLOW_LIST |
|
||||||
if (kind === kinds.Mutelist) return CACHE_TTLS.MUTE_LIST |
|
||||||
return CACHE_TTLS.OTHER_REPLACEABLE |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Check if cached event should be invalidated (too old) |
|
||||||
*/ |
|
||||||
shouldInvalidate(kind: number, cachedAt?: number): boolean { |
|
||||||
if (!cachedAt) return false |
|
||||||
|
|
||||||
const ttl = this.getCacheTTL(kind) |
|
||||||
return Date.now() - cachedAt > ttl |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Warm up cache for common data on login/initialization |
|
||||||
*/ |
|
||||||
async warmupCache(config: CacheWarmupConfig, fetchFn: { |
|
||||||
fetchProfile: (id: string) => Promise<TProfile | undefined> |
|
||||||
fetchRelayList: (pubkey: string) => Promise<TRelayList> |
|
||||||
fetchFollowList?: (pubkey: string) => Promise<string[]> |
|
||||||
fetchMuteList?: (pubkey: string) => Promise<NEvent | undefined> |
|
||||||
fetchDeletionEvents?: (relayUrls: string[], authorPubkey?: string) => Promise<void> |
|
||||||
}): Promise<void> { |
|
||||||
if (this.warmingUp) { |
|
||||||
logger.debug('[CacheService] Already warming up, skipping') |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
this.warmingUp = true |
|
||||||
logger.info('[CacheService] Starting cache warmup', config) |
|
||||||
|
|
||||||
try { |
|
||||||
const promises: Promise<void>[] = [] |
|
||||||
|
|
||||||
// Warm up profiles
|
|
||||||
if (config.profilePubkeys?.length) { |
|
||||||
for (const pubkey of config.profilePubkeys.slice(0, 50)) { // Limit to 50
|
|
||||||
promises.push( |
|
||||||
fetchFn.fetchProfile(pubkey) |
|
||||||
.then(() => logger.debug('[CacheService] Warmed profile', { pubkey: pubkey.substring(0, 8) })) |
|
||||||
.catch(err => logger.warn('[CacheService] Failed to warm profile', { pubkey: pubkey.substring(0, 8), error: err })) |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Warm up relay lists
|
|
||||||
if (config.relayListPubkeys?.length) { |
|
||||||
for (const pubkey of config.relayListPubkeys.slice(0, 20)) { // Limit to 20
|
|
||||||
promises.push( |
|
||||||
fetchFn.fetchRelayList(pubkey) |
|
||||||
.then(() => logger.debug('[CacheService] Warmed relay list', { pubkey: pubkey.substring(0, 8) })) |
|
||||||
.catch(err => logger.warn('[CacheService] Failed to warm relay list', { pubkey: pubkey.substring(0, 8), error: err })) |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Warm up follow lists
|
|
||||||
if (config.warmupFollowLists && fetchFn.fetchFollowList) { |
|
||||||
const currentUserPubkey = config.profilePubkeys?.[0] // Assume first is current user
|
|
||||||
if (currentUserPubkey) { |
|
||||||
promises.push( |
|
||||||
fetchFn.fetchFollowList(currentUserPubkey) |
|
||||||
.then(() => logger.debug('[CacheService] Warmed follow list')) |
|
||||||
.catch(err => logger.warn('[CacheService] Failed to warm follow list', { error: err })) |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Warm up mute lists
|
|
||||||
if (config.warmupMuteLists && fetchFn.fetchMuteList) { |
|
||||||
const currentUserPubkey = config.profilePubkeys?.[0] |
|
||||||
if (currentUserPubkey) { |
|
||||||
promises.push( |
|
||||||
fetchFn.fetchMuteList(currentUserPubkey) |
|
||||||
.then(() => logger.debug('[CacheService] Warmed mute list')) |
|
||||||
.catch(err => logger.warn('[CacheService] Failed to warm mute list', { error: err })) |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (fetchFn.fetchDeletionEvents) { |
|
||||||
const authorPubkey = config.profilePubkeys?.[0] |
|
||||||
fetchFn.fetchDeletionEvents([], authorPubkey).catch((err) => |
|
||||||
logger.warn('[CacheService] Failed to fetch deletion events', { error: err }) |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
await Promise.allSettled(promises) |
|
||||||
logger.info('[CacheService] Cache warmup completed', { count: promises.length }) |
|
||||||
} finally { |
|
||||||
this.warmingUp = false |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Schedule background refresh for stale cache entries |
|
||||||
*/ |
|
||||||
scheduleRefresh(pubkey: string, kind: number, fetchFn: () => Promise<void>): void { |
|
||||||
const key = `${pubkey}:${kind}` |
|
||||||
if (this.refreshQueue.has(key)) { |
|
||||||
return // Already queued
|
|
||||||
} |
|
||||||
|
|
||||||
// Check if actually stale by getting the cached timestamp
|
|
||||||
indexedDb.getReplaceableEventCachedAt(pubkey, kind).then(cachedAt => { |
|
||||||
if (cachedAt === undefined) return // Not in cache
|
|
||||||
|
|
||||||
// Check if stale using the actual cached timestamp
|
|
||||||
const isStale = this.isStale(pubkey, kind, cachedAt) |
|
||||||
|
|
||||||
if (isStale) { |
|
||||||
this.refreshQueue.add(key) |
|
||||||
// Refresh in background (non-blocking)
|
|
||||||
fetchFn() |
|
||||||
.then(() => { |
|
||||||
logger.debug('[CacheService] Refreshed cache', { pubkey: pubkey.substring(0, 8), kind }) |
|
||||||
}) |
|
||||||
.catch(err => { |
|
||||||
logger.warn('[CacheService] Failed to refresh cache', { pubkey: pubkey.substring(0, 8), kind, error: err }) |
|
||||||
}) |
|
||||||
.finally(() => { |
|
||||||
this.refreshQueue.delete(key) |
|
||||||
}) |
|
||||||
} |
|
||||||
}).catch(() => { |
|
||||||
// Ignore errors
|
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Start periodic cache refresh for stale entries |
|
||||||
*/ |
|
||||||
startPeriodicRefresh(refreshFn: (pubkey: string, kind: number) => Promise<void>): void { |
|
||||||
if (this.refreshIntervalId) { |
|
||||||
return // Already running
|
|
||||||
} |
|
||||||
|
|
||||||
logger.info('[CacheService] Starting periodic cache refresh') |
|
||||||
|
|
||||||
this.refreshIntervalId = setInterval(async () => { |
|
||||||
try { |
|
||||||
// Check for stale profiles (limit to avoid overwhelming)
|
|
||||||
await this.refreshStaleProfiles(refreshFn) |
|
||||||
} catch (error) { |
|
||||||
logger.warn('[CacheService] Periodic refresh error', { error }) |
|
||||||
} |
|
||||||
}, 5 * 60 * 1000) // Every 5 minutes
|
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Stop periodic cache refresh |
|
||||||
*/ |
|
||||||
stopPeriodicRefresh(): void { |
|
||||||
if (this.refreshIntervalId) { |
|
||||||
clearInterval(this.refreshIntervalId) |
|
||||||
this.refreshIntervalId = null |
|
||||||
logger.info('[CacheService] Stopped periodic cache refresh') |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Refresh stale profiles (limited batch) |
|
||||||
*/ |
|
||||||
private async refreshStaleProfiles(_refreshFn: (pubkey: string, kind: number) => Promise<void>): Promise<void> { |
|
||||||
// This would iterate through cached profiles and refresh stale ones
|
|
||||||
// For now, this is a placeholder - would need IndexedDB iteration
|
|
||||||
logger.debug('[CacheService] Checking for stale profiles to refresh') |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get cached profile with fallback - returns cached immediately, refreshes in background if stale |
|
||||||
*/ |
|
||||||
async getProfileWithRefresh( |
|
||||||
pubkey: string, |
|
||||||
fetchFn: () => Promise<TProfile | undefined> |
|
||||||
): Promise<TProfile | undefined> { |
|
||||||
// Try cache first
|
|
||||||
const cached = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata) |
|
||||||
if (cached) { |
|
||||||
const profile = getProfileFromEvent(cached) |
|
||||||
|
|
||||||
// Get the timestamp when this was cached
|
|
||||||
const cachedAt = await indexedDb.getReplaceableEventCachedAt(pubkey, kinds.Metadata) |
|
||||||
|
|
||||||
// If stale, refresh in background
|
|
||||||
if (this.isStale(pubkey, kinds.Metadata, cachedAt)) { |
|
||||||
this.scheduleRefresh(pubkey, kinds.Metadata, async () => { |
|
||||||
await fetchFn() |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
return profile |
|
||||||
} |
|
||||||
|
|
||||||
// Not in cache, fetch now
|
|
||||||
return await fetchFn() |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get cached relay list with fallback - returns cached immediately, refreshes in background if stale |
|
||||||
*/ |
|
||||||
async getRelayListWithRefresh( |
|
||||||
pubkey: string, |
|
||||||
fetchFn: () => Promise<TRelayList> |
|
||||||
): Promise<TRelayList> { |
|
||||||
// Try cache first
|
|
||||||
const cached = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList) |
|
||||||
if (cached) { |
|
||||||
const relayList = getRelayListFromEvent(cached) |
|
||||||
|
|
||||||
// Get the timestamp when this was cached
|
|
||||||
const cachedAt = await indexedDb.getReplaceableEventCachedAt(pubkey, kinds.RelayList) |
|
||||||
|
|
||||||
// If stale, refresh in background
|
|
||||||
if (this.isStale(pubkey, kinds.RelayList, cachedAt)) { |
|
||||||
this.scheduleRefresh(pubkey, kinds.RelayList, async () => { |
|
||||||
await fetchFn() |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
return relayList |
|
||||||
} |
|
||||||
|
|
||||||
// Not in cache, fetch now
|
|
||||||
return await fetchFn() |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Clear all caches |
|
||||||
*/ |
|
||||||
clearAll(): void { |
|
||||||
this.refreshQueue.clear() |
|
||||||
logger.info('[CacheService] Cleared all cache refresh queues') |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
export const cacheService = ClientCacheService.getInstance() |
|
||||||
export default cacheService |
|
||||||
@ -1,55 +0,0 @@ |
|||||||
import { JUMBLE_API_BASE_URL } from '@/constants' |
|
||||||
|
|
||||||
class TransactionService { |
|
||||||
static instance: TransactionService |
|
||||||
|
|
||||||
constructor() { |
|
||||||
if (!TransactionService.instance) { |
|
||||||
TransactionService.instance = this |
|
||||||
} |
|
||||||
return TransactionService.instance |
|
||||||
} |
|
||||||
|
|
||||||
async createTransaction( |
|
||||||
pubkey: string, |
|
||||||
amount: number |
|
||||||
): Promise<{ |
|
||||||
transactionId: string |
|
||||||
invoiceId: string |
|
||||||
}> { |
|
||||||
const url = new URL('/v1/transactions', JUMBLE_API_BASE_URL).toString() |
|
||||||
const response = await fetch(url, { |
|
||||||
method: 'POST', |
|
||||||
headers: { |
|
||||||
'Content-Type': 'application/json' |
|
||||||
}, |
|
||||||
body: JSON.stringify({ |
|
||||||
pubkey, |
|
||||||
amount, |
|
||||||
purpose: 'translation' |
|
||||||
}) |
|
||||||
}) |
|
||||||
const data = await response.json() |
|
||||||
if (!response.ok) { |
|
||||||
throw new Error(data.error ?? 'Failed to create transaction') |
|
||||||
} |
|
||||||
return data |
|
||||||
} |
|
||||||
|
|
||||||
async checkTransaction(transactionId: string): Promise<{ |
|
||||||
state: 'pending' | 'failed' | 'settled' |
|
||||||
}> { |
|
||||||
const url = new URL(`/v1/transactions/${transactionId}/check`, JUMBLE_API_BASE_URL).toString() |
|
||||||
const response = await fetch(url, { |
|
||||||
method: 'POST' |
|
||||||
}) |
|
||||||
const data = await response.json() |
|
||||||
if (!response.ok) { |
|
||||||
throw new Error(data.error ?? 'Failed to complete transaction') |
|
||||||
} |
|
||||||
return data |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const instance = new TransactionService() |
|
||||||
export default instance |
|
||||||
@ -1,40 +0,0 @@ |
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/** |
|
||||||
* Navigation Test Runner |
|
||||||
*
|
|
||||||
* Runs the navigation service tests to verify single-pane navigation works |
|
||||||
* correctly for both mobile and desktop scenarios. |
|
||||||
*/ |
|
||||||
|
|
||||||
const { execSync } = require('child_process') |
|
||||||
const path = require('path') |
|
||||||
|
|
||||||
console.log('🧪 Running Navigation Service Tests...\n') |
|
||||||
|
|
||||||
try { |
|
||||||
// Run the tests
|
|
||||||
const testCommand = 'npm test -- --testPathPattern=navigation.service.test.ts --verbose' |
|
||||||
console.log(`Running: ${testCommand}\n`) |
|
||||||
|
|
||||||
execSync(testCommand, {
|
|
||||||
stdio: 'inherit', |
|
||||||
cwd: path.resolve(__dirname) |
|
||||||
}) |
|
||||||
|
|
||||||
console.log('\n✅ All navigation tests passed!') |
|
||||||
console.log('\n📱 Mobile and Desktop Navigation Verification:') |
|
||||||
console.log(' ✓ URL parsing works correctly') |
|
||||||
console.log(' ✓ Component factory creates proper components') |
|
||||||
console.log(' ✓ Navigation service handles all view types') |
|
||||||
console.log(' ✓ Single-pane navigation flow works') |
|
||||||
console.log(' ✓ Back navigation behaves correctly') |
|
||||||
console.log(' ✓ Page titles are generated properly') |
|
||||||
console.log(' ✓ Error handling works gracefully') |
|
||||||
console.log('\n🎉 Navigation system is ready for production!') |
|
||||||
|
|
||||||
} catch (error) { |
|
||||||
console.error('\n❌ Navigation tests failed!') |
|
||||||
console.error('Please check the test output above for details.') |
|
||||||
process.exit(1) |
|
||||||
} |
|
||||||
Loading…
Reference in new issue