feat: add support for commenting and reacting on external content
This commit is contained in:
parent
5ba5c26fcd
commit
0bb62dd3fb
76 changed files with 1635 additions and 639 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
|
||||
import { useBookmarks } from '@/providers/BookmarksProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
|
|
@ -7,12 +8,15 @@ import { useMemo, useState } from 'react'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function BookmarkButton({ event }: { event: Event }) {
|
||||
export default function BookmarkButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey: accountPubkey, bookmarkListEvent, checkLogin } = useNostr()
|
||||
const { addBookmark, removeBookmark } = useBookmarks()
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const { event } = useStuff(stuff)
|
||||
const isBookmarked = useMemo(() => {
|
||||
if (!event) return false
|
||||
|
||||
const isReplaceable = isReplaceableEvent(event.kind)
|
||||
const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id
|
||||
|
||||
|
|
@ -26,7 +30,7 @@ export default function BookmarkButton({ event }: { event: Event }) {
|
|||
const handleBookmark = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (isBookmarked) return
|
||||
if (isBookmarked || !event) return
|
||||
|
||||
setUpdating(true)
|
||||
try {
|
||||
|
|
@ -42,7 +46,7 @@ export default function BookmarkButton({ event }: { event: Event }) {
|
|||
const handleRemoveBookmark = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (!isBookmarked) return
|
||||
if (!isBookmarked || !event) return
|
||||
|
||||
setUpdating(true)
|
||||
try {
|
||||
|
|
@ -59,9 +63,9 @@ export default function BookmarkButton({ event }: { event: Event }) {
|
|||
<button
|
||||
className={`flex items-center gap-1 ${
|
||||
isBookmarked ? 'text-rose-400' : 'text-muted-foreground'
|
||||
} enabled:hover:text-rose-400 px-3 h-full`}
|
||||
} enabled:hover:text-rose-400 px-3 h-full disabled:text-muted-foreground/40 disabled:cursor-default`}
|
||||
onClick={isBookmarked ? handleRemoveBookmark : handleBookmark}
|
||||
disabled={updating}
|
||||
disabled={!event || updating}
|
||||
title={isBookmarked ? t('Remove bookmark') : t('Bookmark')}
|
||||
>
|
||||
{updating ? (
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import ExternalLink from '../ExternalLink'
|
|||
import ImageGallery from '../ImageGallery'
|
||||
import MediaPlayer from '../MediaPlayer'
|
||||
import WebPreview from '../WebPreview'
|
||||
import XEmbeddedPost from '../XEmbeddedPost'
|
||||
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
|
||||
|
||||
export default function Content({
|
||||
|
|
@ -156,6 +157,16 @@ export default function Content({
|
|||
/>
|
||||
)
|
||||
}
|
||||
if (node.type === 'x-post') {
|
||||
return (
|
||||
<XEmbeddedPost
|
||||
key={index}
|
||||
url={node.data}
|
||||
className="mt-2"
|
||||
mustLoad={mustLoadMedia}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
|
||||
|
|
|
|||
94
src/components/ExternalContent/index.tsx
Normal file
94
src/components/ExternalContent/index.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import {
|
||||
EmbeddedHashtagParser,
|
||||
EmbeddedUrlParser,
|
||||
EmbeddedWebsocketUrlParser,
|
||||
parseContent
|
||||
} from '@/lib/content-parser'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useMemo } from 'react'
|
||||
import { EmbeddedHashtag, EmbeddedLNInvoice, EmbeddedWebsocketUrl } from '../Embedded'
|
||||
import ImageGallery from '../ImageGallery'
|
||||
import MediaPlayer from '../MediaPlayer'
|
||||
import WebPreview from '../WebPreview'
|
||||
import XEmbeddedPost from '../XEmbeddedPost'
|
||||
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
|
||||
|
||||
export default function ExternalContent({
|
||||
content,
|
||||
className,
|
||||
mustLoadMedia
|
||||
}: {
|
||||
content?: string
|
||||
className?: string
|
||||
mustLoadMedia?: boolean
|
||||
}) {
|
||||
const nodes = useMemo(() => {
|
||||
if (!content) return []
|
||||
|
||||
return parseContent(content, [
|
||||
EmbeddedUrlParser,
|
||||
EmbeddedWebsocketUrlParser,
|
||||
EmbeddedHashtagParser
|
||||
])
|
||||
}, [content])
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const node = nodes[0]
|
||||
|
||||
if (node.type === 'text') {
|
||||
return (
|
||||
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>{content}</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'url') {
|
||||
return <WebPreview url={node.data} className={className} />
|
||||
}
|
||||
|
||||
if (node.type === 'x-post') {
|
||||
return (
|
||||
<XEmbeddedPost
|
||||
url={node.data}
|
||||
className={className}
|
||||
mustLoad={mustLoadMedia}
|
||||
embedded={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'youtube') {
|
||||
return <YoutubeEmbeddedPlayer url={node.data} className={className} mustLoad={mustLoadMedia} />
|
||||
}
|
||||
|
||||
if (node.type === 'image' || node.type === 'images') {
|
||||
const data = Array.isArray(node.data) ? node.data : [node.data]
|
||||
return (
|
||||
<ImageGallery
|
||||
className={className}
|
||||
images={data.map((url) => ({ url }))}
|
||||
mustLoad={mustLoadMedia}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'media') {
|
||||
return <MediaPlayer className={className} src={node.data} mustLoad={mustLoadMedia} />
|
||||
}
|
||||
|
||||
if (node.type === 'invoice') {
|
||||
return <EmbeddedLNInvoice invoice={node.data} className={className} />
|
||||
}
|
||||
|
||||
if (node.type === 'websocket-url') {
|
||||
return <EmbeddedWebsocketUrl url={node.data} />
|
||||
}
|
||||
|
||||
if (node.type === 'hashtag') {
|
||||
return <EmbeddedHashtag hashtag={node.data} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
63
src/components/ExternalContentInteractions/Tabs.tsx
Normal file
63
src/components/ExternalContentInteractions/Tabs.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
|
||||
export type TTabValue = 'replies' | 'reactions'
|
||||
const TABS = [
|
||||
{ value: 'replies', label: 'Replies' },
|
||||
{ value: 'reactions', label: 'Reactions' }
|
||||
] as { value: TTabValue; label: string }[]
|
||||
|
||||
export function Tabs({
|
||||
selectedTab,
|
||||
onTabChange
|
||||
}: {
|
||||
selectedTab: TTabValue
|
||||
onTabChange: (tab: TTabValue) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
const activeIndex = TABS.findIndex((tab) => tab.value === selectedTab)
|
||||
if (activeIndex >= 0 && tabRefs.current[activeIndex]) {
|
||||
const activeTab = tabRefs.current[activeIndex]
|
||||
const { offsetWidth, offsetLeft } = activeTab
|
||||
const padding = 32 // 16px padding on each side
|
||||
setIndicatorStyle({
|
||||
width: offsetWidth - padding,
|
||||
left: offsetLeft + padding / 2
|
||||
})
|
||||
}
|
||||
}, 20) // ensure tabs are rendered before calculating
|
||||
}, [selectedTab])
|
||||
|
||||
return (
|
||||
<div className="w-fit">
|
||||
<div className="flex relative">
|
||||
{TABS.map((tab, index) => (
|
||||
<div
|
||||
key={tab.value}
|
||||
ref={(el) => (tabRefs.current[index] = el)}
|
||||
className={cn(
|
||||
`text-center px-4 py-2 font-semibold clickable cursor-pointer rounded-lg`,
|
||||
selectedTab === tab.value ? '' : 'text-muted-foreground'
|
||||
)}
|
||||
onClick={() => onTabChange(tab.value)}
|
||||
>
|
||||
{t(tab.label)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="absolute bottom-0 h-1 bg-primary rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${indicatorStyle.width}px`,
|
||||
left: `${indicatorStyle.left}px`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/components/ExternalContentInteractions/index.tsx
Normal file
45
src/components/ExternalContentInteractions/index.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useState } from 'react'
|
||||
import HideUntrustedContentButton from '../HideUntrustedContentButton'
|
||||
import ReplyNoteList from '../ReplyNoteList'
|
||||
import { Tabs, TTabValue } from './Tabs'
|
||||
import ReactionList from '../ReactionList'
|
||||
|
||||
export default function ExternalContentInteractions({
|
||||
pageIndex,
|
||||
externalContent
|
||||
}: {
|
||||
pageIndex?: number
|
||||
externalContent: string
|
||||
}) {
|
||||
const [type, setType] = useState<TTabValue>('replies')
|
||||
let list
|
||||
switch (type) {
|
||||
case 'replies':
|
||||
list = <ReplyNoteList index={pageIndex} stuff={externalContent} />
|
||||
break
|
||||
case 'reactions':
|
||||
list = <ReactionList stuff={externalContent} />
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<ScrollArea className="flex-1 w-0">
|
||||
<Tabs selectedTab={type} onTabChange={setType} />
|
||||
<ScrollBar orientation="horizontal" className="opacity-0 pointer-events-none" />
|
||||
</ScrollArea>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<div className="size-10 flex items-center justify-center">
|
||||
<HideUntrustedContentButton type="interactions" />
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
{list}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,20 +1,53 @@
|
|||
import { useSecondaryPage } from '@/PageManager'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toExternalContent } from '@/lib/link'
|
||||
import { truncateUrl } from '@/lib/url'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ExternalLink as ExternalLinkIcon, MessageSquare } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ExternalLink({ url, className }: { url: string; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const displayUrl = useMemo(() => truncateUrl(url), [url])
|
||||
|
||||
const handleOpenLink = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
window.open(url, '_blank', 'noreferrer')
|
||||
}
|
||||
|
||||
const handleViewDiscussions = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
push(toExternalContent(url))
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
className={cn('text-primary hover:underline', className)}
|
||||
href={url}
|
||||
target="_blank"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
title={url}
|
||||
>
|
||||
{displayUrl}
|
||||
</a>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<span
|
||||
className={cn('cursor-pointer text-primary hover:underline', className)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={url}
|
||||
>
|
||||
{displayUrl}
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem onClick={handleOpenLink}>
|
||||
<ExternalLinkIcon />
|
||||
{t('Open link')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleViewDiscussions}>
|
||||
<MessageSquare />
|
||||
{t('View Nostr discussions')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
import { ExtendedKind } from '@/constants'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function IValue({ event, className }: { event: Event; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const iValue = useMemo(() => {
|
||||
if (event.kind !== ExtendedKind.COMMENT) return undefined
|
||||
const iTag = event.tags.find(tagNameEquals('i'))
|
||||
return iTag ? iTag[1] : undefined
|
||||
}, [event])
|
||||
|
||||
if (!iValue) return null
|
||||
|
||||
return (
|
||||
<div className={cn('truncate text-muted-foreground', className)}>
|
||||
{t('Comment on') + ' '}
|
||||
{iValue.startsWith('http') ? (
|
||||
<a
|
||||
className="hover:text-foreground underline truncate"
|
||||
href={iValue}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{iValue}
|
||||
</a>
|
||||
) : (
|
||||
<span>{iValue}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { ExtendedKind, SUPPORTED_KINDS } from '@/constants'
|
||||
import { getParentBech32Id, isNsfwEvent } from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { getParentStuff, isNsfwEvent } from '@/lib/event'
|
||||
import { toExternalContent, toNote } from '@/lib/link'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
|
|
@ -22,7 +22,6 @@ import CommunityDefinition from './CommunityDefinition'
|
|||
import EmojiPack from './EmojiPack'
|
||||
import GroupMetadata from './GroupMetadata'
|
||||
import Highlight from './Highlight'
|
||||
import IValue from './IValue'
|
||||
import LiveEvent from './LiveEvent'
|
||||
import LongFormArticle from './LongFormArticle'
|
||||
import LongFormArticlePreview from './LongFormArticlePreview'
|
||||
|
|
@ -51,10 +50,9 @@ export default function Note({
|
|||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const parentEventId = useMemo(
|
||||
() => (hideParentNotePreview ? undefined : getParentBech32Id(event)),
|
||||
[event, hideParentNotePreview]
|
||||
)
|
||||
const { parentEventId, parentExternalContent } = useMemo(() => {
|
||||
return getParentStuff(event)
|
||||
}, [event])
|
||||
const { defaultShowNsfw } = useContentPolicy()
|
||||
const [showNsfw, setShowNsfw] = useState(false)
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
|
|
@ -141,17 +139,21 @@ export default function Note({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{parentEventId && (
|
||||
{!hideParentNotePreview && (
|
||||
<ParentNotePreview
|
||||
eventId={parentEventId}
|
||||
externalContent={parentExternalContent}
|
||||
className="mt-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
push(toNote(parentEventId))
|
||||
if (parentExternalContent) {
|
||||
push(toExternalContent(parentExternalContent))
|
||||
} else if (parentEventId) {
|
||||
push(toNote(parentEventId))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<IValue event={event} className="mt-2" />
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useSecondaryPage } from '@/PageManager'
|
|||
import { Event } from 'nostr-tools'
|
||||
import Collapsible from '../Collapsible'
|
||||
import Note from '../Note'
|
||||
import NoteStats from '../NoteStats'
|
||||
import StuffStats from '../StuffStats'
|
||||
import PinnedButton from './PinnedButton'
|
||||
import RepostDescription from './RepostDescription'
|
||||
|
||||
|
|
@ -45,7 +45,7 @@ export default function MainNoteCard({
|
|||
originalNoteId={originalNoteId}
|
||||
/>
|
||||
</Collapsible>
|
||||
{!embedded && <NoteStats className="mt-3 px-4" event={event} />}
|
||||
{!embedded && <StuffStats className="mt-3 px-4" stuff={event} />}
|
||||
</div>
|
||||
{!embedded && <Separator />}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@ export default function NoteInteractions({
|
|||
let list
|
||||
switch (type) {
|
||||
case 'replies':
|
||||
list = <ReplyNoteList index={pageIndex} event={event} />
|
||||
list = <ReplyNoteList index={pageIndex} stuff={event} />
|
||||
break
|
||||
case 'quotes':
|
||||
list = <QuoteList event={event} />
|
||||
break
|
||||
case 'reactions':
|
||||
list = <ReactionList event={event} />
|
||||
list = <ReactionList stuff={event} />
|
||||
break
|
||||
case 'reposts':
|
||||
list = <RepostList event={event} />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import NewNotesButton from '@/components/NewNotesButton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
getEventKey,
|
||||
getEventKeyFromTag,
|
||||
isMentioningMutedUsers,
|
||||
isReplyNoteEvent
|
||||
} from '@/lib/event'
|
||||
import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { isTouchDevice } from '@/lib/utils'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
|
|
@ -173,7 +168,7 @@ const NoteList = forwardRef(
|
|||
|
||||
const targetTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('e'))
|
||||
if (targetTag) {
|
||||
const targetEventKey = getEventKeyFromTag(targetTag)
|
||||
const targetEventKey = getKeyFromTag(targetTag)
|
||||
if (targetEventKey) {
|
||||
// Add to reposters map
|
||||
const reposters = repostersMap.get(targetEventKey)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import ParentNotePreview from '@/components/ParentNotePreview'
|
||||
import { NOTIFICATION_LIST_STYLE } from '@/constants'
|
||||
import { getEmbeddedPubkeys, getParentBech32Id } from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { getEmbeddedPubkeys, getParentStuff } from '@/lib/event'
|
||||
import { toExternalContent, toNote } from '@/lib/link'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
||||
|
|
@ -27,7 +27,9 @@ export function MentionNotification({
|
|||
const mentions = getEmbeddedPubkeys(notification)
|
||||
return mentions.includes(pubkey)
|
||||
}, [pubkey, notification])
|
||||
const parentEventId = useMemo(() => getParentBech32Id(notification), [notification])
|
||||
const { parentEventId, parentExternalContent } = useMemo(() => {
|
||||
return getParentStuff(notification)
|
||||
}, [notification])
|
||||
|
||||
return (
|
||||
<Notification
|
||||
|
|
@ -45,14 +47,18 @@ export function MentionNotification({
|
|||
sentAt={notification.created_at}
|
||||
targetEvent={notification}
|
||||
middle={
|
||||
notificationListStyle === NOTIFICATION_LIST_STYLE.DETAILED &&
|
||||
parentEventId && (
|
||||
notificationListStyle === NOTIFICATION_LIST_STYLE.DETAILED && (
|
||||
<ParentNotePreview
|
||||
eventId={parentEventId}
|
||||
externalContent={parentExternalContent}
|
||||
className=""
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
push(toNote(parentEventId))
|
||||
if (parentExternalContent) {
|
||||
push(toExternalContent(parentExternalContent))
|
||||
} else if (parentEventId) {
|
||||
push(toNote(parentEventId))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import ContentPreview from '@/components/ContentPreview'
|
||||
import { FormattedTimestamp } from '@/components/FormattedTimestamp'
|
||||
import NoteStats from '@/components/NoteStats'
|
||||
import StuffStats from '@/components/StuffStats'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import Username from '@/components/Username'
|
||||
|
|
@ -120,7 +120,7 @@ export default function Notification({
|
|||
/>
|
||||
)}
|
||||
<FormattedTimestamp timestamp={sentAt} className="shrink-0 text-muted-foreground text-sm" />
|
||||
{showStats && targetEvent && <NoteStats event={targetEvent} className="mt-1" />}
|
||||
{showStats && targetEvent && <StuffStats stuff={targetEvent} className="mt-1" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useNostr } from '@/providers/NostrProvider'
|
|||
import { useNotification } from '@/providers/NotificationProvider'
|
||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
||||
import client from '@/services/client.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import { TNotificationType } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { NostrEvent, kinds, matchFilter } from 'nostr-tools'
|
||||
|
|
@ -94,7 +94,7 @@ const NotificationList = forwardRef((_, ref) => {
|
|||
return oldEvents
|
||||
}
|
||||
|
||||
noteStatsService.updateNoteStatsByEvents([event])
|
||||
stuffStatsService.updateStuffStatsByEvents([event])
|
||||
if (index === -1) {
|
||||
return [...oldEvents, event]
|
||||
}
|
||||
|
|
@ -138,7 +138,7 @@ const NotificationList = forwardRef((_, ref) => {
|
|||
if (eosed) {
|
||||
setLoading(false)
|
||||
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
|
||||
noteStatsService.updateNoteStatsByEvents(events)
|
||||
stuffStatsService.updateStuffStatsByEvents(events)
|
||||
}
|
||||
},
|
||||
onNew: (event) => {
|
||||
|
|
|
|||
|
|
@ -7,16 +7,37 @@ import UserAvatar from '../UserAvatar'
|
|||
|
||||
export default function ParentNotePreview({
|
||||
eventId,
|
||||
externalContent,
|
||||
className,
|
||||
onClick
|
||||
}: {
|
||||
eventId: string
|
||||
eventId?: string
|
||||
externalContent?: string
|
||||
className?: string
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { event, isFetching } = useFetchEvent(eventId)
|
||||
|
||||
if (externalContent) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground hover:text-foreground cursor-pointer',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="shrink-0">{t('reply to')}</div>
|
||||
<div className="truncate">{externalContent}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!eventId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -24,15 +24,16 @@ import PostOptions from './PostOptions'
|
|||
import PostRelaySelector from './PostRelaySelector'
|
||||
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
|
||||
import Uploader from './Uploader'
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
|
||||
export default function PostContent({
|
||||
defaultContent = '',
|
||||
parentEvent,
|
||||
parentStuff,
|
||||
close,
|
||||
openFrom
|
||||
}: {
|
||||
defaultContent?: string
|
||||
parentEvent?: Event
|
||||
parentStuff?: Event | string
|
||||
close: () => void
|
||||
openFrom?: string[]
|
||||
}) {
|
||||
|
|
@ -45,6 +46,10 @@ export default function PostContent({
|
|||
const [uploadProgresses, setUploadProgresses] = useState<
|
||||
{ file: File; progress: number; cancel: () => void }[]
|
||||
>([])
|
||||
const parentEvent = useMemo(
|
||||
() => (parentStuff && typeof parentStuff !== 'string' ? parentStuff : undefined),
|
||||
[parentStuff]
|
||||
)
|
||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||
const [addClientTag, setAddClientTag] = useState(false)
|
||||
const [mentions, setMentions] = useState<string[]>([])
|
||||
|
|
@ -85,7 +90,7 @@ export default function PostContent({
|
|||
isFirstRender.current = false
|
||||
const cachedSettings = postEditorCache.getPostSettingsCache({
|
||||
defaultContent,
|
||||
parentEvent
|
||||
parentStuff
|
||||
})
|
||||
if (cachedSettings) {
|
||||
setIsNsfw(cachedSettings.isNsfw ?? false)
|
||||
|
|
@ -103,7 +108,7 @@ export default function PostContent({
|
|||
return
|
||||
}
|
||||
postEditorCache.setPostSettingsCache(
|
||||
{ defaultContent, parentEvent },
|
||||
{ defaultContent, parentStuff },
|
||||
{
|
||||
isNsfw,
|
||||
isPoll,
|
||||
|
|
@ -111,7 +116,7 @@ export default function PostContent({
|
|||
addClientTag
|
||||
}
|
||||
)
|
||||
}, [defaultContent, parentEvent, isNsfw, isPoll, pollCreateData, addClientTag])
|
||||
}, [defaultContent, parentStuff, isNsfw, isPoll, pollCreateData, addClientTag])
|
||||
|
||||
const post = async (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
|
|
@ -121,8 +126,9 @@ export default function PostContent({
|
|||
setPosting(true)
|
||||
try {
|
||||
const draftEvent =
|
||||
parentEvent && parentEvent.kind !== kinds.ShortTextNote
|
||||
? await createCommentDraftEvent(text, parentEvent, mentions, {
|
||||
parentStuff &&
|
||||
(typeof parentStuff === 'string' || parentStuff.kind !== kinds.ShortTextNote)
|
||||
? await createCommentDraftEvent(text, parentStuff, mentions, {
|
||||
addClientTag,
|
||||
protectedEvent: isProtectedEvent,
|
||||
isNsfw
|
||||
|
|
@ -139,12 +145,17 @@ export default function PostContent({
|
|||
isNsfw
|
||||
})
|
||||
|
||||
const _additionalRelayUrls = [...additionalRelayUrls]
|
||||
if (parentStuff && typeof parentStuff === 'string') {
|
||||
_additionalRelayUrls.push(...BIG_RELAY_URLS)
|
||||
}
|
||||
|
||||
const newEvent = await publish(draftEvent, {
|
||||
specifiedRelayUrls: isProtectedEvent ? additionalRelayUrls : undefined,
|
||||
additionalRelayUrls: isPoll ? pollCreateData.relays : additionalRelayUrls,
|
||||
additionalRelayUrls: isPoll ? pollCreateData.relays : _additionalRelayUrls,
|
||||
minPow
|
||||
})
|
||||
postEditorCache.clearPostCache({ defaultContent, parentEvent })
|
||||
postEditorCache.clearPostCache({ defaultContent, parentStuff })
|
||||
deleteDraftEventCache(draftEvent)
|
||||
addReplies([newEvent])
|
||||
close()
|
||||
|
|
@ -166,7 +177,7 @@ export default function PostContent({
|
|||
}
|
||||
|
||||
const handlePollToggle = () => {
|
||||
if (parentEvent) return
|
||||
if (parentStuff) return
|
||||
|
||||
setIsPoll((prev) => !prev)
|
||||
}
|
||||
|
|
@ -199,7 +210,7 @@ export default function PostContent({
|
|||
text={text}
|
||||
setText={setText}
|
||||
defaultContent={defaultContent}
|
||||
parentEvent={parentEvent}
|
||||
parentStuff={parentStuff}
|
||||
onSubmit={() => post()}
|
||||
className={isPoll ? 'min-h-20' : 'min-h-52'}
|
||||
onUploadStart={handleUploadStart}
|
||||
|
|
@ -278,7 +289,7 @@ export default function PostContent({
|
|||
</Button>
|
||||
</EmojiPickerDialog>
|
||||
)}
|
||||
{!parentEvent && (
|
||||
{!parentStuff && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -317,7 +328,7 @@ export default function PostContent({
|
|||
</Button>
|
||||
<Button type="submit" disabled={!canPost} onClick={post}>
|
||||
{posting && <LoaderCircle className="animate-spin" />}
|
||||
{parentEvent ? t('Reply') : t('Post')}
|
||||
{parentStuff ? t('Reply') : t('Post')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -345,7 +356,7 @@ export default function PostContent({
|
|||
</Button>
|
||||
<Button className="w-full" type="submit" disabled={!canPost} onClick={post}>
|
||||
{posting && <LoaderCircle className="animate-spin" />}
|
||||
{parentEvent ? t('Reply') : t('Post')}
|
||||
{parentStuff ? t('Reply') : t('Post')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const PostTextarea = forwardRef<
|
|||
text: string
|
||||
setText: Dispatch<SetStateAction<string>>
|
||||
defaultContent?: string
|
||||
parentEvent?: Event
|
||||
parentStuff?: Event | string
|
||||
onSubmit?: () => void
|
||||
className?: string
|
||||
onUploadStart?: (file: File, cancel: () => void) => void
|
||||
|
|
@ -47,7 +47,7 @@ const PostTextarea = forwardRef<
|
|||
text = '',
|
||||
setText,
|
||||
defaultContent,
|
||||
parentEvent,
|
||||
parentStuff,
|
||||
onSubmit,
|
||||
className,
|
||||
onUploadStart,
|
||||
|
|
@ -103,10 +103,10 @@ const PostTextarea = forwardRef<
|
|||
return parseEditorJsonToText(content.toJSON())
|
||||
}
|
||||
},
|
||||
content: postEditorCache.getPostContentCache({ defaultContent, parentEvent }),
|
||||
content: postEditorCache.getPostContentCache({ defaultContent, parentStuff }),
|
||||
onUpdate(props) {
|
||||
setText(parseEditorJsonToText(props.editor.getJSON()))
|
||||
postEditorCache.setPostContentCache({ defaultContent, parentEvent }, props.editor.getJSON())
|
||||
postEditorCache.setPostContentCache({ defaultContent, parentStuff }, props.editor.getJSON())
|
||||
},
|
||||
onCreate(props) {
|
||||
setText(parseEditorJsonToText(props.editor.getJSON()))
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { Event } from 'nostr-tools'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Title({ parentEvent }: { parentEvent?: Event }) {
|
||||
export default function Title({ parentStuff }: { parentStuff?: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return parentEvent ? (
|
||||
return parentStuff ? (
|
||||
<div className="flex gap-2 items-center w-full">
|
||||
<div className="shrink-0">{t('Reply to')}</div>
|
||||
{typeof parentStuff === 'string' && (
|
||||
<div className="text-primary truncate">{parentStuff}</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
t('New Note')
|
||||
|
|
|
|||
|
|
@ -22,13 +22,13 @@ import Title from './Title'
|
|||
|
||||
export default function PostEditor({
|
||||
defaultContent = '',
|
||||
parentEvent,
|
||||
parentStuff,
|
||||
open,
|
||||
setOpen,
|
||||
openFrom
|
||||
}: {
|
||||
defaultContent?: string
|
||||
parentEvent?: Event
|
||||
parentStuff?: Event | string
|
||||
open: boolean
|
||||
setOpen: Dispatch<boolean>
|
||||
openFrom?: string[]
|
||||
|
|
@ -39,7 +39,7 @@ export default function PostEditor({
|
|||
return (
|
||||
<PostContent
|
||||
defaultContent={defaultContent}
|
||||
parentEvent={parentEvent}
|
||||
parentStuff={parentStuff}
|
||||
close={() => setOpen(false)}
|
||||
openFrom={openFrom}
|
||||
/>
|
||||
|
|
@ -64,7 +64,7 @@ export default function PostEditor({
|
|||
<div className="space-y-4 px-2 py-6">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-start">
|
||||
<Title parentEvent={parentEvent} />
|
||||
<Title parentStuff={parentStuff} />
|
||||
</SheetTitle>
|
||||
<SheetDescription className="hidden" />
|
||||
</SheetHeader>
|
||||
|
|
@ -92,7 +92,7 @@ export default function PostEditor({
|
|||
<div className="space-y-4 px-2 py-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Title parentEvent={parentEvent} />
|
||||
<Title parentStuff={parentStuff} />
|
||||
</DialogTitle>
|
||||
<DialogDescription className="hidden" />
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
|
|
@ -11,20 +11,22 @@ import { FormattedTimestamp } from '../FormattedTimestamp'
|
|||
import Nip05 from '../Nip05'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
|
||||
const SHOW_COUNT = 20
|
||||
|
||||
export default function ReactionList({ event }: { event: Event }) {
|
||||
export default function ReactionList({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const { stuffKey } = useStuff(stuff)
|
||||
const noteStats = useStuffStatsById(stuffKey)
|
||||
const filteredLikes = useMemo(() => {
|
||||
return (noteStats?.likes ?? [])
|
||||
.filter((like) => !hideUntrustedInteractions || isUserTrusted(like.pubkey))
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
}, [noteStats, event.id, hideUntrustedInteractions, isUserTrusted])
|
||||
}, [noteStats, stuffKey, hideUntrustedInteractions, isUserTrusted])
|
||||
|
||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import Content from '../Content'
|
|||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import Nip05 from '../Nip05'
|
||||
import NoteOptions from '../NoteOptions'
|
||||
import NoteStats from '../NoteStats'
|
||||
import StuffStats from '../StuffStats'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import TranslateButton from '../TranslateButton'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
|
|
@ -111,7 +111,7 @@ export default function ReplyNote({
|
|||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
{show && <NoteStats className="ml-14 pl-1 mr-4 mt-2" event={event} displayTopZapsAndLikes />}
|
||||
{show && <StuffStats className="ml-14 pl-1 mr-4 mt-2" stuff={event} displayTopZapsAndLikes />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||
import {
|
||||
getEventKey,
|
||||
getEventKeyFromTag,
|
||||
getKeyFromTag,
|
||||
getParentTag,
|
||||
getReplaceableCoordinateFromEvent,
|
||||
getRootTag,
|
||||
|
|
@ -11,7 +11,7 @@ import {
|
|||
isReplyNoteEvent
|
||||
} from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
|
||||
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
|
|
@ -23,16 +23,23 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { LoadingBar } from '../LoadingBar'
|
||||
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
|
||||
type TRootInfo =
|
||||
| { type: 'E'; id: string; pubkey: string }
|
||||
| { type: 'A'; id: string; eventId: string; pubkey: string; relay?: string }
|
||||
| { type: 'A'; id: string; pubkey: string; relay?: string }
|
||||
| { type: 'I'; id: string }
|
||||
|
||||
const LIMIT = 100
|
||||
const SHOW_COUNT = 10
|
||||
|
||||
export default function ReplyNoteList({ index, event }: { index?: number; event: NEvent }) {
|
||||
export default function ReplyNoteList({
|
||||
stuff,
|
||||
index
|
||||
}: {
|
||||
stuff: NEvent | string
|
||||
index?: number
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { push, currentIndex } = useSecondaryPage()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
|
|
@ -40,13 +47,14 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
|||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
|
||||
const { repliesMap, addReplies } = useReply()
|
||||
const { event, externalContent, stuffKey } = useStuff(stuff)
|
||||
const replies = useMemo(() => {
|
||||
const replyKeySet = new Set<string>()
|
||||
const replyEvents: NEvent[] = []
|
||||
const currentEventKey = getEventKey(event)
|
||||
let parentEventKeys = [currentEventKey]
|
||||
while (parentEventKeys.length > 0) {
|
||||
const events = parentEventKeys.flatMap((key) => repliesMap.get(key)?.events || [])
|
||||
|
||||
let parentKeys = [stuffKey]
|
||||
while (parentKeys.length > 0) {
|
||||
const events = parentKeys.flatMap((key) => repliesMap.get(key)?.events || [])
|
||||
events.forEach((evt) => {
|
||||
const key = getEventKey(evt)
|
||||
if (replyKeySet.has(key)) return
|
||||
|
|
@ -56,10 +64,10 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
|||
replyKeySet.add(key)
|
||||
replyEvents.push(evt)
|
||||
})
|
||||
parentEventKeys = events.map((evt) => getEventKey(evt))
|
||||
parentKeys = events.map((evt) => getEventKey(evt))
|
||||
}
|
||||
return replyEvents.sort((a, b) => a.created_at - b.created_at)
|
||||
}, [event.id, repliesMap])
|
||||
}, [stuffKey, repliesMap])
|
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||
const [until, setUntil] = useState<number | undefined>(undefined)
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
|
|
@ -70,15 +78,18 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
|||
|
||||
useEffect(() => {
|
||||
const fetchRootEvent = async () => {
|
||||
let root: TRootInfo = isReplaceableEvent(event.kind)
|
||||
? {
|
||||
type: 'A',
|
||||
id: getReplaceableCoordinateFromEvent(event),
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
relay: client.getEventHint(event.id)
|
||||
}
|
||||
: { type: 'E', id: event.id, pubkey: event.pubkey }
|
||||
if (!event && !externalContent) return
|
||||
|
||||
let root: TRootInfo = event
|
||||
? isReplaceableEvent(event.kind)
|
||||
? {
|
||||
type: 'A',
|
||||
id: getReplaceableCoordinateFromEvent(event),
|
||||
pubkey: event.pubkey,
|
||||
relay: client.getEventHint(event.id)
|
||||
}
|
||||
: { type: 'E', id: event.id, pubkey: event.pubkey }
|
||||
: { type: 'I', id: externalContent! }
|
||||
|
||||
const rootTag = getRootTag(event)
|
||||
if (rootTag?.type === 'e') {
|
||||
|
|
@ -97,12 +108,9 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
|||
} else if (rootTag?.type === 'a') {
|
||||
const [, coordinate, relay] = rootTag.tag
|
||||
const [, pubkey] = coordinate.split(':')
|
||||
root = { type: 'A', id: coordinate, eventId: event.id, pubkey, relay }
|
||||
} else {
|
||||
const rootITag = event.tags.find(tagNameEquals('I'))
|
||||
if (rootITag) {
|
||||
root = { type: 'I', id: rootITag[1] }
|
||||
}
|
||||
root = { type: 'A', id: coordinate, pubkey, relay }
|
||||
} else if (rootTag?.type === 'i') {
|
||||
root = { type: 'I', id: rootTag.tag[1] }
|
||||
}
|
||||
setRootInfo(root)
|
||||
}
|
||||
|
|
@ -116,13 +124,16 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
|||
setLoading(true)
|
||||
|
||||
try {
|
||||
const relayList = await client.fetchRelayList(
|
||||
(rootInfo as { pubkey?: string }).pubkey ?? event.pubkey
|
||||
)
|
||||
const relayUrls = relayList.read.concat(BIG_RELAY_URLS).slice(0, 4)
|
||||
let relayUrls: string[] = []
|
||||
const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey
|
||||
if (rootPubkey) {
|
||||
const relayList = await client.fetchRelayList(rootPubkey)
|
||||
relayUrls = relayList.read
|
||||
}
|
||||
relayUrls = relayUrls.concat(BIG_RELAY_URLS).slice(0, 4)
|
||||
|
||||
// If current event is protected, we can assume its replies are also protected and stored on the same relays
|
||||
if (isProtectedEvent(event)) {
|
||||
if (event && isProtectedEvent(event)) {
|
||||
const seenOn = client.getSeenEventRelayUrls(event.id)
|
||||
relayUrls.concat(...seenOn)
|
||||
}
|
||||
|
|
@ -136,7 +147,7 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
|||
kinds: [kinds.ShortTextNote],
|
||||
limit: LIMIT
|
||||
})
|
||||
if (event.kind !== kinds.ShortTextNote) {
|
||||
if (event?.kind !== kinds.ShortTextNote) {
|
||||
filters.push({
|
||||
'#E': [rootInfo.id],
|
||||
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
|
||||
|
|
@ -269,7 +280,7 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
|||
return (
|
||||
<div className="min-h-[80vh]">
|
||||
{loading && <LoadingBar />}
|
||||
{!loading && until && until > event.created_at && (
|
||||
{!loading && until && (!event || until > event.created_at) && (
|
||||
<div
|
||||
className={`text-sm text-center text-muted-foreground border-b py-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
||||
onClick={loadMore}
|
||||
|
|
@ -291,14 +302,16 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
|||
}
|
||||
}
|
||||
|
||||
const rootEventKey = getEventKey(event)
|
||||
const rootKey = event ? getEventKey(event) : externalContent!
|
||||
const currentReplyKey = getEventKey(reply)
|
||||
const parentTag = getParentTag(reply)
|
||||
const parentEventKey = parentTag ? getEventKeyFromTag(parentTag.tag) : undefined
|
||||
const parentKey = parentTag ? getKeyFromTag(parentTag.tag) : undefined
|
||||
const parentEventId = parentTag
|
||||
? parentTag.type === 'e'
|
||||
? generateBech32IdFromETag(parentTag.tag)
|
||||
: generateBech32IdFromATag(parentTag.tag)
|
||||
: parentTag.type === 'a'
|
||||
? generateBech32IdFromATag(parentTag.tag)
|
||||
: undefined
|
||||
: undefined
|
||||
return (
|
||||
<div
|
||||
|
|
@ -308,10 +321,10 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
|||
>
|
||||
<ReplyNote
|
||||
event={reply}
|
||||
parentEventId={rootEventKey !== parentEventKey ? parentEventId : undefined}
|
||||
parentEventId={rootKey !== parentKey ? parentEventId : undefined}
|
||||
onClickParent={() => {
|
||||
if (!parentEventKey) return
|
||||
highlightReply(parentEventKey, parentEventId)
|
||||
if (!parentKey) return
|
||||
highlightReply(parentKey, parentEventId)
|
||||
}}
|
||||
highlight={highlightReplyKey === currentReplyKey}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
|
|
@ -19,7 +19,7 @@ export default function RepostList({ event }: { event: Event }) {
|
|||
const { push } = useSecondaryPage()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const noteStats = useStuffStatsById(event.id)
|
||||
const filteredReposts = useMemo(() => {
|
||||
return (noteStats?.reposts ?? [])
|
||||
.filter((repost) => !hideUntrustedInteractions || isUserTrusted(repost.pubkey))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import SearchInput from '@/components/SearchInput'
|
||||
import { useSearchProfiles } from '@/hooks'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { toExternalContent, toNote } from '@/lib/link'
|
||||
import { randomString } from '@/lib/random'
|
||||
import { normalizeUrl } from '@/lib/url'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -8,7 +8,7 @@ import { useSecondaryPage } from '@/PageManager'
|
|||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import modalManager from '@/services/modal-manager.service'
|
||||
import { TSearchParams } from '@/types'
|
||||
import { Hash, Notebook, Search, Server } from 'lucide-react'
|
||||
import { Hash, MessageSquare, Notebook, Search, Server } from 'lucide-react'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import {
|
||||
forwardRef,
|
||||
|
|
@ -45,6 +45,9 @@ const SearchBar = forwardRef<
|
|||
if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) {
|
||||
return undefined
|
||||
}
|
||||
if (!input.includes('.')) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
return normalizeUrl(input)
|
||||
} catch {
|
||||
|
|
@ -89,6 +92,8 @@ const SearchBar = forwardRef<
|
|||
|
||||
if (params.type === 'note') {
|
||||
push(toNote(params.search))
|
||||
} else if (params.type === 'externalContent') {
|
||||
push(toExternalContent(params.search))
|
||||
} else {
|
||||
onSearch(params)
|
||||
}
|
||||
|
|
@ -128,8 +133,9 @@ const SearchBar = forwardRef<
|
|||
|
||||
setSelectableOptions([
|
||||
{ type: 'notes', search },
|
||||
{ type: 'hashtag', search: hashtag, input: `#${hashtag}` },
|
||||
...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []),
|
||||
{ type: 'externalContent', search, input },
|
||||
{ type: 'hashtag', search: hashtag, input: `#${hashtag}` },
|
||||
...profiles.map((profile) => ({
|
||||
type: 'profile',
|
||||
search: profile.npub,
|
||||
|
|
@ -197,6 +203,16 @@ const SearchBar = forwardRef<
|
|||
/>
|
||||
)
|
||||
}
|
||||
if (option.type === 'externalContent') {
|
||||
return (
|
||||
<ExternalContentItem
|
||||
key={index}
|
||||
selected={selectedIndex === index}
|
||||
search={option.search}
|
||||
onClick={() => updateSearch(option)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (option.type === 'profiles') {
|
||||
return (
|
||||
<Item
|
||||
|
|
@ -322,10 +338,16 @@ function NormalItem({
|
|||
onClick?: () => void
|
||||
selected?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Item onClick={onClick} selected={selected}>
|
||||
<Search className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{search}</div>
|
||||
<div className="size-10 flex justify-center items-center">
|
||||
<Search className="text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="font-semibold truncate">{search}</div>
|
||||
<div className="text-sm text-muted-foreground">{t('Search for notes')}</div>
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
|
@ -339,10 +361,16 @@ function HashtagItem({
|
|||
onClick?: () => void
|
||||
selected?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Item onClick={onClick} selected={selected}>
|
||||
<Hash className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{hashtag}</div>
|
||||
<div className="size-10 flex justify-center items-center">
|
||||
<Hash className="text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="font-semibold truncate">#{hashtag}</div>
|
||||
<div className="text-sm text-muted-foreground">{t('Search for hashtag')}</div>
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
|
@ -356,10 +384,16 @@ function NoteItem({
|
|||
onClick?: () => void
|
||||
selected?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Item onClick={onClick} selected={selected}>
|
||||
<Notebook className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{id}</div>
|
||||
<div className="size-10 flex justify-center items-center">
|
||||
<Notebook className="text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="font-semibold truncate font-mono text-sm">{id}</div>
|
||||
<div className="text-sm text-muted-foreground">{t('Go to note')}</div>
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
|
@ -397,10 +431,39 @@ function RelayItem({
|
|||
onClick?: () => void
|
||||
selected?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Item onClick={onClick} selected={selected}>
|
||||
<Server className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{url}</div>
|
||||
<div className="size-10 flex justify-center items-center">
|
||||
<Server className="text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="font-semibold truncate">{url}</div>
|
||||
<div className="text-sm text-muted-foreground">{t('Go to relay')}</div>
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ExternalContentItem({
|
||||
search,
|
||||
onClick,
|
||||
selected
|
||||
}: {
|
||||
search: string
|
||||
onClick?: () => void
|
||||
selected?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Item onClick={onClick} selected={selected}>
|
||||
<div className="size-10 flex justify-center items-center">
|
||||
<MessageSquare className="text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="font-semibold truncate">{search}</div>
|
||||
<div className="text-sm text-muted-foreground">{t('View discussions about this')}</div>
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,18 @@ import {
|
|||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { createReactionDraftEvent } from '@/lib/draft-event'
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import {
|
||||
createExternalContentReactionDraftEvent,
|
||||
createReactionDraftEvent
|
||||
} from '@/lib/draft-event'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import client from '@/services/client.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import { TEmoji } from '@/types'
|
||||
import { Loader, SmilePlus } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
|
|
@ -21,15 +26,16 @@ import EmojiPicker from '../EmojiPicker'
|
|||
import SuggestedEmojis from '../SuggestedEmojis'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function LikeButton({ event }: { event: Event }) {
|
||||
export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { pubkey, publish, checkLogin } = useNostr()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const { event, externalContent, stuffKey } = useStuff(stuff)
|
||||
const [liking, setLiking] = useState(false)
|
||||
const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false)
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false)
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const noteStats = useStuffStatsById(stuffKey)
|
||||
const { myLastEmoji, likeCount } = useMemo(() => {
|
||||
const stats = noteStats || {}
|
||||
const myLike = stats.likes?.find((like) => like.pubkey === pubkey)
|
||||
|
|
@ -48,13 +54,15 @@ export default function LikeButton({ event }: { event: Event }) {
|
|||
|
||||
try {
|
||||
if (!noteStats?.updatedAt) {
|
||||
await noteStatsService.fetchNoteStats(event, pubkey)
|
||||
await stuffStatsService.fetchStuffStats(stuffKey, pubkey)
|
||||
}
|
||||
|
||||
const reaction = createReactionDraftEvent(event, emoji)
|
||||
const seenOn = client.getSeenEventRelayUrls(event.id)
|
||||
const reaction = event
|
||||
? createReactionDraftEvent(event, emoji)
|
||||
: createExternalContentReactionDraftEvent(externalContent, emoji)
|
||||
const seenOn = event ? client.getSeenEventRelayUrls(event.id) : BIG_RELAY_URLS
|
||||
const evt = await publish(reaction, { additionalRelayUrls: seenOn })
|
||||
noteStatsService.updateNoteStatsByEvents([evt])
|
||||
stuffStatsService.updateStuffStatsByEvents([evt])
|
||||
} catch (error) {
|
||||
console.error('like failed', error)
|
||||
} finally {
|
||||
|
|
@ -1,19 +1,25 @@
|
|||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { createReactionDraftEvent } from '@/lib/draft-event'
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import {
|
||||
createExternalContentReactionDraftEvent,
|
||||
createReactionDraftEvent
|
||||
} from '@/lib/draft-event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import { TEmoji } from '@/types'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import Emoji from '../Emoji'
|
||||
|
||||
export default function Likes({ event }: { event: Event }) {
|
||||
export default function Likes({ stuff }: { stuff: Event | string }) {
|
||||
const { pubkey, checkLogin, publish } = useNostr()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const { event, externalContent, stuffKey } = useStuff(stuff)
|
||||
const noteStats = useStuffStatsById(stuffKey)
|
||||
const [liking, setLiking] = useState<string | null>(null)
|
||||
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const [isLongPressing, setIsLongPressing] = useState<string | null>(null)
|
||||
|
|
@ -44,10 +50,12 @@ export default function Likes({ event }: { event: Event }) {
|
|||
const timer = setTimeout(() => setLiking((prev) => (prev === key ? null : prev)), 5000)
|
||||
|
||||
try {
|
||||
const reaction = createReactionDraftEvent(event, emoji)
|
||||
const seenOn = client.getSeenEventRelayUrls(event.id)
|
||||
const reaction = event
|
||||
? createReactionDraftEvent(event, emoji)
|
||||
: createExternalContentReactionDraftEvent(externalContent, emoji)
|
||||
const seenOn = event ? client.getSeenEventRelayUrls(event.id) : BIG_RELAY_URLS
|
||||
const evt = await publish(reaction, { additionalRelayUrls: seenOn })
|
||||
noteStatsService.updateNoteStatsByEvents([evt])
|
||||
stuffStatsService.updateStuffStatsByEvents([evt])
|
||||
} catch (error) {
|
||||
console.error('like failed', error)
|
||||
} finally {
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
|
|
@ -12,21 +13,21 @@ import { useTranslation } from 'react-i18next'
|
|||
import PostEditor from '../PostEditor'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function ReplyButton({ event }: { event: Event }) {
|
||||
export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey, checkLogin } = useNostr()
|
||||
const { event, stuffKey } = useStuff(stuff)
|
||||
const { repliesMap } = useReply()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const { replyCount, hasReplied } = useMemo(() => {
|
||||
const key = getEventKey(event)
|
||||
const hasReplied = pubkey
|
||||
? repliesMap.get(key)?.events.some((evt) => evt.pubkey === pubkey)
|
||||
? repliesMap.get(stuffKey)?.events.some((evt) => evt.pubkey === pubkey)
|
||||
: false
|
||||
|
||||
let replyCount = 0
|
||||
const replies = [...(repliesMap.get(key)?.events || [])]
|
||||
const replies = [...(repliesMap.get(stuffKey)?.events || [])]
|
||||
while (replies.length > 0) {
|
||||
const reply = replies.pop()
|
||||
if (!reply) break
|
||||
|
|
@ -48,7 +49,7 @@ export default function ReplyButton({ event }: { event: Event }) {
|
|||
}
|
||||
|
||||
return { replyCount, hasReplied }
|
||||
}, [repliesMap, event, hideUntrustedInteractions])
|
||||
}, [repliesMap, event, stuffKey, hideUntrustedInteractions])
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
|
|
@ -69,7 +70,7 @@ export default function ReplyButton({ event }: { event: Event }) {
|
|||
<MessageCircle />
|
||||
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
|
||||
</button>
|
||||
<PostEditor parentEvent={event} open={open} setOpen={setOpen} />
|
||||
<PostEditor parentStuff={stuff} open={open} setOpen={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,14 +6,15 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { createRepostDraftEvent } from '@/lib/draft-event'
|
||||
import { getNoteBech32Id } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import { Loader, PencilLine, Repeat } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
|
@ -21,24 +22,28 @@ import { useTranslation } from 'react-i18next'
|
|||
import PostEditor from '../PostEditor'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function RepostButton({ event }: { event: Event }) {
|
||||
export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const { publish, checkLogin, pubkey } = useNostr()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const { event, stuffKey } = useStuff(stuff)
|
||||
const noteStats = useStuffStatsById(stuffKey)
|
||||
const [reposting, setReposting] = useState(false)
|
||||
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
const { repostCount, hasReposted } = useMemo(() => {
|
||||
// external content
|
||||
if (!event) return { repostCount: 0, hasReposted: false }
|
||||
|
||||
return {
|
||||
repostCount: hideUntrustedInteractions
|
||||
? noteStats?.reposts?.filter((repost) => isUserTrusted(repost.pubkey)).length
|
||||
: noteStats?.reposts?.length,
|
||||
hasReposted: pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false
|
||||
}
|
||||
}, [noteStats, event.id, hideUntrustedInteractions])
|
||||
const canRepost = !hasReposted && !reposting
|
||||
}, [noteStats, event, hideUntrustedInteractions])
|
||||
const canRepost = !hasReposted && !reposting && !!event
|
||||
|
||||
const repost = async () => {
|
||||
checkLogin(async () => {
|
||||
|
|
@ -51,7 +56,7 @@ export default function RepostButton({ event }: { event: Event }) {
|
|||
const hasReposted = noteStats?.repostPubkeySet?.has(pubkey)
|
||||
if (hasReposted) return
|
||||
if (!noteStats?.updatedAt) {
|
||||
const noteStats = await noteStatsService.fetchNoteStats(event, pubkey)
|
||||
const noteStats = await stuffStatsService.fetchStuffStats(stuff, pubkey)
|
||||
if (noteStats.repostPubkeySet?.has(pubkey)) {
|
||||
return
|
||||
}
|
||||
|
|
@ -59,7 +64,7 @@ export default function RepostButton({ event }: { event: Event }) {
|
|||
|
||||
const repost = createRepostDraftEvent(event)
|
||||
const evt = await publish(repost)
|
||||
noteStatsService.updateNoteStatsByEvents([evt])
|
||||
stuffStatsService.updateStuffStatsByEvents([evt])
|
||||
} catch (error) {
|
||||
console.error('repost failed', error)
|
||||
} finally {
|
||||
|
|
@ -72,11 +77,14 @@ export default function RepostButton({ event }: { event: Event }) {
|
|||
const trigger = (
|
||||
<button
|
||||
className={cn(
|
||||
'flex gap-1 items-center enabled:hover:text-lime-500 px-3 h-full',
|
||||
'flex gap-1 items-center px-3 h-full enabled:hover:text-lime-500 disabled:text-muted-foreground/40',
|
||||
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
|
||||
)}
|
||||
disabled={!event}
|
||||
title={t('Repost')}
|
||||
onClick={() => {
|
||||
if (!event) return
|
||||
|
||||
if (isSmallScreen) {
|
||||
setIsDrawerOpen(true)
|
||||
}
|
||||
|
|
@ -87,6 +95,10 @@ export default function RepostButton({ event }: { event: Event }) {
|
|||
</button>
|
||||
)
|
||||
|
||||
if (!event) {
|
||||
return trigger
|
||||
}
|
||||
|
||||
const postEditor = (
|
||||
<PostEditor
|
||||
open={isPostDialogOpen}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { toRelay } from '@/lib/link'
|
||||
import { simplifyUrl } from '@/lib/url'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
|
|
@ -19,24 +20,29 @@ import { useEffect, useState } from 'react'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
|
||||
export default function SeenOnButton({ event }: { event: Event }) {
|
||||
export default function SeenOnButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { push } = useSecondaryPage()
|
||||
const { event } = useStuff(stuff)
|
||||
const [relays, setRelays] = useState<string[]>([])
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!event) return
|
||||
|
||||
const seenOn = client.getSeenEventRelayUrls(event.id)
|
||||
setRelays(seenOn)
|
||||
}, [])
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary pl-3 h-full"
|
||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary pl-3 h-full disabled:text-muted-foreground/40"
|
||||
title={t('Seen on')}
|
||||
disabled={relays.length === 0}
|
||||
onClick={() => {
|
||||
if (!event) return
|
||||
|
||||
if (isSmallScreen) {
|
||||
setIsDrawerOpen(true)
|
||||
}
|
||||
|
|
@ -47,6 +53,10 @@ export default function SeenOnButton({ event }: { event: Event }) {
|
|||
</button>
|
||||
)
|
||||
|
||||
if (relays.length === 0) {
|
||||
return trigger
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<>
|
||||
|
|
@ -76,6 +86,7 @@ export default function SeenOnButton({ event }: { event: Event }) {
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { formatAmount } from '@/lib/lightning'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
|
|
@ -7,14 +8,15 @@ import { useMemo, useState } from 'react'
|
|||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import ZapDialog from '../ZapDialog'
|
||||
|
||||
export default function TopZaps({ event }: { event: Event }) {
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
export default function TopZaps({ stuff }: { stuff: Event | string }) {
|
||||
const { event, stuffKey } = useStuff(stuff)
|
||||
const noteStats = useStuffStatsById(stuffKey)
|
||||
const [zapIndex, setZapIndex] = useState(-1)
|
||||
const topZaps = useMemo(() => {
|
||||
return noteStats?.zaps?.sort((a, b) => b.amount - a.amount).slice(0, 10) || []
|
||||
}, [noteStats])
|
||||
|
||||
if (!topZaps.length) return null
|
||||
if (!topZaps.length || !event) return null
|
||||
|
||||
return (
|
||||
<ScrollArea className="pb-2 mb-1">
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import { LONG_PRESS_THRESHOLD } from '@/constants'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { getLightningAddressFromProfile } from '@/lib/lightning'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useZap } from '@/providers/ZapProvider'
|
||||
import client from '@/services/client.service'
|
||||
import lightning from '@/services/lightning.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import { Loader, Zap } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
|
@ -14,10 +15,11 @@ import { useTranslation } from 'react-i18next'
|
|||
import { toast } from 'sonner'
|
||||
import ZapDialog from '../ZapDialog'
|
||||
|
||||
export default function ZapButton({ event }: { event: Event }) {
|
||||
export default function ZapButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { checkLogin, pubkey } = useNostr()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const { event, stuffKey } = useStuff(stuff)
|
||||
const noteStats = useStuffStatsById(stuffKey)
|
||||
const { defaultZapSats, defaultZapComment, quickZap } = useZap()
|
||||
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
|
||||
const [openZapDialog, setOpenZapDialog] = useState(false)
|
||||
|
|
@ -33,6 +35,11 @@ export default function ZapButton({ event }: { event: Event }) {
|
|||
const isLongPressRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!event) {
|
||||
setDisable(true)
|
||||
return
|
||||
}
|
||||
|
||||
client.fetchProfile(event.pubkey).then((profile) => {
|
||||
if (!profile) return
|
||||
const lightningAddress = getLightningAddressFromProfile(profile)
|
||||
|
|
@ -45,7 +52,7 @@ export default function ZapButton({ event }: { event: Event }) {
|
|||
if (!pubkey) {
|
||||
throw new Error('You need to be logged in to zap')
|
||||
}
|
||||
if (zapping) return
|
||||
if (zapping || !event) return
|
||||
|
||||
setZapping(true)
|
||||
const zapResult = await lightning.zap(pubkey, event, defaultZapSats, defaultZapComment)
|
||||
|
|
@ -53,7 +60,7 @@ export default function ZapButton({ event }: { event: Event }) {
|
|||
if (!zapResult) {
|
||||
return
|
||||
}
|
||||
noteStatsService.addZap(
|
||||
stuffStatsService.addZap(
|
||||
pubkey,
|
||||
event.id,
|
||||
zapResult.invoice,
|
||||
|
|
@ -128,11 +135,8 @@ export default function ZapButton({ event }: { event: Event }) {
|
|||
<>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1 select-none px-3 h-full',
|
||||
hasZapped ? 'text-yellow-400' : 'text-muted-foreground',
|
||||
disable
|
||||
? 'cursor-not-allowed text-muted-foreground/40'
|
||||
: 'cursor-pointer enabled:hover:text-yellow-400'
|
||||
'flex items-center gap-1 select-none px-3 h-full cursor-pointer enabled:hover:text-yellow-400 disabled:text-muted-foreground/40 disabled:cursor-default',
|
||||
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
|
||||
)}
|
||||
title={t('Zap')}
|
||||
disabled={disable || zapping}
|
||||
|
|
@ -149,15 +153,17 @@ export default function ZapButton({ event }: { event: Event }) {
|
|||
)}
|
||||
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
|
||||
</button>
|
||||
<ZapDialog
|
||||
open={openZapDialog}
|
||||
setOpen={(open) => {
|
||||
setOpenZapDialog(open)
|
||||
setZapping(open)
|
||||
}}
|
||||
pubkey={event.pubkey}
|
||||
event={event}
|
||||
/>
|
||||
{event && (
|
||||
<ZapDialog
|
||||
open={openZapDialog}
|
||||
setOpen={(open) => {
|
||||
setOpenZapDialog(open)
|
||||
setZapping(open)
|
||||
}}
|
||||
pubkey={event.pubkey}
|
||||
event={event}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import BookmarkButton from '../BookmarkButton'
|
||||
|
|
@ -13,14 +14,14 @@ import SeenOnButton from './SeenOnButton'
|
|||
import TopZaps from './TopZaps'
|
||||
import ZapButton from './ZapButton'
|
||||
|
||||
export default function NoteStats({
|
||||
event,
|
||||
export default function StuffStats({
|
||||
stuff,
|
||||
className,
|
||||
classNames,
|
||||
fetchIfNotExisting = false,
|
||||
displayTopZapsAndLikes = false
|
||||
}: {
|
||||
event: Event
|
||||
stuff: Event | string
|
||||
className?: string
|
||||
classNames?: {
|
||||
buttonBar?: string
|
||||
|
|
@ -31,11 +32,12 @@ export default function NoteStats({
|
|||
const { isSmallScreen } = useScreenSize()
|
||||
const { pubkey } = useNostr()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { event } = useStuff(stuff)
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchIfNotExisting) return
|
||||
setLoading(true)
|
||||
noteStatsService.fetchNoteStats(event, pubkey).finally(() => setLoading(false))
|
||||
stuffStatsService.fetchStuffStats(stuff, pubkey).finally(() => setLoading(false))
|
||||
}, [event, fetchIfNotExisting])
|
||||
|
||||
if (isSmallScreen) {
|
||||
|
|
@ -43,8 +45,8 @@ export default function NoteStats({
|
|||
<div className={cn('select-none', className)}>
|
||||
{displayTopZapsAndLikes && (
|
||||
<>
|
||||
<TopZaps event={event} />
|
||||
<Likes event={event} />
|
||||
<TopZaps stuff={stuff} />
|
||||
<Likes stuff={stuff} />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
|
|
@ -55,12 +57,12 @@ export default function NoteStats({
|
|||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ReplyButton event={event} />
|
||||
<RepostButton event={event} />
|
||||
<LikeButton event={event} />
|
||||
<ZapButton event={event} />
|
||||
<BookmarkButton event={event} />
|
||||
<SeenOnButton event={event} />
|
||||
<ReplyButton stuff={stuff} />
|
||||
<RepostButton stuff={stuff} />
|
||||
<LikeButton stuff={stuff} />
|
||||
<ZapButton stuff={stuff} />
|
||||
<BookmarkButton stuff={stuff} />
|
||||
<SeenOnButton stuff={stuff} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -70,8 +72,8 @@ export default function NoteStats({
|
|||
<div className={cn('select-none', className)}>
|
||||
{displayTopZapsAndLikes && (
|
||||
<>
|
||||
<TopZaps event={event} />
|
||||
<Likes event={event} />
|
||||
<TopZaps stuff={stuff} />
|
||||
<Likes stuff={stuff} />
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-between h-5 [&_svg]:size-4">
|
||||
|
|
@ -79,14 +81,14 @@ export default function NoteStats({
|
|||
className={cn('flex items-center', loading ? 'animate-pulse' : '')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ReplyButton event={event} />
|
||||
<RepostButton event={event} />
|
||||
<LikeButton event={event} />
|
||||
<ZapButton event={event} />
|
||||
<ReplyButton stuff={stuff} />
|
||||
<RepostButton stuff={stuff} />
|
||||
<LikeButton stuff={stuff} />
|
||||
<ZapButton stuff={stuff} />
|
||||
</div>
|
||||
<div className="flex items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<BookmarkButton event={event} />
|
||||
<SeenOnButton event={event} />
|
||||
<BookmarkButton stuff={stuff} />
|
||||
<SeenOnButton stuff={stuff} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
149
src/components/XEmbeddedPost/index.tsx
Normal file
149
src/components/XEmbeddedPost/index.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toExternalContent } from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useTheme } from '@/providers/ThemeProvider'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ExternalLink from '../ExternalLink'
|
||||
|
||||
export default function XEmbeddedPost({
|
||||
url,
|
||||
className,
|
||||
mustLoad = false,
|
||||
embedded = true
|
||||
}: {
|
||||
url: string
|
||||
className?: string
|
||||
mustLoad?: boolean
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const { autoLoadMedia } = useContentPolicy()
|
||||
const { push } = useSecondaryPage()
|
||||
const [display, setDisplay] = useState(autoLoadMedia)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const { tweetId } = useMemo(() => parseXUrl(url), [url])
|
||||
const loadingRef = useRef<boolean>(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoadMedia) {
|
||||
setDisplay(true)
|
||||
} else {
|
||||
setDisplay(false)
|
||||
}
|
||||
}, [autoLoadMedia])
|
||||
|
||||
useEffect(() => {
|
||||
if (!tweetId || !containerRef.current || (!mustLoad && !display) || loadingRef.current) return
|
||||
loadingRef.current = true
|
||||
|
||||
// Load Twitter widgets script if not already loaded
|
||||
if (!window.twttr) {
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://platform.twitter.com/widgets.js'
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
embedTweet()
|
||||
}
|
||||
script.onerror = () => {
|
||||
setError(true)
|
||||
loadingRef.current = false
|
||||
}
|
||||
document.body.appendChild(script)
|
||||
} else {
|
||||
embedTweet()
|
||||
}
|
||||
|
||||
function embedTweet() {
|
||||
if (!containerRef.current || !window.twttr || !tweetId) return
|
||||
|
||||
// Clear container
|
||||
containerRef.current.innerHTML = ''
|
||||
|
||||
window.twttr.widgets
|
||||
.createTweet(tweetId, containerRef.current, {
|
||||
theme: theme === 'light' ? 'light' : 'dark',
|
||||
dnt: true, // Do not track
|
||||
conversation: 'none' // Hide conversation thread
|
||||
})
|
||||
.then((element: HTMLElement | undefined) => {
|
||||
if (element) {
|
||||
setTimeout(() => setLoaded(true), 100)
|
||||
} else {
|
||||
setError(true)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError(true)
|
||||
})
|
||||
.finally(() => {
|
||||
loadingRef.current = false
|
||||
})
|
||||
}
|
||||
}, [tweetId, display, mustLoad, theme])
|
||||
|
||||
const handleViewComments = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
push(toExternalContent(url))
|
||||
},
|
||||
[url, push]
|
||||
)
|
||||
|
||||
if (error || !tweetId) {
|
||||
return <ExternalLink url={url} />
|
||||
}
|
||||
|
||||
if (!mustLoad && !display) {
|
||||
return (
|
||||
<div
|
||||
className="text-primary hover:underline truncate w-fit cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDisplay(true)
|
||||
}}
|
||||
>
|
||||
[{t('Click to load X post')}]
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative group', className)}
|
||||
style={{
|
||||
maxWidth: '550px',
|
||||
minHeight: '225px'
|
||||
}}
|
||||
>
|
||||
<div ref={containerRef} className="cursor-pointer" onClick={handleViewComments} />
|
||||
{!loaded && <Skeleton className="absolute inset-0 w-full h-full rounded-xl" />}
|
||||
{loaded && embedded && (
|
||||
/* Hover overlay mask */
|
||||
<div
|
||||
className="absolute inset-0 bg-muted/30 backdrop-blur-md opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center cursor-pointer rounded-xl"
|
||||
onClick={handleViewComments}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<MessageCircle className="size-12" strokeWidth={1.5} />
|
||||
<span className="text-lg font-medium">{t('View Nostr comments')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function parseXUrl(url: string): { tweetId: string | null } {
|
||||
const pattern = /(?:twitter\.com|x\.com)\/(?:#!\/)?(?:\w+)\/status(?:es)?\/(\d+)/i
|
||||
const match = url.match(pattern)
|
||||
return {
|
||||
tweetId: match ? match[1] : null
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ import { useNostr } from '@/providers/NostrProvider'
|
|||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useZap } from '@/providers/ZapProvider'
|
||||
import lightning from '@/services/lightning.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
|
@ -189,7 +189,7 @@ function ZapDialogContent({
|
|||
return
|
||||
}
|
||||
if (event) {
|
||||
noteStatsService.addZap(pubkey, event.id, zapResult.invoice, sats, comment)
|
||||
stuffStatsService.addZap(pubkey, event.id, zapResult.invoice, sats, comment)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`${t('Zap failed')}: ${(error as Error).message}`)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { formatAmount } from '@/lib/lightning'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
|
|
@ -19,7 +19,7 @@ export default function ZapList({ event }: { event: Event }) {
|
|||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const noteStats = useStuffStatsById(event.id)
|
||||
const filteredZaps = useMemo(() => {
|
||||
return (noteStats?.zaps ?? []).sort((a, b) => b.amount - a.amount)
|
||||
}, [noteStats, event.id])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue