feat: support kind 20

This commit is contained in:
codytseng
2025-01-07 23:19:35 +08:00
parent 4205e32d0f
commit 4343765aba
30 changed files with 1221 additions and 712 deletions

View File

@@ -1,4 +1,6 @@
import { isNsfwEvent } from '@/lib/event'
import { isNsfwEvent, isPictureEvent } from '@/lib/event'
import { extractImetaUrlFromTag } from '@/lib/tag'
import { isImage, isVideo } from '@/lib/url'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { memo } from 'react'
@@ -14,6 +16,7 @@ import {
import ImageGallery from '../ImageGallery'
import VideoPlayer from '../VideoPlayer'
import WebPreview from '../WebPreview'
import { URL_REGEX } from '@/constants'
const Content = memo(
({
@@ -25,7 +28,7 @@ const Content = memo(
className?: string
size?: 'normal' | 'small'
}) => {
const { content, images, videos, embeddedNotes, lastNonMediaUrl } = preprocess(event.content)
const { content, images, videos, embeddedNotes, lastNonMediaUrl } = preprocess(event)
const isNsfw = isNsfwEvent(event)
const nodes = embedded(content, [
embeddedNormalUrlRenderer,
@@ -39,7 +42,7 @@ const Content = memo(
if (images.length) {
nodes.push(
<ImageGallery
className={`w-fit ${size === 'small' ? 'mt-1' : 'mt-2'}`}
className={`${size === 'small' ? 'mt-1' : 'mt-2'}`}
key={`image-gallery-${event.id}`}
images={images}
isNsfw={isNsfw}
@@ -95,9 +98,9 @@ const Content = memo(
Content.displayName = 'Content'
export default Content
function preprocess(content: string) {
const urlRegex = /(https?:\/\/[^\s"']+)/g
const urls = content.match(urlRegex) || []
function preprocess(event: Event) {
const content = event.content
const urls = content.match(URL_REGEX) || []
let lastNonMediaUrl: string | undefined
let c = content
@@ -116,6 +119,15 @@ function preprocess(content: string) {
}
})
if (isPictureEvent(event)) {
event.tags.forEach((tag) => {
const imageUrl = extractImetaUrlFromTag(tag)
if (imageUrl) {
images.push(imageUrl)
}
})
}
const embeddedNotes: string[] = []
const embeddedNoteRegex = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
;(c.match(embeddedNoteRegex) || []).forEach((note) => {
@@ -123,23 +135,7 @@ function preprocess(content: string) {
embeddedNotes.push(note)
})
c = c.replace(/\n{3,}/g, '\n\n').trim()
return { content: c, images, videos, embeddedNotes, lastNonMediaUrl }
}
function isImage(url: string) {
try {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.svg']
return imageExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
} catch {
return false
}
}
function isVideo(url: string) {
try {
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov']
return videoExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
} catch {
return false
}
}

View File

@@ -0,0 +1,35 @@
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import { HTMLAttributes, useState } from 'react'
export default function Image({
src,
alt,
className = '',
classNames = {},
...props
}: HTMLAttributes<HTMLDivElement> & {
src: string
alt?: string
classNames?: {
wrapper?: string
}
}) {
const [isLoading, setIsLoading] = useState(true)
return (
<div className={cn('relative', classNames.wrapper ?? '')} {...props}>
{isLoading && <Skeleton className={cn('absolute inset-0', className)} />}
<img
src={src}
alt={alt}
className={cn(
'object-cover transition-opacity duration-700',
isLoading ? 'opacity-0' : 'opacity-100',
className
)}
onLoad={() => setIsLoading(false)}
/>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import { Carousel, CarouselContent, CarouselItem } from '@/components/ui/carousel'
import { useState } from 'react'
import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import Image from '../Image'
import NsfwOverlay from '../NsfwOverlay'
export function ImageCarousel({ images, isNsfw = false }: { images: string[]; isNsfw?: boolean }) {
const [index, setIndex] = useState(-1)
const handlePhotoClick = (event: React.MouseEvent, current: number) => {
event.preventDefault()
setIndex(current)
}
return (
<>
<Carousel className="w-full">
<CarouselContent>
{images.map((url, index) => (
<CarouselItem key={index}>
<Image src={url} onClick={(e) => handlePhotoClick(e, index)} />
</CarouselItem>
))}
</CarouselContent>
</Carousel>
<Lightbox
index={index}
slides={images.map((src) => ({ src }))}
plugins={[Zoom]}
open={index >= 0}
close={() => setIndex(-1)}
controller={{
closeOnBackdropClick: true,
closeOnPullUp: true,
closeOnPullDown: true
}}
styles={{ toolbar: { paddingTop: '2.25rem' } }}
/>
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</>
)
}

View File

@@ -1,9 +1,9 @@
import { Image } from '@nextui-org/image'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import { useState } from 'react'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { ReactNode, useState } from 'react'
import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import Image from '../Image'
import NsfwOverlay from '../NsfwOverlay'
export default function ImageGallery({
@@ -17,45 +17,84 @@ export default function ImageGallery({
isNsfw?: boolean
size?: 'normal' | 'small'
}) {
const { isSmallScreen } = useScreenSize()
const [index, setIndex] = useState(-1)
const handlePhotoClick = (event: React.MouseEvent, current: number) => {
event.stopPropagation()
event.preventDefault()
setIndex(current)
}
return (
<div className={cn('relative', className)} onClick={(e) => e.stopPropagation()}>
<ScrollArea className="w-full">
<div className="flex space-x-2">
{images.map((src, index) => (
<Image
key={index}
className={cn(
'rounded-lg cursor-pointer z-0 object-cover',
size === 'small' ? 'h-[15vh]' : 'h-[30vh]'
)}
src={src}
onClick={(e) => handlePhotoClick(e, index)}
removeWrapper
/>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<Lightbox
index={index}
slides={images.map((src) => ({ src }))}
plugins={[Zoom]}
open={index >= 0}
close={() => setIndex(-1)}
controller={{
closeOnBackdropClick: true,
closeOnPullUp: true,
closeOnPullDown: true
}}
styles={{ toolbar: { paddingTop: '2.25rem' } }}
let imageContent: ReactNode | null = null
if (images.length === 1) {
imageContent = (
<Image
key={index}
className={cn('rounded-lg cursor-pointer', size === 'small' ? 'h-[15vh]' : 'h-[30vh]')}
src={images[0]}
onClick={(e) => handlePhotoClick(e, 0)}
/>
)
} else if (size === 'small') {
imageContent = (
<div className="grid grid-cols-4 gap-2">
{images.map((src, i) => (
<Image
key={i}
className="rounded-lg cursor-pointer aspect-square"
src={src}
onClick={(e) => handlePhotoClick(e, i)}
/>
))}
</div>
)
} else if (isSmallScreen && (images.length === 2 || images.length === 4)) {
imageContent = (
<div className="grid grid-cols-2 gap-2">
{images.map((src, i) => (
<Image
key={i}
className="rounded-lg cursor-pointer aspect-square"
src={src}
onClick={(e) => handlePhotoClick(e, i)}
/>
))}
</div>
)
} else {
imageContent = (
<div className="grid grid-cols-3 gap-2">
{images.map((src, i) => (
<Image
key={i}
className="rounded-lg cursor-pointer aspect-square"
src={src}
onClick={(e) => handlePhotoClick(e, i)}
/>
))}
</div>
)
}
return (
<div className={cn('relative w-fit max-w-full', className)}>
{imageContent}
<div onClick={(e) => e.stopPropagation()}>
<Lightbox
index={index}
slides={images.map((src) => ({ src }))}
plugins={[Zoom]}
open={index >= 0}
close={() => setIndex(-1)}
controller={{
closeOnBackdropClick: true,
closeOnPullUp: true,
closeOnPullDown: true
}}
styles={{ toolbar: { paddingTop: '2.25rem' } }}
/>
</div>
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</div>
)

View File

@@ -0,0 +1,190 @@
import { Separator } from '@/components/ui/separator'
import { COMMENT_EVENT_KIND } from '@/constants'
import { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useNoteStats } from '@/providers/NoteStatsProvider'
import client from '@/services/client.service'
import dayjs from 'dayjs'
import { Event as NEvent } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReplyNote from '../ReplyNote'
import { isCommentEvent } from '@/lib/event'
const LIMIT = 100
export default function Nip22ReplyNoteList({
event,
className
}: {
event: NEvent
className?: string
}) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(() => dayjs().unix())
const [replies, setReplies] = useState<NEvent[]>([])
const [replyMap, setReplyMap] = useState<
Record<string, { event: NEvent; level: number; parent?: NEvent } | undefined>
>({})
const [loading, setLoading] = useState<boolean>(false)
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
const { updateNoteReplyCount } = useNoteStats()
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const handleEventPublished = (data: Event) => {
const customEvent = data as CustomEvent<NEvent>
const evt = customEvent.detail
if (
isCommentEvent(evt) &&
evt.tags.some(([tagName, tagValue]) => tagName === 'E' && tagValue === event.id)
) {
onNewReply(evt)
}
}
client.addEventListener('eventPublished', handleEventPublished)
return () => {
client.removeEventListener('eventPublished', handleEventPublished)
}
}, [event])
useEffect(() => {
if (loading) return
const init = async () => {
setLoading(true)
setReplies([])
try {
const relayList = await client.fetchRelayList(event.pubkey)
const { closer, timelineKey } = await client.subscribeTimeline(
relayList.read.slice(0, 5),
{
'#E': [event.id],
kinds: [COMMENT_EVENT_KIND],
limit: LIMIT
},
{
onEvents: (evts, eosed) => {
setReplies(evts.reverse())
if (eosed) {
setLoading(false)
setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
}
},
onNew: (evt) => {
onNewReply(evt)
}
}
)
setTimelineKey(timelineKey)
return closer
} catch {
setLoading(false)
}
return
}
const promise = init()
return () => {
promise.then((closer) => closer?.())
}
}, [event])
useEffect(() => {
updateNoteReplyCount(event.id, replies.length)
const replyMap: Record<string, { event: NEvent; level: number; parent?: NEvent } | undefined> =
{}
for (const reply of replies) {
const parentEventId = reply.tags.find(tagNameEquals('e'))?.[1]
if (parentEventId && parentEventId !== event.id) {
const parentReplyInfo = replyMap[parentEventId]
const level = parentReplyInfo ? parentReplyInfo.level + 1 : 1
replyMap[reply.id] = { event: reply, level, parent: parentReplyInfo?.event }
continue
}
replyMap[reply.id] = { event: reply, level: 1 }
continue
}
setReplyMap(replyMap)
}, [replies, event.id, updateNoteReplyCount])
const loadMore = async () => {
if (loading || !until || !timelineKey) return
setLoading(true)
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
const olderReplies = events.reverse()
if (olderReplies.length > 0) {
setReplies((pre) => [...olderReplies, ...pre])
}
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
setLoading(false)
}
const onNewReply = (evt: NEvent) => {
setReplies((pre) => {
if (pre.some((reply) => reply.id === evt.id)) return pre
return [...pre, evt]
})
if (evt.pubkey === pubkey) {
setTimeout(() => {
if (bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
highlightReply(evt.id, false)
}, 100)
}
}
const highlightReply = (eventId: string, scrollTo = true) => {
if (scrollTo) {
const ref = replyRefs.current[eventId]
if (ref) {
ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
setHighlightReplyId(eventId)
setTimeout(() => {
setHighlightReplyId((pre) => (pre === eventId ? undefined : pre))
}, 1500)
}
return (
<>
<div
className={`text-sm text-center text-muted-foreground ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
onClick={loadMore}
>
{loading ? t('loading...') : until ? t('load more older replies') : null}
</div>
{replies.length > 0 && (loading || until) && <Separator className="my-2" />}
<div className={cn('mb-4', className)}>
{replies.map((reply) => {
const info = replyMap[reply.id]
return (
<div ref={(el) => (replyRefs.current[reply.id] = el)} key={reply.id}>
<ReplyNote
event={reply}
parentEvent={info?.parent}
onClickParent={highlightReply}
highlight={highlightReplyId === reply.id}
/>
</div>
)
})}
</div>
{replies.length === 0 && !loading && !until && (
<div className="text-sm text-center text-muted-foreground">{t('no replies')}</div>
)}
<div ref={bottomRef} />
</>
)
}

View File

@@ -62,7 +62,7 @@ export default function Note({
)}
<Content className="mt-2" event={event} />
{!hideStats && (
<NoteStats className="mt-3 sm:mt-4" event={event} fetchIfNotExisting={fetchNoteStats} />
<NoteStats className="mt-3" event={event} fetchIfNotExisting={fetchNoteStats} />
)}
</div>
)

View File

@@ -1,8 +1,10 @@
import { Button } from '@/components/ui/button'
import { PICTURE_EVENT_KIND } from '@/constants'
import { useFetchRelayInfos } from '@/hooks'
import { isReplyNoteEvent } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import client from '@/services/client.service'
import dayjs from 'dayjs'
import { Event, Filter, kinds } from 'nostr-tools'
@@ -10,10 +12,14 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh'
import NoteCard from '../NoteCard'
import PictureNoteCard from '../PictureNoteCard'
import SimpleMasonryGrid from '../SimpleMasonryGrid'
const NORMAL_RELAY_LIMIT = 100
const ALGO_RELAY_LIMIT = 500
type TListMode = 'posts' | 'postsAndReplies' | 'pictures'
export default function NoteList({
relayUrls,
filter = {},
@@ -24,6 +30,7 @@ export default function NoteList({
className?: string
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { signEvent, checkLogin } = useNostr()
const { isFetching: isFetchingRelayInfo, areAlgoRelays } = useFetchRelayInfos([...relayUrls])
const [refreshCount, setRefreshCount] = useState(0)
@@ -32,15 +39,23 @@ export default function NoteList({
const [newEvents, setNewEvents] = useState<Event[]>([])
const [hasMore, setHasMore] = useState<boolean>(true)
const [refreshing, setRefreshing] = useState(true)
const [displayReplies, setDisplayReplies] = useState(false)
const [listMode, setListMode] = useState<TListMode>('posts')
const bottomRef = useRef<HTMLDivElement | null>(null)
const isPictures = useMemo(() => listMode === 'pictures', [listMode])
const noteFilter = useMemo(() => {
if (isPictures) {
return {
kinds: [PICTURE_EVENT_KIND],
limit: areAlgoRelays ? ALGO_RELAY_LIMIT : NORMAL_RELAY_LIMIT,
...filter
}
}
return {
kinds: [kinds.ShortTextNote, kinds.Repost],
kinds: [kinds.ShortTextNote, kinds.Repost, PICTURE_EVENT_KIND],
limit: areAlgoRelays ? ALGO_RELAY_LIMIT : NORMAL_RELAY_LIMIT,
...filter
}
}, [JSON.stringify(filter), areAlgoRelays])
}, [JSON.stringify(filter), areAlgoRelays, isPictures])
useEffect(() => {
if (isFetchingRelayInfo || relayUrls.length === 0) return
@@ -151,55 +166,65 @@ export default function NoteList({
return (
<div className={cn('space-y-2 sm:space-y-2', className)}>
<DisplayRepliesSwitch displayReplies={displayReplies} setDisplayReplies={setDisplayReplies} />
<div className="space-y-2 sm:space-y-2">
{newEvents.filter((event) => displayReplies || !isReplyNoteEvent(event)).length > 0 && (
<div className="flex justify-center w-full max-sm:mt-2">
<Button size="lg" onClick={showNewEvents}>
{t('show new notes')}
</Button>
</div>
)}
<PullToRefresh
onRefresh={async () => {
setRefreshCount((count) => count + 1)
await new Promise((resolve) => setTimeout(resolve, 1000))
}}
pullingContent=""
>
<div>
{events
.filter((event) => displayReplies || !isReplyNoteEvent(event))
.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} />
<ListModeSwitch listMode={listMode} setListMode={setListMode} />
<PullToRefresh
onRefresh={async () => {
setRefreshCount((count) => count + 1)
await new Promise((resolve) => setTimeout(resolve, 1000))
}}
pullingContent=""
>
<div className="space-y-2 sm:space-y-2">
{newEvents.filter((event) => listMode !== 'posts' || !isReplyNoteEvent(event)).length >
0 && (
<div className="flex justify-center w-full max-sm:mt-2">
<Button size="lg" onClick={showNewEvents}>
{t('show new notes')}
</Button>
</div>
)}
{isPictures ? (
<SimpleMasonryGrid
className="px-2 sm:px-4"
columnCount={isSmallScreen ? 2 : 3}
items={events.map((event) => (
<PictureNoteCard key={event.id} className="w-full" event={event} />
))}
/>
) : (
<div>
{events
.filter((event) => listMode === 'postsAndReplies' || !isReplyNoteEvent(event))
.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} />
))}
</div>
)}
<div className="text-center text-sm text-muted-foreground">
{hasMore || refreshing ? (
<div ref={bottomRef}>{t('loading...')}</div>
) : events.length ? (
t('no more notes')
) : (
<div className="flex justify-center w-full max-sm:mt-2">
<Button size="lg" onClick={() => setRefreshCount((pre) => pre + 1)}>
{t('reload notes')}
</Button>
</div>
)}
</div>
</PullToRefresh>
</div>
<div className="text-center text-sm text-muted-foreground">
{hasMore || refreshing ? (
<div ref={bottomRef}>{t('loading...')}</div>
) : events.length ? (
t('no more notes')
) : (
<div className="flex justify-center w-full max-sm:mt-2">
<Button size="lg" onClick={() => setRefreshCount((pre) => pre + 1)}>
{t('reload notes')}
</Button>
</div>
)}
</div>
</div>
</PullToRefresh>
</div>
)
}
function DisplayRepliesSwitch({
displayReplies,
setDisplayReplies
function ListModeSwitch({
listMode,
setListMode
}: {
displayReplies: boolean
setDisplayReplies: (value: boolean) => void
listMode: TListMode
setListMode: (listMode: TListMode) => void
}) {
const { t } = useTranslation()
@@ -207,20 +232,26 @@ function DisplayRepliesSwitch({
<div>
<div className="flex">
<div
className={`w-1/2 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${displayReplies ? 'text-muted-foreground' : ''}`}
onClick={() => setDisplayReplies(false)}
className={`w-1/3 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${listMode === 'posts' ? '' : 'text-muted-foreground'}`}
onClick={() => setListMode('posts')}
>
{t('Notes')}
</div>
<div
className={`w-1/2 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${displayReplies ? '' : 'text-muted-foreground'}`}
onClick={() => setDisplayReplies(true)}
className={`w-1/3 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${listMode === 'postsAndReplies' ? '' : 'text-muted-foreground'}`}
onClick={() => setListMode('postsAndReplies')}
>
{t('Notes & Replies')}
</div>
<div
className={`w-1/3 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${listMode === 'pictures' ? '' : 'text-muted-foreground'}`}
onClick={() => setListMode('pictures')}
>
{t('Pictures')}
</div>
</div>
<div
className={`w-1/2 px-4 sm:px-6 transition-transform duration-500 ${displayReplies ? 'translate-x-full' : ''}`}
className={`w-1/3 px-4 sm:px-6 transition-transform duration-500 ${listMode === 'postsAndReplies' ? 'translate-x-full' : listMode === 'pictures' ? 'translate-x-[200%]' : ''} `}
>
<div className="w-full h-1 bg-primary rounded-full" />
</div>

View File

@@ -1,3 +1,4 @@
import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
import { useFetchEvent } from '@/hooks'
import { toNote } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag'
@@ -5,7 +6,7 @@ import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import dayjs from 'dayjs'
import { Heart, MessageCircle, Repeat } from 'lucide-react'
import { Heart, MessageCircle, Repeat, ThumbsUp } from 'lucide-react'
import { Event, kinds, nip19, validateEvent } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -41,7 +42,7 @@ export default function NotificationList() {
: relayList.read.concat(client.getDefaultRelayUrls()).slice(0, 4),
{
'#p': [pubkey],
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction],
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction, COMMENT_EVENT_KIND],
limit: LIMIT
},
{
@@ -147,6 +148,9 @@ function NotificationItem({ notification }: { notification: Event }) {
if (notification.kind === kinds.Repost) {
return <RepostNotification notification={notification} />
}
if (notification.kind === COMMENT_EVENT_KIND) {
return <CommentNotification notification={notification} />
}
return null
}
@@ -162,7 +166,9 @@ function ReactionNotification({ notification }: { notification: Event }) {
: undefined
}, [notification])
const { event } = useFetchEvent(bech32Id)
if (!event || !bech32Id || event.kind !== kinds.ShortTextNote) return null
if (!event || !bech32Id || ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(event.kind)) {
return null
}
return (
<div
@@ -172,6 +178,7 @@ function ReactionNotification({ notification }: { notification: Event }) {
<div className="flex gap-2 items-center flex-1">
<UserAvatar userId={notification.pubkey} size="small" />
<Heart size={24} className="text-red-400" />
<div>{notification.content === '+' ? <ThumbsUp size={14} /> : notification.content}</div>
<ContentPreview event={event} />
</div>
<div className="text-muted-foreground">
@@ -228,8 +235,37 @@ function RepostNotification({ notification }: { notification: Event }) {
)
}
function CommentNotification({ notification }: { notification: Event }) {
const { push } = useSecondaryPage()
const rootEventId = notification.tags.find(tagNameEquals('E'))?.[1]
const rootPubkey = notification.tags.find(tagNameEquals('P'))?.[1]
const rootKind = notification.tags.find(tagNameEquals('K'))?.[1]
if (
!rootEventId ||
!rootPubkey ||
!rootKind ||
![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(parseInt(rootKind))
) {
return null
}
return (
<div
className="flex gap-2 items-center cursor-pointer py-2"
onClick={() => push(toNote({ id: rootEventId, pubkey: rootPubkey }))}
>
<UserAvatar userId={notification.pubkey} size="small" />
<MessageCircle size={24} className="text-blue-400" />
<ContentPreview event={notification} />
<div className="text-muted-foreground">
<FormattedTimestamp timestamp={notification.created_at} short />
</div>
</div>
)
}
function ContentPreview({ event }: { event?: Event }) {
if (!event || event.kind !== kinds.ShortTextNote) return null
if (!event) return null
return <div className="truncate flex-1 w-0">{event.content}</div>
}

View File

@@ -0,0 +1,48 @@
import { extractImetaUrlFromTag } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { memo, ReactNode } from 'react'
import {
embedded,
embeddedHashtagRenderer,
embeddedNormalUrlRenderer,
embeddedNostrNpubRenderer,
embeddedNostrProfileRenderer,
embeddedWebsocketUrlRenderer
} from '../Embedded'
import { ImageCarousel } from '../ImageCarousel'
import { isNsfwEvent } from '@/lib/event'
const PictureContent = memo(({ event, className }: { event: Event; className?: string }) => {
const images: string[] = []
event.tags.forEach((tag) => {
const imageUrl = extractImetaUrlFromTag(tag)
if (imageUrl) {
images.push(imageUrl)
}
})
const isNsfw = isNsfwEvent(event)
const nodes: ReactNode[] = [
<ImageCarousel key={`image-gallery-${event.id}`} images={images} isNsfw={isNsfw} />
]
nodes.push(
<div className="px-4">
{embedded(event.content, [
embeddedNormalUrlRenderer,
embeddedWebsocketUrlRenderer,
embeddedHashtagRenderer,
embeddedNostrNpubRenderer,
embeddedNostrProfileRenderer
])}
</div>
)
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-2', className)}>
{nodes}
</div>
)
})
PictureContent.displayName = 'PictureContent'
export default PictureContent

View File

@@ -0,0 +1,53 @@
import { getUsingClient } from '@/lib/event'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { FormattedTimestamp } from '../FormattedTimestamp'
import NoteStats from '../NoteStats'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import PictureContent from '../PictureContent'
export default function PictureNote({
event,
className,
hideStats = false,
fetchNoteStats = false
}: {
event: Event
className?: string
hideStats?: boolean
fetchNoteStats?: boolean
}) {
const usingClient = useMemo(() => getUsingClient(event), [event])
return (
<div className={className}>
<div className="px-4 flex items-center space-x-2">
<UserAvatar userId={event.pubkey} />
<div className="flex-1 w-0">
<div className="flex gap-2 items-center">
<Username
userId={event.pubkey}
className="font-semibold flex"
skeletonClassName="h-4"
/>
{usingClient && (
<div className="text-xs text-muted-foreground truncate">using {usingClient}</div>
)}
</div>
<div className="text-xs text-muted-foreground line-clamp-1">
<FormattedTimestamp timestamp={event.created_at} />
</div>
</div>
</div>
<PictureContent className="mt-2" event={event} />
{!hideStats && (
<NoteStats
className="px-4 mt-3 sm:mt-4"
event={event}
fetchIfNotExisting={fetchNoteStats}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,31 @@
import { extractFirstPictureFromPictureEvent } from '@/lib/event'
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { Event } from 'nostr-tools'
import Image from '../Image'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
export default function PictureNoteCard({
event,
className
}: {
event: Event
className?: string
}) {
const { push } = useSecondaryPage()
const firstImage = extractFirstPictureFromPictureEvent(event)
if (!firstImage) return null
return (
<div className={cn('space-y-1 cursor-pointer', className)} onClick={() => push(toNote(event))}>
<Image className="rounded-lg w-full aspect-[6/8]" src={firstImage} />
<div className="line-clamp-2 px-2">{event.content}</div>
<div className="flex items-center gap-2 px-2">
<UserAvatar userId={event.pubkey} size="xSmall" />
<Username userId={event.pubkey} className="text-sm text-muted-foreground truncate" />
</div>
</div>
)
}

View File

@@ -4,16 +4,21 @@ import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { StorageKey } from '@/constants'
import { useToast } from '@/hooks/use-toast'
import { createShortTextNoteDraftEvent } from '@/lib/draft-event'
import {
createCommentDraftEvent,
createPictureNoteDraftEvent,
createShortTextNoteDraftEvent
} from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { ChevronDown, LoaderCircle } from 'lucide-react'
import { Event } from 'nostr-tools'
import { Event, kinds } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Mentions from './Mentions'
import Preview from './Preview'
import Uploader from './Uploader'
import { extractImagesFromContent } from '@/lib/event'
export default function PostContent({
defaultContent = '',
@@ -31,12 +36,19 @@ export default function PostContent({
const [posting, setPosting] = useState(false)
const [showMoreOptions, setShowMoreOptions] = useState(false)
const [addClientTag, setAddClientTag] = useState(false)
const [isPictureNote, setIsPictureNote] = useState(false)
const [hasImages, setHasImages] = useState(false)
const canPost = !!content && !posting
useEffect(() => {
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true')
}, [])
useEffect(() => {
const { images } = extractImagesFromContent(content)
setHasImages(!!images && images.length > 0)
}, [content])
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value)
}
@@ -56,10 +68,18 @@ export default function PostContent({
const relayList = await client.fetchRelayList(parentEvent.pubkey)
additionalRelayUrls.push(...relayList.read.slice(0, 5))
}
const draftEvent = await createShortTextNoteDraftEvent(content, {
parentEvent,
addClientTag
})
if (isPictureNote && !hasImages) {
throw new Error(t('Picture note requires images'))
}
const draftEvent =
isPictureNote && !parentEvent && hasImages
? await createPictureNoteDraftEvent(content, { addClientTag })
: parentEvent && parentEvent.kind !== kinds.ShortTextNote
? await createCommentDraftEvent(content, parentEvent, { addClientTag })
: await createShortTextNoteDraftEvent(content, {
parentEvent,
addClientTag
})
await publish(draftEvent, additionalRelayUrls)
setContent('')
close()
@@ -151,6 +171,21 @@ export default function PostContent({
<div className="text-muted-foreground text-xs">
{t('Show others this was sent via Jumble')}
</div>
{!parentEvent && (
<>
<div className="flex items-center space-x-2">
<Label htmlFor="picture-note">{t('Picture note')}</Label>
<Switch
id="picture-note"
checked={isPictureNote}
onCheckedChange={setIsPictureNote}
/>
</div>
<div className="text-muted-foreground text-xs">
{t('A special note for picture-first clients like Olas')}
</div>
</>
)}
</div>
)}
<div className="flex gap-2 items-center justify-around sm:hidden">

View File

@@ -1,7 +1,6 @@
import { Image } from '@nextui-org/image'
import { generateImageByPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useEffect, useMemo, useState } from 'react'
import Image from '../Image'
export default function ProfileBanner({
pubkey,
@@ -27,9 +26,8 @@ export default function ProfileBanner({
<Image
src={bannerUrl}
alt={`${pubkey} banner`}
className={cn('z-0', className)}
className={className}
onError={() => setBannerUrl(defaultBanner)}
removeWrapper
/>
)
}

View File

@@ -0,0 +1,36 @@
import { cn } from '@/lib/utils'
import { useMemo, ReactNode } from 'react'
export default function SimpleMasonryGrid({
items,
columnCount,
className
}: {
items: ReactNode[]
columnCount: 2 | 3
className?: string
}) {
const columns = useMemo(() => {
const newColumns: ReactNode[][] = Array.from({ length: columnCount }, () => [])
items.forEach((item, i) => {
newColumns[i % columnCount].push(item)
})
return newColumns
}, [items])
return (
<div
className={cn(
'grid',
columnCount === 2 ? 'grid-cols-2 gap-2' : 'grid-cols-3 gap-4',
className
)}
>
{columns.map((column, i) => (
<div key={i} className={columnCount === 2 ? 'space-y-2' : 'space-y-4'}>
{column}
</div>
))}
</div>
)
}

View File

@@ -14,6 +14,7 @@ const UserAvatarSizeCnMap = {
big: 'w-16 h-16',
normal: 'w-10 h-10',
small: 'w-7 h-7',
xSmall: 'w-5 h-5',
tiny: 'w-4 h-4'
}
@@ -24,7 +25,7 @@ export default function UserAvatar({
}: {
userId: string
className?: string
size?: 'large' | 'big' | 'normal' | 'small' | 'tiny'
size?: 'large' | 'big' | 'normal' | 'small' | 'xSmall' | 'tiny'
}) {
const { profile } = useFetchProfile(userId)
const defaultAvatar = useMemo(

View File

@@ -1,8 +1,8 @@
import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata'
import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Image } from '@nextui-org/image'
import { useMemo } from 'react'
import Image from '../Image'
export default function WebPreview({
url,
@@ -30,7 +30,7 @@ export default function WebPreview({
if (isSmallScreen && image) {
return (
<div className="rounded-lg border mt-2">
<Image src={image} className="rounded-t-lg object-cover w-full h-44" removeWrapper />
<Image src={image} className="rounded-t-lg w-full h-44" />
<div className="bg-muted p-2 w-full rounded-b-lg">
<div className="text-xs text-muted-foreground">{hostname}</div>
<div className="font-semibold line-clamp-1">{title}</div>
@@ -48,11 +48,7 @@ export default function WebPreview({
}}
>
{image && (
<Image
src={image}
className={`rounded-l-lg object-cover ${size === 'normal' ? 'h-44' : 'h-24'}`}
removeWrapper
/>
<Image src={image} className={`rounded-l-lg ${size === 'normal' ? 'h-44' : 'h-24'}`} />
)}
<div className="flex-1 w-0 p-2">
<div className="text-xs text-muted-foreground">{hostname}</div>

View File

@@ -0,0 +1,260 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -14,3 +14,8 @@ export const BIG_RELAY_URLS = [
]
export const SEARCHABLE_RELAY_URLS = ['wss://relay.nostr.band/', 'wss://search.nos.today/']
export const PICTURE_EVENT_KIND = 20
export const COMMENT_EVENT_KIND = 1111
export const URL_REGEX = /(https?:\/\/[^\s"']+)/g

View File

@@ -99,6 +99,11 @@ export default {
Dark: 'Dark',
Temporary: 'Temporary',
'Choose a relay collection': 'Choose a relay collection',
'Switch account': 'Switch account'
'Switch account': 'Switch account',
Pictures: 'Pictures',
'Picture note': 'Picture note',
'A special note for picture-first clients like Olas':
'A special note for picture-first clients like Olas',
'Picture note requires images': 'Picture note requires images'
}
}

View File

@@ -98,6 +98,11 @@ export default {
Dark: '深色',
Temporary: '临时',
'Choose a relay collection': '选择一个服务器组',
'Switch account': '切换账户'
'Switch account': '切换账户',
Pictures: '图片',
'Picture note': '图片笔记',
'A special note for picture-first clients like Olas':
'一种可以在图片优先客户端 (如 Olas) 中显示的特殊笔记',
'Picture note requires images': '图片笔记需要有图片'
}
}

View File

@@ -1,7 +1,15 @@
import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
import { TDraftEvent } from '@/types'
import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools'
import { extractHashtags, extractMentions, getEventCoordinate, isReplaceable } from './event'
import {
extractCommentMentions,
extractHashtags,
extractImagesFromContent,
extractMentions,
getEventCoordinate,
isReplaceable
} from './event'
// https://github.com/nostr-protocol/nips/blob/master/25.md
export function createReactionDraftEvent(event: Event): TDraftEvent {
@@ -73,3 +81,79 @@ export async function createShortTextNoteDraftEvent(
created_at: dayjs().unix()
}
}
export async function createPictureNoteDraftEvent(
content: string,
options: {
addClientTag?: boolean
} = {}
): Promise<TDraftEvent> {
const { pubkeys, quoteEventIds } = await extractMentions(content)
const hashtags = extractHashtags(content)
const { images, contentWithoutImages } = extractImagesFromContent(content)
if (!images || !images.length) {
throw new Error('No images found in content')
}
const tags = images
.map((image) => ['imeta', `url ${image}`])
.concat(pubkeys.map((pubkey) => ['p', pubkey]))
.concat(quoteEventIds.map((eventId) => ['q', eventId]))
.concat(hashtags.map((hashtag) => ['t', hashtag]))
if (options.addClientTag) {
tags.push(['client', 'jumble'])
}
return {
kind: PICTURE_EVENT_KIND,
content: contentWithoutImages,
tags,
created_at: dayjs().unix()
}
}
export async function createCommentDraftEvent(
content: string,
parentEvent: Event,
options: {
addClientTag?: boolean
} = {}
): Promise<TDraftEvent> {
const {
pubkeys,
quoteEventIds,
rootEventId,
rootEventKind,
rootEventPubkey,
parentEventId,
parentEventKind,
parentEventPubkey
} = await extractCommentMentions(content, parentEvent)
const hashtags = extractHashtags(content)
const tags = [
['E', rootEventId],
['K', rootEventKind.toString()],
['P', rootEventPubkey],
['e', parentEventId],
['k', parentEventKind.toString()],
['p', parentEventPubkey]
].concat(
pubkeys
.map((pubkey) => ['p', pubkey])
.concat(quoteEventIds.map((eventId) => ['q', eventId]))
.concat(hashtags.map((hashtag) => ['t', hashtag]))
)
if (options.addClientTag) {
tags.push(['client', 'jumble'])
}
return {
kind: COMMENT_EVENT_KIND,
content,
tags,
created_at: dayjs().unix()
}
}

View File

@@ -1,6 +1,7 @@
import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
import client from '@/services/client.service'
import { Event, kinds, nip19 } from 'nostr-tools'
import { isReplyETag, isRootETag, tagNameEquals } from './tag'
import { extractImetaUrlFromTag, isReplyETag, isRootETag, tagNameEquals } from './tag'
export function isNsfwEvent(event: Event) {
return event.tags.some(
@@ -26,6 +27,14 @@ export function isReplyNoteEvent(event: Event) {
return hasETag && !hasMarker
}
export function isCommentEvent(event: Event) {
return event.kind === COMMENT_EVENT_KIND
}
export function isPictureEvent(event: Event) {
return event.kind === PICTURE_EVENT_KIND
}
export function getParentEventId(event?: Event) {
return event?.tags.find(isReplyETag)?.[1]
}
@@ -116,6 +125,54 @@ export async function extractMentions(content: string, parentEvent?: Event) {
}
}
export async function extractCommentMentions(content: string, parentEvent: Event) {
const pubkeySet = new Set<string>()
const quoteEventIdSet = new Set<string>()
const rootEventId = parentEvent.tags.find(tagNameEquals('E'))?.[1] ?? parentEvent.id
const rootEventKind = parentEvent.tags.find(tagNameEquals('K'))?.[1] ?? parentEvent.kind
const rootEventPubkey = parentEvent.tags.find(tagNameEquals('P'))?.[1] ?? parentEvent.pubkey
const parentEventId = parentEvent.id
const parentEventKind = parentEvent.kind
const parentEventPubkey = parentEvent.pubkey
const matches = content.match(
/nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g
)
for (const m of matches || []) {
try {
const id = m.split(':')[1]
const { type, data } = nip19.decode(id)
if (type === 'nprofile') {
pubkeySet.add(data.pubkey)
} else if (type === 'npub') {
pubkeySet.add(data)
} else if (['nevent', 'note', 'naddr'].includes(type)) {
const event = await client.fetchEvent(id)
if (event) {
pubkeySet.add(event.pubkey)
quoteEventIdSet.add(event.id)
}
}
} catch (e) {
console.error(e)
}
}
pubkeySet.add(parentEvent.pubkey)
return {
pubkeys: Array.from(pubkeySet),
quoteEventIds: Array.from(quoteEventIdSet),
rootEventId,
rootEventKind,
rootEventPubkey,
parentEventId,
parentEventKind,
parentEventPubkey
}
}
export function extractHashtags(content: string) {
const hashtags: string[] = []
const matches = content.match(/#[\p{L}\p{N}\p{M}]+/gu)
@@ -127,3 +184,22 @@ export function extractHashtags(content: string) {
})
return hashtags
}
export function extractFirstPictureFromPictureEvent(event: Event) {
if (!isPictureEvent(event)) return null
for (const tag of event.tags) {
const url = extractImetaUrlFromTag(tag)
if (url) return url
}
return null
}
export function extractImagesFromContent(content: string) {
const images = content.match(/https?:\/\/[^\s"']+\.(jpg|jpeg|png|gif|webp|heic)/gi)
let contentWithoutImages = content
images?.forEach((url) => {
contentWithoutImages = contentWithoutImages.replace(url, '').trim()
})
contentWithoutImages = contentWithoutImages.replace(/\n{3,}/g, '\n\n').trim()
return { images, contentWithoutImages }
}

View File

@@ -1,7 +1,7 @@
import { Event, nip19 } from 'nostr-tools'
export const toHome = () => '/'
export const toNote = (eventOrId: Event | string) => {
export const toNote = (eventOrId: Pick<Event, 'id' | 'pubkey'> | string) => {
if (typeof eventOrId === 'string') return `/notes/${eventOrId}`
const nevent = nip19.neventEncode({ id: eventOrId.id, author: eventOrId.pubkey })
return `/notes/${nevent}`

View File

@@ -13,3 +13,10 @@ export function isRootETag([tagName, , , marker]: string[]) {
export function isMentionETag([tagName, , , marker]: string[]) {
return tagName === 'e' && marker === 'mention'
}
export function extractImetaUrlFromTag(tag: string[]) {
if (tag[0] !== 'imeta') return null
const urlItem = tag.find((item) => item.startsWith('url '))
const url = urlItem?.slice(4)
return url || null
}

View File

@@ -18,3 +18,21 @@ export function normalizeUrl(url: string): string {
export function simplifyUrl(url: string): string {
return url.replace('wss://', '').replace('ws://', '').replace(/\/$/, '')
}
export function isImage(url: string) {
try {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.svg']
return imageExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
} catch {
return false
}
}
export function isVideo(url: string) {
try {
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov']
return videoExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
} catch {
return false
}
}

View File

@@ -1,5 +1,7 @@
import { useSecondaryPage } from '@/PageManager'
import Nip22ReplyNoteList from '@/components/Nip22ReplyNoteList'
import Note from '@/components/Note'
import PictureNote from '@/components/PictureNote'
import ReplyNoteList from '@/components/ReplyNoteList'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
@@ -8,14 +10,16 @@ import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchEvent } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { getParentEventId, getRootEventId } from '@/lib/event'
import { getParentEventId, getRootEventId, isPictureEvent } from '@/lib/event'
import { toNote } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import NotFoundPage from '../NotFoundPage'
export default function NotePage({ id, index }: { id?: string; index?: number }) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { event, isFetching } = useFetchEvent(id)
const parentEventId = useMemo(() => getParentEventId(event), [event])
const rootEventId = useMemo(() => getRootEventId(event), [event])
@@ -31,6 +35,20 @@ export default function NotePage({ id, index }: { id?: string; index?: number })
}
if (!event) return <NotFoundPage />
if (isPictureEvent(event) && isSmallScreen) {
return (
<SecondaryPageLayout index={index} titlebarContent={t('Note')} displayScrollToTopButton>
<PictureNote key={`note-${event.id}`} event={event} fetchNoteStats />
<Separator className="mb-2 mt-4" />
<Nip22ReplyNoteList
key={`nip22-reply-note-list-${event.id}`}
event={event}
className="px-2"
/>
</SecondaryPageLayout>
)
}
return (
<SecondaryPageLayout index={index} titlebarContent={t('Note')} displayScrollToTopButton>
<div className="px-4">
@@ -39,7 +57,15 @@ export default function NotePage({ id, index }: { id?: string; index?: number })
<Note key={`note-${event.id}`} event={event} fetchNoteStats />
</div>
<Separator className="mb-2 mt-4" />
<ReplyNoteList key={`reply-note-list-${event.id}`} event={event} className="px-2" />
{isPictureEvent(event) ? (
<Nip22ReplyNoteList
key={`nip22-reply-note-list-${event.id}`}
event={event}
className="px-2"
/>
) : (
<ReplyNoteList key={`reply-note-list-${event.id}`} event={event} className="px-2" />
)}
</SecondaryPageLayout>
)
}

View File

@@ -67,7 +67,7 @@ export default function ProfilePage({ id, index }: { id?: string; index?: number
<ProfileBanner
banner={banner}
pubkey={pubkey}
className="w-full h-full object-cover rounded-lg"
className="w-full aspect-video object-cover rounded-lg"
/>
<Avatar className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background">
<AvatarImage src={avatar} className="object-cover object-center" />