33 changed files with 8 additions and 3105 deletions
@ -1,71 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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> { |
||||
findLast(predicate: (value: T, index: number, obj: T[]) => boolean, thisArg?: any): T | undefined |
||||
} |
||||
@ -1,111 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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