feat: remove picture tab
This commit is contained in:
26
package-lock.json
generated
26
package-lock.json
generated
@@ -41,7 +41,6 @@
|
|||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"dataloader": "^2.2.3",
|
"dataloader": "^2.2.3",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"embla-carousel-react": "^8.5.1",
|
|
||||||
"emoji-picker-react": "^4.12.2",
|
"emoji-picker-react": "^4.12.2",
|
||||||
"flexsearch": "^0.7.43",
|
"flexsearch": "^0.7.43",
|
||||||
"franc-min": "^6.2.0",
|
"franc-min": "^6.2.0",
|
||||||
@@ -6132,31 +6131,6 @@
|
|||||||
"integrity": "sha512-Lf3++DumRE/QmweGjU+ZcKqQ+3bKkU/qjaKYhIJKEOhgIO9Xs6IiAQFkfFoj+RhgDk4LUeNsLo6plExHqSyu6Q==",
|
"integrity": "sha512-Lf3++DumRE/QmweGjU+ZcKqQ+3bKkU/qjaKYhIJKEOhgIO9Xs6IiAQFkfFoj+RhgDk4LUeNsLo6plExHqSyu6Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/embla-carousel-react": {
|
|
||||||
"version": "8.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.5.1.tgz",
|
|
||||||
"integrity": "sha512-z9Y0K84BJvhChXgqn2CFYbfEi6AwEr+FFVVKm/MqbTQ2zIzO1VQri6w67LcfpVF0AjbhwVMywDZqY4alYkjW5w==",
|
|
||||||
"dependencies": {
|
|
||||||
"embla-carousel": "8.5.1",
|
|
||||||
"embla-carousel-reactive-utils": "8.5.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/embla-carousel-react/node_modules/embla-carousel": {
|
|
||||||
"version": "8.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.5.1.tgz",
|
|
||||||
"integrity": "sha512-JUb5+FOHobSiWQ2EJNaueCNT/cQU9L6XWBbWmorWPQT9bkbk+fhsuLr8wWrzXKagO3oWszBO7MSx+GfaRk4E6A=="
|
|
||||||
},
|
|
||||||
"node_modules/embla-carousel-react/node_modules/embla-carousel-reactive-utils": {
|
|
||||||
"version": "8.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.5.1.tgz",
|
|
||||||
"integrity": "sha512-n7VSoGIiiDIc4MfXF3ZRTO59KDp820QDuyBDGlt5/65+lumPHxX2JLz0EZ23hZ4eg4vZGUXwMkYv02fw2JVo/A==",
|
|
||||||
"peerDependencies": {
|
|
||||||
"embla-carousel": "8.5.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/emoji-picker-react": {
|
"node_modules/emoji-picker-react": {
|
||||||
"version": "4.12.2",
|
"version": "4.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.12.2.tgz",
|
||||||
|
|||||||
@@ -51,7 +51,6 @@
|
|||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"dataloader": "^2.2.3",
|
"dataloader": "^2.2.3",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"embla-carousel-react": "^8.5.1",
|
|
||||||
"emoji-picker-react": "^4.12.2",
|
"emoji-picker-react": "^4.12.2",
|
||||||
"flexsearch": "^0.7.43",
|
"flexsearch": "^0.7.43",
|
||||||
"franc-min": "^6.2.0",
|
"franc-min": "^6.2.0",
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function BookmarkList() {
|
|||||||
|
|
||||||
{showCount < eventIds.length ? (
|
{showCount < eventIds.length ? (
|
||||||
<div ref={bottomRef}>
|
<div ref={bottomRef}>
|
||||||
<NoteCardLoadingSkeleton isPictures={false} />
|
<NoteCardLoadingSkeleton />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-sm text-muted-foreground mt-2">
|
<div className="text-center text-sm text-muted-foreground mt-2">
|
||||||
@@ -85,7 +85,7 @@ function BookmarkedNote({ eventId }: { eventId: string }) {
|
|||||||
const { event, isFetching } = useFetchEvent(eventId)
|
const { event, isFetching } = useFetchEvent(eventId)
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
return <NoteCardLoadingSkeleton isPictures={false} />
|
return <NoteCardLoadingSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
import { Carousel, CarouselApi, CarouselContent, CarouselItem } from '@/components/ui/carousel'
|
|
||||||
import { isTouchDevice } from '@/lib/utils'
|
|
||||||
import { TImageInfo } from '@/types'
|
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import Lightbox from 'yet-another-react-lightbox'
|
|
||||||
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
|
|
||||||
import Image from '../Image'
|
|
||||||
|
|
||||||
export function ImageCarousel({ images }: { images: TImageInfo[] }) {
|
|
||||||
const [api, setApi] = useState<CarouselApi>()
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0)
|
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(-1)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!api) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentIndex(api.selectedScrollSnap())
|
|
||||||
|
|
||||||
api.on('select', () => {
|
|
||||||
setCurrentIndex(api.selectedScrollSnap())
|
|
||||||
})
|
|
||||||
}, [api])
|
|
||||||
|
|
||||||
const handlePhotoClick = (event: React.MouseEvent, current: number) => {
|
|
||||||
event.preventDefault()
|
|
||||||
setLightboxIndex(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDotClick = (index: number) => {
|
|
||||||
api?.scrollTo(index)
|
|
||||||
setCurrentIndex(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Carousel className="w-full" setApi={setApi}>
|
|
||||||
<CarouselContent className="xl:px-4">
|
|
||||||
{images.map((image, index) => (
|
|
||||||
<CarouselItem key={index} className="xl:basis-2/3 cursor-zoom-in">
|
|
||||||
<Image
|
|
||||||
className="xl:rounded-lg max-h-[75vh]"
|
|
||||||
classNames={{
|
|
||||||
errorPlaceholder: 'aspect-square'
|
|
||||||
}}
|
|
||||||
image={image}
|
|
||||||
onClick={(e) => handlePhotoClick(e, index)}
|
|
||||||
/>
|
|
||||||
</CarouselItem>
|
|
||||||
))}
|
|
||||||
</CarouselContent>
|
|
||||||
</Carousel>
|
|
||||||
{!isTouchDevice() && (
|
|
||||||
<ArrowButton total={images.length} currentIndex={currentIndex} onClick={onDotClick} />
|
|
||||||
)}
|
|
||||||
{images.length > 1 && (
|
|
||||||
<CarouselDot total={images.length} currentIndex={currentIndex} onClick={onDotClick} />
|
|
||||||
)}
|
|
||||||
<Lightbox
|
|
||||||
index={lightboxIndex}
|
|
||||||
slides={images.map(({ url }) => ({ src: url }))}
|
|
||||||
plugins={[Zoom]}
|
|
||||||
open={lightboxIndex >= 0}
|
|
||||||
close={() => setLightboxIndex(-1)}
|
|
||||||
controller={{
|
|
||||||
closeOnBackdropClick: true,
|
|
||||||
closeOnPullUp: true,
|
|
||||||
closeOnPullDown: true
|
|
||||||
}}
|
|
||||||
styles={{ toolbar: { paddingTop: '2.25rem' } }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CarouselDot({
|
|
||||||
total,
|
|
||||||
currentIndex,
|
|
||||||
onClick
|
|
||||||
}: {
|
|
||||||
total: number
|
|
||||||
currentIndex: number
|
|
||||||
onClick: (index: number) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="w-full flex gap-1 justify-center">
|
|
||||||
{Array.from({ length: total }).map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`w-2 h-2 rounded-full cursor-pointer ${index === currentIndex ? 'bg-foreground/40' : 'bg-muted'}`}
|
|
||||||
onClick={() => onClick(index)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ArrowButton({
|
|
||||||
total,
|
|
||||||
currentIndex,
|
|
||||||
onClick
|
|
||||||
}: {
|
|
||||||
total: number
|
|
||||||
currentIndex: number
|
|
||||||
onClick: (index: number) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none transition-opacity">
|
|
||||||
<div className="w-full flex justify-between px-2 xl:px-4">
|
|
||||||
<button
|
|
||||||
onClick={() => onClick(currentIndex - 1)}
|
|
||||||
className="w-8 h-8 rounded-full bg-background/50 flex justify-center items-center pointer-events-auto disabled:pointer-events-none disabled:opacity-0"
|
|
||||||
disabled={currentIndex === 0}
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onClick(currentIndex + 1)}
|
|
||||||
className="w-8 h-8 rounded-full bg-background/50 flex justify-center items-center pointer-events-auto disabled:pointer-events-none disabled:opacity-0"
|
|
||||||
disabled={currentIndex === total - 1}
|
|
||||||
>
|
|
||||||
<ChevronRightIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import MainNoteCard from './MainNoteCard'
|
import MainNoteCard from './MainNoteCard'
|
||||||
import RepostNoteCard from './RepostNoteCard'
|
import RepostNoteCard from './RepostNoteCard'
|
||||||
|
|
||||||
@@ -27,13 +26,7 @@ export default function NoteCard({
|
|||||||
return <MainNoteCard event={event} className={className} />
|
return <MainNoteCard event={event} className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NoteCardLoadingSkeleton({ isPictures }: { isPictures: boolean }) {
|
export function NoteCardLoadingSkeleton() {
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
if (isPictures) {
|
|
||||||
return <div className="text-center text-sm text-muted-foreground">{t('loading...')}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { checkAlgoRelay } from '@/lib/relay'
|
|||||||
import { isSafari } from '@/lib/utils'
|
import { isSafari } from '@/lib/utils'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import storage from '@/services/local-storage.service'
|
import storage from '@/services/local-storage.service'
|
||||||
@@ -18,7 +17,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import PullToRefresh from 'react-simple-pull-to-refresh'
|
import PullToRefresh from 'react-simple-pull-to-refresh'
|
||||||
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
|
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
|
||||||
import { PictureNoteCardMasonry } from '../PictureNoteCardMasonry'
|
|
||||||
import Tabs from '../Tabs'
|
import Tabs from '../Tabs'
|
||||||
|
|
||||||
const LIMIT = 100
|
const LIMIT = 100
|
||||||
@@ -32,7 +30,8 @@ const KINDS = [
|
|||||||
ExtendedKind.COMMENT,
|
ExtendedKind.COMMENT,
|
||||||
ExtendedKind.POLL,
|
ExtendedKind.POLL,
|
||||||
ExtendedKind.VOICE,
|
ExtendedKind.VOICE,
|
||||||
ExtendedKind.VOICE_COMMENT
|
ExtendedKind.VOICE_COMMENT,
|
||||||
|
ExtendedKind.PICTURE
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function NoteList({
|
export default function NoteList({
|
||||||
@@ -57,7 +56,6 @@ export default function NoteList({
|
|||||||
skipTrustCheck?: boolean
|
skipTrustCheck?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isLargeScreen } = useScreenSize()
|
|
||||||
const { pubkey, startLogin } = useNostr()
|
const { pubkey, startLogin } = useNostr()
|
||||||
const { mutePubkeys } = useMuteList()
|
const { mutePubkeys } = useMuteList()
|
||||||
const [refreshCount, setRefreshCount] = useState(0)
|
const [refreshCount, setRefreshCount] = useState(0)
|
||||||
@@ -90,9 +88,6 @@ export default function NoteList({
|
|||||||
case 'postsAndReplies':
|
case 'postsAndReplies':
|
||||||
setFilterType('posts')
|
setFilterType('posts')
|
||||||
break
|
break
|
||||||
case 'pictures':
|
|
||||||
setFilterType('pictures')
|
|
||||||
break
|
|
||||||
case 'you':
|
case 'you':
|
||||||
if (!pubkey || pubkey === author) {
|
if (!pubkey || pubkey === author) {
|
||||||
setFilterType('posts')
|
setFilterType('posts')
|
||||||
@@ -147,7 +142,7 @@ export default function NoteList({
|
|||||||
}
|
}
|
||||||
const _filter = {
|
const _filter = {
|
||||||
...filter,
|
...filter,
|
||||||
kinds: filterType === 'pictures' ? [ExtendedKind.PICTURE] : KINDS,
|
kinds: KINDS,
|
||||||
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
|
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
|
||||||
}
|
}
|
||||||
if (relayUrls.length === 0 && (_filter.authors?.length || author)) {
|
if (relayUrls.length === 0 && (_filter.authors?.length || author)) {
|
||||||
@@ -310,13 +305,11 @@ export default function NoteList({
|
|||||||
? [
|
? [
|
||||||
{ value: 'posts', label: 'Notes' },
|
{ value: 'posts', label: 'Notes' },
|
||||||
{ value: 'postsAndReplies', label: 'Replies' },
|
{ value: 'postsAndReplies', label: 'Replies' },
|
||||||
{ value: 'pictures', label: 'Pictures' },
|
|
||||||
{ value: 'you', label: 'YouTabName' }
|
{ value: 'you', label: 'YouTabName' }
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ value: 'posts', label: 'Notes' },
|
{ value: 'posts', label: 'Notes' },
|
||||||
{ value: 'postsAndReplies', label: 'Replies' },
|
{ value: 'postsAndReplies', label: 'Replies' }
|
||||||
{ value: 'pictures', label: 'Pictures' }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
onTabChange={(listMode) => {
|
onTabChange={(listMode) => {
|
||||||
@@ -343,34 +336,24 @@ export default function NoteList({
|
|||||||
pullingContent=""
|
pullingContent=""
|
||||||
>
|
>
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{listMode === 'pictures' ? (
|
{events
|
||||||
<PictureNoteCardMasonry
|
.slice(0, showCount)
|
||||||
className="px-2 sm:px-4 mt-2"
|
.filter(
|
||||||
columnCount={isLargeScreen ? 3 : 2}
|
(event: Event) =>
|
||||||
events={events.slice(0, showCount)}
|
(listMode !== 'posts' || !isReplyNoteEvent(event)) &&
|
||||||
/>
|
(skipTrustCheck || !hideUntrustedNotes || isUserTrusted(event.pubkey))
|
||||||
) : (
|
)
|
||||||
<div>
|
.map((event) => (
|
||||||
{events
|
<NoteCard
|
||||||
.slice(0, showCount)
|
key={event.id}
|
||||||
.filter(
|
className="w-full"
|
||||||
(event: Event) =>
|
event={event}
|
||||||
(listMode !== 'posts' || !isReplyNoteEvent(event)) &&
|
filterMutedNotes={filterMutedNotes}
|
||||||
(skipTrustCheck || !hideUntrustedNotes || isUserTrusted(event.pubkey))
|
/>
|
||||||
)
|
))}
|
||||||
.map((event) => (
|
|
||||||
<NoteCard
|
|
||||||
key={event.id}
|
|
||||||
className="w-full"
|
|
||||||
event={event}
|
|
||||||
filterMutedNotes={filterMutedNotes}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasMore || loading ? (
|
{hasMore || loading ? (
|
||||||
<div ref={bottomRef}>
|
<div ref={bottomRef}>
|
||||||
<NoteCardLoadingSkeleton isPictures={listMode === 'pictures'} />
|
<NoteCardLoadingSkeleton />
|
||||||
</div>
|
</div>
|
||||||
) : events.length ? (
|
) : events.length ? (
|
||||||
<div className="text-center text-sm text-muted-foreground mt-2">
|
<div className="text-center text-sm text-muted-foreground mt-2">
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import {
|
|
||||||
EmbeddedEmojiParser,
|
|
||||||
EmbeddedHashtagParser,
|
|
||||||
EmbeddedLNInvoiceParser,
|
|
||||||
EmbeddedMentionParser,
|
|
||||||
EmbeddedNormalUrlParser,
|
|
||||||
EmbeddedWebsocketUrlParser,
|
|
||||||
parseContent
|
|
||||||
} from '@/lib/content-parser'
|
|
||||||
import { getImageInfosFromEvent } from '@/lib/event'
|
|
||||||
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { Event } from 'nostr-tools'
|
|
||||||
import { memo, useMemo } from 'react'
|
|
||||||
import {
|
|
||||||
EmbeddedHashtag,
|
|
||||||
EmbeddedLNInvoice,
|
|
||||||
EmbeddedMention,
|
|
||||||
EmbeddedNormalUrl,
|
|
||||||
EmbeddedWebsocketUrl
|
|
||||||
} from '../Embedded'
|
|
||||||
import Emoji from '../Emoji'
|
|
||||||
import { ImageCarousel } from '../ImageCarousel'
|
|
||||||
|
|
||||||
const PictureContent = memo(({ event, className }: { event: Event; className?: string }) => {
|
|
||||||
const images = useMemo(() => getImageInfosFromEvent(event), [event])
|
|
||||||
|
|
||||||
const nodes = parseContent(event.content, [
|
|
||||||
EmbeddedNormalUrlParser,
|
|
||||||
EmbeddedLNInvoiceParser,
|
|
||||||
EmbeddedWebsocketUrlParser,
|
|
||||||
EmbeddedHashtagParser,
|
|
||||||
EmbeddedMentionParser,
|
|
||||||
EmbeddedEmojiParser
|
|
||||||
])
|
|
||||||
|
|
||||||
const emojiInfos = getEmojiInfosFromEmojiTags(event.tags)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-2', className)}>
|
|
||||||
<ImageCarousel images={images} />
|
|
||||||
<div className="px-4">
|
|
||||||
{nodes.map((node, index) => {
|
|
||||||
if (node.type === 'text') {
|
|
||||||
return node.data
|
|
||||||
}
|
|
||||||
if (node.type === 'url') {
|
|
||||||
return <EmbeddedNormalUrl key={index} url={node.data} />
|
|
||||||
}
|
|
||||||
if (node.type === 'invoice') {
|
|
||||||
return <EmbeddedLNInvoice invoice={node.data} key={index} />
|
|
||||||
}
|
|
||||||
if (node.type === 'websocket-url') {
|
|
||||||
return <EmbeddedWebsocketUrl key={index} url={node.data} />
|
|
||||||
}
|
|
||||||
if (node.type === 'hashtag') {
|
|
||||||
return <EmbeddedHashtag key={index} hashtag={node.data} />
|
|
||||||
}
|
|
||||||
if (node.type === 'mention') {
|
|
||||||
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
|
|
||||||
}
|
|
||||||
if (node.type === 'emoji') {
|
|
||||||
const shortcode = node.data.split(':')[1]
|
|
||||||
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
|
|
||||||
if (!emoji) return node.data
|
|
||||||
return <Emoji key={index} emoji={emoji} />
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
PictureContent.displayName = 'PictureContent'
|
|
||||||
export default PictureContent
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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'
|
|
||||||
import NoteOptions from '../NoteOptions'
|
|
||||||
|
|
||||||
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 justify-between items-start gap-2">
|
|
||||||
<div className="flex items-center space-x-2 flex-1">
|
|
||||||
<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 truncate"
|
|
||||||
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>
|
|
||||||
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
|
|
||||||
</div>
|
|
||||||
<PictureContent className="mt-2" event={event} />
|
|
||||||
{!hideStats && (
|
|
||||||
<NoteStats
|
|
||||||
className="px-4 mt-3 sm:mt-4"
|
|
||||||
event={event}
|
|
||||||
fetchIfNotExisting={fetchNoteStats}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { EmbeddedHashtagParser, EmbeddedMentionParser, parseContent } from '@/lib/content-parser'
|
|
||||||
import { getImageInfosFromEvent } from '@/lib/event'
|
|
||||||
import { toNote } from '@/lib/link'
|
|
||||||
import { tagNameEquals } from '@/lib/tag'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
|
||||||
import { Images } from 'lucide-react'
|
|
||||||
import { Event } from 'nostr-tools'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { EmbeddedHashtag, EmbeddedMention } from '../Embedded'
|
|
||||||
import Image from '../Image'
|
|
||||||
import LikeButton from '../NoteStats/LikeButton'
|
|
||||||
import UserAvatar from '../UserAvatar'
|
|
||||||
import Username from '../Username'
|
|
||||||
|
|
||||||
export default function PictureNoteCard({
|
|
||||||
event,
|
|
||||||
className
|
|
||||||
}: {
|
|
||||||
event: Event
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const { push } = useSecondaryPage()
|
|
||||||
const images = useMemo(() => getImageInfosFromEvent(event), [event])
|
|
||||||
const title = useMemo(() => {
|
|
||||||
const nodes = parseContent(event.tags.find(tagNameEquals('title'))?.[1] ?? event.content, [
|
|
||||||
EmbeddedMentionParser,
|
|
||||||
EmbeddedHashtagParser
|
|
||||||
])
|
|
||||||
return nodes.map((node, index) => {
|
|
||||||
if (node.type === 'text') {
|
|
||||||
return node.data
|
|
||||||
}
|
|
||||||
if (node.type === 'mention') {
|
|
||||||
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
|
|
||||||
}
|
|
||||||
if (node.type === 'hashtag') {
|
|
||||||
return <EmbeddedHashtag key={index} hashtag={node.data} />
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [event])
|
|
||||||
if (!images.length) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('cursor-pointer relative', className)} onClick={() => push(toNote(event))}>
|
|
||||||
<Image className="w-full aspect-[6/8] rounded-lg" image={images[0]} />
|
|
||||||
{images.length > 1 && (
|
|
||||||
<div className="absolute top-2 right-2 bg-background/50 rounded-full p-2">
|
|
||||||
<Images size={16} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="p-2 space-y-1">
|
|
||||||
<div className="line-clamp-2 font-semibold">{title}</div>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2 flex-1 w-0">
|
|
||||||
<UserAvatar userId={event.pubkey} size="xSmall" />
|
|
||||||
<Username userId={event.pubkey} className="text-sm text-muted-foreground truncate" />
|
|
||||||
</div>
|
|
||||||
<LikeButton event={event} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { Event } from 'nostr-tools'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import PictureNoteCard from '../PictureNoteCard'
|
|
||||||
|
|
||||||
export function PictureNoteCardMasonry({
|
|
||||||
events,
|
|
||||||
columnCount,
|
|
||||||
className
|
|
||||||
}: {
|
|
||||||
events: Event[]
|
|
||||||
columnCount: 2 | 3
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
const newColumns: React.ReactNode[][] = Array.from({ length: columnCount }, () => [])
|
|
||||||
events.forEach((event, i) => {
|
|
||||||
newColumns[i % columnCount].push(
|
|
||||||
<PictureNoteCard key={event.id} className="w-full" event={event} />
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return newColumns
|
|
||||||
}, [events, columnCount])
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -135,7 +135,7 @@ export default function QuoteList({ event, className }: { event: Event; classNam
|
|||||||
</div>
|
</div>
|
||||||
{hasMore || loading ? (
|
{hasMore || loading ? (
|
||||||
<div ref={bottomRef}>
|
<div ref={bottomRef}>
|
||||||
<NoteCardLoadingSkeleton isPictures={false} />
|
<NoteCardLoadingSkeleton />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
|
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -3,7 +3,6 @@ import ContentPreview from '@/components/ContentPreview'
|
|||||||
import Note from '@/components/Note'
|
import Note from '@/components/Note'
|
||||||
import NoteInteractions from '@/components/NoteInteractions'
|
import NoteInteractions from '@/components/NoteInteractions'
|
||||||
import NoteStats from '@/components/NoteStats'
|
import NoteStats from '@/components/NoteStats'
|
||||||
import PictureNote from '@/components/PictureNote'
|
|
||||||
import UserAvatar from '@/components/UserAvatar'
|
import UserAvatar from '@/components/UserAvatar'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
@@ -11,7 +10,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import { ExtendedKind } from '@/constants'
|
import { ExtendedKind } from '@/constants'
|
||||||
import { useFetchEvent } from '@/hooks'
|
import { useFetchEvent } from '@/hooks'
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
import { getParentBech32Id, getParentETag, getRootBech32Id, isPictureEvent } from '@/lib/event'
|
import { getParentBech32Id, getParentETag, getRootBech32Id } from '@/lib/event'
|
||||||
import { toNote, toNoteList } from '@/lib/link'
|
import { toNote, toNoteList } from '@/lib/link'
|
||||||
import { tagNameEquals } from '@/lib/tag'
|
import { tagNameEquals } from '@/lib/tag'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -62,16 +61,6 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
|
|||||||
}
|
}
|
||||||
if (!event) return <NotFoundPage />
|
if (!event) return <NotFoundPage />
|
||||||
|
|
||||||
if (isPictureEvent(event)) {
|
|
||||||
return (
|
|
||||||
<SecondaryPageLayout ref={ref} index={index} title={t('Note')} displayScrollToTopButton>
|
|
||||||
<PictureNote key={`note-${event.id}`} event={event} fetchNoteStats />
|
|
||||||
<Separator className="mt-4" />
|
|
||||||
<NoteInteractions key={`note-interactions-${event.id}`} pageIndex={index} event={event} />
|
|
||||||
</SecondaryPageLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout ref={ref} index={index} title={t('Note')} displayScrollToTopButton>
|
<SecondaryPageLayout ref={ref} index={index} title={t('Note')} displayScrollToTopButton>
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
|
|||||||
2
src/types/index.d.ts
vendored
2
src/types/index.d.ts
vendored
@@ -108,7 +108,7 @@ export type TImageInfo = {
|
|||||||
pubkey?: string
|
pubkey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TNoteListMode = 'posts' | 'postsAndReplies' | 'pictures' | 'you'
|
export type TNoteListMode = 'posts' | 'postsAndReplies' | 'you'
|
||||||
|
|
||||||
export type TNotificationType = 'all' | 'mentions' | 'reactions' | 'zaps'
|
export type TNotificationType = 'all' | 'mentions' | 'reactions' | 'zaps'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user