31 changed files with 21 additions and 4305 deletions
@ -1,56 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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