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

@@ -386,6 +386,10 @@ Secondary pages appear in the right column (or full screen on mobile) and suppor
On mobile devices or single-column layouts, primary pages occupy the full screen, while secondary pages are accessed via stack navigation. When navigating to another primary page, it will clear the secondary page stack. On mobile devices or single-column layouts, primary pages occupy the full screen, while secondary pages are accessed via stack navigation. When navigating to another primary page, it will clear the secondary page stack.
### How to Parse and Render Content
First, use the `parseContent` method in `src/lib/content-parser.ts` to parse the content. It supports passing different parsers to parse only the needed content for different scenarios. You will get an array of `TEmbeddedNode[]`, and render the content according to the type of these nodes in order. If you need to support new node types, you can add new parsing methods in `src/lib/content-parser.ts`. If you want to recognize specific URLs as special types of nodes, you can extend the `EmbeddedUrlParser` method in `src/lib/content-parser.ts`. A complete usage example can be found in `src/components/Content/index.tsx`.
### Adding New State Management ### Adding New State Management
1. For global state, create a new Provider in `src/providers/` 1. For global state, create a new Provider in `src/providers/`

View File

@@ -1,3 +1,4 @@
import { useStuff } from '@/hooks/useStuff'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { useBookmarks } from '@/providers/BookmarksProvider' import { useBookmarks } from '@/providers/BookmarksProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@@ -7,12 +8,15 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
export default function BookmarkButton({ event }: { event: Event }) { export default function BookmarkButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey: accountPubkey, bookmarkListEvent, checkLogin } = useNostr() const { pubkey: accountPubkey, bookmarkListEvent, checkLogin } = useNostr()
const { addBookmark, removeBookmark } = useBookmarks() const { addBookmark, removeBookmark } = useBookmarks()
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const { event } = useStuff(stuff)
const isBookmarked = useMemo(() => { const isBookmarked = useMemo(() => {
if (!event) return false
const isReplaceable = isReplaceableEvent(event.kind) const isReplaceable = isReplaceableEvent(event.kind)
const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id
@@ -26,7 +30,7 @@ export default function BookmarkButton({ event }: { event: Event }) {
const handleBookmark = async (e: React.MouseEvent) => { const handleBookmark = async (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
checkLogin(async () => { checkLogin(async () => {
if (isBookmarked) return if (isBookmarked || !event) return
setUpdating(true) setUpdating(true)
try { try {
@@ -42,7 +46,7 @@ export default function BookmarkButton({ event }: { event: Event }) {
const handleRemoveBookmark = async (e: React.MouseEvent) => { const handleRemoveBookmark = async (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
checkLogin(async () => { checkLogin(async () => {
if (!isBookmarked) return if (!isBookmarked || !event) return
setUpdating(true) setUpdating(true)
try { try {
@@ -59,9 +63,9 @@ export default function BookmarkButton({ event }: { event: Event }) {
<button <button
className={`flex items-center gap-1 ${ className={`flex items-center gap-1 ${
isBookmarked ? 'text-rose-400' : 'text-muted-foreground' 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} onClick={isBookmarked ? handleRemoveBookmark : handleBookmark}
disabled={updating} disabled={!event || updating}
title={isBookmarked ? t('Remove bookmark') : t('Bookmark')} title={isBookmarked ? t('Remove bookmark') : t('Bookmark')}
> >
{updating ? ( {updating ? (

View File

@@ -28,6 +28,7 @@ import ExternalLink from '../ExternalLink'
import ImageGallery from '../ImageGallery' import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer' import MediaPlayer from '../MediaPlayer'
import WebPreview from '../WebPreview' import WebPreview from '../WebPreview'
import XEmbeddedPost from '../XEmbeddedPost'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer' import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
export default function Content({ 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 return null
})} })}
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />} {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 { truncateUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { ExternalLink as ExternalLinkIcon, MessageSquare } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function ExternalLink({ url, className }: { url: string; className?: string }) { export default function ExternalLink({ url, className }: { url: string; className?: string }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const displayUrl = useMemo(() => truncateUrl(url), [url]) 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 ( return (
<a <DropdownMenu>
className={cn('text-primary hover:underline', className)} <DropdownMenuTrigger asChild>
href={url} <span
target="_blank" className={cn('cursor-pointer text-primary hover:underline', className)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
rel="noreferrer" title={url}
title={url} >
> {displayUrl}
{displayUrl} </span>
</a> </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 { useSecondaryPage } from '@/PageManager'
import { ExtendedKind, SUPPORTED_KINDS } from '@/constants' import { ExtendedKind, SUPPORTED_KINDS } from '@/constants'
import { getParentBech32Id, isNsfwEvent } from '@/lib/event' import { getParentStuff, isNsfwEvent } from '@/lib/event'
import { toNote } from '@/lib/link' import { toExternalContent, toNote } from '@/lib/link'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
@@ -22,7 +22,6 @@ import CommunityDefinition from './CommunityDefinition'
import EmojiPack from './EmojiPack' import EmojiPack from './EmojiPack'
import GroupMetadata from './GroupMetadata' import GroupMetadata from './GroupMetadata'
import Highlight from './Highlight' import Highlight from './Highlight'
import IValue from './IValue'
import LiveEvent from './LiveEvent' import LiveEvent from './LiveEvent'
import LongFormArticle from './LongFormArticle' import LongFormArticle from './LongFormArticle'
import LongFormArticlePreview from './LongFormArticlePreview' import LongFormArticlePreview from './LongFormArticlePreview'
@@ -51,10 +50,9 @@ export default function Note({
}) { }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const parentEventId = useMemo( const { parentEventId, parentExternalContent } = useMemo(() => {
() => (hideParentNotePreview ? undefined : getParentBech32Id(event)), return getParentStuff(event)
[event, hideParentNotePreview] }, [event])
)
const { defaultShowNsfw } = useContentPolicy() const { defaultShowNsfw } = useContentPolicy()
const [showNsfw, setShowNsfw] = useState(false) const [showNsfw, setShowNsfw] = useState(false)
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
@@ -141,17 +139,21 @@ export default function Note({
)} )}
</div> </div>
</div> </div>
{parentEventId && ( {!hideParentNotePreview && (
<ParentNotePreview <ParentNotePreview
eventId={parentEventId} eventId={parentEventId}
externalContent={parentExternalContent}
className="mt-2" className="mt-2"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
push(toNote(parentEventId)) if (parentExternalContent) {
push(toExternalContent(parentExternalContent))
} else if (parentEventId) {
push(toNote(parentEventId))
}
}} }}
/> />
)} )}
<IValue event={event} className="mt-2" />
{content} {content}
</div> </div>
) )

View File

@@ -5,7 +5,7 @@ import { useSecondaryPage } from '@/PageManager'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import Collapsible from '../Collapsible' import Collapsible from '../Collapsible'
import Note from '../Note' import Note from '../Note'
import NoteStats from '../NoteStats' import StuffStats from '../StuffStats'
import PinnedButton from './PinnedButton' import PinnedButton from './PinnedButton'
import RepostDescription from './RepostDescription' import RepostDescription from './RepostDescription'
@@ -45,7 +45,7 @@ export default function MainNoteCard({
originalNoteId={originalNoteId} originalNoteId={originalNoteId}
/> />
</Collapsible> </Collapsible>
{!embedded && <NoteStats className="mt-3 px-4" event={event} />} {!embedded && <StuffStats className="mt-3 px-4" stuff={event} />}
</div> </div>
{!embedded && <Separator />} {!embedded && <Separator />}
</div> </div>

View File

@@ -21,13 +21,13 @@ export default function NoteInteractions({
let list let list
switch (type) { switch (type) {
case 'replies': case 'replies':
list = <ReplyNoteList index={pageIndex} event={event} /> list = <ReplyNoteList index={pageIndex} stuff={event} />
break break
case 'quotes': case 'quotes':
list = <QuoteList event={event} /> list = <QuoteList event={event} />
break break
case 'reactions': case 'reactions':
list = <ReactionList event={event} /> list = <ReactionList stuff={event} />
break break
case 'reposts': case 'reposts':
list = <RepostList event={event} /> list = <RepostList event={event} />

View File

@@ -1,11 +1,6 @@
import NewNotesButton from '@/components/NewNotesButton' import NewNotesButton from '@/components/NewNotesButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event'
getEventKey,
getEventKeyFromTag,
isMentioningMutedUsers,
isReplyNoteEvent
} from '@/lib/event'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
@@ -173,7 +168,7 @@ const NoteList = forwardRef(
const targetTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('e')) const targetTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('e'))
if (targetTag) { if (targetTag) {
const targetEventKey = getEventKeyFromTag(targetTag) const targetEventKey = getKeyFromTag(targetTag)
if (targetEventKey) { if (targetEventKey) {
// Add to reposters map // Add to reposters map
const reposters = repostersMap.get(targetEventKey) const reposters = repostersMap.get(targetEventKey)

View File

@@ -1,7 +1,7 @@
import ParentNotePreview from '@/components/ParentNotePreview' import ParentNotePreview from '@/components/ParentNotePreview'
import { NOTIFICATION_LIST_STYLE } from '@/constants' import { NOTIFICATION_LIST_STYLE } from '@/constants'
import { getEmbeddedPubkeys, getParentBech32Id } from '@/lib/event' import { getEmbeddedPubkeys, getParentStuff } from '@/lib/event'
import { toNote } from '@/lib/link' import { toExternalContent, toNote } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider'
@@ -27,7 +27,9 @@ export function MentionNotification({
const mentions = getEmbeddedPubkeys(notification) const mentions = getEmbeddedPubkeys(notification)
return mentions.includes(pubkey) return mentions.includes(pubkey)
}, [pubkey, notification]) }, [pubkey, notification])
const parentEventId = useMemo(() => getParentBech32Id(notification), [notification]) const { parentEventId, parentExternalContent } = useMemo(() => {
return getParentStuff(notification)
}, [notification])
return ( return (
<Notification <Notification
@@ -45,14 +47,18 @@ export function MentionNotification({
sentAt={notification.created_at} sentAt={notification.created_at}
targetEvent={notification} targetEvent={notification}
middle={ middle={
notificationListStyle === NOTIFICATION_LIST_STYLE.DETAILED && notificationListStyle === NOTIFICATION_LIST_STYLE.DETAILED && (
parentEventId && (
<ParentNotePreview <ParentNotePreview
eventId={parentEventId} eventId={parentEventId}
externalContent={parentExternalContent}
className="" className=""
onClick={(e) => { onClick={(e) => {
e.stopPropagation() 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 ContentPreview from '@/components/ContentPreview'
import { FormattedTimestamp } from '@/components/FormattedTimestamp' import { FormattedTimestamp } from '@/components/FormattedTimestamp'
import NoteStats from '@/components/NoteStats' import StuffStats from '@/components/StuffStats'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username' import Username from '@/components/Username'
@@ -120,7 +120,7 @@ export default function Notification({
/> />
)} )}
<FormattedTimestamp timestamp={sentAt} className="shrink-0 text-muted-foreground text-sm" /> <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>
</div> </div>
) )

View File

@@ -5,7 +5,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useNotification } from '@/providers/NotificationProvider' import { useNotification } from '@/providers/NotificationProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import client from '@/services/client.service' 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 { TNotificationType } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { NostrEvent, kinds, matchFilter } from 'nostr-tools' import { NostrEvent, kinds, matchFilter } from 'nostr-tools'
@@ -94,7 +94,7 @@ const NotificationList = forwardRef((_, ref) => {
return oldEvents return oldEvents
} }
noteStatsService.updateNoteStatsByEvents([event]) stuffStatsService.updateStuffStatsByEvents([event])
if (index === -1) { if (index === -1) {
return [...oldEvents, event] return [...oldEvents, event]
} }
@@ -138,7 +138,7 @@ const NotificationList = forwardRef((_, ref) => {
if (eosed) { if (eosed) {
setLoading(false) setLoading(false)
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined) setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
noteStatsService.updateNoteStatsByEvents(events) stuffStatsService.updateStuffStatsByEvents(events)
} }
}, },
onNew: (event) => { onNew: (event) => {

View File

@@ -7,16 +7,37 @@ import UserAvatar from '../UserAvatar'
export default function ParentNotePreview({ export default function ParentNotePreview({
eventId, eventId,
externalContent,
className, className,
onClick onClick
}: { }: {
eventId: string eventId?: string
externalContent?: string
className?: string className?: string
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { event, isFetching } = useFetchEvent(eventId) 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) { if (isFetching) {
return ( return (
<div <div

View File

@@ -24,15 +24,16 @@ import PostOptions from './PostOptions'
import PostRelaySelector from './PostRelaySelector' import PostRelaySelector from './PostRelaySelector'
import PostTextarea, { TPostTextareaHandle } from './PostTextarea' import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
import Uploader from './Uploader' import Uploader from './Uploader'
import { BIG_RELAY_URLS } from '@/constants'
export default function PostContent({ export default function PostContent({
defaultContent = '', defaultContent = '',
parentEvent, parentStuff,
close, close,
openFrom openFrom
}: { }: {
defaultContent?: string defaultContent?: string
parentEvent?: Event parentStuff?: Event | string
close: () => void close: () => void
openFrom?: string[] openFrom?: string[]
}) { }) {
@@ -45,6 +46,10 @@ export default function PostContent({
const [uploadProgresses, setUploadProgresses] = useState< const [uploadProgresses, setUploadProgresses] = useState<
{ file: File; progress: number; cancel: () => void }[] { file: File; progress: number; cancel: () => void }[]
>([]) >([])
const parentEvent = useMemo(
() => (parentStuff && typeof parentStuff !== 'string' ? parentStuff : undefined),
[parentStuff]
)
const [showMoreOptions, setShowMoreOptions] = useState(false) const [showMoreOptions, setShowMoreOptions] = useState(false)
const [addClientTag, setAddClientTag] = useState(false) const [addClientTag, setAddClientTag] = useState(false)
const [mentions, setMentions] = useState<string[]>([]) const [mentions, setMentions] = useState<string[]>([])
@@ -85,7 +90,7 @@ export default function PostContent({
isFirstRender.current = false isFirstRender.current = false
const cachedSettings = postEditorCache.getPostSettingsCache({ const cachedSettings = postEditorCache.getPostSettingsCache({
defaultContent, defaultContent,
parentEvent parentStuff
}) })
if (cachedSettings) { if (cachedSettings) {
setIsNsfw(cachedSettings.isNsfw ?? false) setIsNsfw(cachedSettings.isNsfw ?? false)
@@ -103,7 +108,7 @@ export default function PostContent({
return return
} }
postEditorCache.setPostSettingsCache( postEditorCache.setPostSettingsCache(
{ defaultContent, parentEvent }, { defaultContent, parentStuff },
{ {
isNsfw, isNsfw,
isPoll, isPoll,
@@ -111,7 +116,7 @@ export default function PostContent({
addClientTag addClientTag
} }
) )
}, [defaultContent, parentEvent, isNsfw, isPoll, pollCreateData, addClientTag]) }, [defaultContent, parentStuff, isNsfw, isPoll, pollCreateData, addClientTag])
const post = async (e?: React.MouseEvent) => { const post = async (e?: React.MouseEvent) => {
e?.stopPropagation() e?.stopPropagation()
@@ -121,8 +126,9 @@ export default function PostContent({
setPosting(true) setPosting(true)
try { try {
const draftEvent = const draftEvent =
parentEvent && parentEvent.kind !== kinds.ShortTextNote parentStuff &&
? await createCommentDraftEvent(text, parentEvent, mentions, { (typeof parentStuff === 'string' || parentStuff.kind !== kinds.ShortTextNote)
? await createCommentDraftEvent(text, parentStuff, mentions, {
addClientTag, addClientTag,
protectedEvent: isProtectedEvent, protectedEvent: isProtectedEvent,
isNsfw isNsfw
@@ -139,12 +145,17 @@ export default function PostContent({
isNsfw isNsfw
}) })
const _additionalRelayUrls = [...additionalRelayUrls]
if (parentStuff && typeof parentStuff === 'string') {
_additionalRelayUrls.push(...BIG_RELAY_URLS)
}
const newEvent = await publish(draftEvent, { const newEvent = await publish(draftEvent, {
specifiedRelayUrls: isProtectedEvent ? additionalRelayUrls : undefined, specifiedRelayUrls: isProtectedEvent ? additionalRelayUrls : undefined,
additionalRelayUrls: isPoll ? pollCreateData.relays : additionalRelayUrls, additionalRelayUrls: isPoll ? pollCreateData.relays : _additionalRelayUrls,
minPow minPow
}) })
postEditorCache.clearPostCache({ defaultContent, parentEvent }) postEditorCache.clearPostCache({ defaultContent, parentStuff })
deleteDraftEventCache(draftEvent) deleteDraftEventCache(draftEvent)
addReplies([newEvent]) addReplies([newEvent])
close() close()
@@ -166,7 +177,7 @@ export default function PostContent({
} }
const handlePollToggle = () => { const handlePollToggle = () => {
if (parentEvent) return if (parentStuff) return
setIsPoll((prev) => !prev) setIsPoll((prev) => !prev)
} }
@@ -199,7 +210,7 @@ export default function PostContent({
text={text} text={text}
setText={setText} setText={setText}
defaultContent={defaultContent} defaultContent={defaultContent}
parentEvent={parentEvent} parentStuff={parentStuff}
onSubmit={() => post()} onSubmit={() => post()}
className={isPoll ? 'min-h-20' : 'min-h-52'} className={isPoll ? 'min-h-20' : 'min-h-52'}
onUploadStart={handleUploadStart} onUploadStart={handleUploadStart}
@@ -278,7 +289,7 @@ export default function PostContent({
</Button> </Button>
</EmojiPickerDialog> </EmojiPickerDialog>
)} )}
{!parentEvent && ( {!parentStuff && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -317,7 +328,7 @@ export default function PostContent({
</Button> </Button>
<Button type="submit" disabled={!canPost} onClick={post}> <Button type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />} {posting && <LoaderCircle className="animate-spin" />}
{parentEvent ? t('Reply') : t('Post')} {parentStuff ? t('Reply') : t('Post')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -345,7 +356,7 @@ export default function PostContent({
</Button> </Button>
<Button className="w-full" type="submit" disabled={!canPost} onClick={post}> <Button className="w-full" type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />} {posting && <LoaderCircle className="animate-spin" />}
{parentEvent ? t('Reply') : t('Post')} {parentStuff ? t('Reply') : t('Post')}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -34,7 +34,7 @@ const PostTextarea = forwardRef<
text: string text: string
setText: Dispatch<SetStateAction<string>> setText: Dispatch<SetStateAction<string>>
defaultContent?: string defaultContent?: string
parentEvent?: Event parentStuff?: Event | string
onSubmit?: () => void onSubmit?: () => void
className?: string className?: string
onUploadStart?: (file: File, cancel: () => void) => void onUploadStart?: (file: File, cancel: () => void) => void
@@ -47,7 +47,7 @@ const PostTextarea = forwardRef<
text = '', text = '',
setText, setText,
defaultContent, defaultContent,
parentEvent, parentStuff,
onSubmit, onSubmit,
className, className,
onUploadStart, onUploadStart,
@@ -103,10 +103,10 @@ const PostTextarea = forwardRef<
return parseEditorJsonToText(content.toJSON()) return parseEditorJsonToText(content.toJSON())
} }
}, },
content: postEditorCache.getPostContentCache({ defaultContent, parentEvent }), content: postEditorCache.getPostContentCache({ defaultContent, parentStuff }),
onUpdate(props) { onUpdate(props) {
setText(parseEditorJsonToText(props.editor.getJSON())) setText(parseEditorJsonToText(props.editor.getJSON()))
postEditorCache.setPostContentCache({ defaultContent, parentEvent }, props.editor.getJSON()) postEditorCache.setPostContentCache({ defaultContent, parentStuff }, props.editor.getJSON())
}, },
onCreate(props) { onCreate(props) {
setText(parseEditorJsonToText(props.editor.getJSON())) setText(parseEditorJsonToText(props.editor.getJSON()))

View File

@@ -1,12 +1,15 @@
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function Title({ parentEvent }: { parentEvent?: Event }) { export default function Title({ parentStuff }: { parentStuff?: Event | string }) {
const { t } = useTranslation() const { t } = useTranslation()
return parentEvent ? ( return parentStuff ? (
<div className="flex gap-2 items-center w-full"> <div className="flex gap-2 items-center w-full">
<div className="shrink-0">{t('Reply to')}</div> <div className="shrink-0">{t('Reply to')}</div>
{typeof parentStuff === 'string' && (
<div className="text-primary truncate">{parentStuff}</div>
)}
</div> </div>
) : ( ) : (
t('New Note') t('New Note')

View File

@@ -22,13 +22,13 @@ import Title from './Title'
export default function PostEditor({ export default function PostEditor({
defaultContent = '', defaultContent = '',
parentEvent, parentStuff,
open, open,
setOpen, setOpen,
openFrom openFrom
}: { }: {
defaultContent?: string defaultContent?: string
parentEvent?: Event parentStuff?: Event | string
open: boolean open: boolean
setOpen: Dispatch<boolean> setOpen: Dispatch<boolean>
openFrom?: string[] openFrom?: string[]
@@ -39,7 +39,7 @@ export default function PostEditor({
return ( return (
<PostContent <PostContent
defaultContent={defaultContent} defaultContent={defaultContent}
parentEvent={parentEvent} parentStuff={parentStuff}
close={() => setOpen(false)} close={() => setOpen(false)}
openFrom={openFrom} openFrom={openFrom}
/> />
@@ -64,7 +64,7 @@ export default function PostEditor({
<div className="space-y-4 px-2 py-6"> <div className="space-y-4 px-2 py-6">
<SheetHeader> <SheetHeader>
<SheetTitle className="text-start"> <SheetTitle className="text-start">
<Title parentEvent={parentEvent} /> <Title parentStuff={parentStuff} />
</SheetTitle> </SheetTitle>
<SheetDescription className="hidden" /> <SheetDescription className="hidden" />
</SheetHeader> </SheetHeader>
@@ -92,7 +92,7 @@ export default function PostEditor({
<div className="space-y-4 px-2 py-6"> <div className="space-y-4 px-2 py-6">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Title parentEvent={parentEvent} /> <Title parentStuff={parentStuff} />
</DialogTitle> </DialogTitle>
<DialogDescription className="hidden" /> <DialogDescription className="hidden" />
</DialogHeader> </DialogHeader>

View File

@@ -1,5 +1,5 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
@@ -11,20 +11,22 @@ import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05' import Nip05 from '../Nip05'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import Username from '../Username' import Username from '../Username'
import { useStuff } from '@/hooks/useStuff'
const SHOW_COUNT = 20 const SHOW_COUNT = 20
export default function ReactionList({ event }: { event: Event }) { export default function ReactionList({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const noteStats = useNoteStatsById(event.id) const { stuffKey } = useStuff(stuff)
const noteStats = useStuffStatsById(stuffKey)
const filteredLikes = useMemo(() => { const filteredLikes = useMemo(() => {
return (noteStats?.likes ?? []) return (noteStats?.likes ?? [])
.filter((like) => !hideUntrustedInteractions || isUserTrusted(like.pubkey)) .filter((like) => !hideUntrustedInteractions || isUserTrusted(like.pubkey))
.sort((a, b) => b.created_at - a.created_at) .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 [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null) const bottomRef = useRef<HTMLDivElement | null>(null)

View File

@@ -15,7 +15,7 @@ import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp' import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05' import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions' import NoteOptions from '../NoteOptions'
import NoteStats from '../NoteStats' import StuffStats from '../StuffStats'
import ParentNotePreview from '../ParentNotePreview' import ParentNotePreview from '../ParentNotePreview'
import TranslateButton from '../TranslateButton' import TranslateButton from '../TranslateButton'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
@@ -111,7 +111,7 @@ export default function ReplyNote({
</div> </div>
</div> </div>
</Collapsible> </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> </div>
) )
} }

View File

@@ -1,7 +1,7 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { import {
getEventKey, getEventKey,
getEventKeyFromTag, getKeyFromTag,
getParentTag, getParentTag,
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
getRootTag, getRootTag,
@@ -11,7 +11,7 @@ import {
isReplyNoteEvent isReplyNoteEvent
} from '@/lib/event' } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag' import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
@@ -23,16 +23,23 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { LoadingBar } from '../LoadingBar' import { LoadingBar } from '../LoadingBar'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
import { useStuff } from '@/hooks/useStuff'
type TRootInfo = type TRootInfo =
| { type: 'E'; id: string; pubkey: string } | { 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 } | { type: 'I'; id: string }
const LIMIT = 100 const LIMIT = 100
const SHOW_COUNT = 10 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 { t } = useTranslation()
const { push, currentIndex } = useSecondaryPage() const { push, currentIndex } = useSecondaryPage()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
@@ -40,13 +47,14 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined) const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
const { repliesMap, addReplies } = useReply() const { repliesMap, addReplies } = useReply()
const { event, externalContent, stuffKey } = useStuff(stuff)
const replies = useMemo(() => { const replies = useMemo(() => {
const replyKeySet = new Set<string>() const replyKeySet = new Set<string>()
const replyEvents: NEvent[] = [] const replyEvents: NEvent[] = []
const currentEventKey = getEventKey(event)
let parentEventKeys = [currentEventKey] let parentKeys = [stuffKey]
while (parentEventKeys.length > 0) { while (parentKeys.length > 0) {
const events = parentEventKeys.flatMap((key) => repliesMap.get(key)?.events || []) const events = parentKeys.flatMap((key) => repliesMap.get(key)?.events || [])
events.forEach((evt) => { events.forEach((evt) => {
const key = getEventKey(evt) const key = getEventKey(evt)
if (replyKeySet.has(key)) return if (replyKeySet.has(key)) return
@@ -56,10 +64,10 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
replyKeySet.add(key) replyKeySet.add(key)
replyEvents.push(evt) 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) return replyEvents.sort((a, b) => a.created_at - b.created_at)
}, [event.id, repliesMap]) }, [stuffKey, repliesMap])
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined) const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(undefined) const [until, setUntil] = useState<number | undefined>(undefined)
const [loading, setLoading] = useState<boolean>(false) const [loading, setLoading] = useState<boolean>(false)
@@ -70,15 +78,18 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
useEffect(() => { useEffect(() => {
const fetchRootEvent = async () => { const fetchRootEvent = async () => {
let root: TRootInfo = isReplaceableEvent(event.kind) if (!event && !externalContent) return
? {
type: 'A', let root: TRootInfo = event
id: getReplaceableCoordinateFromEvent(event), ? isReplaceableEvent(event.kind)
eventId: event.id, ? {
pubkey: event.pubkey, type: 'A',
relay: client.getEventHint(event.id) id: getReplaceableCoordinateFromEvent(event),
} pubkey: event.pubkey,
: { type: 'E', id: event.id, pubkey: event.pubkey } relay: client.getEventHint(event.id)
}
: { type: 'E', id: event.id, pubkey: event.pubkey }
: { type: 'I', id: externalContent! }
const rootTag = getRootTag(event) const rootTag = getRootTag(event)
if (rootTag?.type === 'e') { if (rootTag?.type === 'e') {
@@ -97,12 +108,9 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
} else if (rootTag?.type === 'a') { } else if (rootTag?.type === 'a') {
const [, coordinate, relay] = rootTag.tag const [, coordinate, relay] = rootTag.tag
const [, pubkey] = coordinate.split(':') const [, pubkey] = coordinate.split(':')
root = { type: 'A', id: coordinate, eventId: event.id, pubkey, relay } root = { type: 'A', id: coordinate, pubkey, relay }
} else { } else if (rootTag?.type === 'i') {
const rootITag = event.tags.find(tagNameEquals('I')) root = { type: 'I', id: rootTag.tag[1] }
if (rootITag) {
root = { type: 'I', id: rootITag[1] }
}
} }
setRootInfo(root) setRootInfo(root)
} }
@@ -116,13 +124,16 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
setLoading(true) setLoading(true)
try { try {
const relayList = await client.fetchRelayList( let relayUrls: string[] = []
(rootInfo as { pubkey?: string }).pubkey ?? event.pubkey const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey
) if (rootPubkey) {
const relayUrls = relayList.read.concat(BIG_RELAY_URLS).slice(0, 4) 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 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) const seenOn = client.getSeenEventRelayUrls(event.id)
relayUrls.concat(...seenOn) relayUrls.concat(...seenOn)
} }
@@ -136,7 +147,7 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
kinds: [kinds.ShortTextNote], kinds: [kinds.ShortTextNote],
limit: LIMIT limit: LIMIT
}) })
if (event.kind !== kinds.ShortTextNote) { if (event?.kind !== kinds.ShortTextNote) {
filters.push({ filters.push({
'#E': [rootInfo.id], '#E': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
@@ -269,7 +280,7 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
return ( return (
<div className="min-h-[80vh]"> <div className="min-h-[80vh]">
{loading && <LoadingBar />} {loading && <LoadingBar />}
{!loading && until && until > event.created_at && ( {!loading && until && (!event || until > event.created_at) && (
<div <div
className={`text-sm text-center text-muted-foreground border-b py-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`} className={`text-sm text-center text-muted-foreground border-b py-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
onClick={loadMore} 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 currentReplyKey = getEventKey(reply)
const parentTag = getParentTag(reply) const parentTag = getParentTag(reply)
const parentEventKey = parentTag ? getEventKeyFromTag(parentTag.tag) : undefined const parentKey = parentTag ? getKeyFromTag(parentTag.tag) : undefined
const parentEventId = parentTag const parentEventId = parentTag
? parentTag.type === 'e' ? parentTag.type === 'e'
? generateBech32IdFromETag(parentTag.tag) ? generateBech32IdFromETag(parentTag.tag)
: generateBech32IdFromATag(parentTag.tag) : parentTag.type === 'a'
? generateBech32IdFromATag(parentTag.tag)
: undefined
: undefined : undefined
return ( return (
<div <div
@@ -308,10 +321,10 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
> >
<ReplyNote <ReplyNote
event={reply} event={reply}
parentEventId={rootEventKey !== parentEventKey ? parentEventId : undefined} parentEventId={rootKey !== parentKey ? parentEventId : undefined}
onClickParent={() => { onClickParent={() => {
if (!parentEventKey) return if (!parentKey) return
highlightReply(parentEventKey, parentEventId) highlightReply(parentKey, parentEventId)
}} }}
highlight={highlightReplyKey === currentReplyKey} highlight={highlightReplyKey === currentReplyKey}
/> />

View File

@@ -1,5 +1,5 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
@@ -19,7 +19,7 @@ export default function RepostList({ event }: { event: Event }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const noteStats = useNoteStatsById(event.id) const noteStats = useStuffStatsById(event.id)
const filteredReposts = useMemo(() => { const filteredReposts = useMemo(() => {
return (noteStats?.reposts ?? []) return (noteStats?.reposts ?? [])
.filter((repost) => !hideUntrustedInteractions || isUserTrusted(repost.pubkey)) .filter((repost) => !hideUntrustedInteractions || isUserTrusted(repost.pubkey))

View File

@@ -1,6 +1,6 @@
import SearchInput from '@/components/SearchInput' import SearchInput from '@/components/SearchInput'
import { useSearchProfiles } from '@/hooks' import { useSearchProfiles } from '@/hooks'
import { toNote } from '@/lib/link' import { toExternalContent, toNote } from '@/lib/link'
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -8,7 +8,7 @@ import { useSecondaryPage } from '@/PageManager'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import modalManager from '@/services/modal-manager.service' import modalManager from '@/services/modal-manager.service'
import { TSearchParams } from '@/types' 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 { nip19 } from 'nostr-tools'
import { import {
forwardRef, forwardRef,
@@ -45,6 +45,9 @@ const SearchBar = forwardRef<
if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) { if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) {
return undefined return undefined
} }
if (!input.includes('.')) {
return undefined
}
try { try {
return normalizeUrl(input) return normalizeUrl(input)
} catch { } catch {
@@ -89,6 +92,8 @@ const SearchBar = forwardRef<
if (params.type === 'note') { if (params.type === 'note') {
push(toNote(params.search)) push(toNote(params.search))
} else if (params.type === 'externalContent') {
push(toExternalContent(params.search))
} else { } else {
onSearch(params) onSearch(params)
} }
@@ -128,8 +133,9 @@ const SearchBar = forwardRef<
setSelectableOptions([ setSelectableOptions([
{ type: 'notes', search }, { type: 'notes', search },
{ type: 'hashtag', search: hashtag, input: `#${hashtag}` },
...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []), ...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []),
{ type: 'externalContent', search, input },
{ type: 'hashtag', search: hashtag, input: `#${hashtag}` },
...profiles.map((profile) => ({ ...profiles.map((profile) => ({
type: 'profile', type: 'profile',
search: profile.npub, 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') { if (option.type === 'profiles') {
return ( return (
<Item <Item
@@ -322,10 +338,16 @@ function NormalItem({
onClick?: () => void onClick?: () => void
selected?: boolean selected?: boolean
}) { }) {
const { t } = useTranslation()
return ( return (
<Item onClick={onClick} selected={selected}> <Item onClick={onClick} selected={selected}>
<Search className="text-muted-foreground" /> <div className="size-10 flex justify-center items-center">
<div className="font-semibold truncate">{search}</div> <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> </Item>
) )
} }
@@ -339,10 +361,16 @@ function HashtagItem({
onClick?: () => void onClick?: () => void
selected?: boolean selected?: boolean
}) { }) {
const { t } = useTranslation()
return ( return (
<Item onClick={onClick} selected={selected}> <Item onClick={onClick} selected={selected}>
<Hash className="text-muted-foreground" /> <div className="size-10 flex justify-center items-center">
<div className="font-semibold truncate">{hashtag}</div> <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> </Item>
) )
} }
@@ -356,10 +384,16 @@ function NoteItem({
onClick?: () => void onClick?: () => void
selected?: boolean selected?: boolean
}) { }) {
const { t } = useTranslation()
return ( return (
<Item onClick={onClick} selected={selected}> <Item onClick={onClick} selected={selected}>
<Notebook className="text-muted-foreground" /> <div className="size-10 flex justify-center items-center">
<div className="font-semibold truncate">{id}</div> <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> </Item>
) )
} }
@@ -397,10 +431,39 @@ function RelayItem({
onClick?: () => void onClick?: () => void
selected?: boolean selected?: boolean
}) { }) {
const { t } = useTranslation()
return ( return (
<Item onClick={onClick} selected={selected}> <Item onClick={onClick} selected={selected}>
<Server className="text-muted-foreground" /> <div className="size-10 flex justify-center items-center">
<div className="font-semibold truncate">{url}</div> <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> </Item>
) )
} }

View File

@@ -4,13 +4,18 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { BIG_RELAY_URLS } from '@/constants'
import { createReactionDraftEvent } from '@/lib/draft-event' import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { useStuff } from '@/hooks/useStuff'
import {
createExternalContentReactionDraftEvent,
createReactionDraftEvent
} from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service' import stuffStatsService from '@/services/stuff-stats.service'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { Loader, SmilePlus } from 'lucide-react' import { Loader, SmilePlus } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@@ -21,15 +26,16 @@ import EmojiPicker from '../EmojiPicker'
import SuggestedEmojis from '../SuggestedEmojis' import SuggestedEmojis from '../SuggestedEmojis'
import { formatCount } from './utils' import { formatCount } from './utils'
export default function LikeButton({ event }: { event: Event }) { export default function LikeButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey, publish, checkLogin } = useNostr() const { pubkey, publish, checkLogin } = useNostr()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { event, externalContent, stuffKey } = useStuff(stuff)
const [liking, setLiking] = useState(false) const [liking, setLiking] = useState(false)
const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false) const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false)
const [isPickerOpen, setIsPickerOpen] = useState(false) const [isPickerOpen, setIsPickerOpen] = useState(false)
const noteStats = useNoteStatsById(event.id) const noteStats = useStuffStatsById(stuffKey)
const { myLastEmoji, likeCount } = useMemo(() => { const { myLastEmoji, likeCount } = useMemo(() => {
const stats = noteStats || {} const stats = noteStats || {}
const myLike = stats.likes?.find((like) => like.pubkey === pubkey) const myLike = stats.likes?.find((like) => like.pubkey === pubkey)
@@ -48,13 +54,15 @@ export default function LikeButton({ event }: { event: Event }) {
try { try {
if (!noteStats?.updatedAt) { if (!noteStats?.updatedAt) {
await noteStatsService.fetchNoteStats(event, pubkey) await stuffStatsService.fetchStuffStats(stuffKey, pubkey)
} }
const reaction = createReactionDraftEvent(event, emoji) const reaction = event
const seenOn = client.getSeenEventRelayUrls(event.id) ? createReactionDraftEvent(event, emoji)
: createExternalContentReactionDraftEvent(externalContent, emoji)
const seenOn = event ? client.getSeenEventRelayUrls(event.id) : BIG_RELAY_URLS
const evt = await publish(reaction, { additionalRelayUrls: seenOn }) const evt = await publish(reaction, { additionalRelayUrls: seenOn })
noteStatsService.updateNoteStatsByEvents([evt]) stuffStatsService.updateStuffStatsByEvents([evt])
} catch (error) { } catch (error) {
console.error('like failed', error) console.error('like failed', error)
} finally { } finally {

View File

@@ -1,19 +1,25 @@
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { BIG_RELAY_URLS } from '@/constants'
import { createReactionDraftEvent } from '@/lib/draft-event' import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { useStuff } from '@/hooks/useStuff'
import {
createExternalContentReactionDraftEvent,
createReactionDraftEvent
} from '@/lib/draft-event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' 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 { TEmoji } from '@/types'
import { Loader } from 'lucide-react' import { Loader } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import Emoji from '../Emoji' 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 { 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 [liking, setLiking] = useState<string | null>(null)
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null) const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
const [isLongPressing, setIsLongPressing] = useState<string | 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) const timer = setTimeout(() => setLiking((prev) => (prev === key ? null : prev)), 5000)
try { try {
const reaction = createReactionDraftEvent(event, emoji) const reaction = event
const seenOn = client.getSeenEventRelayUrls(event.id) ? createReactionDraftEvent(event, emoji)
: createExternalContentReactionDraftEvent(externalContent, emoji)
const seenOn = event ? client.getSeenEventRelayUrls(event.id) : BIG_RELAY_URLS
const evt = await publish(reaction, { additionalRelayUrls: seenOn }) const evt = await publish(reaction, { additionalRelayUrls: seenOn })
noteStatsService.updateNoteStatsByEvents([evt]) stuffStatsService.updateStuffStatsByEvents([evt])
} catch (error) { } catch (error) {
console.error('like failed', error) console.error('like failed', error)
} finally { } finally {

View File

@@ -1,3 +1,4 @@
import { useStuff } from '@/hooks/useStuff'
import { getEventKey, isMentioningMutedUsers } from '@/lib/event' import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
@@ -12,21 +13,21 @@ import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor' import PostEditor from '../PostEditor'
import { formatCount } from './utils' import { formatCount } from './utils'
export default function ReplyButton({ event }: { event: Event }) { export default function ReplyButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr() const { pubkey, checkLogin } = useNostr()
const { event, stuffKey } = useStuff(stuff)
const { repliesMap } = useReply() const { repliesMap } = useReply()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { replyCount, hasReplied } = useMemo(() => { const { replyCount, hasReplied } = useMemo(() => {
const key = getEventKey(event)
const hasReplied = pubkey const hasReplied = pubkey
? repliesMap.get(key)?.events.some((evt) => evt.pubkey === pubkey) ? repliesMap.get(stuffKey)?.events.some((evt) => evt.pubkey === pubkey)
: false : false
let replyCount = 0 let replyCount = 0
const replies = [...(repliesMap.get(key)?.events || [])] const replies = [...(repliesMap.get(stuffKey)?.events || [])]
while (replies.length > 0) { while (replies.length > 0) {
const reply = replies.pop() const reply = replies.pop()
if (!reply) break if (!reply) break
@@ -48,7 +49,7 @@ export default function ReplyButton({ event }: { event: Event }) {
} }
return { replyCount, hasReplied } return { replyCount, hasReplied }
}, [repliesMap, event, hideUntrustedInteractions]) }, [repliesMap, event, stuffKey, hideUntrustedInteractions])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
return ( return (
@@ -69,7 +70,7 @@ export default function ReplyButton({ event }: { event: Event }) {
<MessageCircle /> <MessageCircle />
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>} {!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
</button> </button>
<PostEditor parentEvent={event} open={open} setOpen={setOpen} /> <PostEditor parentStuff={stuff} open={open} setOpen={setOpen} />
</> </>
) )
} }

View File

@@ -6,14 +6,15 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } 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 { createRepostDraftEvent } from '@/lib/draft-event'
import { getNoteBech32Id } from '@/lib/event' import { getNoteBech32Id } from '@/lib/event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' 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 { Loader, PencilLine, Repeat } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
@@ -21,24 +22,28 @@ import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor' import PostEditor from '../PostEditor'
import { formatCount } from './utils' import { formatCount } from './utils'
export default function RepostButton({ event }: { event: Event }) { export default function RepostButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { publish, checkLogin, pubkey } = useNostr() 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 [reposting, setReposting] = useState(false)
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false) const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const { repostCount, hasReposted } = useMemo(() => { const { repostCount, hasReposted } = useMemo(() => {
// external content
if (!event) return { repostCount: 0, hasReposted: false }
return { return {
repostCount: hideUntrustedInteractions repostCount: hideUntrustedInteractions
? noteStats?.reposts?.filter((repost) => isUserTrusted(repost.pubkey)).length ? noteStats?.reposts?.filter((repost) => isUserTrusted(repost.pubkey)).length
: noteStats?.reposts?.length, : noteStats?.reposts?.length,
hasReposted: pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false hasReposted: pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false
} }
}, [noteStats, event.id, hideUntrustedInteractions]) }, [noteStats, event, hideUntrustedInteractions])
const canRepost = !hasReposted && !reposting const canRepost = !hasReposted && !reposting && !!event
const repost = async () => { const repost = async () => {
checkLogin(async () => { checkLogin(async () => {
@@ -51,7 +56,7 @@ export default function RepostButton({ event }: { event: Event }) {
const hasReposted = noteStats?.repostPubkeySet?.has(pubkey) const hasReposted = noteStats?.repostPubkeySet?.has(pubkey)
if (hasReposted) return if (hasReposted) return
if (!noteStats?.updatedAt) { if (!noteStats?.updatedAt) {
const noteStats = await noteStatsService.fetchNoteStats(event, pubkey) const noteStats = await stuffStatsService.fetchStuffStats(stuff, pubkey)
if (noteStats.repostPubkeySet?.has(pubkey)) { if (noteStats.repostPubkeySet?.has(pubkey)) {
return return
} }
@@ -59,7 +64,7 @@ export default function RepostButton({ event }: { event: Event }) {
const repost = createRepostDraftEvent(event) const repost = createRepostDraftEvent(event)
const evt = await publish(repost) const evt = await publish(repost)
noteStatsService.updateNoteStatsByEvents([evt]) stuffStatsService.updateStuffStatsByEvents([evt])
} catch (error) { } catch (error) {
console.error('repost failed', error) console.error('repost failed', error)
} finally { } finally {
@@ -72,11 +77,14 @@ export default function RepostButton({ event }: { event: Event }) {
const trigger = ( const trigger = (
<button <button
className={cn( 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' hasReposted ? 'text-lime-500' : 'text-muted-foreground'
)} )}
disabled={!event}
title={t('Repost')} title={t('Repost')}
onClick={() => { onClick={() => {
if (!event) return
if (isSmallScreen) { if (isSmallScreen) {
setIsDrawerOpen(true) setIsDrawerOpen(true)
} }
@@ -87,6 +95,10 @@ export default function RepostButton({ event }: { event: Event }) {
</button> </button>
) )
if (!event) {
return trigger
}
const postEditor = ( const postEditor = (
<PostEditor <PostEditor
open={isPostDialogOpen} open={isPostDialogOpen}

View File

@@ -9,6 +9,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useStuff } from '@/hooks/useStuff'
import { toRelay } from '@/lib/link' import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url' import { simplifyUrl } from '@/lib/url'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
@@ -19,24 +20,29 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon' import RelayIcon from '../RelayIcon'
export default function SeenOnButton({ event }: { event: Event }) { export default function SeenOnButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { event } = useStuff(stuff)
const [relays, setRelays] = useState<string[]>([]) const [relays, setRelays] = useState<string[]>([])
const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [isDrawerOpen, setIsDrawerOpen] = useState(false)
useEffect(() => { useEffect(() => {
if (!event) return
const seenOn = client.getSeenEventRelayUrls(event.id) const seenOn = client.getSeenEventRelayUrls(event.id)
setRelays(seenOn) setRelays(seenOn)
}, []) }, [])
const trigger = ( const trigger = (
<button <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')} title={t('Seen on')}
disabled={relays.length === 0} disabled={relays.length === 0}
onClick={() => { onClick={() => {
if (!event) return
if (isSmallScreen) { if (isSmallScreen) {
setIsDrawerOpen(true) setIsDrawerOpen(true)
} }
@@ -47,6 +53,10 @@ export default function SeenOnButton({ event }: { event: Event }) {
</button> </button>
) )
if (relays.length === 0) {
return trigger
}
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<> <>
@@ -76,6 +86,7 @@ export default function SeenOnButton({ event }: { event: Event }) {
</> </>
) )
} }
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>

View File

@@ -1,5 +1,6 @@
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' 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 { formatAmount } from '@/lib/lightning'
import { Zap } from 'lucide-react' import { Zap } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@@ -7,14 +8,15 @@ import { useMemo, useState } from 'react'
import { SimpleUserAvatar } from '../UserAvatar' import { SimpleUserAvatar } from '../UserAvatar'
import ZapDialog from '../ZapDialog' import ZapDialog from '../ZapDialog'
export default function TopZaps({ event }: { event: Event }) { export default function TopZaps({ stuff }: { stuff: Event | string }) {
const noteStats = useNoteStatsById(event.id) const { event, stuffKey } = useStuff(stuff)
const noteStats = useStuffStatsById(stuffKey)
const [zapIndex, setZapIndex] = useState(-1) const [zapIndex, setZapIndex] = useState(-1)
const topZaps = useMemo(() => { const topZaps = useMemo(() => {
return noteStats?.zaps?.sort((a, b) => b.amount - a.amount).slice(0, 10) || [] return noteStats?.zaps?.sort((a, b) => b.amount - a.amount).slice(0, 10) || []
}, [noteStats]) }, [noteStats])
if (!topZaps.length) return null if (!topZaps.length || !event) return null
return ( return (
<ScrollArea className="pb-2 mb-1"> <ScrollArea className="pb-2 mb-1">

View File

@@ -1,12 +1,13 @@
import { LONG_PRESS_THRESHOLD } from '@/constants' 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 { getLightningAddressFromProfile } from '@/lib/lightning'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import lightning from '@/services/lightning.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 { Loader, Zap } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 'react' import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 'react'
@@ -14,10 +15,11 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import ZapDialog from '../ZapDialog' import ZapDialog from '../ZapDialog'
export default function ZapButton({ event }: { event: Event }) { export default function ZapButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { checkLogin, pubkey } = useNostr() const { checkLogin, pubkey } = useNostr()
const noteStats = useNoteStatsById(event.id) const { event, stuffKey } = useStuff(stuff)
const noteStats = useStuffStatsById(stuffKey)
const { defaultZapSats, defaultZapComment, quickZap } = useZap() const { defaultZapSats, defaultZapComment, quickZap } = useZap()
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null) const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
const [openZapDialog, setOpenZapDialog] = useState(false) const [openZapDialog, setOpenZapDialog] = useState(false)
@@ -33,6 +35,11 @@ export default function ZapButton({ event }: { event: Event }) {
const isLongPressRef = useRef(false) const isLongPressRef = useRef(false)
useEffect(() => { useEffect(() => {
if (!event) {
setDisable(true)
return
}
client.fetchProfile(event.pubkey).then((profile) => { client.fetchProfile(event.pubkey).then((profile) => {
if (!profile) return if (!profile) return
const lightningAddress = getLightningAddressFromProfile(profile) const lightningAddress = getLightningAddressFromProfile(profile)
@@ -45,7 +52,7 @@ export default function ZapButton({ event }: { event: Event }) {
if (!pubkey) { if (!pubkey) {
throw new Error('You need to be logged in to zap') throw new Error('You need to be logged in to zap')
} }
if (zapping) return if (zapping || !event) return
setZapping(true) setZapping(true)
const zapResult = await lightning.zap(pubkey, event, defaultZapSats, defaultZapComment) const zapResult = await lightning.zap(pubkey, event, defaultZapSats, defaultZapComment)
@@ -53,7 +60,7 @@ export default function ZapButton({ event }: { event: Event }) {
if (!zapResult) { if (!zapResult) {
return return
} }
noteStatsService.addZap( stuffStatsService.addZap(
pubkey, pubkey,
event.id, event.id,
zapResult.invoice, zapResult.invoice,
@@ -128,11 +135,8 @@ export default function ZapButton({ event }: { event: Event }) {
<> <>
<button <button
className={cn( className={cn(
'flex items-center gap-1 select-none px-3 h-full', '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', hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
disable
? 'cursor-not-allowed text-muted-foreground/40'
: 'cursor-pointer enabled:hover:text-yellow-400'
)} )}
title={t('Zap')} title={t('Zap')}
disabled={disable || zapping} disabled={disable || zapping}
@@ -149,15 +153,17 @@ export default function ZapButton({ event }: { event: Event }) {
)} )}
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>} {!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
</button> </button>
<ZapDialog {event && (
open={openZapDialog} <ZapDialog
setOpen={(open) => { open={openZapDialog}
setOpenZapDialog(open) setOpen={(open) => {
setZapping(open) setOpenZapDialog(open)
}} setZapping(open)
pubkey={event.pubkey} }}
event={event} pubkey={event.pubkey}
/> event={event}
/>
)}
</> </>
) )
} }

View File

@@ -1,7 +1,8 @@
import { useStuff } from '@/hooks/useStuff'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' 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 { Event } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import BookmarkButton from '../BookmarkButton' import BookmarkButton from '../BookmarkButton'
@@ -13,14 +14,14 @@ import SeenOnButton from './SeenOnButton'
import TopZaps from './TopZaps' import TopZaps from './TopZaps'
import ZapButton from './ZapButton' import ZapButton from './ZapButton'
export default function NoteStats({ export default function StuffStats({
event, stuff,
className, className,
classNames, classNames,
fetchIfNotExisting = false, fetchIfNotExisting = false,
displayTopZapsAndLikes = false displayTopZapsAndLikes = false
}: { }: {
event: Event stuff: Event | string
className?: string className?: string
classNames?: { classNames?: {
buttonBar?: string buttonBar?: string
@@ -31,11 +32,12 @@ export default function NoteStats({
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const { event } = useStuff(stuff)
useEffect(() => { useEffect(() => {
if (!fetchIfNotExisting) return if (!fetchIfNotExisting) return
setLoading(true) setLoading(true)
noteStatsService.fetchNoteStats(event, pubkey).finally(() => setLoading(false)) stuffStatsService.fetchStuffStats(stuff, pubkey).finally(() => setLoading(false))
}, [event, fetchIfNotExisting]) }, [event, fetchIfNotExisting])
if (isSmallScreen) { if (isSmallScreen) {
@@ -43,8 +45,8 @@ export default function NoteStats({
<div className={cn('select-none', className)}> <div className={cn('select-none', className)}>
{displayTopZapsAndLikes && ( {displayTopZapsAndLikes && (
<> <>
<TopZaps event={event} /> <TopZaps stuff={stuff} />
<Likes event={event} /> <Likes stuff={stuff} />
</> </>
)} )}
<div <div
@@ -55,12 +57,12 @@ export default function NoteStats({
)} )}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<ReplyButton event={event} /> <ReplyButton stuff={stuff} />
<RepostButton event={event} /> <RepostButton stuff={stuff} />
<LikeButton event={event} /> <LikeButton stuff={stuff} />
<ZapButton event={event} /> <ZapButton stuff={stuff} />
<BookmarkButton event={event} /> <BookmarkButton stuff={stuff} />
<SeenOnButton event={event} /> <SeenOnButton stuff={stuff} />
</div> </div>
</div> </div>
) )
@@ -70,8 +72,8 @@ export default function NoteStats({
<div className={cn('select-none', className)}> <div className={cn('select-none', className)}>
{displayTopZapsAndLikes && ( {displayTopZapsAndLikes && (
<> <>
<TopZaps event={event} /> <TopZaps stuff={stuff} />
<Likes event={event} /> <Likes stuff={stuff} />
</> </>
)} )}
<div className="flex justify-between h-5 [&_svg]:size-4"> <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' : '')} className={cn('flex items-center', loading ? 'animate-pulse' : '')}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<ReplyButton event={event} /> <ReplyButton stuff={stuff} />
<RepostButton event={event} /> <RepostButton stuff={stuff} />
<LikeButton event={event} /> <LikeButton stuff={stuff} />
<ZapButton event={event} /> <ZapButton stuff={stuff} />
</div> </div>
<div className="flex items-center" onClick={(e) => e.stopPropagation()}> <div className="flex items-center" onClick={(e) => e.stopPropagation()}>
<BookmarkButton event={event} /> <BookmarkButton stuff={stuff} />
<SeenOnButton event={event} /> <SeenOnButton stuff={stuff} />
</div> </div>
</div> </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 { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import lightning from '@/services/lightning.service' 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 { Loader } from 'lucide-react'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react' import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
@@ -189,7 +189,7 @@ function ZapDialogContent({
return return
} }
if (event) { if (event) {
noteStatsService.addZap(pubkey, event.id, zapResult.invoice, sats, comment) stuffStatsService.addZap(pubkey, event.id, zapResult.invoice, sats, comment)
} }
} catch (error) { } catch (error) {
toast.error(`${t('Zap failed')}: ${(error as Error).message}`) toast.error(`${t('Zap failed')}: ${(error as Error).message}`)

View File

@@ -1,5 +1,5 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { formatAmount } from '@/lib/lightning' import { formatAmount } from '@/lib/lightning'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
@@ -19,7 +19,7 @@ export default function ZapList({ event }: { event: Event }) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const noteStats = useNoteStatsById(event.id) const noteStats = useStuffStatsById(event.id)
const filteredZaps = useMemo(() => { const filteredZaps = useMemo(() => {
return (noteStats?.zaps ?? []).sort((a, b) => b.amount - a.amount) return (noteStats?.zaps ?? []).sort((a, b) => b.amount - a.amount)
}, [noteStats, event.id]) }, [noteStats, event.id])

View File

@@ -68,6 +68,7 @@ export const SEARCHABLE_RELAY_URLS = ['wss://relay.nostr.band/', 'wss://search.n
export const GROUP_METADATA_EVENT_KIND = 39000 export const GROUP_METADATA_EVENT_KIND = 39000
export const ExtendedKind = { export const ExtendedKind = {
EXTERNAL_CONTENT_REACTION: 17,
PICTURE: 20, PICTURE: 20,
VIDEO: 21, VIDEO: 21,
SHORT_VIDEO: 22, SHORT_VIDEO: 22,
@@ -112,6 +113,8 @@ export const EMOJI_REGEX =
/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA70}-\u{1FAFF}]|[\u{1F004}]|[\u{1F0CF}]|[\u{1F18E}]|[\u{3030}]|[\u{2B50}]|[\u{2B55}]|[\u{2934}-\u{2935}]|[\u{2B05}-\u{2B07}]|[\u{2B1B}-\u{2B1C}]|[\u{3297}]|[\u{3299}]|[\u{303D}]|[\u{00A9}]|[\u{00AE}]|[\u{2122}]|[\u{23E9}-\u{23EF}]|[\u{23F0}]|[\u{23F3}]|[\u{FE00}-\u{FE0F}]|[\u{200D}]/gu /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA70}-\u{1FAFF}]|[\u{1F004}]|[\u{1F0CF}]|[\u{1F18E}]|[\u{3030}]|[\u{2B50}]|[\u{2B55}]|[\u{2934}-\u{2935}]|[\u{2B05}-\u{2B07}]|[\u{2B1B}-\u{2B1C}]|[\u{3297}]|[\u{3299}]|[\u{303D}]|[\u{00A9}]|[\u{00AE}]|[\u{2122}]|[\u{23E9}-\u{23EF}]|[\u{23F0}]|[\u{23F3}]|[\u{FE00}-\u{FE0F}]|[\u{200D}]/gu
export const YOUTUBE_URL_REGEX = export const YOUTUBE_URL_REGEX =
/https?:\/\/(?:(?:www|m)\.)?(?:youtube\.com\/(?:watch\?[^#\s]*|embed\/[\w-]+|shorts\/[\w-]+|live\/[\w-]+)|youtu\.be\/[\w-]+)(?:\?[^#\s]*)?(?:#[^\s]*)?/gi /https?:\/\/(?:(?:www|m)\.)?(?:youtube\.com\/(?:watch\?[^#\s]*|embed\/[\w-]+|shorts\/[\w-]+|live\/[\w-]+)|youtu\.be\/[\w-]+)(?:\?[^#\s]*)?(?:#[^\s]*)?/gi
export const X_URL_REGEX =
/https?:\/\/(?:www\.)?(twitter\.com|x\.com)\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)(?:[?#].*)?/gi
export const JUMBLE_PUBKEY = 'f4eb8e62add1340b9cadcd9861e669b2e907cea534e0f7f3ac974c11c758a51a' export const JUMBLE_PUBKEY = 'f4eb8e62add1340b9cadcd9861e669b2e907cea534e0f7f3ac974c11c758a51a'
export const CODY_PUBKEY = '8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883' export const CODY_PUBKEY = '8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883'

View File

@@ -1,9 +0,0 @@
import noteStats from '@/services/note-stats.service'
import { useSyncExternalStore } from 'react'
export function useNoteStatsById(noteId: string) {
return useSyncExternalStore(
(cb) => noteStats.subscribeNoteStats(noteId, cb),
() => noteStats.getNoteStats(noteId)
)
}

15
src/hooks/useStuff.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { getEventKey } from '@/lib/event'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
export function useStuff(stuff: Event | string) {
const resolvedStuff = useMemo(
() =>
typeof stuff === 'string'
? { event: undefined, externalContent: stuff, stuffKey: stuff }
: { event: stuff, externalContent: undefined, stuffKey: getEventKey(stuff) },
[stuff]
)
return resolvedStuff
}

View File

@@ -0,0 +1,9 @@
import stuffStats from '@/services/stuff-stats.service'
import { useSyncExternalStore } from 'react'
export function useStuffStatsById(stuffKey: string) {
return useSyncExternalStore(
(cb) => stuffStats.subscribeStuffStats(stuffKey, cb),
() => stuffStats.getStuffStats(stuffKey)
)
}

View File

@@ -519,7 +519,8 @@ export default {
'Sending...': 'جاري الإرسال...', 'Sending...': 'جاري الإرسال...',
'Send Request': 'إرسال الطلب', 'Send Request': 'إرسال الطلب',
'You can get an invite code from a relay member.': 'يمكنك الحصول على رمز دعوة من عضو المرحل.', 'You can get an invite code from a relay member.': 'يمكنك الحصول على رمز دعوة من عضو المرحل.',
'Enter the invite code you received from a relay member.': 'أدخل رمز الدعوة الذي تلقيته من عضو المرحل.', 'Enter the invite code you received from a relay member.':
'أدخل رمز الدعوة الذي تلقيته من عضو المرحل.',
'Get Invite Code': 'الحصول على رمز الدعوة', 'Get Invite Code': 'الحصول على رمز الدعوة',
'Share this invite code with others to invite them to join this relay.': 'Share this invite code with others to invite them to join this relay.':
'شارك رمز الدعوة هذا مع الآخرين لدعوتهم للانضمام إلى هذا المرحل.', 'شارك رمز الدعوة هذا مع الآخرين لدعوتهم للانضمام إلى هذا المرحل.',
@@ -533,6 +534,15 @@ export default {
'Failed to get invite code': 'فشل الحصول على رمز الدعوة', 'Failed to get invite code': 'فشل الحصول على رمز الدعوة',
'Invite code copied to clipboard': 'تم نسخ رمز الدعوة إلى الحافظة', 'Invite code copied to clipboard': 'تم نسخ رمز الدعوة إلى الحافظة',
'Favicon URL': 'رابط الأيقونة المفضلة', 'Favicon URL': 'رابط الأيقونة المفضلة',
'Filter out onion relays': 'تصفية مرحلات onion' 'Filter out onion relays': 'تصفية مرحلات onion',
'Click to load X post': 'انقر لتحميل منشور X',
'View Nostr comments': 'عرض تعليقات Nostr',
'Search for notes': 'البحث عن الملاحظات',
'Search for hashtag': 'البحث عن الوسم',
'Go to note': 'الانتقال إلى الملاحظة',
'Go to relay': 'الانتقال إلى المرحل',
'View discussions about this': 'عرض المناقشات حول هذا المحتوى',
'Open link': 'فتح الرابط',
'View Nostr discussions': 'عرض مناقشات Nostr'
} }
} }

View File

@@ -549,6 +549,15 @@ export default {
'Failed to get invite code': 'Fehler beim Abrufen des Einladungscodes', 'Failed to get invite code': 'Fehler beim Abrufen des Einladungscodes',
'Invite code copied to clipboard': 'Einladungscode in die Zwischenablage kopiert', 'Invite code copied to clipboard': 'Einladungscode in die Zwischenablage kopiert',
'Favicon URL': 'Favicon-URL', 'Favicon URL': 'Favicon-URL',
'Filter out onion relays': 'Onion-Relays herausfiltern' 'Filter out onion relays': 'Onion-Relays herausfiltern',
'Click to load X post': 'Klicken Sie, um X-Beitrag zu laden',
'View Nostr comments': 'Nostr-Kommentare anzeigen',
'Search for notes': 'Notizen suchen',
'Search for hashtag': 'Hashtag suchen',
'Go to note': 'Zur Notiz gehen',
'Go to relay': 'Zum Relay gehen',
'View discussions about this': 'Diskussionen über diesen Inhalt anzeigen',
'Open link': 'Link öffnen',
'View Nostr discussions': 'Nostr-Diskussionen anzeigen'
} }
} }

View File

@@ -407,6 +407,7 @@ export default {
'Click to load image': 'Click to load image', 'Click to load image': 'Click to load image',
'Click to load media': 'Click to load media', 'Click to load media': 'Click to load media',
'Click to load YouTube video': 'Click to load YouTube video', 'Click to load YouTube video': 'Click to load YouTube video',
'Click to load X post': 'Click to load X post',
'{{count}} reviews': '{{count}} reviews', '{{count}} reviews': '{{count}} reviews',
'Write a review': 'Write a review', 'Write a review': 'Write a review',
'No reviews yet. Be the first to write one!': 'No reviews yet. Be the first to write one!', 'No reviews yet. Be the first to write one!': 'No reviews yet. Be the first to write one!',
@@ -534,6 +535,14 @@ export default {
'Failed to get invite code': 'Failed to get invite code', 'Failed to get invite code': 'Failed to get invite code',
'Invite code copied to clipboard': 'Invite code copied to clipboard', 'Invite code copied to clipboard': 'Invite code copied to clipboard',
'Favicon URL': 'Favicon URL', 'Favicon URL': 'Favicon URL',
'Filter out onion relays': 'Filter out onion relays' 'Filter out onion relays': 'Filter out onion relays',
'View Nostr comments': 'View Nostr comments',
'Search for notes': 'Search for notes',
'Search for hashtag': 'Search for hashtag',
'Go to note': 'Go to note',
'Go to relay': 'Go to relay',
'View discussions about this': 'View discussions about this',
'Open link': 'Open link',
'View Nostr discussions': 'View Nostr discussions'
} }
} }

View File

@@ -515,7 +515,8 @@ export default {
'Request to Join Relay': 'Solicitar unirse al Relay', 'Request to Join Relay': 'Solicitar unirse al Relay',
'Leave Relay': 'Salir del Relay', 'Leave Relay': 'Salir del Relay',
Leave: 'Salir', Leave: 'Salir',
'Are you sure you want to leave this relay?': '¿Estás seguro de que quieres salir de este relay?', 'Are you sure you want to leave this relay?':
'¿Estás seguro de que quieres salir de este relay?',
'Join request sent successfully': 'Solicitud de unión enviada con éxito', 'Join request sent successfully': 'Solicitud de unión enviada con éxito',
'Failed to send join request': 'Error al enviar solicitud de unión', 'Failed to send join request': 'Error al enviar solicitud de unión',
'Leave request sent successfully': 'Solicitud de salida enviada con éxito', 'Leave request sent successfully': 'Solicitud de salida enviada con éxito',
@@ -537,12 +538,22 @@ export default {
Copy: 'Copiar', Copy: 'Copiar',
'This invite code can be used by others to join the relay.': 'This invite code can be used by others to join the relay.':
'Este código de invitación puede ser usado por otros para unirse al relay.', 'Este código de invitación puede ser usado por otros para unirse al relay.',
'No invite code available from this relay.': 'No hay código de invitación disponible de este relay.', 'No invite code available from this relay.':
'No hay código de invitación disponible de este relay.',
Close: 'Cerrar', Close: 'Cerrar',
'Failed to get invite code from relay': 'Error al obtener código de invitación del relay', 'Failed to get invite code from relay': 'Error al obtener código de invitación del relay',
'Failed to get invite code': 'Error al obtener código de invitación', 'Failed to get invite code': 'Error al obtener código de invitación',
'Invite code copied to clipboard': 'Código de invitación copiado al portapapeles', 'Invite code copied to clipboard': 'Código de invitación copiado al portapapeles',
'Favicon URL': 'URL del Favicon', 'Favicon URL': 'URL del Favicon',
'Filter out onion relays': 'Filtrar relés onion' 'Filter out onion relays': 'Filtrar relés onion',
'Click to load X post': 'Haz clic para cargar la publicación de X',
'View Nostr comments': 'Ver comentarios de Nostr',
'Search for notes': 'Buscar notas',
'Search for hashtag': 'Buscar hashtag',
'Go to note': 'Ir a la nota',
'Go to relay': 'Ir al relay',
'View discussions about this': 'Ver discusiones sobre este contenido',
'Open link': 'Abrir enlace',
'View Nostr discussions': 'Ver discusiones de Nostr'
} }
} }

View File

@@ -510,7 +510,8 @@ export default {
'Request to Join Relay': 'درخواست عضویت در رله', 'Request to Join Relay': 'درخواست عضویت در رله',
'Leave Relay': 'خروج از رله', 'Leave Relay': 'خروج از رله',
Leave: 'خروج', Leave: 'خروج',
'Are you sure you want to leave this relay?': 'آیا مطمئن هستید که می‌خواهید از این رله خارج شوید؟', 'Are you sure you want to leave this relay?':
'آیا مطمئن هستید که می‌خواهید از این رله خارج شوید؟',
'Join request sent successfully': 'درخواست عضویت با موفقیت ارسال شد', 'Join request sent successfully': 'درخواست عضویت با موفقیت ارسال شد',
'Failed to send join request': 'ارسال درخواست عضویت ناموفق بود', 'Failed to send join request': 'ارسال درخواست عضویت ناموفق بود',
'Leave request sent successfully': 'درخواست خروج با موفقیت ارسال شد', 'Leave request sent successfully': 'درخواست خروج با موفقیت ارسال شد',
@@ -538,6 +539,15 @@ export default {
'Failed to get invite code': 'دریافت کد دعوت ناموفق بود', 'Failed to get invite code': 'دریافت کد دعوت ناموفق بود',
'Invite code copied to clipboard': 'کد دعوت در کلیپ‌بورد کپی شد', 'Invite code copied to clipboard': 'کد دعوت در کلیپ‌بورد کپی شد',
'Favicon URL': 'آدرس نماد سایت', 'Favicon URL': 'آدرس نماد سایت',
'Filter out onion relays': 'فیلتر کردن رله‌های onion' 'Filter out onion relays': 'فیلتر کردن رله‌های onion',
'Click to load X post': 'برای بارگیری پست X کلیک کنید',
'View Nostr comments': 'مشاهده نظرات Nostr',
'Search for notes': 'جستجوی یادداشت‌ها',
'Search for hashtag': 'جستجوی هشتگ',
'Go to note': 'رفتن به یادداشت',
'Go to relay': 'رفتن به رله',
'View discussions about this': 'مشاهده بحث‌ها درباره این محتوا',
'Open link': 'باز کردن لینک',
'View Nostr discussions': 'مشاهده بحث‌های Nostr'
} }
} }

View File

@@ -548,6 +548,15 @@ export default {
'Failed to get invite code': "Échec de l'obtention du code d'invitation", 'Failed to get invite code': "Échec de l'obtention du code d'invitation",
'Invite code copied to clipboard': "Code d'invitation copié dans le presse-papiers", 'Invite code copied to clipboard': "Code d'invitation copié dans le presse-papiers",
'Favicon URL': 'URL du Favicon', 'Favicon URL': 'URL du Favicon',
'Filter out onion relays': 'Filtrer les relais onion' 'Filter out onion relays': 'Filtrer les relais onion',
'Click to load X post': 'Cliquez pour charger la publication X',
'View Nostr comments': 'Voir les commentaires Nostr',
'Search for notes': 'Rechercher des notes',
'Search for hashtag': 'Rechercher un hashtag',
'Go to note': 'Aller à la note',
'Go to relay': 'Aller au relais',
'View discussions about this': 'Voir les discussions sur ce contenu',
'Open link': 'Ouvrir le lien',
'View Nostr discussions': 'Voir les discussions Nostr'
} }
} }

View File

@@ -540,6 +540,15 @@ export default {
'Failed to get invite code': 'निमंत्रण कोड प्राप्त करने में विफल', 'Failed to get invite code': 'निमंत्रण कोड प्राप्त करने में विफल',
'Invite code copied to clipboard': 'निमंत्रण कोड क्लिपबोर्ड पर कॉपी किया गया', 'Invite code copied to clipboard': 'निमंत्रण कोड क्लिपबोर्ड पर कॉपी किया गया',
'Favicon URL': 'फ़ेविकॉन URL', 'Favicon URL': 'फ़ेविकॉन URL',
'Filter out onion relays': 'ओनियन रिले फ़िल्टर करें' 'Filter out onion relays': 'ओनियन रिले फ़िल्टर करें',
'Click to load X post': 'X पोस्ट लोड करने के लिए क्लिक करें',
'View Nostr comments': 'Nostr टिप्पणियाँ देखें',
'Search for notes': 'नोट्स खोजें',
'Search for hashtag': 'हैशटैग खोजें',
'Go to note': 'नोट पर जाएं',
'Go to relay': 'रिले पर जाएं',
'View discussions about this': 'इस सामग्री के बारे में चर्चाएँ देखें',
'Open link': 'लिंक खोलें',
'View Nostr discussions': 'Nostr चर्चाएँ देखें'
} }
} }

View File

@@ -518,8 +518,7 @@ export default {
'Enter invite code': 'Írja be a meghívókódot', 'Enter invite code': 'Írja be a meghívókódot',
'Sending...': 'Küldés...', 'Sending...': 'Küldés...',
'Send Request': 'Kérelem küldése', 'Send Request': 'Kérelem küldése',
'You can get an invite code from a relay member.': 'You can get an invite code from a relay member.': 'Meghívókódot kaphat egy relay tagtól.',
'Meghívókódot kaphat egy relay tagtól.',
'Enter the invite code you received from a relay member.': 'Enter the invite code you received from a relay member.':
'Írja be a relay tagtól kapott meghívókódot.', 'Írja be a relay tagtól kapott meghívókódot.',
'Get Invite Code': 'Meghívókód Lekérése', 'Get Invite Code': 'Meghívókód Lekérése',
@@ -535,6 +534,15 @@ export default {
'Failed to get invite code': 'Nem sikerült lekérni a meghívókódot', 'Failed to get invite code': 'Nem sikerült lekérni a meghívókódot',
'Invite code copied to clipboard': 'Meghívókód vágólapra másolva', 'Invite code copied to clipboard': 'Meghívókód vágólapra másolva',
'Favicon URL': 'Favicon URL', 'Favicon URL': 'Favicon URL',
'Filter out onion relays': 'Onion relay-ek kiszűrése' 'Filter out onion relays': 'Onion relay-ek kiszűrése',
'Click to load X post': 'Kattintson az X bejegyzés betöltéséhez',
'View Nostr comments': 'Nostr megjegyzések megtekintése',
'Search for notes': 'Jegyzetek keresése',
'Search for hashtag': 'Hashtag keresése',
'Go to note': 'Ugrás a jegyzethez',
'Go to relay': 'Ugrás a relay-hez',
'View discussions about this': 'Beszélgetések megtekintése erről a tartalomról',
'Open link': 'Link megnyitása',
'View Nostr discussions': 'Nostr beszélgetések megtekintése'
} }
} }

View File

@@ -517,11 +517,11 @@ export default {
Leave: 'Esci', Leave: 'Esci',
'Are you sure you want to leave this relay?': 'Sei sicuro di voler lasciare questo relay?', 'Are you sure you want to leave this relay?': 'Sei sicuro di voler lasciare questo relay?',
'Join request sent successfully': 'Richiesta di adesione inviata con successo', 'Join request sent successfully': 'Richiesta di adesione inviata con successo',
'Failed to send join request': "Impossibile inviare la richiesta di adesione", 'Failed to send join request': 'Impossibile inviare la richiesta di adesione',
'Leave request sent successfully': 'Richiesta di uscita inviata con successo', 'Leave request sent successfully': 'Richiesta di uscita inviata con successo',
'Failed to send leave request': "Impossibile inviare la richiesta di uscita", 'Failed to send leave request': 'Impossibile inviare la richiesta di uscita',
'Enter an invite code if you have one. Otherwise, leave it blank to send a request.': 'Enter an invite code if you have one. Otherwise, leave it blank to send a request.':
"Inserisci un codice di invito se ne hai uno. Altrimenti, lascialo vuoto per inviare una richiesta.", 'Inserisci un codice di invito se ne hai uno. Altrimenti, lascialo vuoto per inviare una richiesta.',
'Invite Code (Optional)': 'Codice di Invito (Opzionale)', 'Invite Code (Optional)': 'Codice di Invito (Opzionale)',
'Enter invite code': 'Inserisci il codice di invito', 'Enter invite code': 'Inserisci il codice di invito',
'Sending...': 'Invio...', 'Sending...': 'Invio...',
@@ -537,12 +537,22 @@ export default {
Copy: 'Copia', Copy: 'Copia',
'This invite code can be used by others to join the relay.': 'This invite code can be used by others to join the relay.':
'Questo codice di invito può essere utilizzato da altri per unirsi al relay.', 'Questo codice di invito può essere utilizzato da altri per unirsi al relay.',
'No invite code available from this relay.': 'Nessun codice di invito disponibile da questo relay.', 'No invite code available from this relay.':
'Nessun codice di invito disponibile da questo relay.',
Close: 'Chiudi', Close: 'Chiudi',
'Failed to get invite code from relay': 'Impossibile ottenere il codice di invito dal relay', 'Failed to get invite code from relay': 'Impossibile ottenere il codice di invito dal relay',
'Failed to get invite code': 'Impossibile ottenere il codice di invito', 'Failed to get invite code': 'Impossibile ottenere il codice di invito',
'Invite code copied to clipboard': 'Codice di invito copiato negli appunti', 'Invite code copied to clipboard': 'Codice di invito copiato negli appunti',
'Favicon URL': 'URL Favicon', 'Favicon URL': 'URL Favicon',
'Filter out onion relays': 'Filtra relay onion' 'Filter out onion relays': 'Filtra relay onion',
'Click to load X post': 'Clicca per caricare il post X',
'View Nostr comments': 'Visualizza commenti Nostr',
'Search for notes': 'Cerca note',
'Search for hashtag': 'Cerca hashtag',
'Go to note': 'Vai alla nota',
'Go to relay': 'Vai al relay',
'View discussions about this': 'Visualizza discussioni su questo contenuto',
'Open link': 'Apri link',
'View Nostr discussions': 'Visualizza discussioni Nostr'
} }
} }

View File

@@ -522,8 +522,10 @@ export default {
'Enter invite code': '招待コードを入力', 'Enter invite code': '招待コードを入力',
'Sending...': '送信中...', 'Sending...': '送信中...',
'Send Request': 'リクエストを送信', 'Send Request': 'リクエストを送信',
'You can get an invite code from a relay member.': 'リレーメンバーから招待コードを取得できます。', 'You can get an invite code from a relay member.':
'Enter the invite code you received from a relay member.': 'リレーメンバーから受け取った招待コードを入力してください。', 'リレーメンバーから招待コードを取得できます。',
'Enter the invite code you received from a relay member.':
'リレーメンバーから受け取った招待コードを入力してください。',
'Get Invite Code': '招待コードを取得', 'Get Invite Code': '招待コードを取得',
'Share this invite code with others to invite them to join this relay.': 'Share this invite code with others to invite them to join this relay.':
'この招待コードを他の人と共有して、このリレーへの参加を招待してください。', 'この招待コードを他の人と共有して、このリレーへの参加を招待してください。',
@@ -537,6 +539,15 @@ export default {
'Failed to get invite code': '招待コードの取得に失敗しました', 'Failed to get invite code': '招待コードの取得に失敗しました',
'Invite code copied to clipboard': '招待コードをクリップボードにコピーしました', 'Invite code copied to clipboard': '招待コードをクリップボードにコピーしました',
'Favicon URL': 'ファビコンURL', 'Favicon URL': 'ファビコンURL',
'Filter out onion relays': 'Onionリレーを除外' 'Filter out onion relays': 'Onionリレーを除外',
'Click to load X post': 'クリックしてX投稿を読み込む',
'View Nostr comments': 'Nostrコメントを表示',
'Search for notes': 'ノートを検索',
'Search for hashtag': 'ハッシュタグを検索',
'Go to note': 'ノートへ移動',
'Go to relay': 'リレーへ移動',
'View discussions about this': 'このコンテンツに関する議論を表示',
'Open link': 'リンクを開く',
'View Nostr discussions': 'Nostr の議論を表示'
} }
} }

View File

@@ -522,8 +522,10 @@ export default {
'Enter invite code': '초대 코드 입력', 'Enter invite code': '초대 코드 입력',
'Sending...': '전송 중...', 'Sending...': '전송 중...',
'Send Request': '요청 보내기', 'Send Request': '요청 보내기',
'You can get an invite code from a relay member.': '릴레이 회원으로부터 초대 코드를 받을 수 있습니다.', 'You can get an invite code from a relay member.':
'Enter the invite code you received from a relay member.': '릴레이 회원으로부터 받은 초대 코드를 입력하세요.', '릴레이 회원으로부터 초대 코드를 받을 수 있습니다.',
'Enter the invite code you received from a relay member.':
'릴레이 회원으로부터 받은 초대 코드를 입력하세요.',
'Get Invite Code': '초대 코드 받기', 'Get Invite Code': '초대 코드 받기',
'Share this invite code with others to invite them to join this relay.': 'Share this invite code with others to invite them to join this relay.':
'이 초대 코드를 다른 사람과 공유하여 이 릴레이에 초대하세요.', '이 초대 코드를 다른 사람과 공유하여 이 릴레이에 초대하세요.',
@@ -537,6 +539,15 @@ export default {
'Failed to get invite code': '초대 코드 가져오기 실패', 'Failed to get invite code': '초대 코드 가져오기 실패',
'Invite code copied to clipboard': '초대 코드가 클립보드에 복사되었습니다', 'Invite code copied to clipboard': '초대 코드가 클립보드에 복사되었습니다',
'Favicon URL': '파비콘 URL', 'Favicon URL': '파비콘 URL',
'Filter out onion relays': '어니언 릴레이 필터링' 'Filter out onion relays': '어니언 릴레이 필터링',
'Click to load X post': '클릭하여 X 게시물 로드',
'View Nostr comments': 'Nostr 댓글 보기',
'Search for notes': '노트 검색',
'Search for hashtag': '해시태그 검색',
'Go to note': '노트로 이동',
'Go to relay': '릴레이로 이동',
'View discussions about this': '이 콘텐츠에 대한 토론 보기',
'Open link': '링크 열기',
'View Nostr discussions': 'Nostr 토론 보기'
} }
} }

View File

@@ -537,12 +537,22 @@ export default {
Copy: 'Kopiuj', Copy: 'Kopiuj',
'This invite code can be used by others to join the relay.': 'This invite code can be used by others to join the relay.':
'Ten kod zaproszenia może być używany przez innych do dołączenia do przekaźnika.', 'Ten kod zaproszenia może być używany przez innych do dołączenia do przekaźnika.',
'No invite code available from this relay.': 'Brak dostępnego kodu zaproszenia z tego przekaźnika.', 'No invite code available from this relay.':
'Brak dostępnego kodu zaproszenia z tego przekaźnika.',
Close: 'Zamknij', Close: 'Zamknij',
'Failed to get invite code from relay': 'Nie udało się uzyskać kodu zaproszenia z przekaźnika', 'Failed to get invite code from relay': 'Nie udało się uzyskać kodu zaproszenia z przekaźnika',
'Failed to get invite code': 'Nie udało się uzyskać kodu zaproszenia', 'Failed to get invite code': 'Nie udało się uzyskać kodu zaproszenia',
'Invite code copied to clipboard': 'Kod zaproszenia skopiowany do schowka', 'Invite code copied to clipboard': 'Kod zaproszenia skopiowany do schowka',
'Favicon URL': 'URL Favicon', 'Favicon URL': 'URL Favicon',
'Filter out onion relays': 'Filtruj przekaźniki onion' 'Filter out onion relays': 'Filtruj przekaźniki onion',
'Click to load X post': 'Kliknij, aby załadować post X',
'View Nostr comments': 'Wyświetl komentarze Nostr',
'Search for notes': 'Szukaj notatek',
'Search for hashtag': 'Szukaj hashtaga',
'Go to note': 'Przejdź do notatki',
'Go to relay': 'Przejdź do przekaźnika',
'View discussions about this': 'Zobacz dyskusje o tej treści',
'Open link': 'Otwórz link',
'View Nostr discussions': 'Zobacz dyskusje Nostr'
} }
} }

View File

@@ -540,6 +540,15 @@ export default {
'Failed to get invite code': 'Falha ao obter código de convite', 'Failed to get invite code': 'Falha ao obter código de convite',
'Invite code copied to clipboard': 'Código de convite copiado para a área de transferência', 'Invite code copied to clipboard': 'Código de convite copiado para a área de transferência',
'Favicon URL': 'URL do Favicon', 'Favicon URL': 'URL do Favicon',
'Filter out onion relays': 'Filtrar relays onion' 'Filter out onion relays': 'Filtrar relays onion',
'Click to load X post': 'Clique para carregar a postagem do X',
'View Nostr comments': 'Ver comentários do Nostr',
'Search for notes': 'Buscar notas',
'Search for hashtag': 'Buscar hashtag',
'Go to note': 'Ir para nota',
'Go to relay': 'Ir para relay',
'View discussions about this': 'Ver discussões sobre este conteúdo',
'Open link': 'Abrir link',
'View Nostr discussions': 'Ver discussões do Nostr'
} }
} }

View File

@@ -543,6 +543,15 @@ export default {
'Failed to get invite code': 'Falha ao obter código de convite', 'Failed to get invite code': 'Falha ao obter código de convite',
'Invite code copied to clipboard': 'Código de convite copiado para a área de transferência', 'Invite code copied to clipboard': 'Código de convite copiado para a área de transferência',
'Favicon URL': 'URL do Favicon', 'Favicon URL': 'URL do Favicon',
'Filter out onion relays': 'Filtrar relays onion' 'Filter out onion relays': 'Filtrar relays onion',
'Click to load X post': 'Clique para carregar a publicação do X',
'View Nostr comments': 'Ver comentários do Nostr',
'Search for notes': 'Pesquisar notas',
'Search for hashtag': 'Pesquisar hashtag',
'Go to note': 'Ir para nota',
'Go to relay': 'Ir para relay',
'View discussions about this': 'Ver discussões sobre este conteúdo',
'Open link': 'Abrir ligação',
'View Nostr discussions': 'Ver discussões do Nostr'
} }
} }

View File

@@ -545,6 +545,15 @@ export default {
'Failed to get invite code': 'Не удалось получить код приглашения', 'Failed to get invite code': 'Не удалось получить код приглашения',
'Invite code copied to clipboard': 'Код приглашения скопирован в буфер обмена', 'Invite code copied to clipboard': 'Код приглашения скопирован в буфер обмена',
'Favicon URL': 'URL фавикона', 'Favicon URL': 'URL фавикона',
'Filter out onion relays': 'Фильтровать onion-релеи' 'Filter out onion relays': 'Фильтровать onion-релеи',
'Click to load X post': 'Нажмите, чтобы загрузить пост X',
'View Nostr comments': 'Просмотреть комментарии Nostr',
'Search for notes': 'Искать заметки',
'Search for hashtag': 'Искать хэштег',
'Go to note': 'Перейти к заметке',
'Go to relay': 'Перейти к релею',
'View discussions about this': 'Просмотреть обсуждения об этом контенте',
'Open link': 'Открыть ссылку',
'View Nostr discussions': 'Просмотреть обсуждения Nostr'
} }
} }

View File

@@ -517,7 +517,8 @@ export default {
'Sending...': 'กำลังส่ง...', 'Sending...': 'กำลังส่ง...',
'Send Request': 'ส่งคำขอ', 'Send Request': 'ส่งคำขอ',
'You can get an invite code from a relay member.': 'คุณสามารถรับรหัสเชิญจากสมาชิกรีเลย์', 'You can get an invite code from a relay member.': 'คุณสามารถรับรหัสเชิญจากสมาชิกรีเลย์',
'Enter the invite code you received from a relay member.': 'ป้อนรหัสเชิญที่คุณได้รับจากสมาชิกรีเลย์', 'Enter the invite code you received from a relay member.':
'ป้อนรหัสเชิญที่คุณได้รับจากสมาชิกรีเลย์',
'Get Invite Code': 'รับรหัสเชิญ', 'Get Invite Code': 'รับรหัสเชิญ',
'Share this invite code with others to invite them to join this relay.': 'Share this invite code with others to invite them to join this relay.':
'แชร์รหัสเชิญนี้กับผู้อื่นเพื่อเชิญพวกเขาเข้าร่วมรีเลย์นี้', 'แชร์รหัสเชิญนี้กับผู้อื่นเพื่อเชิญพวกเขาเข้าร่วมรีเลย์นี้',
@@ -531,6 +532,15 @@ export default {
'Failed to get invite code': 'ไม่สามารถรับรหัสเชิญ', 'Failed to get invite code': 'ไม่สามารถรับรหัสเชิญ',
'Invite code copied to clipboard': 'คัดลอกรหัสเชิญไปยังคลิปบอร์ดแล้ว', 'Invite code copied to clipboard': 'คัดลอกรหัสเชิญไปยังคลิปบอร์ดแล้ว',
'Favicon URL': 'URL ไอคอน', 'Favicon URL': 'URL ไอคอน',
'Filter out onion relays': 'กรองรีเลย์ onion' 'Filter out onion relays': 'กรองรีเลย์ onion',
'Click to load X post': 'คลิกเพื่อโหลดโพสต์ X',
'View Nostr comments': 'ดูความคิดเห็น Nostr',
'Search for notes': 'ค้นหาโน้ต',
'Search for hashtag': 'ค้นหาแฮชแท็ก',
'Go to note': 'ไปที่โน้ต',
'Go to relay': 'ไปที่รีเลย์',
'View discussions about this': 'ดูการสนทนาเกี่ยวกับเนื้อหานี้',
'Open link': 'เปิดลิงก์',
'View Nostr discussions': 'ดูการสนทนา Nostr'
} }
} }

View File

@@ -520,14 +520,22 @@ export default {
'将此邀请码分享给他人以邀请他们加入此中继器。', '将此邀请码分享给他人以邀请他们加入此中继器。',
'Invite Code': '邀请码', 'Invite Code': '邀请码',
Copy: '复制', Copy: '复制',
'This invite code can be used by others to join the relay.': 'This invite code can be used by others to join the relay.': '此邀请码可供他人用于加入中继器。',
'此邀请码可供他人用于加入中继器。',
'No invite code available from this relay.': '此中继器没有可用的邀请码。', 'No invite code available from this relay.': '此中继器没有可用的邀请码。',
Close: '关闭', Close: '关闭',
'Failed to get invite code from relay': '从中继器获取邀请码失败', 'Failed to get invite code from relay': '从中继器获取邀请码失败',
'Failed to get invite code': '获取邀请码失败', 'Failed to get invite code': '获取邀请码失败',
'Invite code copied to clipboard': '邀请码已复制到剪贴板', 'Invite code copied to clipboard': '邀请码已复制到剪贴板',
'Favicon URL': '网站图标 URL', 'Favicon URL': '网站图标 URL',
'Filter out onion relays': '过滤洋葱中继' 'Filter out onion relays': '过滤洋葱中继',
'Click to load X post': '点击加载 X 帖子',
'View Nostr comments': '查看 Nostr 评论',
'Search for notes': '搜索笔记',
'Search for hashtag': '搜索话题标签',
'Go to note': '跳转到笔记',
'Go to relay': '跳转到中继器',
'View discussions about this': '查看关于此内容的讨论',
'Open link': '打开链接',
'View Nostr discussions': '查看 Nostr 讨论'
} }
} }

View File

@@ -6,6 +6,7 @@ import {
LN_INVOICE_REGEX, LN_INVOICE_REGEX,
URL_REGEX, URL_REGEX,
WS_URL_REGEX, WS_URL_REGEX,
X_URL_REGEX,
YOUTUBE_URL_REGEX YOUTUBE_URL_REGEX
} from '@/constants' } from '@/constants'
import { isImage, isMedia } from './url' import { isImage, isMedia } from './url'
@@ -24,6 +25,7 @@ export type TEmbeddedNodeType =
| 'emoji' | 'emoji'
| 'invoice' | 'invoice'
| 'youtube' | 'youtube'
| 'x-post'
export type TEmbeddedNode = export type TEmbeddedNode =
| { | {
@@ -96,6 +98,8 @@ export const EmbeddedUrlParser: TContentParser = (content: string) => {
type = 'media' type = 'media'
} else if (url.match(YOUTUBE_URL_REGEX)) { } else if (url.match(YOUTUBE_URL_REGEX)) {
type = 'youtube' type = 'youtube'
} else if (url.match(X_URL_REGEX)) {
type = 'x-post'
} }
// Add the match as specific type // Add the match as specific type

View File

@@ -20,6 +20,7 @@ import {
isProtectedEvent, isProtectedEvent,
isReplaceableEvent isReplaceableEvent
} from './event' } from './event'
import { determineExternalContentKind } from './external-content'
import { randomString } from './random' import { randomString } from './random'
import { generateBech32IdFromETag, tagNameEquals } from './tag' import { generateBech32IdFromETag, tagNameEquals } from './tag'
@@ -85,6 +86,33 @@ export function createReactionDraftEvent(event: Event, emoji: TEmoji | string =
} }
} }
export function createExternalContentReactionDraftEvent(
externalContent: string,
emoji: TEmoji | string = '+'
): TDraftEvent {
const tags: string[][] = []
tags.push(buildITag(externalContent))
const kind = determineExternalContentKind(externalContent)
if (kind) {
tags.push(buildKTag(kind))
}
let content: string
if (typeof emoji === 'string') {
content = emoji
} else {
content = `:${emoji.shortcode}:`
tags.push(buildEmojiTag(emoji))
}
return {
kind: ExtendedKind.EXTERNAL_CONTENT_REACTION,
content,
tags,
created_at: dayjs().unix()
}
}
// https://github.com/nostr-protocol/nips/blob/master/18.md // https://github.com/nostr-protocol/nips/blob/master/18.md
export function createRepostDraftEvent(event: Event): TDraftEvent { export function createRepostDraftEvent(event: Event): TDraftEvent {
const isProtected = isProtectedEvent(event) const isProtected = isProtectedEvent(event)
@@ -177,7 +205,7 @@ export function createRelaySetDraftEvent(relaySet: Omit<TRelaySet, 'aTag'>): TDr
export async function createCommentDraftEvent( export async function createCommentDraftEvent(
content: string, content: string,
parentEvent: Event, parentStuff: Event | string,
mentions: string[], mentions: string[],
options: { options: {
addClientTag?: boolean addClientTag?: boolean
@@ -193,8 +221,10 @@ export async function createCommentDraftEvent(
rootCoordinateTag, rootCoordinateTag,
rootKind, rootKind,
rootPubkey, rootPubkey,
rootUrl rootUrl,
} = await extractCommentMentions(transformedEmojisContent, parentEvent) parentEvent,
externalContent
} = await extractCommentMentions(transformedEmojisContent, parentStuff)
const hashtags = extractHashtags(transformedEmojisContent) const hashtags = extractHashtags(transformedEmojisContent)
const tags = emojiTags const tags = emojiTags
@@ -208,7 +238,9 @@ export async function createCommentDraftEvent(
} }
tags.push( tags.push(
...mentions.filter((pubkey) => pubkey !== parentEvent.pubkey).map((pubkey) => buildPTag(pubkey)) ...mentions
.filter((pubkey) => pubkey !== parentEvent?.pubkey)
.map((pubkey) => buildPTag(pubkey))
) )
if (rootCoordinateTag) { if (rootCoordinateTag) {
@@ -226,14 +258,25 @@ export async function createCommentDraftEvent(
tags.push(buildITag(rootUrl, true)) tags.push(buildITag(rootUrl, true))
} }
tags.push( tags.push(
...[ ...(parentEvent
isReplaceableEvent(parentEvent.kind) ? [
? buildATag(parentEvent) isReplaceableEvent(parentEvent.kind)
: buildETag(parentEvent.id, parentEvent.pubkey), ? buildATag(parentEvent)
buildKTag(parentEvent.kind), : buildETag(parentEvent.id, parentEvent.pubkey),
buildPTag(parentEvent.pubkey) buildPTag(parentEvent.pubkey)
] ]
: externalContent
? [buildITag(externalContent)]
: [])
) )
const parentKind = parentEvent
? parentEvent.kind
: externalContent
? determineExternalContentKind(externalContent)
: undefined
if (parentKind) {
tags.push(buildKTag(parentKind))
}
if (options.addClientTag) { if (options.addClientTag) {
tags.push(buildClientTag()) tags.push(buildClientTag())
@@ -580,19 +623,32 @@ async function extractRelatedEventIds(content: string, parentEvent?: Event) {
} }
} }
async function extractCommentMentions(content: string, parentEvent: Event) { async function extractCommentMentions(content: string, parentStuff: Event | string) {
const quoteEventHexIds: string[] = [] const quoteEventHexIds: string[] = []
const quoteReplaceableCoordinates: string[] = [] const quoteReplaceableCoordinates: string[] = []
const isComment = [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(parentEvent.kind) const { parentEvent, externalContent } =
const rootCoordinateTag = isComment typeof parentStuff === 'string'
? parentEvent.tags.find(tagNameEquals('A')) ? { parentEvent: undefined, externalContent: parentStuff }
: isReplaceableEvent(parentEvent.kind) : { parentEvent: parentStuff, externalContent: undefined }
? buildATag(parentEvent, true) const isComment =
: undefined parentEvent && [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(parentEvent.kind)
const rootEventId = isComment ? parentEvent.tags.find(tagNameEquals('E'))?.[1] : parentEvent.id const rootCoordinateTag = parentEvent
const rootKind = isComment ? parentEvent.tags.find(tagNameEquals('K'))?.[1] : parentEvent.kind ? isComment
const rootPubkey = isComment ? parentEvent.tags.find(tagNameEquals('P'))?.[1] : parentEvent.pubkey ? parentEvent.tags.find(tagNameEquals('A'))
const rootUrl = isComment ? parentEvent.tags.find(tagNameEquals('I'))?.[1] : undefined : isReplaceableEvent(parentEvent.kind)
? buildATag(parentEvent, true)
: undefined
: undefined
const rootEventId = isComment ? parentEvent.tags.find(tagNameEquals('E'))?.[1] : parentEvent?.id
const rootKind = isComment
? parentEvent.tags.find(tagNameEquals('K'))?.[1]
: parentEvent
? parentEvent.kind
: determineExternalContentKind(parentStuff as string)
const rootPubkey = isComment
? parentEvent.tags.find(tagNameEquals('P'))?.[1]
: parentEvent?.pubkey
const rootUrl = isComment ? parentEvent.tags.find(tagNameEquals('I'))?.[1] : externalContent
const addToSet = (arr: string[], item: string) => { const addToSet = (arr: string[], item: string) => {
if (!arr.includes(item)) arr.push(item) if (!arr.includes(item)) arr.push(item)
@@ -626,7 +682,8 @@ async function extractCommentMentions(content: string, parentEvent: Event) {
rootKind, rootKind,
rootPubkey, rootPubkey,
rootUrl, rootUrl,
parentEvent parentEvent,
externalContent
} }
} }

View File

@@ -94,12 +94,23 @@ export function getParentATag(event?: Event) {
return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A')) return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A'))
} }
export function getParentITag(event?: Event) {
if (
!event ||
![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(event.kind)
) {
return undefined
}
return event.tags.find(tagNameEquals('i')) ?? event.tags.find(tagNameEquals('I'))
}
export function getParentEventHexId(event?: Event) { export function getParentEventHexId(event?: Event) {
const tag = getParentETag(event) const tag = getParentETag(event)
return tag?.[1] return tag?.[1]
} }
export function getParentTag(event?: Event): { type: 'e' | 'a'; tag: string[] } | undefined { export function getParentTag(event?: Event): { type: 'e' | 'a' | 'i'; tag: string[] } | undefined {
if (!event) return undefined if (!event) return undefined
if (event.kind === kinds.ShortTextNote) { if (event.kind === kinds.ShortTextNote) {
@@ -114,8 +125,13 @@ export function getParentTag(event?: Event): { type: 'e' | 'a'; tag: string[] }
return tag ? { type: 'a', tag } : undefined return tag ? { type: 'a', tag } : undefined
} }
const tag = getParentETag(event) const parentETag = getParentETag(event)
return tag ? { type: 'e', tag } : undefined if (parentETag) {
return { type: 'e', tag: parentETag }
}
const parentITag = getParentITag(event)
return parentITag ? { type: 'i', tag: parentITag } : undefined
} }
export function getParentBech32Id(event?: Event) { export function getParentBech32Id(event?: Event) {
@@ -159,12 +175,23 @@ export function getRootATag(event?: Event) {
return event.tags.find(tagNameEquals('A')) return event.tags.find(tagNameEquals('A'))
} }
export function getRootITag(event?: Event) {
if (
!event ||
![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(event.kind)
) {
return undefined
}
return event.tags.find(tagNameEquals('I'))
}
export function getRootEventHexId(event?: Event) { export function getRootEventHexId(event?: Event) {
const tag = getRootETag(event) const tag = getRootETag(event)
return tag?.[1] return tag?.[1]
} }
export function getRootTag(event?: Event): { type: 'e' | 'a'; tag: string[] } | undefined { export function getRootTag(event?: Event): { type: 'e' | 'a' | 'i'; tag: string[] } | undefined {
if (!event) return undefined if (!event) return undefined
if (event.kind === kinds.ShortTextNote) { if (event.kind === kinds.ShortTextNote) {
@@ -179,8 +206,13 @@ export function getRootTag(event?: Event): { type: 'e' | 'a'; tag: string[] } |
return tag ? { type: 'a', tag } : undefined return tag ? { type: 'a', tag } : undefined
} }
const tag = getRootETag(event) const rootETag = getRootETag(event)
return tag ? { type: 'e', tag } : undefined if (rootETag) {
return { type: 'e', tag: rootETag }
}
const rootITag = getRootITag(event)
return rootITag ? { type: 'i', tag: rootITag } : undefined
} }
export function getRootBech32Id(event?: Event) { export function getRootBech32Id(event?: Event) {
@@ -192,13 +224,21 @@ export function getRootBech32Id(event?: Event) {
: generateBech32IdFromATag(rootTag.tag) : generateBech32IdFromATag(rootTag.tag)
} }
export function getParentStuff(event: Event) {
const parentEventId = getParentBech32Id(event)
if (parentEventId) return { parentEventId }
const parentITag = getParentITag(event)
return { parentExternalContent: parentITag?.[1] }
}
// For internal identification of events // For internal identification of events
export function getEventKey(event: Event) { export function getEventKey(event: Event) {
return isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id return isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id
} }
// Only used for e, E, a, A tags // Only used for e, E, a, A, i, I tags
export function getEventKeyFromTag([, tagValue]: (string | undefined)[]) { export function getKeyFromTag([, tagValue]: (string | undefined)[]) {
return tagValue return tagValue
} }

View File

@@ -0,0 +1,42 @@
export function determineExternalContentKind(externalContent: string): string | undefined {
if (externalContent.startsWith('http')) {
return 'web'
}
if (externalContent.startsWith('isbn:')) {
return 'isbn'
}
if (externalContent.startsWith('isan:')) {
return 'isan'
}
if (externalContent.startsWith('doi:')) {
return 'doi'
}
if (externalContent.startsWith('#')) {
return '#'
}
if (externalContent.startsWith('podcast:guid:')) {
return 'podcast:guid'
}
if (externalContent.startsWith('podcast:item:guid:')) {
return 'podcast:item:guid'
}
if (externalContent.startsWith('podcast:publisher:guid:')) {
return 'podcast:publisher:guid'
}
// Handle blockchain transaction format: <blockchain>:[<chainId>:]tx:<txid>
// Match pattern: blockchain name, optional chain ID, "tx:", transaction ID
const blockchainTxMatch = externalContent.match(/^([a-z]+):(?:[^:]+:)?tx:[a-f0-9]+$/i)
if (blockchainTxMatch) {
const blockchain = blockchainTxMatch[1].toLowerCase()
return `${blockchain}:tx`
}
// Handle blockchain address format: <blockchain>:[<chainId>:]address:<address>
// Match pattern: blockchain name, optional chain ID, "address:", address
const blockchainAddressMatch = externalContent.match(/^([a-z]+):(?:[^:]+:)?address:[a-zA-Z0-9]+$/i)
if (blockchainAddressMatch) {
const blockchain = blockchainAddressMatch[1].toLowerCase()
return `${blockchain}:address`
}
}

View File

@@ -11,13 +11,11 @@ export const toNote = (eventOrId: Event | string) => {
export const toNoteList = ({ export const toNoteList = ({
hashtag, hashtag,
search, search,
externalContentId,
domain, domain,
kinds kinds
}: { }: {
hashtag?: string hashtag?: string
search?: string search?: string
externalContentId?: string
domain?: string domain?: string
kinds?: number[] kinds?: number[]
}) => { }) => {
@@ -28,7 +26,6 @@ export const toNoteList = ({
kinds.forEach((k) => query.append('k', k.toString())) kinds.forEach((k) => query.append('k', k.toString()))
} }
if (search) query.set('s', search) if (search) query.set('s', search)
if (externalContentId) query.set('i', externalContentId)
if (domain) query.set('d', domain) if (domain) query.set('d', domain)
return `${path}?${query.toString()}` return `${path}?${query.toString()}`
} }
@@ -62,6 +59,7 @@ export const toSearch = (params?: TSearchParams) => {
} }
return `/search?${query.toString()}` return `/search?${query.toString()}`
} }
export const toExternalContent = (id: string) => `/external-content?id=${encodeURIComponent(id)}`
export const toSettings = () => '/settings' export const toSettings = () => '/settings'
export const toRelaySettings = (tag?: 'mailbox' | 'favorite-relays') => { export const toRelaySettings = (tag?: 'mailbox' | 'favorite-relays') => {
return '/settings/relays' + (tag ? '#' + tag : '') return '/settings/relays' + (tag ? '#' + tag : '')

View File

@@ -15,7 +15,12 @@ export function isOnionUrl(url: string): boolean {
export function normalizeUrl(url: string): string { export function normalizeUrl(url: string): string {
try { try {
if (url.indexOf('://') === -1) { if (url.indexOf('://') === -1) {
if (url.startsWith('localhost:') || url.startsWith('localhost/')) { if (
url.startsWith('localhost:') ||
url.startsWith('localhost/') ||
url.startsWith('127.') ||
url.startsWith('192.168.')
) {
url = 'ws://' + url url = 'ws://' + url
} else { } else {
url = 'wss://' + url url = 'wss://' + url

View File

@@ -0,0 +1,41 @@
import ExternalContent from '@/components/ExternalContent'
import ExternalContentInteractions from '@/components/ExternalContentInteractions'
import StuffStats from '@/components/StuffStats'
import { Separator } from '@/components/ui/separator'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NotFoundPage from '../NotFoundPage'
const ExternalContentPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
const [id, setId] = useState<string | undefined>()
useEffect(() => {
const searchParams = new URLSearchParams(window.location.search)
const id = searchParams.get('id')
if (id) {
setId(id)
}
}, [])
if (!id) return <NotFoundPage index={index} />
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={t('External Content')}
displayScrollToTopButton
>
<div className="px-4 mt-3">
<ExternalContent content={id} />
<StuffStats className="mt-3" stuff={id} fetchIfNotExisting displayTopZapsAndLikes />
</div>
<Separator className="mt-4" />
<ExternalContentInteractions pageIndex={index} externalContent={id} />
</SecondaryPageLayout>
)
})
ExternalContentPage.displayName = 'ExternalContentPage'
export default ExternalContentPage

View File

@@ -2,7 +2,7 @@ import { useSecondaryPage } from '@/PageManager'
import ContentPreview from '@/components/ContentPreview' import ContentPreview from '@/components/ContentPreview'
import Note from '@/components/Note' import Note from '@/components/Note'
import NoteInteractions from '@/components/NoteInteractions' import NoteInteractions from '@/components/NoteInteractions'
import NoteStats from '@/components/NoteStats' import StuffStats from '@/components/StuffStats'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
@@ -12,12 +12,12 @@ import { useFetchEvent } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { import {
getEventKey, getEventKey,
getEventKeyFromTag, getKeyFromTag,
getParentBech32Id, getParentBech32Id,
getParentTag, getParentTag,
getRootBech32Id getRootBech32Id
} from '@/lib/event' } from '@/lib/event'
import { toNote, toNoteList } from '@/lib/link' import { toExternalContent, toNote } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Ellipsis } from 'lucide-react' import { Ellipsis } from 'lucide-react'
@@ -102,7 +102,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
originalNoteId={id} originalNoteId={id}
showFull showFull
/> />
<NoteStats className="mt-3" event={event} fetchIfNotExisting displayTopZapsAndLikes /> <StuffStats className="mt-3" stuff={event} fetchIfNotExisting displayTopZapsAndLikes />
</div> </div>
<Separator className="mt-4" /> <Separator className="mt-4" />
<NoteInteractions key={`note-interactions-${event.id}`} pageIndex={index} event={event} /> <NoteInteractions key={`note-interactions-${event.id}`} pageIndex={index} event={event} />
@@ -119,7 +119,7 @@ function ExternalRoot({ value }: { value: string }) {
<div> <div>
<Card <Card
className="flex space-x-1 px-1.5 py-1 items-center clickable text-sm text-muted-foreground hover:text-foreground" className="flex space-x-1 px-1.5 py-1 items-center clickable text-sm text-muted-foreground hover:text-foreground"
onClick={() => push(toNoteList({ externalContentId: value }))} onClick={() => push(toExternalContent(value))}
> >
<div className="truncate">{value}</div> <div className="truncate">{value}</div>
</Card> </Card>
@@ -184,5 +184,5 @@ function isConsecutive(rootEvent?: Event, parentEvent?: Event) {
const tag = getParentTag(parentEvent) const tag = getParentTag(parentEvent)
if (!tag) return false if (!tag) return false
return getEventKey(rootEvent) === getEventKeyFromTag(tag.tag) return getEventKey(rootEvent) === getKeyFromTag(tag.tag)
} }

View File

@@ -19,7 +19,7 @@ import client from '@/services/client.service'
import customEmojiService from '@/services/custom-emoji.service' import customEmojiService from '@/services/custom-emoji.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import noteStatsService from '@/services/note-stats.service' import stuffStatsService from '@/services/stuff-stats.service'
import { import {
ISigner, ISigner,
TAccount, TAccount,
@@ -369,7 +369,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
limit: 100 limit: 100
} }
]) ])
noteStatsService.updateNoteStatsByEvents(events) stuffStatsService.updateStuffStatsByEvents(events)
} }
initInteractions() initInteractions()
}, [account]) }, [account])

View File

@@ -1,4 +1,4 @@
import { getEventKey, getEventKeyFromTag, getParentTag } from '@/lib/event' import { getEventKey, getKeyFromTag, getParentTag } from '@/lib/event'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { createContext, useCallback, useContext, useState } from 'react' import { createContext, useCallback, useContext, useState } from 'react'
@@ -32,7 +32,7 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) {
const parentTag = getParentTag(reply) const parentTag = getParentTag(reply)
if (parentTag) { if (parentTag) {
const parentKey = getEventKeyFromTag(parentTag.tag) const parentKey = getKeyFromTag(parentTag.tag)
if (parentKey) { if (parentKey) {
newReplyEventMap.set(parentKey, [...(newReplyEventMap.get(parentKey) || []), reply]) newReplyEventMap.set(parentKey, [...(newReplyEventMap.get(parentKey) || []), reply])
} }

View File

@@ -4,6 +4,7 @@ import { TTheme, TThemeSetting } from '@/types'
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'
type ThemeProviderState = { type ThemeProviderState = {
theme: TTheme
themeSetting: TThemeSetting themeSetting: TThemeSetting
setThemeSetting: (themeSetting: TThemeSetting) => void setThemeSetting: (themeSetting: TThemeSetting) => void
primaryColor: TPrimaryColor primaryColor: TPrimaryColor
@@ -83,6 +84,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
return ( return (
<ThemeProviderContext.Provider <ThemeProviderContext.Provider
value={{ value={{
theme,
themeSetting, themeSetting,
setThemeSetting: updateThemeSetting, setThemeSetting: updateThemeSetting,
primaryColor, primaryColor,

View File

@@ -1,6 +1,7 @@
import AppearanceSettingsPage from '@/pages/secondary/AppearanceSettingsPage' import AppearanceSettingsPage from '@/pages/secondary/AppearanceSettingsPage'
import BookmarkPage from '@/pages/secondary/BookmarkPage' import BookmarkPage from '@/pages/secondary/BookmarkPage'
import EmojiPackSettingsPage from '@/pages/secondary/EmojiPackSettingsPage' import EmojiPackSettingsPage from '@/pages/secondary/EmojiPackSettingsPage'
import ExternalContentPage from '@/pages/secondary/ExternalContentPage'
import FollowingListPage from '@/pages/secondary/FollowingListPage' import FollowingListPage from '@/pages/secondary/FollowingListPage'
import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage' import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage'
import MuteListPage from '@/pages/secondary/MuteListPage' import MuteListPage from '@/pages/secondary/MuteListPage'
@@ -34,6 +35,7 @@ const SECONDARY_ROUTE_CONFIGS = [
{ path: '/relays/:url', element: <RelayPage /> }, { path: '/relays/:url', element: <RelayPage /> },
{ path: '/relays/:url/reviews', element: <RelayReviewsPage /> }, { path: '/relays/:url/reviews', element: <RelayReviewsPage /> },
{ path: '/search', element: <SearchPage /> }, { path: '/search', element: <SearchPage /> },
{ path: '/external-content', element: <ExternalContentPage /> },
{ path: '/settings', element: <SettingsPage /> }, { path: '/settings', element: <SettingsPage /> },
{ path: '/settings/relays', element: <RelaySettingsPage /> }, { path: '/settings/relays', element: <RelaySettingsPage /> },
{ path: '/settings/wallet', element: <WalletPage /> }, { path: '/settings/wallet', element: <WalletPage /> },

View File

@@ -98,11 +98,11 @@ class ClientService extends EventTarget {
} }
} }
let relays: string[] const relaySet = new Set<string>()
if (specifiedRelayUrls?.length) { if (specifiedRelayUrls?.length) {
relays = specifiedRelayUrls specifiedRelayUrls.forEach((url) => relaySet.add(url))
} else { } else {
const _additionalRelayUrls: string[] = additionalRelayUrls ?? [] additionalRelayUrls?.forEach((url) => relaySet.add(url))
if (!specifiedRelayUrls?.length && ![kinds.Contacts, kinds.Mutelist].includes(event.kind)) { if (!specifiedRelayUrls?.length && ![kinds.Contacts, kinds.Mutelist].includes(event.kind)) {
const mentions: string[] = [] const mentions: string[] = []
event.tags.forEach(([tagName, tagValue]) => { event.tags.forEach(([tagName, tagValue]) => {
@@ -118,10 +118,14 @@ class ClientService extends EventTarget {
if (mentions.length > 0) { if (mentions.length > 0) {
const relayLists = await this.fetchRelayLists(mentions) const relayLists = await this.fetchRelayLists(mentions)
relayLists.forEach((relayList) => { relayLists.forEach((relayList) => {
_additionalRelayUrls.push(...relayList.read.slice(0, 4)) relayList.read.slice(0, 4).forEach((url) => relaySet.add(url))
}) })
} }
} }
const relayList = await this.fetchRelayList(event.pubkey)
relayList.write.forEach((url) => relaySet.add(url))
if ( if (
[ [
kinds.RelayList, kinds.RelayList,
@@ -131,20 +135,23 @@ class ClientService extends EventTarget {
ExtendedKind.RELAY_REVIEW ExtendedKind.RELAY_REVIEW
].includes(event.kind) ].includes(event.kind)
) { ) {
_additionalRelayUrls.push(...BIG_RELAY_URLS) BIG_RELAY_URLS.forEach((url) => relaySet.add(url))
} }
const relayList = await this.fetchRelayList(event.pubkey) if (event.kind === ExtendedKind.COMMENT) {
relays = (relayList?.write.slice(0, 10) ?? []).concat( const rootITag = event.tags.find(tagNameEquals('I'))
Array.from(new Set(_additionalRelayUrls)) ?? [] if (rootITag) {
) // For external content comments, always publish to big relays
BIG_RELAY_URLS.forEach((url) => relaySet.add(url))
}
}
} }
if (!relays.length) { if (!relaySet.size) {
relays.push(...BIG_RELAY_URLS) BIG_RELAY_URLS.forEach((url) => relaySet.add(url))
} }
return relays return Array.from(relaySet)
} }
async publishEvent(relayUrls: string[], event: NEvent) { async publishEvent(relayUrls: string[], event: NEvent) {

View File

@@ -1,273 +0,0 @@
import { BIG_RELAY_URLS } from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { getEmojiInfosFromEmojiTags, tagNameEquals } from '@/lib/tag'
import client from '@/services/client.service'
import { TEmoji } from '@/types'
import dayjs from 'dayjs'
import { Event, Filter, kinds } from 'nostr-tools'
export type TNoteStats = {
likeIdSet: Set<string>
likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[]
repostPubkeySet: Set<string>
reposts: { id: string; pubkey: string; created_at: number }[]
zapPrSet: Set<string>
zaps: { pr: string; pubkey: string; amount: number; created_at: number; comment?: string }[]
updatedAt?: number
}
class NoteStatsService {
static instance: NoteStatsService
private noteStatsMap: Map<string, Partial<TNoteStats>> = new Map()
private noteStatsSubscribers = new Map<string, Set<() => void>>()
constructor() {
if (!NoteStatsService.instance) {
NoteStatsService.instance = this
}
return NoteStatsService.instance
}
async fetchNoteStats(event: Event, pubkey?: string | null) {
const oldStats = this.noteStatsMap.get(event.id)
let since: number | undefined
if (oldStats?.updatedAt) {
since = oldStats.updatedAt
}
const [relayList, authorProfile] = await Promise.all([
client.fetchRelayList(event.pubkey),
client.fetchProfile(event.pubkey)
])
const replaceableCoordinate = isReplaceableEvent(event.kind)
? getReplaceableCoordinateFromEvent(event)
: undefined
const filters: Filter[] = [
{
'#e': [event.id],
kinds: [kinds.Reaction],
limit: 500
},
{
'#e': [event.id],
kinds: [kinds.Repost],
limit: 100
}
]
if (replaceableCoordinate) {
filters.push(
{
'#a': [replaceableCoordinate],
kinds: [kinds.Reaction],
limit: 500
},
{
'#a': [replaceableCoordinate],
kinds: [kinds.Repost],
limit: 100
}
)
}
if (authorProfile?.lightningAddress) {
filters.push({
'#e': [event.id],
kinds: [kinds.Zap],
limit: 500
})
if (replaceableCoordinate) {
filters.push({
'#a': [replaceableCoordinate],
kinds: [kinds.Zap],
limit: 500
})
}
}
if (pubkey) {
filters.push({
'#e': [event.id],
authors: [pubkey],
kinds: [kinds.Reaction, kinds.Repost]
})
if (replaceableCoordinate) {
filters.push({
'#a': [replaceableCoordinate],
authors: [pubkey],
kinds: [kinds.Reaction, kinds.Repost]
})
}
if (authorProfile?.lightningAddress) {
filters.push({
'#e': [event.id],
'#P': [pubkey],
kinds: [kinds.Zap]
})
if (replaceableCoordinate) {
filters.push({
'#a': [replaceableCoordinate],
'#P': [pubkey],
kinds: [kinds.Zap]
})
}
}
}
if (since) {
filters.forEach((filter) => {
filter.since = since
})
}
const events: Event[] = []
await client.fetchEvents(relayList.read.concat(BIG_RELAY_URLS).slice(0, 5), filters, {
onevent: (evt) => {
this.updateNoteStatsByEvents([evt])
events.push(evt)
}
})
this.noteStatsMap.set(event.id, {
...(this.noteStatsMap.get(event.id) ?? {}),
updatedAt: dayjs().unix()
})
return this.noteStatsMap.get(event.id) ?? {}
}
subscribeNoteStats(noteId: string, callback: () => void) {
let set = this.noteStatsSubscribers.get(noteId)
if (!set) {
set = new Set()
this.noteStatsSubscribers.set(noteId, set)
}
set.add(callback)
return () => {
set?.delete(callback)
if (set?.size === 0) this.noteStatsSubscribers.delete(noteId)
}
}
private notifyNoteStats(noteId: string) {
const set = this.noteStatsSubscribers.get(noteId)
if (set) {
set.forEach((cb) => cb())
}
}
getNoteStats(id: string): Partial<TNoteStats> | undefined {
return this.noteStatsMap.get(id)
}
addZap(
pubkey: string,
eventId: string,
pr: string,
amount: number,
comment?: string,
created_at: number = dayjs().unix(),
notify: boolean = true
) {
const old = this.noteStatsMap.get(eventId) || {}
const zapPrSet = old.zapPrSet || new Set()
const zaps = old.zaps || []
if (zapPrSet.has(pr)) return
zapPrSet.add(pr)
zaps.push({ pr, pubkey, amount, comment, created_at })
this.noteStatsMap.set(eventId, { ...old, zapPrSet, zaps })
if (notify) {
this.notifyNoteStats(eventId)
}
return eventId
}
updateNoteStatsByEvents(events: Event[]) {
const updatedEventIdSet = new Set<string>()
events.forEach((evt) => {
let updatedEventId: string | undefined
if (evt.kind === kinds.Reaction) {
updatedEventId = this.addLikeByEvent(evt)
} else if (evt.kind === kinds.Repost) {
updatedEventId = this.addRepostByEvent(evt)
} else if (evt.kind === kinds.Zap) {
updatedEventId = this.addZapByEvent(evt)
}
if (updatedEventId) {
updatedEventIdSet.add(updatedEventId)
}
})
updatedEventIdSet.forEach((eventId) => {
this.notifyNoteStats(eventId)
})
}
private addLikeByEvent(evt: Event) {
const targetEventId = evt.tags.findLast(tagNameEquals('e'))?.[1]
if (!targetEventId) return
const old = this.noteStatsMap.get(targetEventId) || {}
const likeIdSet = old.likeIdSet || new Set()
const likes = old.likes || []
if (likeIdSet.has(evt.id)) return
let emoji: TEmoji | string = evt.content.trim()
if (!emoji) return
if (emoji.startsWith(':') && emoji.endsWith(':')) {
const emojiInfos = getEmojiInfosFromEmojiTags(evt.tags)
const shortcode = emoji.split(':')[1]
const emojiInfo = emojiInfos.find((info) => info.shortcode === shortcode)
if (emojiInfo) {
emoji = emojiInfo
} else {
emoji = '+'
}
}
likeIdSet.add(evt.id)
likes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at, emoji })
this.noteStatsMap.set(targetEventId, { ...old, likeIdSet, likes })
return targetEventId
}
private addRepostByEvent(evt: Event) {
const eventId = evt.tags.find(tagNameEquals('e'))?.[1]
if (!eventId) return
const old = this.noteStatsMap.get(eventId) || {}
const repostPubkeySet = old.repostPubkeySet || new Set()
const reposts = old.reposts || []
if (repostPubkeySet.has(evt.pubkey)) return
repostPubkeySet.add(evt.pubkey)
reposts.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at })
this.noteStatsMap.set(eventId, { ...old, repostPubkeySet, reposts })
return eventId
}
private addZapByEvent(evt: Event) {
const info = getZapInfoFromEvent(evt)
if (!info) return
const { originalEventId, senderPubkey, invoice, amount, comment } = info
if (!originalEventId || !senderPubkey) return
return this.addZap(
senderPubkey,
originalEventId,
invoice,
amount,
comment,
evt.created_at,
false
)
}
}
const instance = new NoteStatsService()
export default instance

View File

@@ -24,49 +24,53 @@ class PostEditorCacheService {
getPostContentCache({ getPostContentCache({
defaultContent, defaultContent,
parentEvent parentStuff
}: { defaultContent?: string; parentEvent?: Event } = {}) { }: { defaultContent?: string; parentStuff?: Event | string } = {}) {
return ( return (
this.postContentCache.get(this.generateCacheKey(defaultContent, parentEvent)) ?? this.postContentCache.get(this.generateCacheKey(defaultContent, parentStuff)) ??
defaultContent defaultContent
) )
} }
setPostContentCache( setPostContentCache(
{ defaultContent, parentEvent }: { defaultContent?: string; parentEvent?: Event }, { defaultContent, parentStuff }: { defaultContent?: string; parentStuff?: Event | string },
content: Content content: Content
) { ) {
this.postContentCache.set(this.generateCacheKey(defaultContent, parentEvent), content) this.postContentCache.set(this.generateCacheKey(defaultContent, parentStuff), content)
} }
getPostSettingsCache({ getPostSettingsCache({
defaultContent, defaultContent,
parentEvent parentStuff
}: { defaultContent?: string; parentEvent?: Event } = {}): TPostSettings | undefined { }: { defaultContent?: string; parentStuff?: Event | string } = {}): TPostSettings | undefined {
return this.postSettingsCache.get(this.generateCacheKey(defaultContent, parentEvent)) return this.postSettingsCache.get(this.generateCacheKey(defaultContent, parentStuff))
} }
setPostSettingsCache( setPostSettingsCache(
{ defaultContent, parentEvent }: { defaultContent?: string; parentEvent?: Event }, { defaultContent, parentStuff }: { defaultContent?: string; parentStuff?: Event | string },
settings: TPostSettings settings: TPostSettings
) { ) {
this.postSettingsCache.set(this.generateCacheKey(defaultContent, parentEvent), settings) this.postSettingsCache.set(this.generateCacheKey(defaultContent, parentStuff), settings)
} }
clearPostCache({ clearPostCache({
defaultContent, defaultContent,
parentEvent parentStuff
}: { }: {
defaultContent?: string defaultContent?: string
parentEvent?: Event parentStuff?: Event | string
}) { }) {
const cacheKey = this.generateCacheKey(defaultContent, parentEvent) const cacheKey = this.generateCacheKey(defaultContent, parentStuff)
this.postContentCache.delete(cacheKey) this.postContentCache.delete(cacheKey)
this.postSettingsCache.delete(cacheKey) this.postSettingsCache.delete(cacheKey)
} }
generateCacheKey(defaultContent: string = '', parentEvent?: Event): string { generateCacheKey(defaultContent: string = '', parentStuff?: Event | string): string {
return parentEvent ? parentEvent.id : defaultContent return parentStuff
? typeof parentStuff === 'string'
? parentStuff
: parentStuff.id
: defaultContent
} }
} }

View File

@@ -0,0 +1,328 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { getEventKey, getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { getEmojiInfosFromEmojiTags, tagNameEquals } from '@/lib/tag'
import client from '@/services/client.service'
import { TEmoji } from '@/types'
import dayjs from 'dayjs'
import { Event, Filter, kinds } from 'nostr-tools'
export type TStuffStats = {
likeIdSet: Set<string>
likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[]
repostPubkeySet: Set<string>
reposts: { id: string; pubkey: string; created_at: number }[]
zapPrSet: Set<string>
zaps: { pr: string; pubkey: string; amount: number; created_at: number; comment?: string }[]
updatedAt?: number
}
class StuffStatsService {
static instance: StuffStatsService
private stuffStatsMap: Map<string, Partial<TStuffStats>> = new Map()
private stuffStatsSubscribers = new Map<string, Set<() => void>>()
constructor() {
if (!StuffStatsService.instance) {
StuffStatsService.instance = this
}
return StuffStatsService.instance
}
async fetchStuffStats(stuff: Event | string, pubkey?: string | null) {
const { event, externalContent } =
typeof stuff === 'string'
? { event: undefined, externalContent: stuff }
: { event: stuff, externalContent: undefined }
const key = event ? getEventKey(event) : externalContent
const oldStats = this.stuffStatsMap.get(key)
let since: number | undefined
if (oldStats?.updatedAt) {
since = oldStats.updatedAt
}
const [relayList, authorProfile] = event
? await Promise.all([client.fetchRelayList(event.pubkey), client.fetchProfile(event.pubkey)])
: []
const replaceableCoordinate =
event && isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : undefined
const filters: Filter[] = []
if (event) {
filters.push(
{
'#e': [event.id],
kinds: [kinds.Reaction],
limit: 500
},
{
'#e': [event.id],
kinds: [kinds.Repost],
limit: 100
}
)
} else {
filters.push({
'#i': [externalContent],
kinds: [ExtendedKind.EXTERNAL_CONTENT_REACTION],
limit: 500
})
}
if (replaceableCoordinate) {
filters.push(
{
'#a': [replaceableCoordinate],
kinds: [kinds.Reaction],
limit: 500
},
{
'#a': [replaceableCoordinate],
kinds: [kinds.Repost],
limit: 100
}
)
}
if (event && authorProfile?.lightningAddress) {
filters.push({
'#e': [event.id],
kinds: [kinds.Zap],
limit: 500
})
if (replaceableCoordinate) {
filters.push({
'#a': [replaceableCoordinate],
kinds: [kinds.Zap],
limit: 500
})
}
}
if (pubkey) {
filters.push(
event
? {
'#e': [event.id],
authors: [pubkey],
kinds: [kinds.Reaction, kinds.Repost]
}
: {
'#i': [externalContent],
authors: [pubkey],
kinds: [ExtendedKind.EXTERNAL_CONTENT_REACTION]
}
)
if (replaceableCoordinate) {
filters.push({
'#a': [replaceableCoordinate],
authors: [pubkey],
kinds: [kinds.Reaction, kinds.Repost]
})
}
if (event && authorProfile?.lightningAddress) {
filters.push({
'#e': [event.id],
'#P': [pubkey],
kinds: [kinds.Zap]
})
if (replaceableCoordinate) {
filters.push({
'#a': [replaceableCoordinate],
'#P': [pubkey],
kinds: [kinds.Zap]
})
}
}
}
if (since) {
filters.forEach((filter) => {
filter.since = since
})
}
const relays = relayList ? relayList.read.concat(BIG_RELAY_URLS).slice(0, 5) : BIG_RELAY_URLS
const events: Event[] = []
await client.fetchEvents(relays, filters, {
onevent: (evt) => {
this.updateStuffStatsByEvents([evt])
events.push(evt)
}
})
this.stuffStatsMap.set(key, {
...(this.stuffStatsMap.get(key) ?? {}),
updatedAt: dayjs().unix()
})
return this.stuffStatsMap.get(key) ?? {}
}
subscribeStuffStats(stuffKey: string, callback: () => void) {
let set = this.stuffStatsSubscribers.get(stuffKey)
if (!set) {
set = new Set()
this.stuffStatsSubscribers.set(stuffKey, set)
}
set.add(callback)
return () => {
set?.delete(callback)
if (set?.size === 0) this.stuffStatsSubscribers.delete(stuffKey)
}
}
private notifyStuffStats(stuffKey: string) {
const set = this.stuffStatsSubscribers.get(stuffKey)
if (set) {
set.forEach((cb) => cb())
}
}
getStuffStats(stuffKey: string): Partial<TStuffStats> | undefined {
return this.stuffStatsMap.get(stuffKey)
}
addZap(
pubkey: string,
eventId: string,
pr: string,
amount: number,
comment?: string,
created_at: number = dayjs().unix(),
notify: boolean = true
) {
const old = this.stuffStatsMap.get(eventId) || {}
const zapPrSet = old.zapPrSet || new Set()
const zaps = old.zaps || []
if (zapPrSet.has(pr)) return
zapPrSet.add(pr)
zaps.push({ pr, pubkey, amount, comment, created_at })
this.stuffStatsMap.set(eventId, { ...old, zapPrSet, zaps })
if (notify) {
this.notifyStuffStats(eventId)
}
return eventId
}
updateStuffStatsByEvents(events: Event[]) {
const targetKeySet = new Set<string>()
events.forEach((evt) => {
let targetKey: string | undefined
if (evt.kind === kinds.Reaction) {
targetKey = this.addLikeByEvent(evt)
} else if (evt.kind === ExtendedKind.EXTERNAL_CONTENT_REACTION) {
targetKey = this.addExternalContentLikeByEvent(evt)
} else if (evt.kind === kinds.Repost) {
targetKey = this.addRepostByEvent(evt)
} else if (evt.kind === kinds.Zap) {
targetKey = this.addZapByEvent(evt)
}
if (targetKey) {
targetKeySet.add(targetKey)
}
})
targetKeySet.forEach((targetKey) => {
this.notifyStuffStats(targetKey)
})
}
private addLikeByEvent(evt: Event) {
const targetEventId = evt.tags.findLast(tagNameEquals('e'))?.[1]
if (!targetEventId) return
const old = this.stuffStatsMap.get(targetEventId) || {}
const likeIdSet = old.likeIdSet || new Set()
const likes = old.likes || []
if (likeIdSet.has(evt.id)) return
let emoji: TEmoji | string = evt.content.trim()
if (!emoji) return
if (emoji.startsWith(':') && emoji.endsWith(':')) {
const emojiInfos = getEmojiInfosFromEmojiTags(evt.tags)
const shortcode = emoji.split(':')[1]
const emojiInfo = emojiInfos.find((info) => info.shortcode === shortcode)
if (emojiInfo) {
emoji = emojiInfo
} else {
emoji = '+'
}
}
likeIdSet.add(evt.id)
likes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at, emoji })
this.stuffStatsMap.set(targetEventId, { ...old, likeIdSet, likes })
return targetEventId
}
private addExternalContentLikeByEvent(evt: Event) {
const target = evt.tags.findLast(tagNameEquals('i'))?.[1]
if (!target) return
const old = this.stuffStatsMap.get(target) || {}
const likeIdSet = old.likeIdSet || new Set()
const likes = old.likes || []
if (likeIdSet.has(evt.id)) return
let emoji: TEmoji | string = evt.content.trim()
if (!emoji) return
if (emoji.startsWith(':') && emoji.endsWith(':')) {
const emojiInfos = getEmojiInfosFromEmojiTags(evt.tags)
const shortcode = emoji.split(':')[1]
const emojiInfo = emojiInfos.find((info) => info.shortcode === shortcode)
if (emojiInfo) {
emoji = emojiInfo
} else {
emoji = '+'
}
}
likeIdSet.add(evt.id)
likes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at, emoji })
this.stuffStatsMap.set(target, { ...old, likeIdSet, likes })
return target
}
private addRepostByEvent(evt: Event) {
const eventId = evt.tags.find(tagNameEquals('e'))?.[1]
if (!eventId) return
const old = this.stuffStatsMap.get(eventId) || {}
const repostPubkeySet = old.repostPubkeySet || new Set()
const reposts = old.reposts || []
if (repostPubkeySet.has(evt.pubkey)) return
repostPubkeySet.add(evt.pubkey)
reposts.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at })
this.stuffStatsMap.set(eventId, { ...old, repostPubkeySet, reposts })
return eventId
}
private addZapByEvent(evt: Event) {
const info = getZapInfoFromEvent(evt)
if (!info) return
const { originalEventId, senderPubkey, invoice, amount, comment } = info
if (!originalEventId || !senderPubkey) return
return this.addZap(
senderPubkey,
originalEventId,
invoice,
amount,
comment,
evt.created_at,
false
)
}
}
const instance = new StuffStatsService()
export default instance

View File

@@ -170,7 +170,14 @@ export type TPollCreateData = {
endsAt?: number endsAt?: number
} }
export type TSearchType = 'profile' | 'profiles' | 'notes' | 'note' | 'hashtag' | 'relay' export type TSearchType =
| 'profile'
| 'profiles'
| 'notes'
| 'note'
| 'hashtag'
| 'relay'
| 'externalContent'
export type TSearchParams = { export type TSearchParams = {
type: TSearchType type: TSearchType

19
src/types/twitter.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
declare global {
interface Window {
twttr?: {
widgets: {
createTweet: (
tweetId: string,
container: HTMLElement,
options?: {
theme?: 'light' | 'dark'
dnt?: boolean
conversation?: 'none' | 'all'
}
) => Promise<HTMLElement | undefined>
}
}
}
}
export {}