Browse Source

reduce repo size

imwald
Silberengel 3 weeks ago
parent
commit
41fdae462c
  1. 71
      src/components/ArticleExportMenu/ArticleExportMenu.tsx
  2. 142
      src/components/BookmarkList/index.tsx
  3. 27
      src/components/Donation/PlatinumSponsors.tsx
  4. 48
      src/components/Donation/RecentSupporters.tsx
  5. 53
      src/components/Donation/index.tsx
  6. 184
      src/components/ImageCarousel/ImageCarousel.tsx
  7. 100
      src/components/MediaRenderer/index.tsx
  8. 2
      src/components/Note/Highlight/index.tsx
  9. 102
      src/components/Note/LongFormArticlePreview.tsx
  10. 63
      src/components/NoteStats/DiscussionNoteStats.tsx
  11. 169
      src/components/NoteStats/VoteButtons.tsx
  12. 38
      src/components/PostEditor/Title.tsx
  13. 30
      src/components/Profile/Followings.tsx
  14. 84
      src/components/Profile/ProfileFeed.tsx
  15. 22
      src/components/Profile/Relays.tsx
  16. 91
      src/components/RelayList/index.tsx
  17. 127
      src/components/SearchInfo.tsx
  18. 28
      src/components/SuggestedEmojis/DiscussionEmojis.tsx
  19. 69
      src/components/SuggestedEmojis/index.tsx
  20. 373
      src/components/TextareaWithMentionAutocomplete/index.tsx
  21. 42
      src/components/ThemeToggle/index.tsx
  22. 91
      src/components/TopicSubscribeButton/index.tsx
  23. 54
      src/components/UniversalContent/SimpleContent.tsx
  24. 47
      src/components/UniversalContent/WikilinkProcessor.tsx
  25. 4
      src/global-polyfill-types.d.ts
  26. 111
      src/hooks/useProfileReports.tsx
  27. 79
      src/lib/article-media.ts
  28. 4
      src/lib/content-patterns.ts
  29. 2
      src/lib/like-reaction-emojis.ts
  30. 73
      src/lib/markdown-cleanup.ts
  31. 13
      src/lib/nip89-utils.ts
  32. 724
      src/lib/nostr-parser.tsx
  33. 46
      src/lib/notification.ts

71
src/components/ArticleExportMenu/ArticleExportMenu.tsx

@ -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>
)
}

142
src/components/BookmarkList/index.tsx

@ -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" />
}

27
src/components/Donation/PlatinumSponsors.tsx

@ -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>
)
}

48
src/components/Donation/RecentSupporters.tsx

@ -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>
)
}

53
src/components/Donation/index.tsx

@ -1,53 +0,0 @@ @@ -1,53 +0,0 @@
import { Button } from '@/components/ui/button'
import { IMWALD_MAINTAINER_PUBKEY } from '@/constants'
import { cn } from '@/lib/utils'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ZapDialog from '../ZapDialog'
import PlatinumSponsors from './PlatinumSponsors'
import RecentSupporters from './RecentSupporters'
export default function Donation({ className }: { className?: string }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [donationAmount, setDonationAmount] = useState<number | undefined>(undefined)
return (
<div className={cn('p-4 border rounded-lg space-y-4', className)}>
<div className="text-center font-semibold">{t('Enjoying Imwald?')}</div>
<div className="text-center text-muted-foreground">
{t('Your donation helps me maintain Imwald and make it better! 😊')}
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ amount: 1000, text: '☕ 1k' },
{ amount: 10000, text: '🍜 10k' },
{ amount: 100000, text: '🍣 100k' },
{ amount: 1000000, text: '✈ 1M' }
].map(({ amount, text }) => {
return (
<Button
variant="secondary"
className=""
key={amount}
onClick={() => {
setDonationAmount(amount)
setOpen(true)
}}
>
{text}
</Button>
)
})}
</div>
<PlatinumSponsors />
<RecentSupporters />
<ZapDialog
open={open}
setOpen={setOpen}
pubkey={IMWALD_MAINTAINER_PUBKEY}
defaultAmount={donationAmount}
/>
</div>
)
}

184
src/components/ImageCarousel/ImageCarousel.tsx

@ -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>
)}
</>
)
}

100
src/components/MediaRenderer/index.tsx

@ -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>
)
}

2
src/components/Note/Highlight/index.tsx

@ -276,7 +276,7 @@ export default function Highlight({ @@ -276,7 +276,7 @@ export default function Highlight({
// Events with special preview cards that should always use full preview
const specialCardKinds = [
kinds.LongFormArticle, // 30023 - has LongFormArticlePreview
kinds.LongFormArticle, // 30023 — long-form preview card
ExtendedKind.POLL, // Has PollPreview
ExtendedKind.DISCUSSION, // Has DiscussionNote
ExtendedKind.VIDEO, // Has VideoNotePreview

102
src/components/Note/LongFormArticlePreview.tsx

@ -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>
)
}

63
src/components/NoteStats/DiscussionNoteStats.tsx

@ -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>
)
}

169
src/components/NoteStats/VoteButtons.tsx

@ -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>
)
}

38
src/components/PostEditor/Title.tsx

@ -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')
}

30
src/components/Profile/Followings.tsx

@ -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>
)
}

84
src/components/Profile/ProfileFeed.tsx

@ -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

22
src/components/Profile/Relays.tsx

@ -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>
)
}

91
src/components/RelayList/index.tsx

@ -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>
)
}

127
src/components/SearchInfo.tsx

@ -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>
)
}

28
src/components/SuggestedEmojis/DiscussionEmojis.tsx

@ -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>
)
}

69
src/components/SuggestedEmojis/index.tsx

@ -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>
)
}

373
src/components/TextareaWithMentionAutocomplete/index.tsx

@ -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

42
src/components/ThemeToggle/index.tsx

@ -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>
)}
</>
)
}

91
src/components/TopicSubscribeButton/index.tsx

@ -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>
)
}

54
src/components/UniversalContent/SimpleContent.tsx

@ -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>
)
}

47
src/components/UniversalContent/WikilinkProcessor.tsx

@ -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}
/>
)
}

4
src/index.d.ts → src/global-polyfill-types.d.ts vendored

@ -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
}

111
src/hooks/useProfileReports.tsx

@ -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 }
}

79
src/lib/article-media.ts

@ -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
}

4
src/lib/content-patterns.ts

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
/**
* Single source of truth for :emoji: shortcodes and nostr: bech32 patterns.
* Used by MarkdownArticle, parseContent, nostr-parser, previews, post editor, AsciiDoc, etc.
* Used by MarkdownArticle, parseContent, previews, post editor, AsciiDoc, etc.
*/
// --- Emoji (:shortcode:) ----------------------------------------------------
@ -81,7 +81,7 @@ export const NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX = new RegExp( @@ -81,7 +81,7 @@ export const NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX = new RegExp(
/** Legacy bare bech32 (no nostr: prefix) */
export const LEGACY_PROFILE_BECH32_REGEX = new RegExp(`${BECH32_NPUB}|${BECH32_NPROFILE}`, 'g')
/** nostr-parser.tsx: boundary + lookahead so punctuation does not stick to bech32 */
/** Boundary + lookahead so punctuation does not stick to bech32 */
export const NOSTR_PARSER_LOOKAHEAD = '(?=\\s|$|>|\\]|,|\\.|!|\\?|;|:)'
export const NOSTR_PARSER_REGEX = new RegExp(
`(?:^|\\s|>|\\[)nostr:(${NOSTR_CONTENT_BECH32_ALT})${NOSTR_PARSER_LOOKAHEAD}`,

2
src/lib/like-reaction-emojis.ts

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/**
* Single source for the quick-like emoji row used by SuggestedEmojis and the EmojiPicker
* Single source for the quick-like emoji row used by the EmojiPicker / LikeButton
* reactions row. Also re-exported as EMOJI_PICKER_REACTIONS for LikeButton.
*/
export const DEFAULT_SUGGESTED_EMOJIS = ['❤', '👍', '🔥', '😂', '😢', '🫂', '🚀'] as const

73
src/lib/markdown-cleanup.ts

@ -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: ![alt](url)
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
})
}

13
src/lib/nip89-utils.ts

@ -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

724
src/lib/nostr-parser.tsx

@ -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 ![text](url)
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>
)
}

46
src/lib/notification.ts

@ -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…
Cancel
Save