33 changed files with 8 additions and 3105 deletions
@ -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 ( |
|
||||||
<DropdownMenu> |
|
||||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> |
|
||||||
<Button variant="ghost" size="icon" className="shrink-0" aria-label="Export article"> |
|
||||||
<MoreVertical className="h-4 w-4" /> |
|
||||||
</Button> |
|
||||||
</DropdownMenuTrigger> |
|
||||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}> |
|
||||||
<DropdownMenuItem onClick={exportArticle}> |
|
||||||
<FileDown className="mr-2 h-4 w-4" /> |
|
||||||
Export as {format.label} |
|
||||||
</DropdownMenuItem> |
|
||||||
</DropdownMenuContent> |
|
||||||
</DropdownMenu> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
@ -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<HTMLDivElement | null>(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 ( |
|
||||||
<div className="mt-2 text-sm text-center text-muted-foreground"> |
|
||||||
{t('no bookmarks found')} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div> |
|
||||||
{eventIds.slice(0, showCount).map((eventId) => ( |
|
||||||
<BookmarkedNote key={eventId} eventId={eventId} /> |
|
||||||
))} |
|
||||||
|
|
||||||
{showCount < eventIds.length ? ( |
|
||||||
<div ref={bottomRef}> |
|
||||||
<NoteCardLoadingSkeleton /> |
|
||||||
</div> |
|
||||||
) : ( |
|
||||||
<div className="text-center text-sm text-muted-foreground mt-2"> |
|
||||||
{t('no more bookmarks')} |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
) |
|
||||||
}) |
|
||||||
|
|
||||||
BookmarkList.displayName = 'BookmarkList' |
|
||||||
export default BookmarkList |
|
||||||
|
|
||||||
function BookmarkedNote({ eventId }: { eventId: string }) { |
|
||||||
const { event, isFetching } = useFetchEvent(eventId) |
|
||||||
|
|
||||||
if (isFetching) { |
|
||||||
return <NoteCardLoadingSkeleton /> |
|
||||||
} |
|
||||||
|
|
||||||
if (!event) { |
|
||||||
return null |
|
||||||
} |
|
||||||
|
|
||||||
return <NoteCard event={event} className="w-full" /> |
|
||||||
} |
|
||||||
@ -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 ( |
|
||||||
<div className="space-y-2"> |
|
||||||
<div className="font-semibold text-center">{t('Platinum Sponsors')}</div> |
|
||||||
<div className="flex flex-col gap-2 items-center"> |
|
||||||
<div |
|
||||||
className="flex items-center gap-4 cursor-pointer" |
|
||||||
onClick={() => window.open('https://opensats.org/', '_blank')} |
|
||||||
> |
|
||||||
<Image |
|
||||||
image={{ |
|
||||||
url: OpenSatsLogo |
|
||||||
}} |
|
||||||
className="h-11" |
|
||||||
/> |
|
||||||
<div className="text-2xl font-semibold">OpenSats</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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<TRecentSupporter[]>([]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const init = async () => { |
|
||||||
const items = await lightning.fetchRecentSupporters() |
|
||||||
setSupporters(items) |
|
||||||
} |
|
||||||
init() |
|
||||||
}, []) |
|
||||||
|
|
||||||
if (!supporters.length) return null |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="space-y-2"> |
|
||||||
<div className="font-semibold text-center">{t('Recent Supporters')}</div> |
|
||||||
<div className="flex flex-col gap-2"> |
|
||||||
{supporters.map((item, index) => ( |
|
||||||
<div |
|
||||||
key={index} |
|
||||||
className="flex items-center justify-between rounded-md border p-2 sm:p-4 gap-2" |
|
||||||
> |
|
||||||
<div className="flex items-center gap-2 flex-1 w-0"> |
|
||||||
<UserAvatar userId={item.pubkey} /> |
|
||||||
<div className="flex-1 w-0"> |
|
||||||
<Username className="font-semibold w-fit" userId={item.pubkey} /> |
|
||||||
<div className="text-xs text-muted-foreground line-clamp-3 select-text"> |
|
||||||
{item.comment} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
<div className="font-semibold text-yellow-400 shrink-0"> |
|
||||||
{formatAmount(item.amount)} {t('sats')} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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 ( |
|
||||||
<> |
|
||||||
<div className={`relative ${className}`}> |
|
||||||
{/* Thumbnail grid */} |
|
||||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-2"> |
|
||||||
{images.map((image, index) => ( |
|
||||||
<div |
|
||||||
key={index} |
|
||||||
className={`aspect-square rounded-lg overflow-hidden cursor-pointer transition-all duration-200 ${ |
|
||||||
index === currentIndex
|
|
||||||
? 'ring-2 ring-blue-500 ring-offset-2'
|
|
||||||
: 'hover:opacity-80' |
|
||||||
}`}
|
|
||||||
onClick={() => setCurrentIndex(index)} |
|
||||||
> |
|
||||||
{image.m?.startsWith('video/') ? ( |
|
||||||
<video |
|
||||||
src={image.url} |
|
||||||
className="w-full h-full object-cover" |
|
||||||
controls |
|
||||||
preload="metadata" |
|
||||||
/> |
|
||||||
) : ( |
|
||||||
<ImageWithLightbox |
|
||||||
image={image} |
|
||||||
className="w-full h-full object-cover" |
|
||||||
classNames={{ |
|
||||||
wrapper: 'w-full h-full' |
|
||||||
}} |
|
||||||
/> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
|
|
||||||
{/* Main image display */} |
|
||||||
{images.length > 0 && ( |
|
||||||
<div className="mt-4 relative"> |
|
||||||
<div className="relative rounded-lg overflow-hidden bg-muted"> |
|
||||||
{currentImage.m?.startsWith('video/') ? ( |
|
||||||
<video |
|
||||||
src={currentImage.url} |
|
||||||
className="w-full max-w-[400px] h-auto object-contain mx-auto" |
|
||||||
controls |
|
||||||
preload="metadata" |
|
||||||
onClick={openFullscreen} |
|
||||||
/> |
|
||||||
) : ( |
|
||||||
<div onClick={openFullscreen} className="cursor-pointer"> |
|
||||||
<ImageWithLightbox |
|
||||||
image={currentImage} |
|
||||||
className="w-full max-w-[400px] h-auto object-contain mx-auto" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
)} |
|
||||||
|
|
||||||
{/* Navigation arrows */} |
|
||||||
{images.length > 1 && ( |
|
||||||
<> |
|
||||||
<button |
|
||||||
onClick={goToPrevious} |
|
||||||
className="absolute left-2 top-1/2 transform -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors" |
|
||||||
aria-label="Previous image" |
|
||||||
> |
|
||||||
<ChevronLeft className="w-5 h-5" /> |
|
||||||
</button> |
|
||||||
<button |
|
||||||
onClick={goToNext} |
|
||||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors" |
|
||||||
aria-label="Next image" |
|
||||||
> |
|
||||||
<ChevronRight className="w-5 h-5" /> |
|
||||||
</button> |
|
||||||
</> |
|
||||||
)} |
|
||||||
|
|
||||||
{/* Image counter */} |
|
||||||
{images.length > 1 && ( |
|
||||||
<div className="absolute bottom-2 right-2 bg-black/50 text-white text-sm px-2 py-1 rounded"> |
|
||||||
{currentIndex + 1} / {images.length} |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
|
|
||||||
{/* Fullscreen modal */} |
|
||||||
{isFullscreen && ( |
|
||||||
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"> |
|
||||||
<button |
|
||||||
onClick={closeFullscreen} |
|
||||||
className="absolute top-4 right-4 text-white hover:text-gray-300 z-10" |
|
||||||
aria-label="Close fullscreen" |
|
||||||
> |
|
||||||
<X className="w-8 h-8" /> |
|
||||||
</button> |
|
||||||
|
|
||||||
<div className="relative max-w-full max-h-full"> |
|
||||||
{currentImage.m?.startsWith('video/') ? ( |
|
||||||
<video |
|
||||||
src={currentImage.url} |
|
||||||
className="max-w-full max-h-full object-contain" |
|
||||||
controls |
|
||||||
autoPlay |
|
||||||
preload="metadata" |
|
||||||
/> |
|
||||||
) : ( |
|
||||||
<ImageWithLightbox |
|
||||||
image={currentImage} |
|
||||||
className="max-w-full max-h-full object-contain" |
|
||||||
/> |
|
||||||
)} |
|
||||||
|
|
||||||
{/* Fullscreen navigation */} |
|
||||||
{images.length > 1 && ( |
|
||||||
<> |
|
||||||
<button |
|
||||||
onClick={goToPrevious} |
|
||||||
className="absolute left-4 top-1/2 transform -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-3 transition-colors" |
|
||||||
aria-label="Previous image" |
|
||||||
> |
|
||||||
<ChevronLeft className="w-6 h-6" /> |
|
||||||
</button> |
|
||||||
<button |
|
||||||
onClick={goToNext} |
|
||||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-3 transition-colors" |
|
||||||
aria-label="Next image" |
|
||||||
> |
|
||||||
<ChevronRight className="w-6 h-6" /> |
|
||||||
</button> |
|
||||||
|
|
||||||
{/* Fullscreen counter */} |
|
||||||
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-black/50 text-white text-lg px-4 py-2 rounded"> |
|
||||||
{currentIndex + 1} / {images.length} |
|
||||||
</div> |
|
||||||
</> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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<string>() |
|
||||||
|
|
||||||
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<string>() |
|
||||||
|
|
||||||
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 ( |
|
||||||
<div className={cn(className)}> |
|
||||||
{/* Render images from content in a single carousel at the top */} |
|
||||||
{groupImagesInCarousel && imagesInContent.length > 0 && ( |
|
||||||
<ImageGallery |
|
||||||
className="mt-2 mb-4" |
|
||||||
key="content-images-gallery" |
|
||||||
images={imagesInContent} |
|
||||||
start={0} |
|
||||||
end={imagesInContent.length} |
|
||||||
mustLoad={mustLoadMedia} |
|
||||||
/> |
|
||||||
)} |
|
||||||
|
|
||||||
{/* Render images from tags only (not in content) in a separate carousel */} |
|
||||||
{groupImagesInCarousel && imagesFromTags.length > 0 && ( |
|
||||||
<ImageGallery |
|
||||||
className="mt-2 mb-4" |
|
||||||
key="tag-images-gallery" |
|
||||||
images={imagesFromTags} |
|
||||||
start={0} |
|
||||||
end={imagesFromTags.length} |
|
||||||
mustLoad={mustLoadMedia} |
|
||||||
/> |
|
||||||
)} |
|
||||||
|
|
||||||
{/* Videos and audio should never be in carousel - they're rendered individually elsewhere */} |
|
||||||
{/* This component just provides the extracted media data */} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
@ -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 = <div className="text-xl font-semibold break-words min-w-0 sm:line-clamp-2">{metadata.title}</div> |
|
||||||
|
|
||||||
const tagsComponent = metadata.tags.length > 0 && ( |
|
||||||
<div className="flex gap-1 flex-wrap"> |
|
||||||
{metadata.tags.map((tag) => ( |
|
||||||
<div |
|
||||||
key={tag} |
|
||||||
className="flex items-center rounded-full text-xs px-2.5 py-0.5 bg-muted text-muted-foreground max-w-32 cursor-pointer hover:bg-accent hover:text-accent-foreground" |
|
||||||
onClick={(e) => { |
|
||||||
e.stopPropagation() |
|
||||||
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) |
|
||||||
}} |
|
||||||
> |
|
||||||
#<span className="truncate">{tag}</span> |
|
||||||
</div> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
) |
|
||||||
|
|
||||||
const summaryComponent = metadata.summary && ( |
|
||||||
<div className="text-base text-muted-foreground line-clamp-4">{metadata.summary}</div> |
|
||||||
) |
|
||||||
|
|
||||||
if (isSmallScreen) { |
|
||||||
return ( |
|
||||||
<div className={className}> |
|
||||||
<div
|
|
||||||
className="cursor-pointer rounded-lg border p-4 hover:bg-muted/50 transition-colors" |
|
||||||
onClick={handleCardClick} |
|
||||||
> |
|
||||||
{metadata.image && autoLoadMedia && ( |
|
||||||
<Image |
|
||||||
image={{ url: metadata.image, pubkey: event.pubkey }} |
|
||||||
className="w-full max-w-[400px] aspect-video mb-3" |
|
||||||
hideIfError |
|
||||||
/> |
|
||||||
)} |
|
||||||
<div className="space-y-1"> |
|
||||||
{titleComponent} |
|
||||||
{summaryComponent} |
|
||||||
{tagsComponent} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={className}> |
|
||||||
<div
|
|
||||||
className="cursor-pointer rounded-lg border p-4 hover:bg-muted/50 transition-colors" |
|
||||||
onClick={handleCardClick} |
|
||||||
> |
|
||||||
<div className="flex gap-4"> |
|
||||||
{metadata.image && autoLoadMedia && ( |
|
||||||
<Image |
|
||||||
image={{ url: metadata.image, pubkey: event.pubkey }} |
|
||||||
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44 max-w-[400px]" |
|
||||||
hideIfError |
|
||||||
/> |
|
||||||
)} |
|
||||||
<div className="flex-1 w-0 space-y-1"> |
|
||||||
{titleComponent} |
|
||||||
{summaryComponent} |
|
||||||
{tagsComponent} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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 ( |
|
||||||
<div className={cn('select-none', className)}> |
|
||||||
<div |
|
||||||
className={cn( |
|
||||||
'flex justify-between items-center h-5 [&_svg]:size-5', |
|
||||||
loading ? 'animate-pulse' : '', |
|
||||||
classNames?.buttonBar |
|
||||||
)} |
|
||||||
> |
|
||||||
<VoteButtons event={event} /> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={cn('select-none', className)}> |
|
||||||
<div className="flex justify-between h-5 [&_svg]:size-4"> |
|
||||||
<div |
|
||||||
className={cn('flex items-center gap-2', loading ? 'animate-pulse' : '')} |
|
||||||
> |
|
||||||
</div> |
|
||||||
<div className="flex items-center gap-2"> |
|
||||||
<VoteButtons event={event} /> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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<string | null>(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 ( |
|
||||||
<div className="flex flex-col items-center gap-1"> |
|
||||||
<Button |
|
||||||
variant="ghost" |
|
||||||
size="sm" |
|
||||||
className={`h-6 w-6 p-0 hover:bg-muted hover:text-foreground ${ |
|
||||||
userVote === 'up' ? 'bg-muted text-foreground' : 'text-muted-foreground' |
|
||||||
}`}
|
|
||||||
onClick={() => vote('up')} |
|
||||||
disabled={voting !== null || userVote !== null} |
|
||||||
> |
|
||||||
<ChevronUp className={`h-4 w-4 ${userVote === 'up' ? 'font-bold stroke-2 text-foreground' : ''}`} /> |
|
||||||
</Button> |
|
||||||
|
|
||||||
<span className={`text-xs font-medium min-w-[20px] text-center ${ |
|
||||||
score > 0 ? 'text-green-600' : score < 0 ? 'text-red-600' : 'text-muted-foreground' |
|
||||||
}`}>
|
|
||||||
{score} |
|
||||||
</span> |
|
||||||
|
|
||||||
<Button |
|
||||||
variant="ghost" |
|
||||||
size="sm" |
|
||||||
className={`h-6 w-6 p-0 hover:bg-muted hover:text-foreground ${ |
|
||||||
userVote === 'down' ? 'bg-muted text-foreground' : 'text-muted-foreground' |
|
||||||
}`}
|
|
||||||
onClick={() => vote('down')} |
|
||||||
disabled={voting !== null || userVote !== null} |
|
||||||
> |
|
||||||
<ChevronDown className={`h-4 w-4 ${userVote === 'down' ? 'font-bold stroke-2 text-foreground' : ''}`} /> |
|
||||||
</Button> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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 ( |
|
||||||
<div className="flex gap-2 items-center w-full"> |
|
||||||
<div className="shrink-0"> |
|
||||||
{parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE
|
|
||||||
? t('Reply to Public Message') |
|
||||||
: t('Reply to') |
|
||||||
} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
if (isPoll) { |
|
||||||
return t('New Poll') |
|
||||||
} |
|
||||||
|
|
||||||
if (isPublicMessage) { |
|
||||||
return t('New Public Message') |
|
||||||
} |
|
||||||
|
|
||||||
return t('New Note') |
|
||||||
} |
|
||||||
@ -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 ( |
|
||||||
<SecondaryPageLink |
|
||||||
to={toFollowingList(pubkey)} |
|
||||||
className="flex gap-1 hover:underline w-fit items-center" |
|
||||||
> |
|
||||||
{accountPubkey === pubkey ? ( |
|
||||||
selfFollowings.length |
|
||||||
) : isFetching ? ( |
|
||||||
<Skeleton className="inline-block size-4 shrink-0 rounded-sm" aria-hidden /> |
|
||||||
) : ( |
|
||||||
followings.length |
|
||||||
)} |
|
||||||
<div className="text-muted-foreground">{t('Following')}</div> |
|
||||||
</SecondaryPageLink> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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 ( |
|
||||||
<ProfileTimeline |
|
||||||
ref={ref} |
|
||||||
pubkey={pubkey} |
|
||||||
topSpace={topSpace} |
|
||||||
searchQuery={searchQuery} |
|
||||||
kindFilter={kindFilter} |
|
||||||
onEventsChange={onEventsChange} |
|
||||||
kinds={POST_KIND_LIST} |
|
||||||
cacheKey={cacheKey} |
|
||||||
filterPredicate={filterPredicate} |
|
||||||
getKindLabel={getKindLabel} |
|
||||||
refreshLabel="Refreshing posts..." |
|
||||||
emptyLabel="No posts found" |
|
||||||
emptySearchLabel="No posts match your search" |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
) |
|
||||||
|
|
||||||
ProfileFeed.displayName = 'ProfileFeed' |
|
||||||
|
|
||||||
export default ProfileFeed |
|
||||||
@ -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 ( |
|
||||||
<SecondaryPageLink |
|
||||||
to={accountPubkey === pubkey ? toRelaySettings('mailbox') : toOthersRelaySettings(pubkey)} |
|
||||||
className="flex gap-1 hover:underline w-fit items-center" |
|
||||||
> |
|
||||||
{isFetching ? <Skeleton className="inline-block size-4 shrink-0 rounded-sm" aria-hidden /> : relayList.originalRelays.length} |
|
||||||
<div className="text-muted-foreground">{t('Relays')}</div> |
|
||||||
</SecondaryPageLink> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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<TRelayInfo[]>([]) |
|
||||||
const [showCount, setShowCount] = useState(20) |
|
||||||
const [input, setInput] = useState('') |
|
||||||
const [debouncedInput, setDebouncedInput] = useState(input) |
|
||||||
const bottomRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => { |
|
||||||
setInput(e.target.value) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div> |
|
||||||
<div className="px-4 py-2"> |
|
||||||
<SearchInput placeholder={t('Search relays')} value={input} onChange={handleInputChange} /> |
|
||||||
</div> |
|
||||||
{relays.slice(0, showCount).map((relay) => ( |
|
||||||
<RelaySimpleInfo |
|
||||||
key={relay.url} |
|
||||||
relayInfo={relay} |
|
||||||
className="clickable p-4 border-b" |
|
||||||
onClick={(e) => { |
|
||||||
e.stopPropagation() |
|
||||||
navigate('relay', { url: relay.url }) |
|
||||||
}} |
|
||||||
/> |
|
||||||
))} |
|
||||||
{showCount < relays.length && <div ref={bottomRef} />} |
|
||||||
{loading && <RelaySimpleInfoSkeleton className="p-4" />} |
|
||||||
{!loading && relays.length === 0 && ( |
|
||||||
<div className="text-center text-muted-foreground text-sm">{t('no relays found')}</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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 = ( |
|
||||||
<div className="space-y-3"> |
|
||||||
<div> |
|
||||||
<h4 className="font-semibold mb-2">Search Parameters</h4> |
|
||||||
<div className="space-y-2 text-sm"> |
|
||||||
<div> |
|
||||||
<strong>Plain text:</strong> Searches by d-tag for replaceable events (normalized, hyphenated) |
|
||||||
</div> |
|
||||||
<div> |
|
||||||
<strong>Event IDs:</strong> Bare event IDs work as standard search (hex, note1, nevent1, naddr1) |
|
||||||
</div> |
|
||||||
<div> |
|
||||||
<strong>Filters:</strong> |
|
||||||
<ul className="ml-4 mt-1 space-y-1 list-disc"> |
|
||||||
<li><code className="text-xs">t:hashtag</code> or <code className="text-xs">hashtag:hashtag</code> - Filter by hashtag (t-tag)</li> |
|
||||||
<li>Multiple values supported: <code className="text-xs">t:bitcoin,nostr</code></li> |
|
||||||
</ul> |
|
||||||
</div> |
|
||||||
<div> |
|
||||||
<strong>Kind filter:</strong> Use URL parameter <code className="text-xs">k=</code> with other filters (e.g., <code className="text-xs">?t=bitcoin&k=1</code> or <code className="text-xs">?t=testfile&k=30023</code>). Cannot be used alone. |
|
||||||
</div> |
|
||||||
<div className="pt-2 border-t"> |
|
||||||
<p className="text-xs text-muted-foreground"> |
|
||||||
<strong>Examples:</strong> |
|
||||||
</p> |
|
||||||
<ul className="ml-4 mt-1 space-y-1 list-disc text-xs text-muted-foreground"> |
|
||||||
<li><code>jumble search</code> → searches d-tag</li> |
|
||||||
<li><code>t:bitcoin</code> → hashtag search</li> |
|
||||||
<li><code>note1abc...</code> → searches for event ID</li> |
|
||||||
</ul> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
<div className="pt-2 border-t"> |
|
||||||
<a |
|
||||||
href="https://next-alexandria.gitcitadel.eu/events" |
|
||||||
target="_blank" |
|
||||||
rel="noopener noreferrer" |
|
||||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors" |
|
||||||
> |
|
||||||
<BookOpen className="h-4 w-4" /> |
|
||||||
<span>Advanced search on Alexandria</span> |
|
||||||
</a> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
|
|
||||||
if (isSmallScreen) { |
|
||||||
return ( |
|
||||||
<Drawer> |
|
||||||
<DrawerTrigger asChild> |
|
||||||
<Button |
|
||||||
variant="ghost" |
|
||||||
size="icon" |
|
||||||
className={cn("h-9 w-9 shrink-0 text-muted-foreground hover:text-foreground border border-border/50 hover:border-border rounded-md relative z-10")} |
|
||||||
title="Search help" |
|
||||||
> |
|
||||||
<Info className="h-4 w-4" /> |
|
||||||
</Button> |
|
||||||
</DrawerTrigger> |
|
||||||
<DrawerContent> |
|
||||||
<DrawerHeader> |
|
||||||
<DrawerTitle>Advanced Search Help</DrawerTitle> |
|
||||||
<DrawerDescription> |
|
||||||
Learn about available search parameters |
|
||||||
</DrawerDescription> |
|
||||||
</DrawerHeader> |
|
||||||
<div className="px-4 pb-4 max-h-[60vh] overflow-y-auto"> |
|
||||||
{searchInfoContent} |
|
||||||
</div> |
|
||||||
<div className="px-4 pb-4 border-t"> |
|
||||||
<a |
|
||||||
href="https://next-alexandria.gitcitadel.eu/events" |
|
||||||
target="_blank" |
|
||||||
rel="noopener noreferrer" |
|
||||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors" |
|
||||||
> |
|
||||||
<BookOpen className="h-4 w-4" /> |
|
||||||
<span>Advanced search on Alexandria</span> |
|
||||||
</a> |
|
||||||
</div> |
|
||||||
<DrawerClose asChild> |
|
||||||
<Button variant="outline" className="m-4">Close</Button> |
|
||||||
</DrawerClose> |
|
||||||
</DrawerContent> |
|
||||||
</Drawer> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<HoverCard> |
|
||||||
<HoverCardTrigger asChild> |
|
||||||
<Button |
|
||||||
variant="ghost" |
|
||||||
size="icon" |
|
||||||
className={cn("h-9 w-9 shrink-0 text-muted-foreground hover:text-foreground border border-border/50 hover:border-border rounded-md relative z-10")} |
|
||||||
title="Search help" |
|
||||||
> |
|
||||||
<Info className="h-4 w-4" /> |
|
||||||
</Button> |
|
||||||
</HoverCardTrigger> |
|
||||||
<HoverCardContent className="w-96 max-h-[80vh] overflow-y-auto" side="left" align="start"> |
|
||||||
<h3 className="font-semibold mb-3">Advanced Search Help</h3> |
|
||||||
{searchInfoContent} |
|
||||||
</HoverCardContent> |
|
||||||
</HoverCard> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
@ -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 ( |
|
||||||
<div className="flex gap-1 p-1" style={{ width: '60px', maxWidth: '60px' }} onClick={(e) => e.stopPropagation()}> |
|
||||||
{DISCUSSION_VOTE_EMOJIS.map((emoji, i) => ( |
|
||||||
<div |
|
||||||
key={emoji} |
|
||||||
className="w-6 h-6 rounded-lg clickable flex justify-center items-center text-base hover:bg-muted flex-shrink-0" |
|
||||||
onClick={() => onEmojiClick(emoji)} |
|
||||||
> |
|
||||||
{GLYPHS[i]} |
|
||||||
</div> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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<string>() |
|
||||||
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 ( |
|
||||||
<div className="flex gap-1 p-1" onClick={(e) => e.stopPropagation()}> |
|
||||||
<div |
|
||||||
className="w-8 h-8 rounded-lg clickable flex justify-center items-center text-xl" |
|
||||||
onClick={() => onEmojiClick('+')} |
|
||||||
> |
|
||||||
<Emoji emoji="+" /> |
|
||||||
</div> |
|
||||||
{suggestedEmojis.map((emoji, index) => |
|
||||||
typeof emoji === 'string' ? ( |
|
||||||
<div |
|
||||||
key={index} |
|
||||||
className="w-8 h-8 rounded-lg clickable flex justify-center items-center text-xl" |
|
||||||
onClick={() => onEmojiClick(emoji)} |
|
||||||
> |
|
||||||
{emoji} |
|
||||||
</div> |
|
||||||
) : ( |
|
||||||
<div |
|
||||||
className="flex flex-col items-center justify-center p-1 rounded-lg clickable" |
|
||||||
key={index} |
|
||||||
onClick={() => onEmojiClick(emoji)} |
|
||||||
> |
|
||||||
<Emoji emoji={emoji} classNames={{ img: 'size-6 rounded-md' }} /> |
|
||||||
</div> |
|
||||||
) |
|
||||||
)} |
|
||||||
<Button variant="ghost" className="w-8 h-8 text-muted-foreground" onClick={onMoreButtonClick}> |
|
||||||
<MoreHorizontal size={24} /> |
|
||||||
</Button> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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<typeof Textarea>, |
|
||||||
'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<HTMLTextAreaElement, TextareaWithMentionAutocompleteProps>(function TextareaWithMentionAutocomplete({ |
|
||||||
value, |
|
||||||
onChange, |
|
||||||
onKeyDown, |
|
||||||
onOpenNeventPicker, |
|
||||||
...textareaProps |
|
||||||
}, refProp) { |
|
||||||
const [mentionOpen, setMentionOpen] = useState(false) |
|
||||||
const [mentionQuery, setMentionQuery] = useState('') |
|
||||||
const [mentionItems, setMentionItems] = useState<string[]>([]) |
|
||||||
const [mentionStart, setMentionStart] = useState(0) |
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0) |
|
||||||
const [emojiOpen, setEmojiOpen] = useState(false) |
|
||||||
const [emojiQuery, setEmojiQuery] = useState('') |
|
||||||
const [emojiItems, setEmojiItems] = useState<string[]>([]) |
|
||||||
const [emojiStart, setEmojiStart] = useState(0) |
|
||||||
const [selectedEmojiIndex, setSelectedEmojiIndex] = useState(0) |
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null) |
|
||||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) |
|
||||||
const emojiSearchTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLTextAreaElement>) => { |
|
||||||
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<HTMLTextAreaElement>) => { |
|
||||||
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<HTMLTextAreaElement | null>).current = el |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const dropdownContent = |
|
||||||
dropdownRect && typeof document !== 'undefined' |
|
||||||
? createPortal( |
|
||||||
<div |
|
||||||
className="border rounded-lg bg-background shadow-lg overflow-hidden" |
|
||||||
role="listbox" |
|
||||||
style={{ |
|
||||||
position: 'fixed', |
|
||||||
top: dropdownRect.top, |
|
||||||
left: dropdownRect.left, |
|
||||||
width: dropdownRect.width, |
|
||||||
maxWidth: 'min(400px, 95vw)', |
|
||||||
zIndex: 10000 |
|
||||||
}} |
|
||||||
> |
|
||||||
{emojiOpen && emojiItems.length > 0 && ( |
|
||||||
<EmojiList |
|
||||||
items={emojiItems} |
|
||||||
command={({ name }) => name != null && insertEmoji(name)} |
|
||||||
selectedIndex={selectedEmojiIndex} |
|
||||||
onSelectIndex={setSelectedEmojiIndex} |
|
||||||
/> |
|
||||||
)} |
|
||||||
{mentionOpen && mentionItems.length > 0 && !emojiOpen && ( |
|
||||||
<MentionList |
|
||||||
items={mentionItems} |
|
||||||
command={({ id }) => insertMention(id as string)} |
|
||||||
selectedIndex={selectedIndex} |
|
||||||
onSelectIndex={setSelectedIndex} |
|
||||||
/> |
|
||||||
)} |
|
||||||
</div>, |
|
||||||
document.body |
|
||||||
) |
|
||||||
: null |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="relative"> |
|
||||||
<Textarea |
|
||||||
{...textareaProps} |
|
||||||
ref={setRef} |
|
||||||
value={value} |
|
||||||
onChange={handleChange} |
|
||||||
onKeyDown={handleKeyDown} |
|
||||||
/> |
|
||||||
{dropdownContent} |
|
||||||
</div> |
|
||||||
) |
|
||||||
}) |
|
||||||
export default TextareaWithMentionAutocomplete |
|
||||||
@ -1,42 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { useTheme } from '@/providers/ThemeProvider' |
|
||||||
import { Moon, Sun, SunMoon } from 'lucide-react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
export default function ThemeToggle() { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { themeSetting, setThemeSetting } = useTheme() |
|
||||||
|
|
||||||
return ( |
|
||||||
<> |
|
||||||
{themeSetting === 'system' ? ( |
|
||||||
<Button |
|
||||||
variant="ghost" |
|
||||||
size="titlebar-icon" |
|
||||||
onClick={() => setThemeSetting('light')} |
|
||||||
title={t('switch to light theme')} |
|
||||||
> |
|
||||||
<SunMoon /> |
|
||||||
</Button> |
|
||||||
) : themeSetting === 'light' ? ( |
|
||||||
<Button |
|
||||||
variant="ghost" |
|
||||||
size="titlebar-icon" |
|
||||||
onClick={() => setThemeSetting('dark')} |
|
||||||
title={t('switch to dark theme')} |
|
||||||
> |
|
||||||
<Sun /> |
|
||||||
</Button> |
|
||||||
) : ( |
|
||||||
<Button |
|
||||||
variant="ghost" |
|
||||||
size="titlebar-icon" |
|
||||||
onClick={() => setThemeSetting('system')} |
|
||||||
title={t('switch to system theme')} |
|
||||||
> |
|
||||||
<Moon /> |
|
||||||
</Button> |
|
||||||
)} |
|
||||||
</> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,91 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { Skeleton } from '@/components/ui/skeleton' |
|
||||||
import { useInterestList } from '@/providers/interest-list-context' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { Bell, BellOff } from 'lucide-react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
interface TopicSubscribeButtonProps { |
|
||||||
topic: string |
|
||||||
variant?: 'default' | 'outline' | 'ghost' | 'icon' |
|
||||||
size?: 'default' | 'sm' | 'lg' | 'icon' |
|
||||||
showLabel?: boolean |
|
||||||
} |
|
||||||
|
|
||||||
export default function TopicSubscribeButton({ |
|
||||||
topic, |
|
||||||
variant = 'outline', |
|
||||||
size = 'sm', |
|
||||||
showLabel = true |
|
||||||
}: TopicSubscribeButtonProps) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { pubkey } = useNostr() |
|
||||||
const { isSubscribed, subscribe, unsubscribe, changing } = useInterestList() |
|
||||||
|
|
||||||
if (!pubkey) { |
|
||||||
return null |
|
||||||
} |
|
||||||
|
|
||||||
const subscribed = isSubscribed(topic) |
|
||||||
|
|
||||||
const handleClick = async (e: React.MouseEvent) => { |
|
||||||
e.stopPropagation() |
|
||||||
e.preventDefault() |
|
||||||
|
|
||||||
if (changing) return |
|
||||||
|
|
||||||
if (subscribed) { |
|
||||||
await unsubscribe(topic) |
|
||||||
} else { |
|
||||||
await subscribe(topic) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (variant === 'icon' || !showLabel) { |
|
||||||
return ( |
|
||||||
<Button |
|
||||||
variant={subscribed ? 'default' : 'outline'} |
|
||||||
size={size === 'icon' ? 'icon' : size} |
|
||||||
onClick={handleClick} |
|
||||||
disabled={changing} |
|
||||||
title={subscribed ? t('Unsubscribe') : t('Subscribe')} |
|
||||||
> |
|
||||||
{changing ? ( |
|
||||||
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> |
|
||||||
) : subscribed ? ( |
|
||||||
<Bell className="h-4 w-4" fill="currentColor" /> |
|
||||||
) : ( |
|
||||||
<BellOff className="h-4 w-4" /> |
|
||||||
)} |
|
||||||
</Button> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<Button |
|
||||||
variant={subscribed ? 'default' : variant} |
|
||||||
size={size} |
|
||||||
onClick={handleClick} |
|
||||||
disabled={changing} |
|
||||||
className="flex items-center gap-2" |
|
||||||
> |
|
||||||
{changing ? ( |
|
||||||
<> |
|
||||||
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> |
|
||||||
{subscribed ? t('Unsubscribing...') : t('Subscribing...')} |
|
||||||
</> |
|
||||||
) : subscribed ? ( |
|
||||||
<> |
|
||||||
<Bell className="h-4 w-4" fill="currentColor" /> |
|
||||||
{t('Subscribed')} |
|
||||||
</> |
|
||||||
) : ( |
|
||||||
<> |
|
||||||
<BellOff className="h-4 w-4" /> |
|
||||||
{t('Subscribe')} |
|
||||||
</> |
|
||||||
)} |
|
||||||
</Button> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
@ -1,54 +0,0 @@ |
|||||||
import { useMemo } from 'react' |
|
||||||
import { rewritePlainTextHttpUrls } from '@/lib/url' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug' |
|
||||||
import { parseNostrContent, renderNostrContent } from '@/lib/nostr-parser.tsx' |
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
|
|
||||||
interface SimpleContentProps { |
|
||||||
event?: Event |
|
||||||
content?: string |
|
||||||
className?: string |
|
||||||
} |
|
||||||
|
|
||||||
export default function SimpleContent({ |
|
||||||
event, |
|
||||||
content, |
|
||||||
className |
|
||||||
}: SimpleContentProps) { |
|
||||||
const processedContent = useMemo(() => { |
|
||||||
const rawContent = content || event?.content || '' |
|
||||||
|
|
||||||
// Clean URLs to remove tracking parameters
|
|
||||||
const cleaned = rewritePlainTextHttpUrls(rawContent) |
|
||||||
|
|
||||||
if (rawContent.includes('nostr:')) { |
|
||||||
logContentSpacing('SimpleContent:processedContent', { |
|
||||||
rawRepr: reprString(rawContent), |
|
||||||
cleanedRepr: reprString(cleaned), |
|
||||||
same: rawContent === cleaned |
|
||||||
}) |
|
||||||
} |
|
||||||
return cleaned |
|
||||||
}, [content, event?.content]) |
|
||||||
|
|
||||||
// Parse content for nostr addresses and media
|
|
||||||
const parsedContent = useMemo(() => { |
|
||||||
const parsed = parseNostrContent(processedContent, event) |
|
||||||
if (processedContent.includes('nostr:')) { |
|
||||||
logContentSpacing('SimpleContent:parsedContent', { |
|
||||||
elementCount: parsed.elements.length, |
|
||||||
tail: parsed.elements.slice(-3).map((e) => |
|
||||||
e.type === 'text' ? { type: 'text', repr: reprString(e.content) } : { type: e.type } |
|
||||||
) |
|
||||||
}) |
|
||||||
} |
|
||||||
return parsed |
|
||||||
}, [processedContent, event]) |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={cn('prose prose-sm prose-zinc max-w-none break-words dark:prose-invert w-full', className)}> |
|
||||||
{renderNostrContent(parsedContent, undefined, event)} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,47 +0,0 @@ |
|||||||
import { useEffect, useRef } from 'react' |
|
||||||
import Wikilink from './Wikilink' |
|
||||||
|
|
||||||
interface WikilinkProcessorProps { |
|
||||||
htmlContent: string |
|
||||||
className?: string |
|
||||||
} |
|
||||||
|
|
||||||
export default function WikilinkProcessor({ htmlContent, className }: WikilinkProcessorProps) { |
|
||||||
const containerRef = useRef<HTMLDivElement>(null) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (!containerRef.current) return |
|
||||||
|
|
||||||
// Find all wikilink spans and replace them with Wikilink components
|
|
||||||
const wikilinkSpans = containerRef.current.querySelectorAll('span.wikilink') |
|
||||||
|
|
||||||
wikilinkSpans.forEach((span) => { |
|
||||||
const dTag = span.getAttribute('data-dtag') |
|
||||||
const displayText = span.getAttribute('data-display') |
|
||||||
|
|
||||||
if (dTag && displayText) { |
|
||||||
// Create a container for the Wikilink component
|
|
||||||
const container = document.createElement('div') |
|
||||||
container.className = 'inline-block' |
|
||||||
|
|
||||||
// Replace the span with the container
|
|
||||||
span.parentNode?.replaceChild(container, span) |
|
||||||
|
|
||||||
// Render the Wikilink component into the container
|
|
||||||
// We'll use React's createRoot for this
|
|
||||||
import('react-dom/client').then(({ createRoot }) => { |
|
||||||
const root = createRoot(container) |
|
||||||
root.render(<Wikilink dTag={dTag} displayText={displayText} />) |
|
||||||
}) |
|
||||||
} |
|
||||||
}) |
|
||||||
}, [htmlContent]) |
|
||||||
|
|
||||||
return ( |
|
||||||
<div
|
|
||||||
ref={containerRef} |
|
||||||
dangerouslySetInnerHTML={{ __html: htmlContent }} |
|
||||||
className={className} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,3 +1,7 @@ |
|||||||
|
/** |
||||||
|
* Ambient augmentations (no imports — must stay a script so these merge into global scope). |
||||||
|
* Runtime polyfills live in {@link ./polyfill.ts}; target lib is ES2020. |
||||||
|
*/ |
||||||
interface Array<T> { |
interface Array<T> { |
||||||
findLast(predicate: (value: T, index: number, obj: T[]) => boolean, thisArg?: any): T | undefined |
findLast(predicate: (value: T, index: number, obj: T[]) => boolean, thisArg?: any): T | undefined |
||||||
} |
} |
||||||
@ -1,111 +0,0 @@ |
|||||||
import { ExtendedKind } from '@/constants' |
|
||||||
import { buildProfileReportRelayUrls } from '@/lib/profile-report-relay-urls' |
|
||||||
import { |
|
||||||
profileAccordionGetCachedReports, |
|
||||||
profileAccordionRelayUrlsKey, |
|
||||||
profileAccordionSetReports |
|
||||||
} from '@/lib/profile-accordion-session-cache' |
|
||||||
import { queryService } from '@/services/client.service' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react' |
|
||||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
||||||
|
|
||||||
const REPORT_LIMIT = 50 |
|
||||||
|
|
||||||
/** NIP-56 reports (kind 1984) about `profilePubkey`, from viewer favorites + inboxes only. */ |
|
||||||
export function useProfileReports( |
|
||||||
profilePubkey: string | undefined, |
|
||||||
viewerPubkey: string | null | undefined |
|
||||||
) { |
|
||||||
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
|
||||||
const favoriteRelaysRef = useRef(favoriteRelays) |
|
||||||
favoriteRelaysRef.current = favoriteRelays |
|
||||||
const blockedRelaysRef = useRef(blockedRelays) |
|
||||||
blockedRelaysRef.current = blockedRelays |
|
||||||
const favoriteRelaysKey = profileAccordionRelayUrlsKey(favoriteRelays ?? []) |
|
||||||
const blockedRelaysKey = profileAccordionRelayUrlsKey(blockedRelays) |
|
||||||
|
|
||||||
const [reports, setReports] = useState<Event[]>([]) |
|
||||||
const [loading, setLoading] = useState(false) |
|
||||||
const fetchIdRef = useRef(0) |
|
||||||
|
|
||||||
const fetchReports = useCallback(async (force = false) => { |
|
||||||
const viewer = viewerPubkey?.trim() |
|
||||||
const myFetchId = (fetchIdRef.current += 1) |
|
||||||
|
|
||||||
if (!profilePubkey || !viewer) { |
|
||||||
if (myFetchId === fetchIdRef.current) { |
|
||||||
setReports([]) |
|
||||||
setLoading(false) |
|
||||||
} |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
if (!force) { |
|
||||||
const cached = profileAccordionGetCachedReports(profilePubkey, viewer) |
|
||||||
if (cached) { |
|
||||||
if (myFetchId !== fetchIdRef.current) return |
|
||||||
setReports(cached) |
|
||||||
setLoading(false) |
|
||||||
return |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const seed = profileAccordionGetCachedReports(profilePubkey, viewer) |
|
||||||
if (seed?.length && myFetchId === fetchIdRef.current) { |
|
||||||
setReports(seed) |
|
||||||
} |
|
||||||
|
|
||||||
if (myFetchId !== fetchIdRef.current) return |
|
||||||
if (!seed?.length) { |
|
||||||
setLoading(true) |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
const urls = await buildProfileReportRelayUrls({ |
|
||||||
viewerPubkey: viewer, |
|
||||||
favoriteRelays: favoriteRelaysRef.current ?? [], |
|
||||||
blockedRelays: blockedRelaysRef.current |
|
||||||
}) |
|
||||||
if (urls.length === 0) { |
|
||||||
if (myFetchId === fetchIdRef.current && !seed?.length) setReports([]) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
const events = await queryService.fetchEvents( |
|
||||||
urls, |
|
||||||
[{ '#p': [profilePubkey], kinds: [ExtendedKind.REPORT], limit: REPORT_LIMIT }], |
|
||||||
{ eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false } |
|
||||||
) |
|
||||||
|
|
||||||
if (myFetchId !== fetchIdRef.current) return |
|
||||||
|
|
||||||
const byId = new Map<string, Event>() |
|
||||||
for (const evt of seed ?? []) byId.set(evt.id, evt) |
|
||||||
const seen = new Set<string>(byId.keys()) |
|
||||||
for (const evt of events) { |
|
||||||
if (seen.has(evt.id)) continue |
|
||||||
seen.add(evt.id) |
|
||||||
byId.set(evt.id, evt) |
|
||||||
} |
|
||||||
const merged = [...byId.values()].sort((a, b) => b.created_at - a.created_at) |
|
||||||
setReports(merged) |
|
||||||
profileAccordionSetReports(profilePubkey, viewer, merged) |
|
||||||
} catch { |
|
||||||
if (myFetchId !== fetchIdRef.current) return |
|
||||||
if (!seed?.length) setReports([]) |
|
||||||
} finally { |
|
||||||
if (myFetchId === fetchIdRef.current) setLoading(false) |
|
||||||
} |
|
||||||
}, [profilePubkey, viewerPubkey, favoriteRelaysKey, blockedRelaysKey]) |
|
||||||
|
|
||||||
const refresh = useCallback(() => { |
|
||||||
void fetchReports(true) |
|
||||||
}, [profilePubkey, viewerPubkey, fetchReports]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
void fetchReports(false) |
|
||||||
}, [fetchReports]) |
|
||||||
|
|
||||||
return { reports, loading, refresh } |
|
||||||
} |
|
||||||
@ -1,79 +0,0 @@ |
|||||||
import { getImetaInfosFromEvent } from './event' |
|
||||||
import { TImetaInfo } from '@/types' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
|
|
||||||
/** |
|
||||||
* Extract all media URLs from an article event |
|
||||||
*/ |
|
||||||
export function extractArticleMedia(event: Event): TImetaInfo[] { |
|
||||||
const media: TImetaInfo[] = [] |
|
||||||
const seenUrls = new Set<string>() |
|
||||||
|
|
||||||
// Extract from imeta tags
|
|
||||||
const imetaInfos = getImetaInfosFromEvent(event) |
|
||||||
imetaInfos.forEach(imeta => { |
|
||||||
if (!seenUrls.has(imeta.url)) { |
|
||||||
seenUrls.add(imeta.url) |
|
||||||
media.push(imeta) |
|
||||||
} |
|
||||||
}) |
|
||||||
|
|
||||||
// Extract from metadata tags
|
|
||||||
const imageTag = event.tags.find(tag => tag[0] === 'image')?.[1] |
|
||||||
if (imageTag && !seenUrls.has(imageTag)) { |
|
||||||
seenUrls.add(imageTag) |
|
||||||
media.push({ |
|
||||||
url: imageTag, |
|
||||||
pubkey: event.pubkey |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
// Extract URLs from content (image/video extensions)
|
|
||||||
const contentUrls = extractUrlsFromContent(event.content) |
|
||||||
contentUrls.forEach(url => { |
|
||||||
if (!seenUrls.has(url)) { |
|
||||||
seenUrls.add(url) |
|
||||||
media.push({ |
|
||||||
url, |
|
||||||
pubkey: event.pubkey |
|
||||||
}) |
|
||||||
} |
|
||||||
}) |
|
||||||
|
|
||||||
return media |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Extract URLs from content that look like media files |
|
||||||
*/ |
|
||||||
function extractUrlsFromContent(content: string): string[] { |
|
||||||
const urls: string[] = [] |
|
||||||
|
|
||||||
// Match URLs in content
|
|
||||||
const urlRegex = /https?:\/\/[^\s<>"']+/g |
|
||||||
const matches = content.match(urlRegex) || [] |
|
||||||
|
|
||||||
matches.forEach(url => { |
|
||||||
try { |
|
||||||
const urlObj = new URL(url) |
|
||||||
const pathname = urlObj.pathname.toLowerCase() |
|
||||||
|
|
||||||
// Check if it's a media file
|
|
||||||
const mediaExtensions = [ |
|
||||||
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.tiff', |
|
||||||
'.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.flv', '.3gp', '.3g2', |
|
||||||
'.mp3', '.wav', '.flac', '.aac', '.m4a', '.mka' |
|
||||||
] |
|
||||||
|
|
||||||
const isMediaFile = mediaExtensions.some(ext => pathname.endsWith(ext)) |
|
||||||
|
|
||||||
if (isMediaFile) { |
|
||||||
urls.push(url) |
|
||||||
} |
|
||||||
} catch { |
|
||||||
// Invalid URL, skip
|
|
||||||
} |
|
||||||
}) |
|
||||||
|
|
||||||
return urls |
|
||||||
} |
|
||||||
@ -1,73 +0,0 @@ |
|||||||
/** |
|
||||||
* Markdown cleanup utility for leftover markdown syntax after Asciidoc rendering |
|
||||||
*/ |
|
||||||
|
|
||||||
export function cleanupMarkdown(html: string): string { |
|
||||||
let cleaned = html |
|
||||||
|
|
||||||
// Clean up markdown image syntax: 
|
|
||||||
cleaned = cleaned.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => { |
|
||||||
const altText = alt || '' |
|
||||||
return `<img src="${url}" alt="${altText}" class="max-w-[400px] object-contain my-0" />` |
|
||||||
}) |
|
||||||
|
|
||||||
// Clean up markdown link syntax: [text](url)
|
|
||||||
cleaned = cleaned.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => { |
|
||||||
// Check if it's already an HTML link
|
|
||||||
if (cleaned.includes(`href="${url}"`)) { |
|
||||||
return _match |
|
||||||
} |
|
||||||
return `<a href="${url}" target="_blank" rel="noreferrer noopener" class="break-words inline-flex items-baseline gap-1">${text} <svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg></a>` |
|
||||||
}) |
|
||||||
|
|
||||||
// Clean up markdown table syntax
|
|
||||||
cleaned = cleanupMarkdownTables(cleaned) |
|
||||||
|
|
||||||
return cleaned |
|
||||||
} |
|
||||||
|
|
||||||
function cleanupMarkdownTables(html: string): string { |
|
||||||
// Simple markdown table detection and conversion
|
|
||||||
const tableRegex = /(\|.*\|[\r\n]+\|[\s\-\|]*[\r\n]+(\|.*\|[\r\n]+)*)/g |
|
||||||
|
|
||||||
return html.replace(tableRegex, (match) => { |
|
||||||
const lines = match.trim().split('\n').filter(line => line.trim()) |
|
||||||
if (lines.length < 2) return match |
|
||||||
|
|
||||||
const headerRow = lines[0] |
|
||||||
const separatorRow = lines[1] |
|
||||||
const dataRows = lines.slice(2) |
|
||||||
|
|
||||||
// Check if it's actually a table (has separator row with dashes)
|
|
||||||
if (!separatorRow.includes('-')) return match |
|
||||||
|
|
||||||
const headers = headerRow.split('|').map(cell => cell.trim()).filter(cell => cell) |
|
||||||
const rows = dataRows.map(row =>
|
|
||||||
row.split('|').map(cell => cell.trim()).filter(cell => cell) |
|
||||||
) |
|
||||||
|
|
||||||
let tableHtml = '<table class="min-w-full border-collapse border border-gray-300 my-4">\n' |
|
||||||
|
|
||||||
// Header
|
|
||||||
tableHtml += ' <thead>\n <tr>\n' |
|
||||||
headers.forEach(header => { |
|
||||||
tableHtml += ` <th class="border border-gray-300 px-4 py-2 bg-gray-50 font-semibold text-left">${header}</th>\n` |
|
||||||
}) |
|
||||||
tableHtml += ' </tr>\n </thead>\n' |
|
||||||
|
|
||||||
// Body
|
|
||||||
tableHtml += ' <tbody>\n' |
|
||||||
rows.forEach(row => { |
|
||||||
tableHtml += ' <tr>\n' |
|
||||||
row.forEach((cell, index) => { |
|
||||||
const tag = index < headers.length ? 'td' : 'td' |
|
||||||
tableHtml += ` <${tag} class="border border-gray-300 px-4 py-2">${cell}</${tag}>\n` |
|
||||||
}) |
|
||||||
tableHtml += ' </tr>\n' |
|
||||||
}) |
|
||||||
tableHtml += ' </tbody>\n' |
|
||||||
tableHtml += '</table>' |
|
||||||
|
|
||||||
return tableHtml |
|
||||||
}) |
|
||||||
} |
|
||||||
@ -1,13 +0,0 @@ |
|||||||
import { Event } from 'nostr-tools' |
|
||||||
import nip89Service from '@/services/nip89.service' |
|
||||||
|
|
||||||
/** |
|
||||||
* Create the Imwald application handler info event (kind 31990). |
|
||||||
* This can be published using the existing publish function from NostrProvider. |
|
||||||
*/ |
|
||||||
export function createImwaldHandlerInfoEvent(pubkey: string): Omit<Event, 'id' | 'sig'> { |
|
||||||
return nip89Service.createImwaldHandlerInfo(pubkey) |
|
||||||
} |
|
||||||
|
|
||||||
/** @deprecated Use {@link createImwaldHandlerInfoEvent} */ |
|
||||||
export const createJumbleImWaldHandlerInfoEvent = createImwaldHandlerInfoEvent |
|
||||||
@ -1,724 +0,0 @@ |
|||||||
/** |
|
||||||
* Nostr address parser that converts nostr: addresses to embedded content |
|
||||||
*/ |
|
||||||
|
|
||||||
import { nip19 } from 'nostr-tools' |
|
||||||
import { EmbeddedMention, EmbeddedNote, HttpNostrAwareUrl } from '@/components/Embedded' |
|
||||||
import ImageGallery from '@/components/ImageGallery' |
|
||||||
import { BookstrContent } from '@/components/Bookstr/BookstrContent' |
|
||||||
import { cleanUrl, isImage, isMedia, isPseudoNostrHttpsUrl } from '@/lib/url' |
|
||||||
import { getImetaInfosFromEvent } from '@/lib/event' |
|
||||||
import { parsePaytoUri } from '@/lib/payto' |
|
||||||
import PaytoLink from '@/components/PaytoLink' |
|
||||||
import { TImetaInfo } from '@/types' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { NOSTR_PARSER_REGEX } from '@/lib/content-patterns' |
|
||||||
import logger from '@/lib/logger' |
|
||||||
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug' |
|
||||||
import { useState } from 'react' |
|
||||||
|
|
||||||
export interface ParsedNostrContent { |
|
||||||
elements: Array<{ |
|
||||||
type: 'text' | 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'bookstr-wikilink' | 'gallery' | 'url' | 'payto' |
|
||||||
content: string |
|
||||||
bech32Id?: string |
|
||||||
nostrType?: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note' |
|
||||||
mediaUrl?: string |
|
||||||
hashtag?: string |
|
||||||
wikilink?: string |
|
||||||
displayText?: string |
|
||||||
bookstrWikilink?: string |
|
||||||
sourceUrl?: string |
|
||||||
images?: TImetaInfo[] |
|
||||||
url?: string |
|
||||||
noteId?: string |
|
||||||
paytoUri?: string |
|
||||||
}> |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Parse content and convert nostr: addresses and media URLs to embedded components |
|
||||||
*/ |
|
||||||
export function parseNostrContent(content: string, event?: Event): ParsedNostrContent { |
|
||||||
const elements: ParsedNostrContent['elements'] = [] |
|
||||||
const traceNostr = content.includes('nostr:') |
|
||||||
if (traceNostr) { |
|
||||||
logContentSpacing('parseNostrContent:input', { |
|
||||||
length: content.length, |
|
||||||
repr: reprString(content), |
|
||||||
eventId: event?.id |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
const nostrRegex = new RegExp(NOSTR_PARSER_REGEX.source, NOSTR_PARSER_REGEX.flags) |
|
||||||
|
|
||||||
// Regex to match all URLs (we'll filter by type later)
|
|
||||||
const urlRegex = /(https?:\/\/[^\s]+)/gi |
|
||||||
|
|
||||||
|
|
||||||
// Regex to match hashtags
|
|
||||||
const hashtagRegex = /#([a-zA-Z0-9_]+)/g |
|
||||||
|
|
||||||
// Regex to match wikilinks: [[target]] or [[target|display text]] or [[book::...]]
|
|
||||||
const wikilinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g |
|
||||||
|
|
||||||
// Regex to match bookstr search URLs: any URL containing book%3A%3A or book::
|
|
||||||
// Matches the pattern and captures the search term (everything after book%3A%3A or book:: until /, ?, #, &, or end)
|
|
||||||
const bookstrUrlRegex = /(https?:\/\/[^\s]*(?:book%3A%3A|book::)([^\/\?\#\&\s]+))/gi |
|
||||||
|
|
||||||
// Collect all matches (nostr, URLs, hashtags, wikilinks, bookstr URLs) and sort by position
|
|
||||||
const allMatches: Array<{ |
|
||||||
type: 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'bookstr-wikilink' | 'url' | 'payto' |
|
||||||
match: RegExpExecArray |
|
||||||
start: number |
|
||||||
end: number |
|
||||||
url?: string |
|
||||||
hashtag?: string |
|
||||||
wikilink?: string |
|
||||||
displayText?: string |
|
||||||
bookstrWikilink?: string |
|
||||||
sourceUrl?: string |
|
||||||
paytoUri?: string |
|
||||||
}> = [] |
|
||||||
|
|
||||||
// Find nostr matches
|
|
||||||
let nostrMatch |
|
||||||
while ((nostrMatch = nostrRegex.exec(content)) !== null) { |
|
||||||
const nStart = nostrMatch.index |
|
||||||
const nEnd = nostrMatch.index + nostrMatch[0].length |
|
||||||
const valid = isNostrAddressInValidContext(content, nStart, nEnd) |
|
||||||
if (traceNostr) { |
|
||||||
logContentSpacing('parseNostrContent:nostr-regex', { |
|
||||||
index: nStart, |
|
||||||
end: nEnd, |
|
||||||
fullMatchRepr: reprString(nostrMatch[0]), |
|
||||||
validContext: valid, |
|
||||||
charBeforeIndex: nStart > 0 ? reprString(content[nStart - 1]) : '(start)', |
|
||||||
charAtIndex: reprString(content[nStart] ?? '') |
|
||||||
}) |
|
||||||
} |
|
||||||
if (valid) { |
|
||||||
allMatches.push({ |
|
||||||
type: 'nostr', |
|
||||||
match: nostrMatch, |
|
||||||
start: nStart, |
|
||||||
end: nEnd |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Find bookstr URL matches first (before regular URL matching to avoid conflicts)
|
|
||||||
// Look for any URL containing book%3A%3A or book:: pattern
|
|
||||||
let bookstrUrlMatch |
|
||||||
while ((bookstrUrlMatch = bookstrUrlRegex.exec(content)) !== null) { |
|
||||||
const fullUrl = bookstrUrlMatch[1] |
|
||||||
const searchTermEncoded = bookstrUrlMatch[2] |
|
||||||
|
|
||||||
try { |
|
||||||
// Decode the URL-encoded search term
|
|
||||||
const decodedSearchTerm = decodeURIComponent(searchTermEncoded) |
|
||||||
|
|
||||||
// Check if it starts with book:: (it should, but handle both cases)
|
|
||||||
let bookstrWikilink = decodedSearchTerm |
|
||||||
if (!bookstrWikilink.startsWith('book::')) { |
|
||||||
// If it doesn't start with book::, add it
|
|
||||||
bookstrWikilink = `book::${bookstrWikilink}` |
|
||||||
} |
|
||||||
|
|
||||||
allMatches.push({ |
|
||||||
type: 'bookstr-wikilink', |
|
||||||
match: bookstrUrlMatch, |
|
||||||
start: bookstrUrlMatch.index, |
|
||||||
end: bookstrUrlMatch.index + bookstrUrlMatch[0].length, |
|
||||||
bookstrWikilink: bookstrWikilink.trim(), |
|
||||||
sourceUrl: fullUrl |
|
||||||
}) |
|
||||||
} catch (err) { |
|
||||||
// If decoding fails, treat as regular URL
|
|
||||||
logger.warn('Failed to decode bookstr URL', { url: fullUrl, error: err }) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Find URL matches and categorize them (skip if already matched as bookstr URL)
|
|
||||||
let urlMatch |
|
||||||
while ((urlMatch = urlRegex.exec(content)) !== null) { |
|
||||||
const url = urlMatch[1] |
|
||||||
|
|
||||||
// Skip if this URL was already matched as a bookstr URL (check if it contains book%3A%3A or book::)
|
|
||||||
const isBookstrUrl = /(?:book%3A%3A|book::)/i.test(url) |
|
||||||
if (isBookstrUrl) { |
|
||||||
continue |
|
||||||
} |
|
||||||
|
|
||||||
const cleanedUrl = cleanUrl(url) |
|
||||||
if (isPseudoNostrHttpsUrl(url)) { |
|
||||||
continue |
|
||||||
} |
|
||||||
|
|
||||||
// Check if it's an image
|
|
||||||
if (isImage(cleanedUrl)) { |
|
||||||
allMatches.push({ |
|
||||||
type: 'image', |
|
||||||
match: urlMatch, |
|
||||||
start: urlMatch.index, |
|
||||||
end: urlMatch.index + urlMatch[0].length, |
|
||||||
url: cleanedUrl |
|
||||||
}) |
|
||||||
} |
|
||||||
// Check if it's media (video/audio)
|
|
||||||
else if (isMedia(cleanedUrl)) { |
|
||||||
// Determine if it's video or audio based on extension
|
|
||||||
const isVideo = /\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v|3gp|3g2|ogv)$/i.test(cleanedUrl) |
|
||||||
allMatches.push({ |
|
||||||
type: isVideo ? 'video' : 'audio', |
|
||||||
match: urlMatch, |
|
||||||
start: urlMatch.index, |
|
||||||
end: urlMatch.index + urlMatch[0].length, |
|
||||||
url: cleanedUrl |
|
||||||
}) |
|
||||||
} |
|
||||||
// payto: URI (RFC-8905 / NIP-A3) – handle as payment link, not external URL
|
|
||||||
else if (cleanedUrl.startsWith('payto://')) { |
|
||||||
const parsed = parsePaytoUri(cleanedUrl) |
|
||||||
if (parsed) { |
|
||||||
allMatches.push({ |
|
||||||
type: 'payto', |
|
||||||
match: urlMatch, |
|
||||||
start: urlMatch.index, |
|
||||||
end: urlMatch.index + urlMatch[0].length, |
|
||||||
paytoUri: parsed.raw |
|
||||||
}) |
|
||||||
} else { |
|
||||||
allMatches.push({ |
|
||||||
type: 'url', |
|
||||||
match: urlMatch, |
|
||||||
start: urlMatch.index, |
|
||||||
end: urlMatch.index + urlMatch[0].length, |
|
||||||
url: cleanedUrl |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
// Regular URL (not media)
|
|
||||||
else { |
|
||||||
allMatches.push({ |
|
||||||
type: 'url', |
|
||||||
match: urlMatch, |
|
||||||
start: urlMatch.index, |
|
||||||
end: urlMatch.index + urlMatch[0].length, |
|
||||||
url: cleanedUrl |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Find hashtag matches
|
|
||||||
let hashtagMatch |
|
||||||
while ((hashtagMatch = hashtagRegex.exec(content)) !== null) { |
|
||||||
allMatches.push({ |
|
||||||
type: 'hashtag', |
|
||||||
match: hashtagMatch, |
|
||||||
start: hashtagMatch.index, |
|
||||||
end: hashtagMatch.index + hashtagMatch[0].length, |
|
||||||
hashtag: hashtagMatch[1] |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
// Find wikilink matches (including bookstr wikilinks)
|
|
||||||
let wikilinkMatch |
|
||||||
while ((wikilinkMatch = wikilinkRegex.exec(content)) !== null) { |
|
||||||
const linkContent = wikilinkMatch[1] |
|
||||||
const displayText = wikilinkMatch[2] || linkContent |
|
||||||
|
|
||||||
// Check if this is a bookstr wikilink (NKBIP-08 format: book::...)
|
|
||||||
if (linkContent.startsWith('book::')) { |
|
||||||
allMatches.push({ |
|
||||||
type: 'bookstr-wikilink', |
|
||||||
match: wikilinkMatch, |
|
||||||
start: wikilinkMatch.index, |
|
||||||
end: wikilinkMatch.index + wikilinkMatch[0].length, |
|
||||||
bookstrWikilink: linkContent.trim() |
|
||||||
}) |
|
||||||
} else { |
|
||||||
allMatches.push({ |
|
||||||
type: 'wikilink', |
|
||||||
match: wikilinkMatch, |
|
||||||
start: wikilinkMatch.index, |
|
||||||
end: wikilinkMatch.index + wikilinkMatch[0].length, |
|
||||||
wikilink: linkContent, |
|
||||||
displayText: displayText |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Sort matches by position
|
|
||||||
allMatches.sort((a, b) => a.start - b.start) |
|
||||||
|
|
||||||
let lastIndex = 0 |
|
||||||
|
|
||||||
for (const { type, match, start, end, url, hashtag, wikilink, displayText, bookstrWikilink, sourceUrl, paytoUri } of allMatches) { |
|
||||||
// Add text before the match
|
|
||||||
if (start > lastIndex) { |
|
||||||
const textContent = content.slice(lastIndex, start) |
|
||||||
if (textContent) { |
|
||||||
elements.push({ |
|
||||||
type: 'text', |
|
||||||
content: textContent |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (type === 'nostr') { |
|
||||||
const bech32Id = match[1] |
|
||||||
const nostrType = getNostrType(bech32Id) |
|
||||||
// Store content without leading whitespace/newline (regex may capture \n or space before "nostr:")
|
|
||||||
const nostrContent = `nostr:${bech32Id}` |
|
||||||
|
|
||||||
// Add spacing around handles if they're not at the beginning or end of a line
|
|
||||||
const isAtStart = start === 0 || content[start - 1] === '\n' |
|
||||||
const isAtEnd = end === content.length || content[end] === '\n' |
|
||||||
const needsSpaceBefore = !isAtStart && content[start - 1] !== ' ' |
|
||||||
const needsSpaceAfter = !isAtEnd && content[end] !== ' ' |
|
||||||
if (traceNostr) { |
|
||||||
const textBefore = start > lastIndex ? content.slice(lastIndex, start) : '' |
|
||||||
logContentSpacing('parseNostrContent:nostr-element', { |
|
||||||
lastIndex, |
|
||||||
start, |
|
||||||
end, |
|
||||||
textBeforeSliceRepr: reprString(textBefore), |
|
||||||
isAtStart, |
|
||||||
isAtEnd, |
|
||||||
needsSpaceBefore, |
|
||||||
needsSpaceAfter, |
|
||||||
prevCharRepr: |
|
||||||
start > 0 ? reprString(content[start - 1]) : '(none)', |
|
||||||
nextCharRepr: |
|
||||||
end < content.length ? reprString(content[end]) : '(eof)' |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
if (needsSpaceBefore) { |
|
||||||
elements.push({ |
|
||||||
type: 'text', |
|
||||||
content: ' ' |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
elements.push({ |
|
||||||
type: 'nostr', |
|
||||||
content: nostrContent, |
|
||||||
bech32Id, |
|
||||||
nostrType: nostrType || undefined |
|
||||||
}) |
|
||||||
|
|
||||||
if (needsSpaceAfter) { |
|
||||||
elements.push({ |
|
||||||
type: 'text', |
|
||||||
content: ' ' |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
// If nostr is at the start of a line and immediately followed by a single newline, skip it
|
|
||||||
// so we don't render an errant hard-return behind the address
|
|
||||||
if (isAtStart && content[end] === '\n') { |
|
||||||
lastIndex = end + 1 |
|
||||||
} else { |
|
||||||
lastIndex = end |
|
||||||
} |
|
||||||
continue |
|
||||||
} else if (['image', 'video', 'audio'].includes(type) && url) { |
|
||||||
elements.push({ |
|
||||||
type: type as 'image' | 'video' | 'audio', |
|
||||||
content: match[0], |
|
||||||
mediaUrl: url |
|
||||||
}) |
|
||||||
} else if (type === 'hashtag' && hashtag) { |
|
||||||
elements.push({ |
|
||||||
type: 'hashtag', |
|
||||||
content: match[0], |
|
||||||
hashtag: hashtag |
|
||||||
}) |
|
||||||
} else if (type === 'bookstr-wikilink' && bookstrWikilink) { |
|
||||||
elements.push({ |
|
||||||
type: 'bookstr-wikilink', |
|
||||||
content: match[0], |
|
||||||
bookstrWikilink: bookstrWikilink, |
|
||||||
sourceUrl: sourceUrl |
|
||||||
}) |
|
||||||
} else if (type === 'wikilink' && wikilink) { |
|
||||||
elements.push({ |
|
||||||
type: 'wikilink', |
|
||||||
content: match[0], |
|
||||||
wikilink: wikilink, |
|
||||||
displayText: displayText |
|
||||||
}) |
|
||||||
} else if (type === 'url' && url) { |
|
||||||
elements.push({ |
|
||||||
type: 'url', |
|
||||||
content: match[0], |
|
||||||
url: url |
|
||||||
}) |
|
||||||
} else if (type === 'payto' && paytoUri) { |
|
||||||
elements.push({ |
|
||||||
type: 'payto', |
|
||||||
content: match[0], |
|
||||||
paytoUri: paytoUri |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
lastIndex = end |
|
||||||
} |
|
||||||
|
|
||||||
// Add remaining text after the last match
|
|
||||||
if (lastIndex < content.length) { |
|
||||||
const textContent = content.slice(lastIndex) |
|
||||||
if (textContent) { |
|
||||||
elements.push({ |
|
||||||
type: 'text', |
|
||||||
content: textContent |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Collect all images from content and imeta tags
|
|
||||||
const allImages: TImetaInfo[] = [] |
|
||||||
const processedUrls = new Set<string>() |
|
||||||
|
|
||||||
// Add imeta images first (they have priority) - only actual images, not videos
|
|
||||||
if (event) { |
|
||||||
const imetaInfos = getImetaInfosFromEvent(event) |
|
||||||
imetaInfos.forEach(imageInfo => { |
|
||||||
// Only add if it's actually an image (not video/audio)
|
|
||||||
if (!processedUrls.has(imageInfo.url) && isImage(imageInfo.url)) { |
|
||||||
allImages.push(imageInfo) |
|
||||||
processedUrls.add(imageInfo.url) |
|
||||||
} |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
// Add content images that aren't already in imeta
|
|
||||||
elements.forEach(element => { |
|
||||||
if (element.type === 'image' && element.mediaUrl) { |
|
||||||
if (!processedUrls.has(element.mediaUrl)) { |
|
||||||
allImages.push({ url: element.mediaUrl, pubkey: event?.pubkey }) |
|
||||||
processedUrls.add(element.mediaUrl) |
|
||||||
} |
|
||||||
} |
|
||||||
}) |
|
||||||
|
|
||||||
// Process imeta videos separately
|
|
||||||
if (event) { |
|
||||||
const imetaInfos = getImetaInfosFromEvent(event) |
|
||||||
imetaInfos.forEach(imetaInfo => { |
|
||||||
// Check if it's a video that hasn't been processed yet
|
|
||||||
if (isMedia(imetaInfo.url) && !isImage(imetaInfo.url)) { |
|
||||||
// Check if this video is already in elements
|
|
||||||
const alreadyProcessed = elements.some(element =>
|
|
||||||
element.type === 'video' && element.mediaUrl === imetaInfo.url |
|
||||||
) |
|
||||||
|
|
||||||
if (!alreadyProcessed) { |
|
||||||
// Determine if it's video or audio based on extension
|
|
||||||
const isVideo = /\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v|3gp|3g2|ogv)$/i.test(imetaInfo.url) |
|
||||||
elements.push({ |
|
||||||
type: isVideo ? 'video' : 'audio', |
|
||||||
content: imetaInfo.url, |
|
||||||
mediaUrl: imetaInfo.url |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
// If we have images, add a gallery element and remove individual image elements
|
|
||||||
if (allImages.length > 0) { |
|
||||||
// Remove individual image elements
|
|
||||||
const filteredElements = elements.filter(element => element.type !== 'image') |
|
||||||
|
|
||||||
// Add gallery element at the end
|
|
||||||
filteredElements.push({ |
|
||||||
type: 'gallery', |
|
||||||
content: '', |
|
||||||
images: allImages |
|
||||||
}) |
|
||||||
|
|
||||||
if (traceNostr) { |
|
||||||
logContentSpacing('parseNostrContent:result', { |
|
||||||
branch: 'gallery', |
|
||||||
sequence: summarizeParsedElementsForDebug(filteredElements) |
|
||||||
}) |
|
||||||
} |
|
||||||
return { elements: filteredElements } |
|
||||||
} |
|
||||||
|
|
||||||
// If no special content found, return the whole content as text
|
|
||||||
if (elements.length === 0) { |
|
||||||
elements.push({ |
|
||||||
type: 'text', |
|
||||||
content |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
if (traceNostr) { |
|
||||||
logContentSpacing('parseNostrContent:result', { |
|
||||||
branch: elements.length === 1 && elements[0].type === 'text' ? 'text-only' : 'elements', |
|
||||||
sequence: summarizeParsedElementsForDebug(elements) |
|
||||||
}) |
|
||||||
} |
|
||||||
return { elements } |
|
||||||
} |
|
||||||
|
|
||||||
function summarizeParsedElementsForDebug( |
|
||||||
els: ParsedNostrContent['elements'] |
|
||||||
): Array<{ type: string; repr?: string; bech32Id?: string }> { |
|
||||||
return els.map((e) => { |
|
||||||
if (e.type === 'text') return { type: 'text', repr: reprString(e.content) } |
|
||||||
if (e.type === 'nostr') return { type: 'nostr', bech32Id: e.bech32Id } |
|
||||||
return { type: e.type } |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Check if a nostr address is in a valid context (not inside URLs, etc.) |
|
||||||
*/ |
|
||||||
function isNostrAddressInValidContext(content: string, start: number, _end: number): boolean { |
|
||||||
// Don't parse if it's inside a URL (preceded by http://, https://, or www.)
|
|
||||||
const beforeContext = content.slice(Math.max(0, start - 20), start) |
|
||||||
if (beforeContext.match(/(https?:\/\/|www\.)[^\s]*$/)) { |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
// Don't parse if it's inside markdown links [text](url) or images 
|
|
||||||
const beforeMatch = content.slice(Math.max(0, start - 10), start) |
|
||||||
if (beforeMatch.match(/[!]?\[[^\]]*\]\([^)]*$/)) { |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
// Don't parse if it's inside HTML tags
|
|
||||||
const beforeTag = content.slice(Math.max(0, start - 50), start) |
|
||||||
if (beforeTag.match(/<[^>]*$/)) { |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
// Don't parse if it's inside code blocks or inline code
|
|
||||||
const beforeCode = content.slice(Math.max(0, start - 10), start) |
|
||||||
if (beforeCode.match(/`[^`]*$/)) { |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
// Don't parse if it's inside a code block (```)
|
|
||||||
const beforeCodeBlock = content.slice(0, start) |
|
||||||
const codeBlockMatches = beforeCodeBlock.match(/```/g) |
|
||||||
if (codeBlockMatches && codeBlockMatches.length % 2 === 1) { |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
return true |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get the nostr type from a bech32 ID |
|
||||||
*/ |
|
||||||
function getNostrType(bech32Id: string): 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note' | null { |
|
||||||
try { |
|
||||||
const { type } = nip19.decode(bech32Id) |
|
||||||
if (['npub', 'nprofile', 'nevent', 'naddr', 'note'].includes(type)) { |
|
||||||
return type as 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note' |
|
||||||
} |
|
||||||
} catch (error) { |
|
||||||
logger.error('Invalid bech32 ID', { bech32Id, error }) |
|
||||||
} |
|
||||||
return null |
|
||||||
} |
|
||||||
|
|
||||||
function NostrInlineVideo({ mediaUrl, fallbackText }: { mediaUrl: string; fallbackText: string }) { |
|
||||||
const [failed, setFailed] = useState(false) |
|
||||||
if (failed) { |
|
||||||
return ( |
|
||||||
<span className="whitespace-pre-wrap break-words text-primary hover:underline"> |
|
||||||
{fallbackText} |
|
||||||
</span> |
|
||||||
) |
|
||||||
} |
|
||||||
return ( |
|
||||||
<div className="not-prose my-2 max-w-full sm:max-w-[400px] w-full"> |
|
||||||
<video |
|
||||||
src={mediaUrl} |
|
||||||
controls |
|
||||||
className="m-0 max-w-full w-full h-auto rounded-lg block" |
|
||||||
preload="metadata" |
|
||||||
onError={() => setFailed(true)} |
|
||||||
> |
|
||||||
Your browser does not support the video tag. |
|
||||||
</video> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function NostrInlineAudio({ mediaUrl, fallbackText }: { mediaUrl: string; fallbackText: string }) { |
|
||||||
const [failed, setFailed] = useState(false) |
|
||||||
if (failed) { |
|
||||||
return ( |
|
||||||
<span className="whitespace-pre-wrap break-words text-primary hover:underline"> |
|
||||||
{fallbackText} |
|
||||||
</span> |
|
||||||
) |
|
||||||
} |
|
||||||
return ( |
|
||||||
<audio |
|
||||||
src={mediaUrl} |
|
||||||
controls |
|
||||||
className="w-full my-2 block" |
|
||||||
preload="metadata" |
|
||||||
onError={() => setFailed(true)} |
|
||||||
> |
|
||||||
Your browser does not support the audio tag. |
|
||||||
</audio> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Render parsed nostr content as React elements |
|
||||||
*/ |
|
||||||
export function renderNostrContent( |
|
||||||
parsedContent: ParsedNostrContent, |
|
||||||
className?: string, |
|
||||||
containingEvent?: Event |
|
||||||
): JSX.Element { |
|
||||||
return ( |
|
||||||
<div className={className}> |
|
||||||
{parsedContent.elements.map((element, index) => { |
|
||||||
if (element.type === 'text') { |
|
||||||
return ( |
|
||||||
<span key={index} className="whitespace-pre-wrap break-words"> |
|
||||||
{element.content} |
|
||||||
</span> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
if (element.type === 'gallery' && element.images) { |
|
||||||
return ( |
|
||||||
<div key={index} className="my-2"> |
|
||||||
<ImageGallery |
|
||||||
images={element.images} |
|
||||||
className="max-w-[400px]" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
if (element.type === 'video' && element.mediaUrl) { |
|
||||||
return ( |
|
||||||
<NostrInlineVideo |
|
||||||
key={index} |
|
||||||
mediaUrl={element.mediaUrl} |
|
||||||
fallbackText={element.content} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
if (element.type === 'audio' && element.mediaUrl) { |
|
||||||
return ( |
|
||||||
<NostrInlineAudio |
|
||||||
key={index} |
|
||||||
mediaUrl={element.mediaUrl} |
|
||||||
fallbackText={element.content} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
if (element.type === 'hashtag' && element.hashtag) { |
|
||||||
const normalizedHashtag = element.hashtag.toLowerCase() |
|
||||||
// Only render as green link if this hashtag was parsed from the content
|
|
||||||
// (parseNostrContent already only extracts hashtags from content, not t-tags)
|
|
||||||
const nextElement = parsedContent.elements[index + 1] |
|
||||||
const shouldAddSpace = nextElement && nextElement.type === 'hashtag' |
|
||||||
|
|
||||||
return ( |
|
||||||
<> |
|
||||||
<a |
|
||||||
key={index} |
|
||||||
href={`/notes?t=${normalizedHashtag}`} |
|
||||||
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" |
|
||||||
> |
|
||||||
#{element.hashtag} |
|
||||||
</a> |
|
||||||
{shouldAddSpace && <span> </span>} |
|
||||||
</> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
if (element.type === 'bookstr-wikilink' && element.bookstrWikilink) { |
|
||||||
return ( |
|
||||||
<BookstrContent |
|
||||||
key={index} |
|
||||||
wikilink={element.bookstrWikilink} |
|
||||||
sourceUrl={element.sourceUrl} |
|
||||||
className="my-2" |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
if (element.type === 'wikilink' && element.wikilink && element.displayText) { |
|
||||||
const normalizedWikilink = element.wikilink.toLowerCase() |
|
||||||
return ( |
|
||||||
<a |
|
||||||
key={index} |
|
||||||
href={`/wiki/${encodeURIComponent(normalizedWikilink)}`} |
|
||||||
className="text-primary hover:text-primary/80 hover:underline break-words" |
|
||||||
> |
|
||||||
{element.displayText} |
|
||||||
</a> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
if (element.type === 'url' && element.url) { |
|
||||||
return ( |
|
||||||
<HttpNostrAwareUrl |
|
||||||
key={index} |
|
||||||
url={element.url} |
|
||||||
renderMode="article" |
|
||||||
containingEvent={containingEvent} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
if (element.type === 'payto' && element.paytoUri) { |
|
||||||
return ( |
|
||||||
<PaytoLink |
|
||||||
key={index} |
|
||||||
paytoUri={element.paytoUri} |
|
||||||
className="text-primary hover:underline break-words" |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
if (element.type === 'nostr' && element.bech32Id && element.nostrType) { |
|
||||||
// Render as embedded content
|
|
||||||
if (element.nostrType === 'npub' || element.nostrType === 'nprofile') { |
|
||||||
return ( |
|
||||||
<EmbeddedMention
|
|
||||||
key={index}
|
|
||||||
userId={element.bech32Id}
|
|
||||||
className="inline"
|
|
||||||
/> |
|
||||||
) |
|
||||||
} else if (['nevent', 'naddr', 'note'].includes(element.nostrType)) { |
|
||||||
return ( |
|
||||||
<EmbeddedNote
|
|
||||||
key={index}
|
|
||||||
noteId={element.bech32Id}
|
|
||||||
className="not-prose inline-block"
|
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Fallback to text if something goes wrong
|
|
||||||
return ( |
|
||||||
<span key={index} className="whitespace-pre-wrap break-words"> |
|
||||||
{element.content} |
|
||||||
</span> |
|
||||||
) |
|
||||||
})} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,46 +0,0 @@ |
|||||||
import { kinds, NostrEvent } from 'nostr-tools' |
|
||||||
import { ExtendedKind } from '@/constants' |
|
||||||
import { hexPubkeysEqual } from '@/lib/pubkey' |
|
||||||
import { isMentioningMutedUsers } from './event' |
|
||||||
import { muteSetHas } from './mute-set' |
|
||||||
import { tagNameEquals } from './tag' |
|
||||||
|
|
||||||
export function notificationFilter( |
|
||||||
event: NostrEvent, |
|
||||||
{ |
|
||||||
pubkey, |
|
||||||
mutePubkeySet, |
|
||||||
hideContentMentioningMutedUsers, |
|
||||||
hideUntrustedNotifications, |
|
||||||
isUserTrusted |
|
||||||
}: { |
|
||||||
pubkey?: string | null |
|
||||||
mutePubkeySet: Set<string> |
|
||||||
hideContentMentioningMutedUsers?: boolean |
|
||||||
hideUntrustedNotifications?: boolean |
|
||||||
isUserTrusted: (pubkey: string) => boolean |
|
||||||
} |
|
||||||
): boolean { |
|
||||||
if ( |
|
||||||
muteSetHas(mutePubkeySet, event.pubkey) || |
|
||||||
(hideContentMentioningMutedUsers && isMentioningMutedUsers(event, mutePubkeySet)) || |
|
||||||
(hideUntrustedNotifications && !isUserTrusted(event.pubkey)) |
|
||||||
) { |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
if (pubkey && (event.kind === kinds.Reaction || event.kind === ExtendedKind.EXTERNAL_REACTION)) { |
|
||||||
const targetPubkey = event.tags.findLast(tagNameEquals('p'))?.[1] |
|
||||||
if (!targetPubkey || !hexPubkeysEqual(targetPubkey, pubkey)) return false |
|
||||||
} |
|
||||||
|
|
||||||
// For PUBLIC_MESSAGE (kind 24) events, ensure the user is in the 'p' tags
|
|
||||||
if (pubkey && event.kind === ExtendedKind.PUBLIC_MESSAGE) { |
|
||||||
const hasUserInPTags = event.tags.some( |
|
||||||
(tag) => tag[0] === 'p' && tag[1] && hexPubkeysEqual(tag[1], pubkey) |
|
||||||
) |
|
||||||
if (!hasUserInPTags) return false |
|
||||||
} |
|
||||||
|
|
||||||
return true |
|
||||||
} |
|
||||||
Loading…
Reference in new issue