diff --git a/src/components/ArticleExportMenu/ArticleExportMenu.tsx b/src/components/ArticleExportMenu/ArticleExportMenu.tsx deleted file mode 100644 index cfa83d35..00000000 --- a/src/components/ArticleExportMenu/ArticleExportMenu.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { MoreVertical, FileDown } from 'lucide-react' -import logger from '@/lib/logger' -import { Event, kinds } from 'nostr-tools' -import { ExtendedKind } from '@/constants' - -interface ArticleExportMenuProps { - event: Event - title: string -} - -export default function ArticleExportMenu({ event, title }: ArticleExportMenuProps) { - // Determine export format based on event kind - const getExportFormat = () => { - if (event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { - return { extension: 'md', mimeType: 'text/markdown', label: 'Markdown' } - } - // For 30818, 30041, 30040 - use AsciiDoc - return { extension: 'adoc', mimeType: 'text/plain', label: 'AsciiDoc' } - } - - const exportArticle = async () => { - try { - const content = event.content - const format = getExportFormat() - const filename = `${title}.${format.extension}` - - // Export raw content - const blob = new Blob([content], { type: format.mimeType }) - - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) - - logger.info(`[ArticleExportMenu] Exported article as .${format.extension}`) - } catch (error) { - logger.error('[ArticleExportMenu] Error exporting article:', error) - alert('Failed to export article. Please try again.') - } - } - - const format = getExportFormat() - - return ( - - e.stopPropagation()}> - - - e.stopPropagation()}> - - - Export as {format.label} - - - - ) -} - diff --git a/src/components/BookmarkList/index.tsx b/src/components/BookmarkList/index.tsx deleted file mode 100644 index 7a980a1c..00000000 --- a/src/components/BookmarkList/index.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useFetchEvent } from '@/hooks' -import { PROFILE_FETCH_RELAY_URLS } from '@/constants' -import { getLatestEvent } from '@/lib/event' -import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag' -import { normalizeUrl } from '@/lib/url' -import { useNostr } from '@/providers/NostrProvider' -import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' -import { queryService } from '@/services/client.service' -import { kinds } from 'nostr-tools' -import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' - -const SHOW_COUNT = 10 - -const BookmarkList = forwardRef(function BookmarkList(_, ref) { - const { t } = useTranslation() - const { bookmarkListEvent, pubkey, relayList, updateBookmarkListEvent } = useNostr() - const eventIds = useMemo(() => { - if (!bookmarkListEvent) return [] - - return ( - bookmarkListEvent.tags - .map((tag) => - tag[0] === 'e' - ? generateBech32IdFromETag(tag) - : tag[0] === 'a' - ? generateBech32IdFromATag(tag) - : null - ) - .filter(Boolean) as (`nevent1${string}` | `naddr1${string}`)[] - ).reverse() - }, [bookmarkListEvent]) - const [showCount, setShowCount] = useState(SHOW_COUNT) - const bottomRef = useRef(null) - - useImperativeHandle( - ref, - () => ({ - refresh: async () => { - if (!pubkey) return - await syncUserDeletionTombstones(pubkey, relayList) - const urls = Array.from( - new Set( - [ - ...PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u), - ...(relayList?.write ?? []).map((u) => normalizeUrl(u) || u) - ].filter(Boolean) - ) - ).slice(0, 12) - if (urls.length === 0) return - try { - const events = await queryService.fetchEvents(urls, { - kinds: [kinds.BookmarkList], - authors: [pubkey], - limit: 5 - }) - const latest = getLatestEvent(events) - if (latest) await updateBookmarkListEvent(latest) - } catch { - /* ignore */ - } - } - }), - [pubkey, relayList, updateBookmarkListEvent] - ) - - useEffect(() => { - const options = { - root: null, - rootMargin: '10px', - threshold: 0.1 - } - - const loadMore = () => { - if (showCount < eventIds.length) { - setShowCount((prev) => prev + SHOW_COUNT) - } - } - - 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) - } - } - }, [showCount, eventIds]) - - if (eventIds.length === 0) { - return ( -
- {t('no bookmarks found')} -
- ) - } - - return ( -
- {eventIds.slice(0, showCount).map((eventId) => ( - - ))} - - {showCount < eventIds.length ? ( -
- -
- ) : ( -
- {t('no more bookmarks')} -
- )} -
- ) -}) - -BookmarkList.displayName = 'BookmarkList' -export default BookmarkList - -function BookmarkedNote({ eventId }: { eventId: string }) { - const { event, isFetching } = useFetchEvent(eventId) - - if (isFetching) { - return - } - - if (!event) { - return null - } - - return -} diff --git a/src/components/Donation/PlatinumSponsors.tsx b/src/components/Donation/PlatinumSponsors.tsx deleted file mode 100644 index 6c3c52cd..00000000 --- a/src/components/Donation/PlatinumSponsors.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useTranslation } from 'react-i18next' -import Image from '../Image' -import OpenSatsLogo from './open-sats-logo.svg' - -export default function PlatinumSponsors() { - const { t } = useTranslation() - - return ( -
-
{t('Platinum Sponsors')}
-
-
window.open('https://opensats.org/', '_blank')} - > - -
OpenSats
-
-
-
- ) -} diff --git a/src/components/Donation/RecentSupporters.tsx b/src/components/Donation/RecentSupporters.tsx deleted file mode 100644 index 274f562f..00000000 --- a/src/components/Donation/RecentSupporters.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { formatAmount } from '@/lib/lightning' -import lightning, { TRecentSupporter } from '@/services/lightning.service' -import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import UserAvatar from '../UserAvatar' -import Username from '../Username' - -export default function RecentSupporters() { - const { t } = useTranslation() - const [supporters, setSupporters] = useState([]) - - useEffect(() => { - const init = async () => { - const items = await lightning.fetchRecentSupporters() - setSupporters(items) - } - init() - }, []) - - if (!supporters.length) return null - - return ( -
-
{t('Recent Supporters')}
-
- {supporters.map((item, index) => ( -
-
- -
- -
- {item.comment} -
-
-
-
- {formatAmount(item.amount)} {t('sats')} -
-
- ))} -
-
- ) -} diff --git a/src/components/Donation/index.tsx b/src/components/Donation/index.tsx deleted file mode 100644 index 15dfcbaf..00000000 --- a/src/components/Donation/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Button } from '@/components/ui/button' -import { IMWALD_MAINTAINER_PUBKEY } from '@/constants' -import { cn } from '@/lib/utils' -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import ZapDialog from '../ZapDialog' -import PlatinumSponsors from './PlatinumSponsors' -import RecentSupporters from './RecentSupporters' - -export default function Donation({ className }: { className?: string }) { - const { t } = useTranslation() - const [open, setOpen] = useState(false) - const [donationAmount, setDonationAmount] = useState(undefined) - - return ( -
-
{t('Enjoying Imwald?')}
-
- {t('Your donation helps me maintain Imwald and make it better! 😊')} -
-
- {[ - { amount: 1000, text: '☕️ 1k' }, - { amount: 10000, text: '🍜 10k' }, - { amount: 100000, text: '🍣 100k' }, - { amount: 1000000, text: '✈️ 1M' } - ].map(({ amount, text }) => { - return ( - - ) - })} -
- - - -
- ) -} diff --git a/src/components/ImageCarousel/ImageCarousel.tsx b/src/components/ImageCarousel/ImageCarousel.tsx deleted file mode 100644 index 98eb14c9..00000000 --- a/src/components/ImageCarousel/ImageCarousel.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { useState } from 'react' -import { ChevronLeft, ChevronRight, X } from 'lucide-react' -import ImageWithLightbox from '@/components/ImageWithLightbox' -import { TImetaInfo } from '@/types' - -interface ImageCarouselProps { - images: TImetaInfo[] - className?: string -} - -export default function ImageCarousel({ images, className = '' }: ImageCarouselProps) { - const [currentIndex, setCurrentIndex] = useState(0) - const [isFullscreen, setIsFullscreen] = useState(false) - - if (!images || images.length === 0) { - return null - } - - const goToPrevious = () => { - setCurrentIndex((prevIndex) => - prevIndex === 0 ? images.length - 1 : prevIndex - 1 - ) - } - - const goToNext = () => { - setCurrentIndex((prevIndex) => - prevIndex === images.length - 1 ? 0 : prevIndex + 1 - ) - } - - const openFullscreen = () => { - setIsFullscreen(true) - } - - const closeFullscreen = () => { - setIsFullscreen(false) - } - - const currentImage = images[currentIndex] - - return ( - <> -
- {/* Thumbnail grid */} -
- {images.map((image, index) => ( -
setCurrentIndex(index)} - > - {image.m?.startsWith('video/') ? ( -
- ))} -
- - {/* Main image display */} - {images.length > 0 && ( -
-
- {currentImage.m?.startsWith('video/') ? ( -
-
- )} -
- - {/* Fullscreen modal */} - {isFullscreen && ( -
- - -
- {currentImage.m?.startsWith('video/') ? ( -
-
- )} - - ) -} diff --git a/src/components/MediaRenderer/index.tsx b/src/components/MediaRenderer/index.tsx deleted file mode 100644 index 50136a77..00000000 --- a/src/components/MediaRenderer/index.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { useMemo } from 'react' -import { cleanUrl, isImage } from '@/lib/url' -import ImageGallery from '../ImageGallery' -import { ExtractedMedia } from '@/services/media-extraction.service' -import { cn } from '@/lib/utils' - -interface MediaRendererProps { - extractedMedia: ExtractedMedia - content?: string - className?: string - mustLoadMedia?: boolean - /** - * If true, render images that appear in content in a single carousel at the top - * If false, render images individually where they appear in content - */ - groupImagesInCarousel?: boolean -} - -/** - * Unified component for rendering media (images, videos, audio) from an event - * Handles deduplication, carousel grouping, and proper component selection - */ -export default function MediaRenderer({ - extractedMedia, - content, - className, - mustLoadMedia = false, - groupImagesInCarousel = true -}: MediaRendererProps) { - // Find which images appear in content (for carousel grouping) - const imagesInContent = useMemo(() => { - if (!content || !groupImagesInCarousel) return [] - - const urlRegex = /https?:\/\/[^\s<>"']+/g - const urlMatches = content.matchAll(urlRegex) - const imageUrls = new Set() - - for (const match of urlMatches) { - const url = match[0] - const cleaned = cleanUrl(url) - if (isImage(cleaned)) { - imageUrls.add(cleaned) - } - } - - // Get image info for URLs that appear in content - return extractedMedia.images.filter(img => imageUrls.has(img.url)) - }, [content, extractedMedia.images, groupImagesInCarousel]) - - // Images from tags only (not in content) go in separate carousel - const imagesFromTags = useMemo(() => { - if (!content || !groupImagesInCarousel) return extractedMedia.images - - const urlRegex = /https?:\/\/[^\s<>"']+/g - const urlMatches = content.matchAll(urlRegex) - const contentImageUrls = new Set() - - for (const match of urlMatches) { - const url = match[0] - const cleaned = cleanUrl(url) - if (isImage(cleaned)) { - contentImageUrls.add(cleaned) - } - } - - return extractedMedia.images.filter(img => !contentImageUrls.has(img.url)) - }, [content, extractedMedia.images, groupImagesInCarousel]) - - return ( -
- {/* Render images from content in a single carousel at the top */} - {groupImagesInCarousel && imagesInContent.length > 0 && ( - - )} - - {/* Render images from tags only (not in content) in a separate carousel */} - {groupImagesInCarousel && imagesFromTags.length > 0 && ( - - )} - - {/* Videos and audio should never be in carousel - they're rendered individually elsewhere */} - {/* This component just provides the extracted media data */} -
- ) -} - diff --git a/src/components/Note/Highlight/index.tsx b/src/components/Note/Highlight/index.tsx index 62a720a5..cafa2ab9 100644 --- a/src/components/Note/Highlight/index.tsx +++ b/src/components/Note/Highlight/index.tsx @@ -276,7 +276,7 @@ export default function Highlight({ // Events with special preview cards that should always use full preview const specialCardKinds = [ - kinds.LongFormArticle, // 30023 - has LongFormArticlePreview + kinds.LongFormArticle, // 30023 — long-form preview card ExtendedKind.POLL, // Has PollPreview ExtendedKind.DISCUSSION, // Has DiscussionNote ExtendedKind.VIDEO, // Has VideoNotePreview diff --git a/src/components/Note/LongFormArticlePreview.tsx b/src/components/Note/LongFormArticlePreview.tsx deleted file mode 100644 index 85975558..00000000 --- a/src/components/Note/LongFormArticlePreview.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' -import { toNote, toNoteList } from '@/lib/link' -import { useSecondaryPageOptional } from '@/PageManager' -import client from '@/services/client.service' -import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' -import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' -import { Event, kinds } from 'nostr-tools' -import { useMemo } from 'react' -import Image from '../Image' - -export default function LongFormArticlePreview({ - event, - className -}: { - event: Event - className?: string -}) { - const screenSize = useScreenSizeOptional() - const isSmallScreen = screenSize?.isSmallScreen ?? false - const secondaryPage = useSecondaryPageOptional() - const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) - const contentPolicy = useContentPolicyOptional() - const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true - const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) - - const handleCardClick = (e: React.MouseEvent) => { - e.stopPropagation() - client.addEventToCache(event) - push(toNote(event.id)) - } - - const titleComponent =
{metadata.title}
- - const tagsComponent = metadata.tags.length > 0 && ( -
- {metadata.tags.map((tag) => ( -
{ - e.stopPropagation() - push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) - }} - > - #{tag} -
- ))} -
- ) - - const summaryComponent = metadata.summary && ( -
{metadata.summary}
- ) - - if (isSmallScreen) { - return ( -
-
- {metadata.image && autoLoadMedia && ( - - )} -
- {titleComponent} - {summaryComponent} - {tagsComponent} -
-
-
- ) - } - - return ( -
-
-
- {metadata.image && autoLoadMedia && ( - - )} -
- {titleComponent} - {summaryComponent} - {tagsComponent} -
-
-
-
- ) -} diff --git a/src/components/NoteStats/DiscussionNoteStats.tsx b/src/components/NoteStats/DiscussionNoteStats.tsx deleted file mode 100644 index e907b958..00000000 --- a/src/components/NoteStats/DiscussionNoteStats.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { cn } from '@/lib/utils' -import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' -import { useNostr } from '@/providers/NostrProvider' -import { useScreenSize } from '@/providers/ScreenSizeProvider' -import noteStatsService from '@/services/note-stats.service' -import { Event } from 'nostr-tools' -import { useEffect, useState } from 'react' -import VoteButtons from './VoteButtons' - -export default function DiscussionNoteStats({ - event, - className, - classNames, - fetchIfNotExisting = false -}: { - event: Event - className?: string - classNames?: { - buttonBar?: string - } - fetchIfNotExisting?: boolean -}) { - const { isSmallScreen } = useScreenSize() - const { pubkey } = useNostr() - const { relays: statsRelays, key: statsRelaysKey } = useNoteStatsRelayHints() - const [loading, setLoading] = useState(false) - - useEffect(() => { - if (!fetchIfNotExisting) return - setLoading(true) - noteStatsService.fetchNoteStats(event, pubkey, statsRelays).finally(() => setLoading(false)) - }, [event.id, event.kind, event.created_at, event.sig, fetchIfNotExisting, pubkey, statsRelaysKey]) - - if (isSmallScreen) { - return ( -
-
- -
-
- ) - } - - return ( -
-
-
-
-
- -
-
-
- ) -} diff --git a/src/components/NoteStats/VoteButtons.tsx b/src/components/NoteStats/VoteButtons.tsx deleted file mode 100644 index f8365202..00000000 --- a/src/components/NoteStats/VoteButtons.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { Button } from '@/components/ui/button' -import { - DISCUSSION_DOWNVOTE, - DISCUSSION_UPVOTE, - isDiscussionDownvoteEmoji, - isDiscussionUpvoteEmoji -} from '@/lib/discussion-votes' -import { createReactionDraftEvent } from '@/lib/draft-event' -import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' -import { useNostr } from '@/providers/NostrProvider' -import noteStatsService from '@/services/note-stats.service' -import storage from '@/services/local-storage.service' -import { Event } from 'nostr-tools' -import { ChevronDown, ChevronUp } from 'lucide-react' -import { useMemo, useState } from 'react' -import { useNoteStatsById } from '@/hooks/useNoteStatsById' -import logger from '@/lib/logger' -import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' -import { useTranslation } from 'react-i18next' - -export default function VoteButtons({ event }: { event: Event }) { - const { t } = useTranslation() - const { pubkey, publish, checkLogin } = useNostr() - const { relays: statsRelays } = useNoteStatsRelayHints() - const [voting, setVoting] = useState(null) - const noteStats = useNoteStatsById(event.id) - - // Calculate vote counts and user's current vote - const { userVote, score } = useMemo(() => { - const stats = noteStats || {} - const reactions = stats.likes || [] - - const upvoteReactions = reactions.filter((r) => isDiscussionUpvoteEmoji(r.emoji)) - const downvoteReactions = reactions.filter((r) => isDiscussionDownvoteEmoji(r.emoji)) - - const score = upvoteReactions.length - downvoteReactions.length - - // Check if current user has voted - let userVote: 'up' | 'down' | null = null - if (pubkey) { - if (upvoteReactions.some(r => r.pubkey === pubkey)) { - userVote = 'up' - } else if (downvoteReactions.some(r => r.pubkey === pubkey)) { - userVote = 'down' - } - } - - return { userVote, score } - }, [noteStats, pubkey]) - - const vote = async (type: 'up' | 'down') => { - checkLogin(async () => { - if (voting || !pubkey) return - - // Prevent voting if user already voted (no toggling allowed) - if (userVote) { - return // User already voted, don't allow multiple votes - } - - setVoting(type) - const timer = setTimeout(() => setVoting(null), 10_000) - - try { - if (!noteStats?.updatedAt) { - await noteStatsService.fetchNoteStats(event, pubkey, statsRelays) - } - - // Create the vote reaction - const emoji = type === 'up' ? DISCUSSION_UPVOTE : DISCUSSION_DOWNVOTE - - // Check if user already voted this way - const existingVote = userVote === type - if (existingVote) { - // Remove vote by creating a reaction with the same emoji (this will toggle it off) - const reaction = createReactionDraftEvent(event, emoji) - const evt = await publish(reaction, { addClientTag: storage.getAddClientTag() }) - - // Show publishing feedback - if ((evt as any)?.relayStatuses) { - showPublishingFeedback({ - success: true, - relayStatuses: (evt as any).relayStatuses, - successCount: (evt as any).relayStatuses.filter((s: any) => s.success).length, - totalCount: (evt as any).relayStatuses.length - }, { - message: t('Vote removed'), - duration: 4000 - }) - } else { - showSimplePublishSuccess(t('Vote removed')) - } - - noteStatsService.updateNoteStatsByEvents([evt], undefined, { - interactionTargetNoteId: event.id - }) - } else { - // If user voted the opposite way, first remove the old vote - if (userVote) { - const oldEmoji = userVote === 'up' ? DISCUSSION_UPVOTE : DISCUSSION_DOWNVOTE - const removeReaction = createReactionDraftEvent(event, oldEmoji) - await publish(removeReaction, { addClientTag: storage.getAddClientTag() }) - } - - // Then add the new vote - const reaction = createReactionDraftEvent(event, emoji) - const evt = await publish(reaction, { addClientTag: storage.getAddClientTag() }) - - // Show publishing feedback - if ((evt as any)?.relayStatuses) { - showPublishingFeedback({ - success: true, - relayStatuses: (evt as any).relayStatuses, - successCount: (evt as any).relayStatuses.filter((s: any) => s.success).length, - totalCount: (evt as any).relayStatuses.length - }, { - message: t('Vote published'), - duration: 4000 - }) - } else { - showSimplePublishSuccess(t('Vote published')) - } - - noteStatsService.updateNoteStatsByEvents([evt], undefined, { - interactionTargetNoteId: event.id - }) - } - } catch (error) { - logger.error('Vote failed', { error, eventId: event.id }) - } finally { - setVoting(null) - clearTimeout(timer) - } - }) - } - - return ( -
- - - 0 ? 'text-green-600' : score < 0 ? 'text-red-600' : 'text-muted-foreground' - }`}> - {score} - - - -
- ) -} diff --git a/src/components/PostEditor/Title.tsx b/src/components/PostEditor/Title.tsx deleted file mode 100644 index a5adf4d2..00000000 --- a/src/components/PostEditor/Title.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { ExtendedKind } from '@/constants' -import { Event } from 'nostr-tools' -import { useTranslation } from 'react-i18next' - -export default function Title({ - parentEvent, - isPoll = false, - isPublicMessage = false -}: { - parentEvent?: Event - isPoll?: boolean - isPublicMessage?: boolean -}) { - const { t } = useTranslation() - - if (parentEvent) { - return ( -
-
- {parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE - ? t('Reply to Public Message') - : t('Reply to') - } -
-
- ) - } - - if (isPoll) { - return t('New Poll') - } - - if (isPublicMessage) { - return t('New Public Message') - } - - return t('New Note') -} diff --git a/src/components/Profile/Followings.tsx b/src/components/Profile/Followings.tsx deleted file mode 100644 index 980a3ad7..00000000 --- a/src/components/Profile/Followings.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useFetchFollowings } from '@/hooks' -import { toFollowingList } from '@/lib/link' -import { SecondaryPageLink } from '@/PageManager' -import { useFollowList } from '@/providers/follow-list-context' -import { useNostr } from '@/providers/NostrProvider' -import { Skeleton } from '@/components/ui/skeleton' -import { useTranslation } from 'react-i18next' - -export default function Followings({ pubkey }: { pubkey: string }) { - const { t } = useTranslation() - const { pubkey: accountPubkey } = useNostr() - const { followings: selfFollowings } = useFollowList() - const { followings, isFetching } = useFetchFollowings(pubkey) - - return ( - - {accountPubkey === pubkey ? ( - selfFollowings.length - ) : isFetching ? ( - - ) : ( - followings.length - )} -
{t('Following')}
-
- ) -} diff --git a/src/components/Profile/ProfileFeed.tsx b/src/components/Profile/ProfileFeed.tsx deleted file mode 100644 index 5a858c9b..00000000 --- a/src/components/Profile/ProfileFeed.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { ExtendedKind } from '@/constants' -import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' -import { kinds, Event } from 'nostr-tools' -import { forwardRef, useMemo } from 'react' -import { useZap } from '@/providers/ZapProvider' -import ProfileTimeline from './ProfileTimeline' - -const POST_KIND_LIST = [ - kinds.ShortTextNote, - kinds.Repost, - ExtendedKind.GENERIC_REPOST, - ExtendedKind.COMMENT, - ExtendedKind.DISCUSSION, - ExtendedKind.POLL, - ExtendedKind.CALENDAR_EVENT_DATE, - ExtendedKind.CALENDAR_EVENT_TIME, - ExtendedKind.ZAP_RECEIPT, - ExtendedKind.VOICE, - ExtendedKind.VOICE_COMMENT -] - -interface ProfileFeedProps { - pubkey: string - topSpace?: number - searchQuery?: string - kindFilter?: string - onEventsChange?: (events: Event[]) => void -} - -const ProfileFeed = forwardRef<{ refresh: () => void; getEvents?: () => Event[] }, ProfileFeedProps>( - ({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => { - const { zapReplyThreshold } = useZap() - - const filterPredicate = useMemo( - () => (event: Event) => { - if (event.kind === ExtendedKind.ZAP_RECEIPT) { - return shouldIncludeZapReceiptAtReplyThreshold(event, zapReplyThreshold) - } - return true - }, - [zapReplyThreshold] - ) - - const cacheKey = useMemo(() => `${pubkey}-posts-${zapReplyThreshold}`, [pubkey, zapReplyThreshold]) - - const getKindLabel = (kindValue: string) => { - if (!kindValue || kindValue === 'all') return 'posts' - const kindNum = parseInt(kindValue, 10) - if (kindNum === kinds.ShortTextNote) return 'notes' - if (kindNum === kinds.Repost || kindNum === ExtendedKind.GENERIC_REPOST) return 'boosts' - if (kindNum === ExtendedKind.COMMENT) return 'comments' - if (kindNum === ExtendedKind.DISCUSSION) return 'discussions' - if (kindNum === ExtendedKind.POLL) return 'polls' - if (kindNum === ExtendedKind.CALENDAR_EVENT_TIME || kindNum === ExtendedKind.CALENDAR_EVENT_DATE) - return 'calendar events' - if (kindNum === ExtendedKind.ZAP_RECEIPT) return 'zaps' - if (kindNum === ExtendedKind.VOICE) return 'voice posts' - if (kindNum === ExtendedKind.VOICE_COMMENT) return 'voice comments' - return 'posts' - } - - return ( - - ) - } -) - -ProfileFeed.displayName = 'ProfileFeed' - -export default ProfileFeed diff --git a/src/components/Profile/Relays.tsx b/src/components/Profile/Relays.tsx deleted file mode 100644 index d7dbdbd5..00000000 --- a/src/components/Profile/Relays.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useFetchRelayList } from '@/hooks' -import { toOthersRelaySettings, toRelaySettings } from '@/lib/link' -import { SecondaryPageLink } from '@/PageManager' -import { Skeleton } from '@/components/ui/skeleton' -import { useNostr } from '@/providers/NostrProvider' -import { useTranslation } from 'react-i18next' - -export default function Relays({ pubkey }: { pubkey: string }) { - const { t } = useTranslation() - const { pubkey: accountPubkey } = useNostr() - const { relayList, isFetching } = useFetchRelayList(pubkey) - - return ( - - {isFetching ? : relayList.originalRelays.length} -
{t('Relays')}
-
- ) -} diff --git a/src/components/RelayList/index.tsx b/src/components/RelayList/index.tsx deleted file mode 100644 index 5f35a5fc..00000000 --- a/src/components/RelayList/index.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { usePrimaryPage } from '@/contexts/primary-page-context' -import relayInfoService from '@/services/relay-info.service' -import { TRelayInfo } from '@/types' -import { useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo' -import SearchInput from '../SearchInput' - -export default function RelayList() { - const { t } = useTranslation() - const { navigate } = usePrimaryPage() - const [loading, setLoading] = useState(true) - const [relays, setRelays] = useState([]) - const [showCount, setShowCount] = useState(20) - const [input, setInput] = useState('') - const [debouncedInput, setDebouncedInput] = useState(input) - const bottomRef = useRef(null) - - useEffect(() => { - const search = async () => { - const relayInfos = await relayInfoService.search(debouncedInput) - setShowCount(20) - setRelays(relayInfos) - setLoading(false) - } - search() - }, [debouncedInput]) - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedInput(input) - }, 1000) - - return () => { - clearTimeout(handler) - } - }, [input]) - - useEffect(() => { - const options = { - root: null, - rootMargin: '10px', - threshold: 1 - } - - const observerInstance = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && showCount < relays.length) { - setShowCount((prev) => prev + 20) - } - }, options) - - const currentBottomRef = bottomRef.current - if (currentBottomRef) { - observerInstance.observe(currentBottomRef) - } - - return () => { - if (observerInstance && currentBottomRef) { - observerInstance.unobserve(currentBottomRef) - } - } - }, [showCount, relays]) - - const handleInputChange = (e: React.ChangeEvent) => { - setInput(e.target.value) - } - - return ( -
-
- -
- {relays.slice(0, showCount).map((relay) => ( - { - e.stopPropagation() - navigate('relay', { url: relay.url }) - }} - /> - ))} - {showCount < relays.length &&
} - {loading && } - {!loading && relays.length === 0 && ( -
{t('no relays found')}
- )} -
- ) -} diff --git a/src/components/SearchInfo.tsx b/src/components/SearchInfo.tsx deleted file mode 100644 index 0b95ceb4..00000000 --- a/src/components/SearchInfo.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { Info, BookOpen } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, - DrawerTrigger -} from '@/components/ui/drawer' -import { useScreenSize } from '@/providers/ScreenSizeProvider' -import { cn } from '@/lib/utils' - -export default function SearchInfo() { - const { isSmallScreen } = useScreenSize() - - const searchInfoContent = ( -
-
-

Search Parameters

-
-
- Plain text: Searches by d-tag for replaceable events (normalized, hyphenated) -
-
- Event IDs: Bare event IDs work as standard search (hex, note1, nevent1, naddr1) -
-
- Filters: -
    -
  • t:hashtag or hashtag:hashtag - Filter by hashtag (t-tag)
  • -
  • Multiple values supported: t:bitcoin,nostr
  • -
-
-
- Kind filter: Use URL parameter k= with other filters (e.g., ?t=bitcoin&k=1 or ?t=testfile&k=30023). Cannot be used alone. -
-
-

- Examples: -

-
    -
  • jumble search → searches d-tag
  • -
  • t:bitcoin → hashtag search
  • -
  • note1abc... → searches for event ID
  • -
-
-
-
- -
- ) - - if (isSmallScreen) { - return ( - - - - - - - Advanced Search Help - - Learn about available search parameters - - -
- {searchInfoContent} -
- - - - -
-
- ) - } - - return ( - - - - - -

Advanced Search Help

- {searchInfoContent} -
-
- ) -} - diff --git a/src/components/SuggestedEmojis/DiscussionEmojis.tsx b/src/components/SuggestedEmojis/DiscussionEmojis.tsx deleted file mode 100644 index 99dc5ccb..00000000 --- a/src/components/SuggestedEmojis/DiscussionEmojis.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { - DISCUSSION_DOWNVOTE_DISPLAY, - DISCUSSION_UPVOTE_DISPLAY, - DISCUSSION_VOTE_EMOJIS -} from '@/lib/discussion-votes' -import { TEmoji } from '@/types' - -const GLYPHS = [DISCUSSION_UPVOTE_DISPLAY, DISCUSSION_DOWNVOTE_DISPLAY] as const - -export default function DiscussionEmojis({ - onEmojiClick -}: { - onEmojiClick: (emoji: string | TEmoji) => void -}) { - return ( -
e.stopPropagation()}> - {DISCUSSION_VOTE_EMOJIS.map((emoji, i) => ( -
onEmojiClick(emoji)} - > - {GLYPHS[i]} -
- ))} -
- ) -} diff --git a/src/components/SuggestedEmojis/index.tsx b/src/components/SuggestedEmojis/index.tsx deleted file mode 100644 index ce3b6445..00000000 --- a/src/components/SuggestedEmojis/index.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Button } from '@/components/ui/button' -import { DEFAULT_SUGGESTED_EMOJIS } from '@/lib/like-reaction-emojis' -import { getRecentlyUsedEmojis } from '@/lib/recently-used-emojis' -import { TEmoji } from '@/types' -import { MoreHorizontal } from 'lucide-react' -import { useEffect, useState } from 'react' -import Emoji from '../Emoji' - -export default function SuggestedEmojis({ - onEmojiClick, - onMoreButtonClick -}: { - onEmojiClick: (emoji: string | TEmoji) => void - onMoreButtonClick: () => void -}) { - const [suggestedEmojis, setSuggestedEmojis] = - useState<(string | TEmoji)[]>(() => [...DEFAULT_SUGGESTED_EMOJIS]) - - useEffect(() => { - try { - const recent = getRecentlyUsedEmojis() - if (recent.length === 0) return - - const emojiSet = new Set() - const merged = [...recent, ...DEFAULT_SUGGESTED_EMOJIS].filter((emoji) => { - const key = typeof emoji === 'string' ? emoji : emoji.shortcode - if (emojiSet.has(key)) return false - emojiSet.add(key) - return true - }) - setSuggestedEmojis(merged.slice(0, 9)) - } catch { - // ignore - } - }, []) - - return ( -
e.stopPropagation()}> -
onEmojiClick('+')} - > - -
- {suggestedEmojis.map((emoji, index) => - typeof emoji === 'string' ? ( -
onEmojiClick(emoji)} - > - {emoji} -
- ) : ( -
onEmojiClick(emoji)} - > - -
- ) - )} - -
- ) -} diff --git a/src/components/TextareaWithMentionAutocomplete/index.tsx b/src/components/TextareaWithMentionAutocomplete/index.tsx deleted file mode 100644 index 28f9bc87..00000000 --- a/src/components/TextareaWithMentionAutocomplete/index.tsx +++ /dev/null @@ -1,373 +0,0 @@ -import { Textarea } from '@/components/ui/textarea' -import MentionList from '@/components/PostEditor/PostTextarea/Mention/MentionList' -import { NEVENT_NADDR_PICKER_ID } from '@/components/PostEditor/PostTextarea/Mention/constants' -import { useNeventPicker } from '@/components/PostEditor/PostTextarea/Mention/useNeventPicker' -import { EmojiList } from '@/components/PostEditor/PostTextarea/Emoji/EmojiList' -import { - searchNpubsForMention, - type PickerSearchMode -} from '@/services/mention-event-search.service' -import { useNostr } from '@/providers/NostrProvider' -import customEmojiService from '@/services/custom-emoji.service' -import { searchStandardEmojiShortcodes } from '@/lib/emoji-content' -import { createPortal } from 'react-dom' -import { forwardRef, useCallback, useEffect, useRef, useState } from 'react' - -const MENTION_LIMIT = 20 -const MENTION_INSERT_PREFIX = 'nostr:' -const EMOJI_LIMIT = 25 - -export type TextareaWithMentionAutocompleteProps = Omit< - React.ComponentProps, - 'value' | 'onChange' -> & { - value: string - onChange: (value: string) => void - /** When provided, used to open the nevent/naddr picker when user selects that option. Use when context may be unavailable (e.g. modal). */ - onOpenNeventPicker?: (onSelected: (link: string) => void, initialMode?: PickerSearchMode) => void -} - -/** - * Plain textarea with @-mention autocomplete (same npub search as post form). - * When user types @query, shows a dropdown of matching profiles; on select inserts nostr:npub... - */ -const TextareaWithMentionAutocomplete = forwardRef(function TextareaWithMentionAutocomplete({ - value, - onChange, - onKeyDown, - onOpenNeventPicker, - ...textareaProps -}, refProp) { - const [mentionOpen, setMentionOpen] = useState(false) - const [mentionQuery, setMentionQuery] = useState('') - const [mentionItems, setMentionItems] = useState([]) - const [mentionStart, setMentionStart] = useState(0) - const [selectedIndex, setSelectedIndex] = useState(0) - const [emojiOpen, setEmojiOpen] = useState(false) - const [emojiQuery, setEmojiQuery] = useState('') - const [emojiItems, setEmojiItems] = useState([]) - const [emojiStart, setEmojiStart] = useState(0) - const [selectedEmojiIndex, setSelectedEmojiIndex] = useState(0) - const textareaRef = useRef(null) - const searchTimeoutRef = useRef | null>(null) - const emojiSearchTimeoutRef = useRef | null>(null) - const mentionQueryRef = useRef(mentionQuery) - const neventPicker = useNeventPicker() - const { pubkey } = useNostr() - mentionQueryRef.current = mentionQuery - const [dropdownRect, setDropdownRect] = useState<{ top: number; left: number; width: number } | null>(null) - - const closeMention = useCallback(() => { - setMentionOpen(false) - setMentionQuery('') - setMentionItems([]) - }, []) - - const closeEmoji = useCallback(() => { - setEmojiOpen(false) - setEmojiQuery('') - setEmojiItems([]) - }, []) - - // When value is cleared or changed from outside, or @/: segment is gone, close dropdowns so they don't linger - useEffect(() => { - if (!value) { - closeMention() - closeEmoji() - return - } - if (mentionOpen) { - if (value.length <= mentionStart || value[mentionStart] !== '@' || !value.includes('@')) { - closeMention() - } - } - if (emojiOpen) { - if (value.length <= emojiStart || value[emojiStart] !== ':') { - closeEmoji() - } - } - }, [value, mentionOpen, emojiOpen, mentionStart, emojiStart, closeMention, closeEmoji]) - - /** Find end of @-mention segment in value (from start, after the @): alphanumeric, underscore, hyphen, dot (NIP-05). */ - const findMentionSegmentEnd = useCallback((val: string, from: number) => { - let i = from + 1 - while (i < val.length && /[\w.-]/.test(val[i]!)) i++ - return i - }, []) - - const insertMention = useCallback( - (id: string) => { - const ta = textareaRef.current - if (!ta) return - const start = mentionStart - const end = findMentionSegmentEnd(value, start) - const before = value.slice(0, start) - const after = value.slice(end) - - const openPicker = onOpenNeventPicker ?? neventPicker?.openNeventPicker - if (id === NEVENT_NADDR_PICKER_ID && openPicker) { - closeMention() - const initialMode: PickerSearchMode = - mentionQuery.trim().toLowerCase().startsWith('naddr') ? 'naddr' : 'nevent' - openPicker((link: string) => { - const insert = link + ' ' - onChange(before + insert + after) - setTimeout(() => { - ta.focus() - const newPos = start + insert.length - ta.setSelectionRange(newPos, newPos) - }, 0) - }, initialMode) - return - } - - const insert = MENTION_INSERT_PREFIX + id - onChange(before + insert + after) - closeMention() - setTimeout(() => { - ta.focus() - const newPos = start + insert.length - ta.setSelectionRange(newPos, newPos) - }, 0) - }, - [value, mentionStart, onChange, closeMention, onOpenNeventPicker, neventPicker, findMentionSegmentEnd] - ) - - const insertEmoji = useCallback( - (shortcode: string) => { - const ta = textareaRef.current - if (!ta) return - const end = emojiStart + 1 + emojiQuery.length - const before = value.slice(0, emojiStart) - const after = value.slice(end) - const insert = `:${shortcode}:` - onChange(before + insert + after) - closeEmoji() - setTimeout(() => { - ta.focus() - const newPos = emojiStart + insert.length - ta.setSelectionRange(newPos, newPos) - }, 0) - }, - [value, emojiStart, emojiQuery.length, onChange, closeEmoji] - ) - - useEffect(() => { - if (!mentionQuery.trim()) { - setMentionItems([]) - setMentionOpen(false) - return - } - const q = mentionQuery.trim().toLowerCase() - if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) { - setMentionItems([NEVENT_NADDR_PICKER_ID]) - setMentionOpen(true) - setSelectedIndex(0) - return - } - if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) - searchTimeoutRef.current = setTimeout(() => { - searchNpubsForMention(mentionQuery.trim(), MENTION_LIMIT) - .then((npubs) => { - const q = mentionQueryRef.current.trim().toLowerCase() - if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) { - return - } - const list = npubs ?? [] - setMentionItems(list) - setMentionOpen(list.length > 0) - setSelectedIndex(0) - }) - .catch(() => { - const q = mentionQueryRef.current.trim().toLowerCase() - if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) { - return - } - setMentionItems([]) - setMentionOpen(false) - }) - }, 150) - return () => { - if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) - } - }, [mentionQuery]) - - useEffect(() => { - if (!emojiQuery.trim()) { - setEmojiItems([]) - setEmojiOpen(false) - return - } - const q = emojiQuery.trim().toLowerCase() - if (emojiSearchTimeoutRef.current) clearTimeout(emojiSearchTimeoutRef.current) - emojiSearchTimeoutRef.current = setTimeout(() => { - Promise.all([ - customEmojiService.searchEmojis(q, pubkey ?? null), - Promise.resolve(searchStandardEmojiShortcodes(q, EMOJI_LIMIT)) - ]).then(([custom, standard]) => { - const customSet = new Set(custom) - const merged = [...custom, ...standard.filter((s) => !customSet.has(s))].slice(0, 50) - setEmojiItems(merged) - setEmojiOpen(merged.length > 0) - setSelectedEmojiIndex(0) - }) - }, 150) - return () => { - if (emojiSearchTimeoutRef.current) clearTimeout(emojiSearchTimeoutRef.current) - } - }, [emojiQuery, pubkey]) - - const open = (emojiOpen && emojiItems.length > 0) || (mentionOpen && mentionItems.length > 0) - useEffect(() => { - if (!open) { - setDropdownRect(null) - return - } - const el = textareaRef.current - if (!el) return - const update = () => { - const r = el.getBoundingClientRect() - setDropdownRect({ top: r.bottom + 4, left: r.left, width: r.width }) - } - update() - window.addEventListener('resize', update) - return () => window.removeEventListener('resize', update) - }, [open]) - - const handleChange = (e: React.ChangeEvent) => { - const v = e.target.value - const cursor = e.target.selectionStart ?? v.length - onChange(v) - - const textBeforeCursor = v.slice(0, cursor) - const lastAt = textBeforeCursor.lastIndexOf('@') - const lastColon = textBeforeCursor.lastIndexOf(':') - const segmentAfterColon = lastColon >= 0 ? textBeforeCursor.slice(lastColon + 1) : '' - const segmentAfterAt = lastAt >= 0 ? textBeforeCursor.slice(lastAt + 1) : '' - - const inEmoji = lastColon >= 0 && !/\s/.test(segmentAfterColon) && (lastColon > lastAt || lastAt === -1) - const inMention = lastAt >= 0 && !/\s/.test(segmentAfterAt) - - if (inEmoji) { - closeMention() - setEmojiStart(lastColon) - setEmojiQuery(segmentAfterColon) - return - } - if (inMention) { - closeEmoji() - setMentionStart(lastAt) - setMentionQuery(segmentAfterAt) - return - } - closeMention() - closeEmoji() - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (emojiOpen && emojiItems.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault() - setSelectedEmojiIndex((i) => (i + 1) % emojiItems.length) - return - } - if (e.key === 'ArrowUp') { - e.preventDefault() - setSelectedEmojiIndex((i) => (i + emojiItems.length - 1) % emojiItems.length) - return - } - if (e.key === 'Enter') { - e.preventDefault() - insertEmoji(emojiItems[selectedEmojiIndex]!) - return - } - if (e.key === 'Escape') { - e.preventDefault() - closeEmoji() - return - } - } - if (mentionOpen && mentionItems.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault() - setSelectedIndex((i) => (i + 1) % mentionItems.length) - return - } - if (e.key === 'ArrowUp') { - e.preventDefault() - setSelectedIndex((i) => (i + mentionItems.length - 1) % mentionItems.length) - return - } - if (e.key === 'Enter') { - e.preventDefault() - insertMention(mentionItems[selectedIndex]!) - return - } - if (e.key === 'Escape') { - e.preventDefault() - closeMention() - return - } - } - onKeyDown?.(e) - } - - const setRef = (el: HTMLTextAreaElement | null) => { - textareaRef.current = el - if (typeof refProp === 'function') { - refProp(el) - } else if (refProp) { - (refProp as React.MutableRefObject).current = el - } - } - - const dropdownContent = - dropdownRect && typeof document !== 'undefined' - ? createPortal( -
- {emojiOpen && emojiItems.length > 0 && ( - name != null && insertEmoji(name)} - selectedIndex={selectedEmojiIndex} - onSelectIndex={setSelectedEmojiIndex} - /> - )} - {mentionOpen && mentionItems.length > 0 && !emojiOpen && ( - insertMention(id as string)} - selectedIndex={selectedIndex} - onSelectIndex={setSelectedIndex} - /> - )} -
, - document.body - ) - : null - - return ( -
-