feat: add support for commenting and reacting on external content

This commit is contained in:
codytseng 2025-11-15 16:26:19 +08:00
parent 5ba5c26fcd
commit 0bb62dd3fb
76 changed files with 1635 additions and 639 deletions

View file

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

View file

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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