feat: add support for commenting and reacting on external content
This commit is contained in:
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
1. For global state, create a new Provider in `src/providers/`
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
|
||||
import { useBookmarks } from '@/providers/BookmarksProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
@@ -7,12 +8,15 @@ import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function BookmarkButton({ event }: { event: Event }) {
|
||||
export default function BookmarkButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey: accountPubkey, bookmarkListEvent, checkLogin } = useNostr()
|
||||
const { addBookmark, removeBookmark } = useBookmarks()
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const { event } = useStuff(stuff)
|
||||
const isBookmarked = useMemo(() => {
|
||||
if (!event) return false
|
||||
|
||||
const isReplaceable = isReplaceableEvent(event.kind)
|
||||
const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id
|
||||
|
||||
@@ -26,7 +30,7 @@ export default function BookmarkButton({ event }: { event: Event }) {
|
||||
const handleBookmark = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (isBookmarked) return
|
||||
if (isBookmarked || !event) return
|
||||
|
||||
setUpdating(true)
|
||||
try {
|
||||
@@ -42,7 +46,7 @@ export default function BookmarkButton({ event }: { event: Event }) {
|
||||
const handleRemoveBookmark = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (!isBookmarked) return
|
||||
if (!isBookmarked || !event) return
|
||||
|
||||
setUpdating(true)
|
||||
try {
|
||||
@@ -59,9 +63,9 @@ export default function BookmarkButton({ event }: { event: Event }) {
|
||||
<button
|
||||
className={`flex items-center gap-1 ${
|
||||
isBookmarked ? 'text-rose-400' : 'text-muted-foreground'
|
||||
} enabled:hover:text-rose-400 px-3 h-full`}
|
||||
} enabled:hover:text-rose-400 px-3 h-full disabled:text-muted-foreground/40 disabled:cursor-default`}
|
||||
onClick={isBookmarked ? handleRemoveBookmark : handleBookmark}
|
||||
disabled={updating}
|
||||
disabled={!event || updating}
|
||||
title={isBookmarked ? t('Remove bookmark') : t('Bookmark')}
|
||||
>
|
||||
{updating ? (
|
||||
|
||||
@@ -28,6 +28,7 @@ import ExternalLink from '../ExternalLink'
|
||||
import ImageGallery from '../ImageGallery'
|
||||
import MediaPlayer from '../MediaPlayer'
|
||||
import WebPreview from '../WebPreview'
|
||||
import XEmbeddedPost from '../XEmbeddedPost'
|
||||
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
|
||||
|
||||
export default function Content({
|
||||
@@ -156,6 +157,16 @@ export default function Content({
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.type === 'x-post') {
|
||||
return (
|
||||
<XEmbeddedPost
|
||||
key={index}
|
||||
url={node.data}
|
||||
className="mt-2"
|
||||
mustLoad={mustLoadMedia}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
|
||||
|
||||
94
src/components/ExternalContent/index.tsx
Normal file
94
src/components/ExternalContent/index.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
EmbeddedHashtagParser,
|
||||
EmbeddedUrlParser,
|
||||
EmbeddedWebsocketUrlParser,
|
||||
parseContent
|
||||
} from '@/lib/content-parser'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useMemo } from 'react'
|
||||
import { EmbeddedHashtag, EmbeddedLNInvoice, EmbeddedWebsocketUrl } from '../Embedded'
|
||||
import ImageGallery from '../ImageGallery'
|
||||
import MediaPlayer from '../MediaPlayer'
|
||||
import WebPreview from '../WebPreview'
|
||||
import XEmbeddedPost from '../XEmbeddedPost'
|
||||
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
|
||||
|
||||
export default function ExternalContent({
|
||||
content,
|
||||
className,
|
||||
mustLoadMedia
|
||||
}: {
|
||||
content?: string
|
||||
className?: string
|
||||
mustLoadMedia?: boolean
|
||||
}) {
|
||||
const nodes = useMemo(() => {
|
||||
if (!content) return []
|
||||
|
||||
return parseContent(content, [
|
||||
EmbeddedUrlParser,
|
||||
EmbeddedWebsocketUrlParser,
|
||||
EmbeddedHashtagParser
|
||||
])
|
||||
}, [content])
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const node = nodes[0]
|
||||
|
||||
if (node.type === 'text') {
|
||||
return (
|
||||
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>{content}</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'url') {
|
||||
return <WebPreview url={node.data} className={className} />
|
||||
}
|
||||
|
||||
if (node.type === 'x-post') {
|
||||
return (
|
||||
<XEmbeddedPost
|
||||
url={node.data}
|
||||
className={className}
|
||||
mustLoad={mustLoadMedia}
|
||||
embedded={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'youtube') {
|
||||
return <YoutubeEmbeddedPlayer url={node.data} className={className} mustLoad={mustLoadMedia} />
|
||||
}
|
||||
|
||||
if (node.type === 'image' || node.type === 'images') {
|
||||
const data = Array.isArray(node.data) ? node.data : [node.data]
|
||||
return (
|
||||
<ImageGallery
|
||||
className={className}
|
||||
images={data.map((url) => ({ url }))}
|
||||
mustLoad={mustLoadMedia}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'media') {
|
||||
return <MediaPlayer className={className} src={node.data} mustLoad={mustLoadMedia} />
|
||||
}
|
||||
|
||||
if (node.type === 'invoice') {
|
||||
return <EmbeddedLNInvoice invoice={node.data} className={className} />
|
||||
}
|
||||
|
||||
if (node.type === 'websocket-url') {
|
||||
return <EmbeddedWebsocketUrl url={node.data} />
|
||||
}
|
||||
|
||||
if (node.type === 'hashtag') {
|
||||
return <EmbeddedHashtag hashtag={node.data} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
63
src/components/ExternalContentInteractions/Tabs.tsx
Normal file
63
src/components/ExternalContentInteractions/Tabs.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
|
||||
export type TTabValue = 'replies' | 'reactions'
|
||||
const TABS = [
|
||||
{ value: 'replies', label: 'Replies' },
|
||||
{ value: 'reactions', label: 'Reactions' }
|
||||
] as { value: TTabValue; label: string }[]
|
||||
|
||||
export function Tabs({
|
||||
selectedTab,
|
||||
onTabChange
|
||||
}: {
|
||||
selectedTab: TTabValue
|
||||
onTabChange: (tab: TTabValue) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
const activeIndex = TABS.findIndex((tab) => tab.value === selectedTab)
|
||||
if (activeIndex >= 0 && tabRefs.current[activeIndex]) {
|
||||
const activeTab = tabRefs.current[activeIndex]
|
||||
const { offsetWidth, offsetLeft } = activeTab
|
||||
const padding = 32 // 16px padding on each side
|
||||
setIndicatorStyle({
|
||||
width: offsetWidth - padding,
|
||||
left: offsetLeft + padding / 2
|
||||
})
|
||||
}
|
||||
}, 20) // ensure tabs are rendered before calculating
|
||||
}, [selectedTab])
|
||||
|
||||
return (
|
||||
<div className="w-fit">
|
||||
<div className="flex relative">
|
||||
{TABS.map((tab, index) => (
|
||||
<div
|
||||
key={tab.value}
|
||||
ref={(el) => (tabRefs.current[index] = el)}
|
||||
className={cn(
|
||||
`text-center px-4 py-2 font-semibold clickable cursor-pointer rounded-lg`,
|
||||
selectedTab === tab.value ? '' : 'text-muted-foreground'
|
||||
)}
|
||||
onClick={() => onTabChange(tab.value)}
|
||||
>
|
||||
{t(tab.label)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="absolute bottom-0 h-1 bg-primary rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${indicatorStyle.width}px`,
|
||||
left: `${indicatorStyle.left}px`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/components/ExternalContentInteractions/index.tsx
Normal file
45
src/components/ExternalContentInteractions/index.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useState } from 'react'
|
||||
import HideUntrustedContentButton from '../HideUntrustedContentButton'
|
||||
import ReplyNoteList from '../ReplyNoteList'
|
||||
import { Tabs, TTabValue } from './Tabs'
|
||||
import ReactionList from '../ReactionList'
|
||||
|
||||
export default function ExternalContentInteractions({
|
||||
pageIndex,
|
||||
externalContent
|
||||
}: {
|
||||
pageIndex?: number
|
||||
externalContent: string
|
||||
}) {
|
||||
const [type, setType] = useState<TTabValue>('replies')
|
||||
let list
|
||||
switch (type) {
|
||||
case 'replies':
|
||||
list = <ReplyNoteList index={pageIndex} stuff={externalContent} />
|
||||
break
|
||||
case 'reactions':
|
||||
list = <ReactionList stuff={externalContent} />
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<ScrollArea className="flex-1 w-0">
|
||||
<Tabs selectedTab={type} onTabChange={setType} />
|
||||
<ScrollBar orientation="horizontal" className="opacity-0 pointer-events-none" />
|
||||
</ScrollArea>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<div className="size-10 flex items-center justify-center">
|
||||
<HideUntrustedContentButton type="interactions" />
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
{list}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,53 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toExternalContent } from '@/lib/link'
|
||||
import { truncateUrl } from '@/lib/url'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ExternalLink as ExternalLinkIcon, MessageSquare } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ExternalLink({ url, className }: { url: string; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const displayUrl = useMemo(() => truncateUrl(url), [url])
|
||||
|
||||
const handleOpenLink = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
window.open(url, '_blank', 'noreferrer')
|
||||
}
|
||||
|
||||
const handleViewDiscussions = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
push(toExternalContent(url))
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
className={cn('text-primary hover:underline', className)}
|
||||
href={url}
|
||||
target="_blank"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
title={url}
|
||||
>
|
||||
{displayUrl}
|
||||
</a>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<span
|
||||
className={cn('cursor-pointer text-primary hover:underline', className)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={url}
|
||||
>
|
||||
{displayUrl}
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem onClick={handleOpenLink}>
|
||||
<ExternalLinkIcon />
|
||||
{t('Open link')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleViewDiscussions}>
|
||||
<MessageSquare />
|
||||
{t('View Nostr discussions')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { ExtendedKind } from '@/constants'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function IValue({ event, className }: { event: Event; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const iValue = useMemo(() => {
|
||||
if (event.kind !== ExtendedKind.COMMENT) return undefined
|
||||
const iTag = event.tags.find(tagNameEquals('i'))
|
||||
return iTag ? iTag[1] : undefined
|
||||
}, [event])
|
||||
|
||||
if (!iValue) return null
|
||||
|
||||
return (
|
||||
<div className={cn('truncate text-muted-foreground', className)}>
|
||||
{t('Comment on') + ' '}
|
||||
{iValue.startsWith('http') ? (
|
||||
<a
|
||||
className="hover:text-foreground underline truncate"
|
||||
href={iValue}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{iValue}
|
||||
</a>
|
||||
) : (
|
||||
<span>{iValue}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { ExtendedKind, SUPPORTED_KINDS } from '@/constants'
|
||||
import { getParentBech32Id, isNsfwEvent } from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { getParentStuff, isNsfwEvent } from '@/lib/event'
|
||||
import { toExternalContent, toNote } from '@/lib/link'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
@@ -22,7 +22,6 @@ import CommunityDefinition from './CommunityDefinition'
|
||||
import EmojiPack from './EmojiPack'
|
||||
import GroupMetadata from './GroupMetadata'
|
||||
import Highlight from './Highlight'
|
||||
import IValue from './IValue'
|
||||
import LiveEvent from './LiveEvent'
|
||||
import LongFormArticle from './LongFormArticle'
|
||||
import LongFormArticlePreview from './LongFormArticlePreview'
|
||||
@@ -51,10 +50,9 @@ export default function Note({
|
||||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const parentEventId = useMemo(
|
||||
() => (hideParentNotePreview ? undefined : getParentBech32Id(event)),
|
||||
[event, hideParentNotePreview]
|
||||
)
|
||||
const { parentEventId, parentExternalContent } = useMemo(() => {
|
||||
return getParentStuff(event)
|
||||
}, [event])
|
||||
const { defaultShowNsfw } = useContentPolicy()
|
||||
const [showNsfw, setShowNsfw] = useState(false)
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
@@ -141,17 +139,21 @@ export default function Note({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{parentEventId && (
|
||||
{!hideParentNotePreview && (
|
||||
<ParentNotePreview
|
||||
eventId={parentEventId}
|
||||
externalContent={parentExternalContent}
|
||||
className="mt-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
push(toNote(parentEventId))
|
||||
if (parentExternalContent) {
|
||||
push(toExternalContent(parentExternalContent))
|
||||
} else if (parentEventId) {
|
||||
push(toNote(parentEventId))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<IValue event={event} className="mt-2" />
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSecondaryPage } from '@/PageManager'
|
||||
import { Event } from 'nostr-tools'
|
||||
import Collapsible from '../Collapsible'
|
||||
import Note from '../Note'
|
||||
import NoteStats from '../NoteStats'
|
||||
import StuffStats from '../StuffStats'
|
||||
import PinnedButton from './PinnedButton'
|
||||
import RepostDescription from './RepostDescription'
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function MainNoteCard({
|
||||
originalNoteId={originalNoteId}
|
||||
/>
|
||||
</Collapsible>
|
||||
{!embedded && <NoteStats className="mt-3 px-4" event={event} />}
|
||||
{!embedded && <StuffStats className="mt-3 px-4" stuff={event} />}
|
||||
</div>
|
||||
{!embedded && <Separator />}
|
||||
</div>
|
||||
|
||||
@@ -21,13 +21,13 @@ export default function NoteInteractions({
|
||||
let list
|
||||
switch (type) {
|
||||
case 'replies':
|
||||
list = <ReplyNoteList index={pageIndex} event={event} />
|
||||
list = <ReplyNoteList index={pageIndex} stuff={event} />
|
||||
break
|
||||
case 'quotes':
|
||||
list = <QuoteList event={event} />
|
||||
break
|
||||
case 'reactions':
|
||||
list = <ReactionList event={event} />
|
||||
list = <ReactionList stuff={event} />
|
||||
break
|
||||
case 'reposts':
|
||||
list = <RepostList event={event} />
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import NewNotesButton from '@/components/NewNotesButton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
getEventKey,
|
||||
getEventKeyFromTag,
|
||||
isMentioningMutedUsers,
|
||||
isReplyNoteEvent
|
||||
} from '@/lib/event'
|
||||
import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { isTouchDevice } from '@/lib/utils'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
@@ -173,7 +168,7 @@ const NoteList = forwardRef(
|
||||
|
||||
const targetTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('e'))
|
||||
if (targetTag) {
|
||||
const targetEventKey = getEventKeyFromTag(targetTag)
|
||||
const targetEventKey = getKeyFromTag(targetTag)
|
||||
if (targetEventKey) {
|
||||
// Add to reposters map
|
||||
const reposters = repostersMap.get(targetEventKey)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ParentNotePreview from '@/components/ParentNotePreview'
|
||||
import { NOTIFICATION_LIST_STYLE } from '@/constants'
|
||||
import { getEmbeddedPubkeys, getParentBech32Id } from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { getEmbeddedPubkeys, getParentStuff } from '@/lib/event'
|
||||
import { toExternalContent, toNote } from '@/lib/link'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
||||
@@ -27,7 +27,9 @@ export function MentionNotification({
|
||||
const mentions = getEmbeddedPubkeys(notification)
|
||||
return mentions.includes(pubkey)
|
||||
}, [pubkey, notification])
|
||||
const parentEventId = useMemo(() => getParentBech32Id(notification), [notification])
|
||||
const { parentEventId, parentExternalContent } = useMemo(() => {
|
||||
return getParentStuff(notification)
|
||||
}, [notification])
|
||||
|
||||
return (
|
||||
<Notification
|
||||
@@ -45,14 +47,18 @@ export function MentionNotification({
|
||||
sentAt={notification.created_at}
|
||||
targetEvent={notification}
|
||||
middle={
|
||||
notificationListStyle === NOTIFICATION_LIST_STYLE.DETAILED &&
|
||||
parentEventId && (
|
||||
notificationListStyle === NOTIFICATION_LIST_STYLE.DETAILED && (
|
||||
<ParentNotePreview
|
||||
eventId={parentEventId}
|
||||
externalContent={parentExternalContent}
|
||||
className=""
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
push(toNote(parentEventId))
|
||||
if (parentExternalContent) {
|
||||
push(toExternalContent(parentExternalContent))
|
||||
} else if (parentEventId) {
|
||||
push(toNote(parentEventId))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ContentPreview from '@/components/ContentPreview'
|
||||
import { FormattedTimestamp } from '@/components/FormattedTimestamp'
|
||||
import NoteStats from '@/components/NoteStats'
|
||||
import StuffStats from '@/components/StuffStats'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import Username from '@/components/Username'
|
||||
@@ -120,7 +120,7 @@ export default function Notification({
|
||||
/>
|
||||
)}
|
||||
<FormattedTimestamp timestamp={sentAt} className="shrink-0 text-muted-foreground text-sm" />
|
||||
{showStats && targetEvent && <NoteStats event={targetEvent} className="mt-1" />}
|
||||
{showStats && targetEvent && <StuffStats stuff={targetEvent} className="mt-1" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNotification } from '@/providers/NotificationProvider'
|
||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
||||
import client from '@/services/client.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import { TNotificationType } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { NostrEvent, kinds, matchFilter } from 'nostr-tools'
|
||||
@@ -94,7 +94,7 @@ const NotificationList = forwardRef((_, ref) => {
|
||||
return oldEvents
|
||||
}
|
||||
|
||||
noteStatsService.updateNoteStatsByEvents([event])
|
||||
stuffStatsService.updateStuffStatsByEvents([event])
|
||||
if (index === -1) {
|
||||
return [...oldEvents, event]
|
||||
}
|
||||
@@ -138,7 +138,7 @@ const NotificationList = forwardRef((_, ref) => {
|
||||
if (eosed) {
|
||||
setLoading(false)
|
||||
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
|
||||
noteStatsService.updateNoteStatsByEvents(events)
|
||||
stuffStatsService.updateStuffStatsByEvents(events)
|
||||
}
|
||||
},
|
||||
onNew: (event) => {
|
||||
|
||||
@@ -7,16 +7,37 @@ import UserAvatar from '../UserAvatar'
|
||||
|
||||
export default function ParentNotePreview({
|
||||
eventId,
|
||||
externalContent,
|
||||
className,
|
||||
onClick
|
||||
}: {
|
||||
eventId: string
|
||||
eventId?: string
|
||||
externalContent?: string
|
||||
className?: string
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { event, isFetching } = useFetchEvent(eventId)
|
||||
|
||||
if (externalContent) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground hover:text-foreground cursor-pointer',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="shrink-0">{t('reply to')}</div>
|
||||
<div className="truncate">{externalContent}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!eventId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -24,15 +24,16 @@ import PostOptions from './PostOptions'
|
||||
import PostRelaySelector from './PostRelaySelector'
|
||||
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
|
||||
import Uploader from './Uploader'
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
|
||||
export default function PostContent({
|
||||
defaultContent = '',
|
||||
parentEvent,
|
||||
parentStuff,
|
||||
close,
|
||||
openFrom
|
||||
}: {
|
||||
defaultContent?: string
|
||||
parentEvent?: Event
|
||||
parentStuff?: Event | string
|
||||
close: () => void
|
||||
openFrom?: string[]
|
||||
}) {
|
||||
@@ -45,6 +46,10 @@ export default function PostContent({
|
||||
const [uploadProgresses, setUploadProgresses] = useState<
|
||||
{ file: File; progress: number; cancel: () => void }[]
|
||||
>([])
|
||||
const parentEvent = useMemo(
|
||||
() => (parentStuff && typeof parentStuff !== 'string' ? parentStuff : undefined),
|
||||
[parentStuff]
|
||||
)
|
||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||
const [addClientTag, setAddClientTag] = useState(false)
|
||||
const [mentions, setMentions] = useState<string[]>([])
|
||||
@@ -85,7 +90,7 @@ export default function PostContent({
|
||||
isFirstRender.current = false
|
||||
const cachedSettings = postEditorCache.getPostSettingsCache({
|
||||
defaultContent,
|
||||
parentEvent
|
||||
parentStuff
|
||||
})
|
||||
if (cachedSettings) {
|
||||
setIsNsfw(cachedSettings.isNsfw ?? false)
|
||||
@@ -103,7 +108,7 @@ export default function PostContent({
|
||||
return
|
||||
}
|
||||
postEditorCache.setPostSettingsCache(
|
||||
{ defaultContent, parentEvent },
|
||||
{ defaultContent, parentStuff },
|
||||
{
|
||||
isNsfw,
|
||||
isPoll,
|
||||
@@ -111,7 +116,7 @@ export default function PostContent({
|
||||
addClientTag
|
||||
}
|
||||
)
|
||||
}, [defaultContent, parentEvent, isNsfw, isPoll, pollCreateData, addClientTag])
|
||||
}, [defaultContent, parentStuff, isNsfw, isPoll, pollCreateData, addClientTag])
|
||||
|
||||
const post = async (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
@@ -121,8 +126,9 @@ export default function PostContent({
|
||||
setPosting(true)
|
||||
try {
|
||||
const draftEvent =
|
||||
parentEvent && parentEvent.kind !== kinds.ShortTextNote
|
||||
? await createCommentDraftEvent(text, parentEvent, mentions, {
|
||||
parentStuff &&
|
||||
(typeof parentStuff === 'string' || parentStuff.kind !== kinds.ShortTextNote)
|
||||
? await createCommentDraftEvent(text, parentStuff, mentions, {
|
||||
addClientTag,
|
||||
protectedEvent: isProtectedEvent,
|
||||
isNsfw
|
||||
@@ -139,12 +145,17 @@ export default function PostContent({
|
||||
isNsfw
|
||||
})
|
||||
|
||||
const _additionalRelayUrls = [...additionalRelayUrls]
|
||||
if (parentStuff && typeof parentStuff === 'string') {
|
||||
_additionalRelayUrls.push(...BIG_RELAY_URLS)
|
||||
}
|
||||
|
||||
const newEvent = await publish(draftEvent, {
|
||||
specifiedRelayUrls: isProtectedEvent ? additionalRelayUrls : undefined,
|
||||
additionalRelayUrls: isPoll ? pollCreateData.relays : additionalRelayUrls,
|
||||
additionalRelayUrls: isPoll ? pollCreateData.relays : _additionalRelayUrls,
|
||||
minPow
|
||||
})
|
||||
postEditorCache.clearPostCache({ defaultContent, parentEvent })
|
||||
postEditorCache.clearPostCache({ defaultContent, parentStuff })
|
||||
deleteDraftEventCache(draftEvent)
|
||||
addReplies([newEvent])
|
||||
close()
|
||||
@@ -166,7 +177,7 @@ export default function PostContent({
|
||||
}
|
||||
|
||||
const handlePollToggle = () => {
|
||||
if (parentEvent) return
|
||||
if (parentStuff) return
|
||||
|
||||
setIsPoll((prev) => !prev)
|
||||
}
|
||||
@@ -199,7 +210,7 @@ export default function PostContent({
|
||||
text={text}
|
||||
setText={setText}
|
||||
defaultContent={defaultContent}
|
||||
parentEvent={parentEvent}
|
||||
parentStuff={parentStuff}
|
||||
onSubmit={() => post()}
|
||||
className={isPoll ? 'min-h-20' : 'min-h-52'}
|
||||
onUploadStart={handleUploadStart}
|
||||
@@ -278,7 +289,7 @@ export default function PostContent({
|
||||
</Button>
|
||||
</EmojiPickerDialog>
|
||||
)}
|
||||
{!parentEvent && (
|
||||
{!parentStuff && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -317,7 +328,7 @@ export default function PostContent({
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canPost} onClick={post}>
|
||||
{posting && <LoaderCircle className="animate-spin" />}
|
||||
{parentEvent ? t('Reply') : t('Post')}
|
||||
{parentStuff ? t('Reply') : t('Post')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -345,7 +356,7 @@ export default function PostContent({
|
||||
</Button>
|
||||
<Button className="w-full" type="submit" disabled={!canPost} onClick={post}>
|
||||
{posting && <LoaderCircle className="animate-spin" />}
|
||||
{parentEvent ? t('Reply') : t('Post')}
|
||||
{parentStuff ? t('Reply') : t('Post')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ const PostTextarea = forwardRef<
|
||||
text: string
|
||||
setText: Dispatch<SetStateAction<string>>
|
||||
defaultContent?: string
|
||||
parentEvent?: Event
|
||||
parentStuff?: Event | string
|
||||
onSubmit?: () => void
|
||||
className?: string
|
||||
onUploadStart?: (file: File, cancel: () => void) => void
|
||||
@@ -47,7 +47,7 @@ const PostTextarea = forwardRef<
|
||||
text = '',
|
||||
setText,
|
||||
defaultContent,
|
||||
parentEvent,
|
||||
parentStuff,
|
||||
onSubmit,
|
||||
className,
|
||||
onUploadStart,
|
||||
@@ -103,10 +103,10 @@ const PostTextarea = forwardRef<
|
||||
return parseEditorJsonToText(content.toJSON())
|
||||
}
|
||||
},
|
||||
content: postEditorCache.getPostContentCache({ defaultContent, parentEvent }),
|
||||
content: postEditorCache.getPostContentCache({ defaultContent, parentStuff }),
|
||||
onUpdate(props) {
|
||||
setText(parseEditorJsonToText(props.editor.getJSON()))
|
||||
postEditorCache.setPostContentCache({ defaultContent, parentEvent }, props.editor.getJSON())
|
||||
postEditorCache.setPostContentCache({ defaultContent, parentStuff }, props.editor.getJSON())
|
||||
},
|
||||
onCreate(props) {
|
||||
setText(parseEditorJsonToText(props.editor.getJSON()))
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Title({ parentEvent }: { parentEvent?: Event }) {
|
||||
export default function Title({ parentStuff }: { parentStuff?: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return parentEvent ? (
|
||||
return parentStuff ? (
|
||||
<div className="flex gap-2 items-center w-full">
|
||||
<div className="shrink-0">{t('Reply to')}</div>
|
||||
{typeof parentStuff === 'string' && (
|
||||
<div className="text-primary truncate">{parentStuff}</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
t('New Note')
|
||||
|
||||
@@ -22,13 +22,13 @@ import Title from './Title'
|
||||
|
||||
export default function PostEditor({
|
||||
defaultContent = '',
|
||||
parentEvent,
|
||||
parentStuff,
|
||||
open,
|
||||
setOpen,
|
||||
openFrom
|
||||
}: {
|
||||
defaultContent?: string
|
||||
parentEvent?: Event
|
||||
parentStuff?: Event | string
|
||||
open: boolean
|
||||
setOpen: Dispatch<boolean>
|
||||
openFrom?: string[]
|
||||
@@ -39,7 +39,7 @@ export default function PostEditor({
|
||||
return (
|
||||
<PostContent
|
||||
defaultContent={defaultContent}
|
||||
parentEvent={parentEvent}
|
||||
parentStuff={parentStuff}
|
||||
close={() => setOpen(false)}
|
||||
openFrom={openFrom}
|
||||
/>
|
||||
@@ -64,7 +64,7 @@ export default function PostEditor({
|
||||
<div className="space-y-4 px-2 py-6">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-start">
|
||||
<Title parentEvent={parentEvent} />
|
||||
<Title parentStuff={parentStuff} />
|
||||
</SheetTitle>
|
||||
<SheetDescription className="hidden" />
|
||||
</SheetHeader>
|
||||
@@ -92,7 +92,7 @@ export default function PostEditor({
|
||||
<div className="space-y-4 px-2 py-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Title parentEvent={parentEvent} />
|
||||
<Title parentStuff={parentStuff} />
|
||||
</DialogTitle>
|
||||
<DialogDescription className="hidden" />
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
@@ -11,20 +11,22 @@ import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import Nip05 from '../Nip05'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
|
||||
const SHOW_COUNT = 20
|
||||
|
||||
export default function ReactionList({ event }: { event: Event }) {
|
||||
export default function ReactionList({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const { stuffKey } = useStuff(stuff)
|
||||
const noteStats = useStuffStatsById(stuffKey)
|
||||
const filteredLikes = useMemo(() => {
|
||||
return (noteStats?.likes ?? [])
|
||||
.filter((like) => !hideUntrustedInteractions || isUserTrusted(like.pubkey))
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
}, [noteStats, event.id, hideUntrustedInteractions, isUserTrusted])
|
||||
}, [noteStats, stuffKey, hideUntrustedInteractions, isUserTrusted])
|
||||
|
||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
@@ -15,7 +15,7 @@ import Content from '../Content'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import Nip05 from '../Nip05'
|
||||
import NoteOptions from '../NoteOptions'
|
||||
import NoteStats from '../NoteStats'
|
||||
import StuffStats from '../StuffStats'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import TranslateButton from '../TranslateButton'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
@@ -111,7 +111,7 @@ export default function ReplyNote({
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
{show && <NoteStats className="ml-14 pl-1 mr-4 mt-2" event={event} displayTopZapsAndLikes />}
|
||||
{show && <StuffStats className="ml-14 pl-1 mr-4 mt-2" stuff={event} displayTopZapsAndLikes />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||
import {
|
||||
getEventKey,
|
||||
getEventKeyFromTag,
|
||||
getKeyFromTag,
|
||||
getParentTag,
|
||||
getReplaceableCoordinateFromEvent,
|
||||
getRootTag,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
isReplyNoteEvent
|
||||
} from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
|
||||
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
@@ -23,16 +23,23 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LoadingBar } from '../LoadingBar'
|
||||
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
|
||||
type TRootInfo =
|
||||
| { type: 'E'; id: string; pubkey: string }
|
||||
| { type: 'A'; id: string; eventId: string; pubkey: string; relay?: string }
|
||||
| { type: 'A'; id: string; pubkey: string; relay?: string }
|
||||
| { type: 'I'; id: string }
|
||||
|
||||
const LIMIT = 100
|
||||
const SHOW_COUNT = 10
|
||||
|
||||
export default function ReplyNoteList({ index, event }: { index?: number; event: NEvent }) {
|
||||
export default function ReplyNoteList({
|
||||
stuff,
|
||||
index
|
||||
}: {
|
||||
stuff: NEvent | string
|
||||
index?: number
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { push, currentIndex } = useSecondaryPage()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
@@ -40,13 +47,14 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
|
||||
const { repliesMap, addReplies } = useReply()
|
||||
const { event, externalContent, stuffKey } = useStuff(stuff)
|
||||
const replies = useMemo(() => {
|
||||
const replyKeySet = new Set<string>()
|
||||
const replyEvents: NEvent[] = []
|
||||
const currentEventKey = getEventKey(event)
|
||||
let parentEventKeys = [currentEventKey]
|
||||
while (parentEventKeys.length > 0) {
|
||||
const events = parentEventKeys.flatMap((key) => repliesMap.get(key)?.events || [])
|
||||
|
||||
let parentKeys = [stuffKey]
|
||||
while (parentKeys.length > 0) {
|
||||
const events = parentKeys.flatMap((key) => repliesMap.get(key)?.events || [])
|
||||
events.forEach((evt) => {
|
||||
const key = getEventKey(evt)
|
||||
if (replyKeySet.has(key)) return
|
||||
@@ -56,10 +64,10 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
||||
replyKeySet.add(key)
|
||||
replyEvents.push(evt)
|
||||
})
|
||||
parentEventKeys = events.map((evt) => getEventKey(evt))
|
||||
parentKeys = events.map((evt) => getEventKey(evt))
|
||||
}
|
||||
return replyEvents.sort((a, b) => a.created_at - b.created_at)
|
||||
}, [event.id, repliesMap])
|
||||
}, [stuffKey, repliesMap])
|
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||
const [until, setUntil] = useState<number | undefined>(undefined)
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
@@ -70,15 +78,18 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRootEvent = async () => {
|
||||
let root: TRootInfo = isReplaceableEvent(event.kind)
|
||||
? {
|
||||
type: 'A',
|
||||
id: getReplaceableCoordinateFromEvent(event),
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
relay: client.getEventHint(event.id)
|
||||
}
|
||||
: { type: 'E', id: event.id, pubkey: event.pubkey }
|
||||
if (!event && !externalContent) return
|
||||
|
||||
let root: TRootInfo = event
|
||||
? isReplaceableEvent(event.kind)
|
||||
? {
|
||||
type: 'A',
|
||||
id: getReplaceableCoordinateFromEvent(event),
|
||||
pubkey: event.pubkey,
|
||||
relay: client.getEventHint(event.id)
|
||||
}
|
||||
: { type: 'E', id: event.id, pubkey: event.pubkey }
|
||||
: { type: 'I', id: externalContent! }
|
||||
|
||||
const rootTag = getRootTag(event)
|
||||
if (rootTag?.type === 'e') {
|
||||
@@ -97,12 +108,9 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
||||
} else if (rootTag?.type === 'a') {
|
||||
const [, coordinate, relay] = rootTag.tag
|
||||
const [, pubkey] = coordinate.split(':')
|
||||
root = { type: 'A', id: coordinate, eventId: event.id, pubkey, relay }
|
||||
} else {
|
||||
const rootITag = event.tags.find(tagNameEquals('I'))
|
||||
if (rootITag) {
|
||||
root = { type: 'I', id: rootITag[1] }
|
||||
}
|
||||
root = { type: 'A', id: coordinate, pubkey, relay }
|
||||
} else if (rootTag?.type === 'i') {
|
||||
root = { type: 'I', id: rootTag.tag[1] }
|
||||
}
|
||||
setRootInfo(root)
|
||||
}
|
||||
@@ -116,13 +124,16 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const relayList = await client.fetchRelayList(
|
||||
(rootInfo as { pubkey?: string }).pubkey ?? event.pubkey
|
||||
)
|
||||
const relayUrls = relayList.read.concat(BIG_RELAY_URLS).slice(0, 4)
|
||||
let relayUrls: string[] = []
|
||||
const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey
|
||||
if (rootPubkey) {
|
||||
const relayList = await client.fetchRelayList(rootPubkey)
|
||||
relayUrls = relayList.read
|
||||
}
|
||||
relayUrls = relayUrls.concat(BIG_RELAY_URLS).slice(0, 4)
|
||||
|
||||
// If current event is protected, we can assume its replies are also protected and stored on the same relays
|
||||
if (isProtectedEvent(event)) {
|
||||
if (event && isProtectedEvent(event)) {
|
||||
const seenOn = client.getSeenEventRelayUrls(event.id)
|
||||
relayUrls.concat(...seenOn)
|
||||
}
|
||||
@@ -136,7 +147,7 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
||||
kinds: [kinds.ShortTextNote],
|
||||
limit: LIMIT
|
||||
})
|
||||
if (event.kind !== kinds.ShortTextNote) {
|
||||
if (event?.kind !== kinds.ShortTextNote) {
|
||||
filters.push({
|
||||
'#E': [rootInfo.id],
|
||||
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
|
||||
@@ -269,7 +280,7 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
||||
return (
|
||||
<div className="min-h-[80vh]">
|
||||
{loading && <LoadingBar />}
|
||||
{!loading && until && until > event.created_at && (
|
||||
{!loading && until && (!event || until > event.created_at) && (
|
||||
<div
|
||||
className={`text-sm text-center text-muted-foreground border-b py-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
||||
onClick={loadMore}
|
||||
@@ -291,14 +302,16 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
||||
}
|
||||
}
|
||||
|
||||
const rootEventKey = getEventKey(event)
|
||||
const rootKey = event ? getEventKey(event) : externalContent!
|
||||
const currentReplyKey = getEventKey(reply)
|
||||
const parentTag = getParentTag(reply)
|
||||
const parentEventKey = parentTag ? getEventKeyFromTag(parentTag.tag) : undefined
|
||||
const parentKey = parentTag ? getKeyFromTag(parentTag.tag) : undefined
|
||||
const parentEventId = parentTag
|
||||
? parentTag.type === 'e'
|
||||
? generateBech32IdFromETag(parentTag.tag)
|
||||
: generateBech32IdFromATag(parentTag.tag)
|
||||
: parentTag.type === 'a'
|
||||
? generateBech32IdFromATag(parentTag.tag)
|
||||
: undefined
|
||||
: undefined
|
||||
return (
|
||||
<div
|
||||
@@ -308,10 +321,10 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
||||
>
|
||||
<ReplyNote
|
||||
event={reply}
|
||||
parentEventId={rootEventKey !== parentEventKey ? parentEventId : undefined}
|
||||
parentEventId={rootKey !== parentKey ? parentEventId : undefined}
|
||||
onClickParent={() => {
|
||||
if (!parentEventKey) return
|
||||
highlightReply(parentEventKey, parentEventId)
|
||||
if (!parentKey) return
|
||||
highlightReply(parentKey, parentEventId)
|
||||
}}
|
||||
highlight={highlightReplyKey === currentReplyKey}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
@@ -19,7 +19,7 @@ export default function RepostList({ event }: { event: Event }) {
|
||||
const { push } = useSecondaryPage()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const noteStats = useStuffStatsById(event.id)
|
||||
const filteredReposts = useMemo(() => {
|
||||
return (noteStats?.reposts ?? [])
|
||||
.filter((repost) => !hideUntrustedInteractions || isUserTrusted(repost.pubkey))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SearchInput from '@/components/SearchInput'
|
||||
import { useSearchProfiles } from '@/hooks'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { toExternalContent, toNote } from '@/lib/link'
|
||||
import { randomString } from '@/lib/random'
|
||||
import { normalizeUrl } from '@/lib/url'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -8,7 +8,7 @@ import { useSecondaryPage } from '@/PageManager'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import modalManager from '@/services/modal-manager.service'
|
||||
import { TSearchParams } from '@/types'
|
||||
import { Hash, Notebook, Search, Server } from 'lucide-react'
|
||||
import { Hash, MessageSquare, Notebook, Search, Server } from 'lucide-react'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import {
|
||||
forwardRef,
|
||||
@@ -45,6 +45,9 @@ const SearchBar = forwardRef<
|
||||
if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) {
|
||||
return undefined
|
||||
}
|
||||
if (!input.includes('.')) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
return normalizeUrl(input)
|
||||
} catch {
|
||||
@@ -89,6 +92,8 @@ const SearchBar = forwardRef<
|
||||
|
||||
if (params.type === 'note') {
|
||||
push(toNote(params.search))
|
||||
} else if (params.type === 'externalContent') {
|
||||
push(toExternalContent(params.search))
|
||||
} else {
|
||||
onSearch(params)
|
||||
}
|
||||
@@ -128,8 +133,9 @@ const SearchBar = forwardRef<
|
||||
|
||||
setSelectableOptions([
|
||||
{ type: 'notes', search },
|
||||
{ type: 'hashtag', search: hashtag, input: `#${hashtag}` },
|
||||
...(normalizedUrl ? [{ type: 'relay', search: normalizedUrl, input: normalizedUrl }] : []),
|
||||
{ type: 'externalContent', search, input },
|
||||
{ type: 'hashtag', search: hashtag, input: `#${hashtag}` },
|
||||
...profiles.map((profile) => ({
|
||||
type: 'profile',
|
||||
search: profile.npub,
|
||||
@@ -197,6 +203,16 @@ const SearchBar = forwardRef<
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (option.type === 'externalContent') {
|
||||
return (
|
||||
<ExternalContentItem
|
||||
key={index}
|
||||
selected={selectedIndex === index}
|
||||
search={option.search}
|
||||
onClick={() => updateSearch(option)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (option.type === 'profiles') {
|
||||
return (
|
||||
<Item
|
||||
@@ -322,10 +338,16 @@ function NormalItem({
|
||||
onClick?: () => void
|
||||
selected?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Item onClick={onClick} selected={selected}>
|
||||
<Search className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{search}</div>
|
||||
<div className="size-10 flex justify-center items-center">
|
||||
<Search className="text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="font-semibold truncate">{search}</div>
|
||||
<div className="text-sm text-muted-foreground">{t('Search for notes')}</div>
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -339,10 +361,16 @@ function HashtagItem({
|
||||
onClick?: () => void
|
||||
selected?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Item onClick={onClick} selected={selected}>
|
||||
<Hash className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{hashtag}</div>
|
||||
<div className="size-10 flex justify-center items-center">
|
||||
<Hash className="text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="font-semibold truncate">#{hashtag}</div>
|
||||
<div className="text-sm text-muted-foreground">{t('Search for hashtag')}</div>
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -356,10 +384,16 @@ function NoteItem({
|
||||
onClick?: () => void
|
||||
selected?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Item onClick={onClick} selected={selected}>
|
||||
<Notebook className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{id}</div>
|
||||
<div className="size-10 flex justify-center items-center">
|
||||
<Notebook className="text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="font-semibold truncate font-mono text-sm">{id}</div>
|
||||
<div className="text-sm text-muted-foreground">{t('Go to note')}</div>
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
@@ -397,10 +431,39 @@ function RelayItem({
|
||||
onClick?: () => void
|
||||
selected?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Item onClick={onClick} selected={selected}>
|
||||
<Server className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{url}</div>
|
||||
<div className="size-10 flex justify-center items-center">
|
||||
<Server className="text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="font-semibold truncate">{url}</div>
|
||||
<div className="text-sm text-muted-foreground">{t('Go to relay')}</div>
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ExternalContentItem({
|
||||
search,
|
||||
onClick,
|
||||
selected
|
||||
}: {
|
||||
search: string
|
||||
onClick?: () => void
|
||||
selected?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Item onClick={onClick} selected={selected}>
|
||||
<div className="size-10 flex justify-center items-center">
|
||||
<MessageSquare className="text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="font-semibold truncate">{search}</div>
|
||||
<div className="text-sm text-muted-foreground">{t('View discussions about this')}</div>
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,13 +4,18 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { createReactionDraftEvent } from '@/lib/draft-event'
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import {
|
||||
createExternalContentReactionDraftEvent,
|
||||
createReactionDraftEvent
|
||||
} from '@/lib/draft-event'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import client from '@/services/client.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import { TEmoji } from '@/types'
|
||||
import { Loader, SmilePlus } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
@@ -21,15 +26,16 @@ import EmojiPicker from '../EmojiPicker'
|
||||
import SuggestedEmojis from '../SuggestedEmojis'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function LikeButton({ event }: { event: Event }) {
|
||||
export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { pubkey, publish, checkLogin } = useNostr()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const { event, externalContent, stuffKey } = useStuff(stuff)
|
||||
const [liking, setLiking] = useState(false)
|
||||
const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false)
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false)
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const noteStats = useStuffStatsById(stuffKey)
|
||||
const { myLastEmoji, likeCount } = useMemo(() => {
|
||||
const stats = noteStats || {}
|
||||
const myLike = stats.likes?.find((like) => like.pubkey === pubkey)
|
||||
@@ -48,13 +54,15 @@ export default function LikeButton({ event }: { event: Event }) {
|
||||
|
||||
try {
|
||||
if (!noteStats?.updatedAt) {
|
||||
await noteStatsService.fetchNoteStats(event, pubkey)
|
||||
await stuffStatsService.fetchStuffStats(stuffKey, pubkey)
|
||||
}
|
||||
|
||||
const reaction = createReactionDraftEvent(event, emoji)
|
||||
const seenOn = client.getSeenEventRelayUrls(event.id)
|
||||
const reaction = event
|
||||
? createReactionDraftEvent(event, emoji)
|
||||
: createExternalContentReactionDraftEvent(externalContent, emoji)
|
||||
const seenOn = event ? client.getSeenEventRelayUrls(event.id) : BIG_RELAY_URLS
|
||||
const evt = await publish(reaction, { additionalRelayUrls: seenOn })
|
||||
noteStatsService.updateNoteStatsByEvents([evt])
|
||||
stuffStatsService.updateStuffStatsByEvents([evt])
|
||||
} catch (error) {
|
||||
console.error('like failed', error)
|
||||
} finally {
|
||||
@@ -1,19 +1,25 @@
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { createReactionDraftEvent } from '@/lib/draft-event'
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import {
|
||||
createExternalContentReactionDraftEvent,
|
||||
createReactionDraftEvent
|
||||
} from '@/lib/draft-event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import { TEmoji } from '@/types'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import Emoji from '../Emoji'
|
||||
|
||||
export default function Likes({ event }: { event: Event }) {
|
||||
export default function Likes({ stuff }: { stuff: Event | string }) {
|
||||
const { pubkey, checkLogin, publish } = useNostr()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const { event, externalContent, stuffKey } = useStuff(stuff)
|
||||
const noteStats = useStuffStatsById(stuffKey)
|
||||
const [liking, setLiking] = useState<string | null>(null)
|
||||
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const [isLongPressing, setIsLongPressing] = useState<string | null>(null)
|
||||
@@ -44,10 +50,12 @@ export default function Likes({ event }: { event: Event }) {
|
||||
const timer = setTimeout(() => setLiking((prev) => (prev === key ? null : prev)), 5000)
|
||||
|
||||
try {
|
||||
const reaction = createReactionDraftEvent(event, emoji)
|
||||
const seenOn = client.getSeenEventRelayUrls(event.id)
|
||||
const reaction = event
|
||||
? createReactionDraftEvent(event, emoji)
|
||||
: createExternalContentReactionDraftEvent(externalContent, emoji)
|
||||
const seenOn = event ? client.getSeenEventRelayUrls(event.id) : BIG_RELAY_URLS
|
||||
const evt = await publish(reaction, { additionalRelayUrls: seenOn })
|
||||
noteStatsService.updateNoteStatsByEvents([evt])
|
||||
stuffStatsService.updateStuffStatsByEvents([evt])
|
||||
} catch (error) {
|
||||
console.error('like failed', error)
|
||||
} finally {
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
@@ -12,21 +13,21 @@ import { useTranslation } from 'react-i18next'
|
||||
import PostEditor from '../PostEditor'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function ReplyButton({ event }: { event: Event }) {
|
||||
export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey, checkLogin } = useNostr()
|
||||
const { event, stuffKey } = useStuff(stuff)
|
||||
const { repliesMap } = useReply()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const { replyCount, hasReplied } = useMemo(() => {
|
||||
const key = getEventKey(event)
|
||||
const hasReplied = pubkey
|
||||
? repliesMap.get(key)?.events.some((evt) => evt.pubkey === pubkey)
|
||||
? repliesMap.get(stuffKey)?.events.some((evt) => evt.pubkey === pubkey)
|
||||
: false
|
||||
|
||||
let replyCount = 0
|
||||
const replies = [...(repliesMap.get(key)?.events || [])]
|
||||
const replies = [...(repliesMap.get(stuffKey)?.events || [])]
|
||||
while (replies.length > 0) {
|
||||
const reply = replies.pop()
|
||||
if (!reply) break
|
||||
@@ -48,7 +49,7 @@ export default function ReplyButton({ event }: { event: Event }) {
|
||||
}
|
||||
|
||||
return { replyCount, hasReplied }
|
||||
}, [repliesMap, event, hideUntrustedInteractions])
|
||||
}, [repliesMap, event, stuffKey, hideUntrustedInteractions])
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -69,7 +70,7 @@ export default function ReplyButton({ event }: { event: Event }) {
|
||||
<MessageCircle />
|
||||
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
|
||||
</button>
|
||||
<PostEditor parentEvent={event} open={open} setOpen={setOpen} />
|
||||
<PostEditor parentStuff={stuff} open={open} setOpen={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -6,14 +6,15 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { createRepostDraftEvent } from '@/lib/draft-event'
|
||||
import { getNoteBech32Id } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import { Loader, PencilLine, Repeat } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
@@ -21,24 +22,28 @@ import { useTranslation } from 'react-i18next'
|
||||
import PostEditor from '../PostEditor'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function RepostButton({ event }: { event: Event }) {
|
||||
export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const { publish, checkLogin, pubkey } = useNostr()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const { event, stuffKey } = useStuff(stuff)
|
||||
const noteStats = useStuffStatsById(stuffKey)
|
||||
const [reposting, setReposting] = useState(false)
|
||||
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
const { repostCount, hasReposted } = useMemo(() => {
|
||||
// external content
|
||||
if (!event) return { repostCount: 0, hasReposted: false }
|
||||
|
||||
return {
|
||||
repostCount: hideUntrustedInteractions
|
||||
? noteStats?.reposts?.filter((repost) => isUserTrusted(repost.pubkey)).length
|
||||
: noteStats?.reposts?.length,
|
||||
hasReposted: pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false
|
||||
}
|
||||
}, [noteStats, event.id, hideUntrustedInteractions])
|
||||
const canRepost = !hasReposted && !reposting
|
||||
}, [noteStats, event, hideUntrustedInteractions])
|
||||
const canRepost = !hasReposted && !reposting && !!event
|
||||
|
||||
const repost = async () => {
|
||||
checkLogin(async () => {
|
||||
@@ -51,7 +56,7 @@ export default function RepostButton({ event }: { event: Event }) {
|
||||
const hasReposted = noteStats?.repostPubkeySet?.has(pubkey)
|
||||
if (hasReposted) return
|
||||
if (!noteStats?.updatedAt) {
|
||||
const noteStats = await noteStatsService.fetchNoteStats(event, pubkey)
|
||||
const noteStats = await stuffStatsService.fetchStuffStats(stuff, pubkey)
|
||||
if (noteStats.repostPubkeySet?.has(pubkey)) {
|
||||
return
|
||||
}
|
||||
@@ -59,7 +64,7 @@ export default function RepostButton({ event }: { event: Event }) {
|
||||
|
||||
const repost = createRepostDraftEvent(event)
|
||||
const evt = await publish(repost)
|
||||
noteStatsService.updateNoteStatsByEvents([evt])
|
||||
stuffStatsService.updateStuffStatsByEvents([evt])
|
||||
} catch (error) {
|
||||
console.error('repost failed', error)
|
||||
} finally {
|
||||
@@ -72,11 +77,14 @@ export default function RepostButton({ event }: { event: Event }) {
|
||||
const trigger = (
|
||||
<button
|
||||
className={cn(
|
||||
'flex gap-1 items-center enabled:hover:text-lime-500 px-3 h-full',
|
||||
'flex gap-1 items-center px-3 h-full enabled:hover:text-lime-500 disabled:text-muted-foreground/40',
|
||||
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
|
||||
)}
|
||||
disabled={!event}
|
||||
title={t('Repost')}
|
||||
onClick={() => {
|
||||
if (!event) return
|
||||
|
||||
if (isSmallScreen) {
|
||||
setIsDrawerOpen(true)
|
||||
}
|
||||
@@ -87,6 +95,10 @@ export default function RepostButton({ event }: { event: Event }) {
|
||||
</button>
|
||||
)
|
||||
|
||||
if (!event) {
|
||||
return trigger
|
||||
}
|
||||
|
||||
const postEditor = (
|
||||
<PostEditor
|
||||
open={isPostDialogOpen}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { toRelay } from '@/lib/link'
|
||||
import { simplifyUrl } from '@/lib/url'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
@@ -19,24 +20,29 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
|
||||
export default function SeenOnButton({ event }: { event: Event }) {
|
||||
export default function SeenOnButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { push } = useSecondaryPage()
|
||||
const { event } = useStuff(stuff)
|
||||
const [relays, setRelays] = useState<string[]>([])
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!event) return
|
||||
|
||||
const seenOn = client.getSeenEventRelayUrls(event.id)
|
||||
setRelays(seenOn)
|
||||
}, [])
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary pl-3 h-full"
|
||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary pl-3 h-full disabled:text-muted-foreground/40"
|
||||
title={t('Seen on')}
|
||||
disabled={relays.length === 0}
|
||||
onClick={() => {
|
||||
if (!event) return
|
||||
|
||||
if (isSmallScreen) {
|
||||
setIsDrawerOpen(true)
|
||||
}
|
||||
@@ -47,6 +53,10 @@ export default function SeenOnButton({ event }: { event: Event }) {
|
||||
</button>
|
||||
)
|
||||
|
||||
if (relays.length === 0) {
|
||||
return trigger
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<>
|
||||
@@ -76,6 +86,7 @@ export default function SeenOnButton({ event }: { event: Event }) {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { formatAmount } from '@/lib/lightning'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
@@ -7,14 +8,15 @@ import { useMemo, useState } from 'react'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import ZapDialog from '../ZapDialog'
|
||||
|
||||
export default function TopZaps({ event }: { event: Event }) {
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
export default function TopZaps({ stuff }: { stuff: Event | string }) {
|
||||
const { event, stuffKey } = useStuff(stuff)
|
||||
const noteStats = useStuffStatsById(stuffKey)
|
||||
const [zapIndex, setZapIndex] = useState(-1)
|
||||
const topZaps = useMemo(() => {
|
||||
return noteStats?.zaps?.sort((a, b) => b.amount - a.amount).slice(0, 10) || []
|
||||
}, [noteStats])
|
||||
|
||||
if (!topZaps.length) return null
|
||||
if (!topZaps.length || !event) return null
|
||||
|
||||
return (
|
||||
<ScrollArea className="pb-2 mb-1">
|
||||
@@ -1,12 +1,13 @@
|
||||
import { LONG_PRESS_THRESHOLD } from '@/constants'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { getLightningAddressFromProfile } from '@/lib/lightning'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useZap } from '@/providers/ZapProvider'
|
||||
import client from '@/services/client.service'
|
||||
import lightning from '@/services/lightning.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import { Loader, Zap } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -14,10 +15,11 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import ZapDialog from '../ZapDialog'
|
||||
|
||||
export default function ZapButton({ event }: { event: Event }) {
|
||||
export default function ZapButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { checkLogin, pubkey } = useNostr()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const { event, stuffKey } = useStuff(stuff)
|
||||
const noteStats = useStuffStatsById(stuffKey)
|
||||
const { defaultZapSats, defaultZapComment, quickZap } = useZap()
|
||||
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
|
||||
const [openZapDialog, setOpenZapDialog] = useState(false)
|
||||
@@ -33,6 +35,11 @@ export default function ZapButton({ event }: { event: Event }) {
|
||||
const isLongPressRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!event) {
|
||||
setDisable(true)
|
||||
return
|
||||
}
|
||||
|
||||
client.fetchProfile(event.pubkey).then((profile) => {
|
||||
if (!profile) return
|
||||
const lightningAddress = getLightningAddressFromProfile(profile)
|
||||
@@ -45,7 +52,7 @@ export default function ZapButton({ event }: { event: Event }) {
|
||||
if (!pubkey) {
|
||||
throw new Error('You need to be logged in to zap')
|
||||
}
|
||||
if (zapping) return
|
||||
if (zapping || !event) return
|
||||
|
||||
setZapping(true)
|
||||
const zapResult = await lightning.zap(pubkey, event, defaultZapSats, defaultZapComment)
|
||||
@@ -53,7 +60,7 @@ export default function ZapButton({ event }: { event: Event }) {
|
||||
if (!zapResult) {
|
||||
return
|
||||
}
|
||||
noteStatsService.addZap(
|
||||
stuffStatsService.addZap(
|
||||
pubkey,
|
||||
event.id,
|
||||
zapResult.invoice,
|
||||
@@ -128,11 +135,8 @@ export default function ZapButton({ event }: { event: Event }) {
|
||||
<>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1 select-none px-3 h-full',
|
||||
hasZapped ? 'text-yellow-400' : 'text-muted-foreground',
|
||||
disable
|
||||
? 'cursor-not-allowed text-muted-foreground/40'
|
||||
: 'cursor-pointer enabled:hover:text-yellow-400'
|
||||
'flex items-center gap-1 select-none px-3 h-full cursor-pointer enabled:hover:text-yellow-400 disabled:text-muted-foreground/40 disabled:cursor-default',
|
||||
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
|
||||
)}
|
||||
title={t('Zap')}
|
||||
disabled={disable || zapping}
|
||||
@@ -149,15 +153,17 @@ export default function ZapButton({ event }: { event: Event }) {
|
||||
)}
|
||||
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
|
||||
</button>
|
||||
<ZapDialog
|
||||
open={openZapDialog}
|
||||
setOpen={(open) => {
|
||||
setOpenZapDialog(open)
|
||||
setZapping(open)
|
||||
}}
|
||||
pubkey={event.pubkey}
|
||||
event={event}
|
||||
/>
|
||||
{event && (
|
||||
<ZapDialog
|
||||
open={openZapDialog}
|
||||
setOpen={(open) => {
|
||||
setOpenZapDialog(open)
|
||||
setZapping(open)
|
||||
}}
|
||||
pubkey={event.pubkey}
|
||||
event={event}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import BookmarkButton from '../BookmarkButton'
|
||||
@@ -13,14 +14,14 @@ import SeenOnButton from './SeenOnButton'
|
||||
import TopZaps from './TopZaps'
|
||||
import ZapButton from './ZapButton'
|
||||
|
||||
export default function NoteStats({
|
||||
event,
|
||||
export default function StuffStats({
|
||||
stuff,
|
||||
className,
|
||||
classNames,
|
||||
fetchIfNotExisting = false,
|
||||
displayTopZapsAndLikes = false
|
||||
}: {
|
||||
event: Event
|
||||
stuff: Event | string
|
||||
className?: string
|
||||
classNames?: {
|
||||
buttonBar?: string
|
||||
@@ -31,11 +32,12 @@ export default function NoteStats({
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { pubkey } = useNostr()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { event } = useStuff(stuff)
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchIfNotExisting) return
|
||||
setLoading(true)
|
||||
noteStatsService.fetchNoteStats(event, pubkey).finally(() => setLoading(false))
|
||||
stuffStatsService.fetchStuffStats(stuff, pubkey).finally(() => setLoading(false))
|
||||
}, [event, fetchIfNotExisting])
|
||||
|
||||
if (isSmallScreen) {
|
||||
@@ -43,8 +45,8 @@ export default function NoteStats({
|
||||
<div className={cn('select-none', className)}>
|
||||
{displayTopZapsAndLikes && (
|
||||
<>
|
||||
<TopZaps event={event} />
|
||||
<Likes event={event} />
|
||||
<TopZaps stuff={stuff} />
|
||||
<Likes stuff={stuff} />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
@@ -55,12 +57,12 @@ export default function NoteStats({
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ReplyButton event={event} />
|
||||
<RepostButton event={event} />
|
||||
<LikeButton event={event} />
|
||||
<ZapButton event={event} />
|
||||
<BookmarkButton event={event} />
|
||||
<SeenOnButton event={event} />
|
||||
<ReplyButton stuff={stuff} />
|
||||
<RepostButton stuff={stuff} />
|
||||
<LikeButton stuff={stuff} />
|
||||
<ZapButton stuff={stuff} />
|
||||
<BookmarkButton stuff={stuff} />
|
||||
<SeenOnButton stuff={stuff} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -70,8 +72,8 @@ export default function NoteStats({
|
||||
<div className={cn('select-none', className)}>
|
||||
{displayTopZapsAndLikes && (
|
||||
<>
|
||||
<TopZaps event={event} />
|
||||
<Likes event={event} />
|
||||
<TopZaps stuff={stuff} />
|
||||
<Likes stuff={stuff} />
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-between h-5 [&_svg]:size-4">
|
||||
@@ -79,14 +81,14 @@ export default function NoteStats({
|
||||
className={cn('flex items-center', loading ? 'animate-pulse' : '')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ReplyButton event={event} />
|
||||
<RepostButton event={event} />
|
||||
<LikeButton event={event} />
|
||||
<ZapButton event={event} />
|
||||
<ReplyButton stuff={stuff} />
|
||||
<RepostButton stuff={stuff} />
|
||||
<LikeButton stuff={stuff} />
|
||||
<ZapButton stuff={stuff} />
|
||||
</div>
|
||||
<div className="flex items-center" onClick={(e) => e.stopPropagation()}>
|
||||
<BookmarkButton event={event} />
|
||||
<SeenOnButton event={event} />
|
||||
<BookmarkButton stuff={stuff} />
|
||||
<SeenOnButton stuff={stuff} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
149
src/components/XEmbeddedPost/index.tsx
Normal file
149
src/components/XEmbeddedPost/index.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toExternalContent } from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useTheme } from '@/providers/ThemeProvider'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ExternalLink from '../ExternalLink'
|
||||
|
||||
export default function XEmbeddedPost({
|
||||
url,
|
||||
className,
|
||||
mustLoad = false,
|
||||
embedded = true
|
||||
}: {
|
||||
url: string
|
||||
className?: string
|
||||
mustLoad?: boolean
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const { autoLoadMedia } = useContentPolicy()
|
||||
const { push } = useSecondaryPage()
|
||||
const [display, setDisplay] = useState(autoLoadMedia)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const { tweetId } = useMemo(() => parseXUrl(url), [url])
|
||||
const loadingRef = useRef<boolean>(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoadMedia) {
|
||||
setDisplay(true)
|
||||
} else {
|
||||
setDisplay(false)
|
||||
}
|
||||
}, [autoLoadMedia])
|
||||
|
||||
useEffect(() => {
|
||||
if (!tweetId || !containerRef.current || (!mustLoad && !display) || loadingRef.current) return
|
||||
loadingRef.current = true
|
||||
|
||||
// Load Twitter widgets script if not already loaded
|
||||
if (!window.twttr) {
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://platform.twitter.com/widgets.js'
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
embedTweet()
|
||||
}
|
||||
script.onerror = () => {
|
||||
setError(true)
|
||||
loadingRef.current = false
|
||||
}
|
||||
document.body.appendChild(script)
|
||||
} else {
|
||||
embedTweet()
|
||||
}
|
||||
|
||||
function embedTweet() {
|
||||
if (!containerRef.current || !window.twttr || !tweetId) return
|
||||
|
||||
// Clear container
|
||||
containerRef.current.innerHTML = ''
|
||||
|
||||
window.twttr.widgets
|
||||
.createTweet(tweetId, containerRef.current, {
|
||||
theme: theme === 'light' ? 'light' : 'dark',
|
||||
dnt: true, // Do not track
|
||||
conversation: 'none' // Hide conversation thread
|
||||
})
|
||||
.then((element: HTMLElement | undefined) => {
|
||||
if (element) {
|
||||
setTimeout(() => setLoaded(true), 100)
|
||||
} else {
|
||||
setError(true)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError(true)
|
||||
})
|
||||
.finally(() => {
|
||||
loadingRef.current = false
|
||||
})
|
||||
}
|
||||
}, [tweetId, display, mustLoad, theme])
|
||||
|
||||
const handleViewComments = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
push(toExternalContent(url))
|
||||
},
|
||||
[url, push]
|
||||
)
|
||||
|
||||
if (error || !tweetId) {
|
||||
return <ExternalLink url={url} />
|
||||
}
|
||||
|
||||
if (!mustLoad && !display) {
|
||||
return (
|
||||
<div
|
||||
className="text-primary hover:underline truncate w-fit cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDisplay(true)
|
||||
}}
|
||||
>
|
||||
[{t('Click to load X post')}]
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative group', className)}
|
||||
style={{
|
||||
maxWidth: '550px',
|
||||
minHeight: '225px'
|
||||
}}
|
||||
>
|
||||
<div ref={containerRef} className="cursor-pointer" onClick={handleViewComments} />
|
||||
{!loaded && <Skeleton className="absolute inset-0 w-full h-full rounded-xl" />}
|
||||
{loaded && embedded && (
|
||||
/* Hover overlay mask */
|
||||
<div
|
||||
className="absolute inset-0 bg-muted/30 backdrop-blur-md opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center cursor-pointer rounded-xl"
|
||||
onClick={handleViewComments}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<MessageCircle className="size-12" strokeWidth={1.5} />
|
||||
<span className="text-lg font-medium">{t('View Nostr comments')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function parseXUrl(url: string): { tweetId: string | null } {
|
||||
const pattern = /(?:twitter\.com|x\.com)\/(?:#!\/)?(?:\w+)\/status(?:es)?\/(\d+)/i
|
||||
const match = url.match(pattern)
|
||||
return {
|
||||
tweetId: match ? match[1] : null
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useZap } from '@/providers/ZapProvider'
|
||||
import lightning from '@/services/lightning.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -189,7 +189,7 @@ function ZapDialogContent({
|
||||
return
|
||||
}
|
||||
if (event) {
|
||||
noteStatsService.addZap(pubkey, event.id, zapResult.invoice, sats, comment)
|
||||
stuffStatsService.addZap(pubkey, event.id, zapResult.invoice, sats, comment)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`${t('Zap failed')}: ${(error as Error).message}`)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { useStuffStatsById } from '@/hooks/useStuffStatsById'
|
||||
import { formatAmount } from '@/lib/lightning'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
@@ -19,7 +19,7 @@ export default function ZapList({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const noteStats = useStuffStatsById(event.id)
|
||||
const filteredZaps = useMemo(() => {
|
||||
return (noteStats?.zaps ?? []).sort((a, b) => b.amount - a.amount)
|
||||
}, [noteStats, event.id])
|
||||
|
||||
@@ -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 ExtendedKind = {
|
||||
EXTERNAL_CONTENT_REACTION: 17,
|
||||
PICTURE: 20,
|
||||
VIDEO: 21,
|
||||
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
|
||||
export const YOUTUBE_URL_REGEX =
|
||||
/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 CODY_PUBKEY = '8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883'
|
||||
|
||||
@@ -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
15
src/hooks/useStuff.tsx
Normal 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
|
||||
}
|
||||
9
src/hooks/useStuffStatsById.tsx
Normal file
9
src/hooks/useStuffStatsById.tsx
Normal 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)
|
||||
)
|
||||
}
|
||||
@@ -519,7 +519,8 @@ export default {
|
||||
'Sending...': 'جاري الإرسال...',
|
||||
'Send Request': 'إرسال الطلب',
|
||||
'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': 'الحصول على رمز الدعوة',
|
||||
'Share this invite code with others to invite them to join this relay.':
|
||||
'شارك رمز الدعوة هذا مع الآخرين لدعوتهم للانضمام إلى هذا المرحل.',
|
||||
@@ -533,6 +534,15 @@ export default {
|
||||
'Failed to get invite code': 'فشل الحصول على رمز الدعوة',
|
||||
'Invite code copied to clipboard': 'تم نسخ رمز الدعوة إلى الحافظة',
|
||||
'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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,6 +549,15 @@ export default {
|
||||
'Failed to get invite code': 'Fehler beim Abrufen des Einladungscodes',
|
||||
'Invite code copied to clipboard': 'Einladungscode in die Zwischenablage kopiert',
|
||||
'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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +407,7 @@ export default {
|
||||
'Click to load image': 'Click to load image',
|
||||
'Click to load media': 'Click to load media',
|
||||
'Click to load YouTube video': 'Click to load YouTube video',
|
||||
'Click to load X post': 'Click to load X post',
|
||||
'{{count}} reviews': '{{count}} reviews',
|
||||
'Write a review': 'Write a review',
|
||||
'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',
|
||||
'Invite code copied to clipboard': 'Invite code copied to clipboard',
|
||||
'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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,7 +515,8 @@ export default {
|
||||
'Request to Join Relay': 'Solicitar unirse al Relay',
|
||||
'Leave Relay': 'Salir del Relay',
|
||||
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',
|
||||
'Failed to send join request': 'Error al enviar solicitud de unión',
|
||||
'Leave request sent successfully': 'Solicitud de salida enviada con éxito',
|
||||
@@ -537,12 +538,22 @@ export default {
|
||||
Copy: 'Copiar',
|
||||
'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.',
|
||||
'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',
|
||||
'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',
|
||||
'Invite code copied to clipboard': 'Código de invitación copiado al portapapeles',
|
||||
'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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,7 +510,8 @@ export default {
|
||||
'Request to Join Relay': 'درخواست عضویت در رله',
|
||||
'Leave Relay': 'خروج از رله',
|
||||
Leave: 'خروج',
|
||||
'Are you sure you want to leave this relay?': 'آیا مطمئن هستید که میخواهید از این رله خارج شوید؟',
|
||||
'Are you sure you want to leave this relay?':
|
||||
'آیا مطمئن هستید که میخواهید از این رله خارج شوید؟',
|
||||
'Join request sent successfully': 'درخواست عضویت با موفقیت ارسال شد',
|
||||
'Failed to send join request': 'ارسال درخواست عضویت ناموفق بود',
|
||||
'Leave request sent successfully': 'درخواست خروج با موفقیت ارسال شد',
|
||||
@@ -538,6 +539,15 @@ export default {
|
||||
'Failed to get invite code': 'دریافت کد دعوت ناموفق بود',
|
||||
'Invite code copied to clipboard': 'کد دعوت در کلیپبورد کپی شد',
|
||||
'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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,6 +548,15 @@ export default {
|
||||
'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",
|
||||
'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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,6 +540,15 @@ export default {
|
||||
'Failed to get invite code': 'निमंत्रण कोड प्राप्त करने में विफल',
|
||||
'Invite code copied to clipboard': 'निमंत्रण कोड क्लिपबोर्ड पर कॉपी किया गया',
|
||||
'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 चर्चाएँ देखें'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,8 +518,7 @@ export default {
|
||||
'Enter invite code': 'Írja be a meghívókódot',
|
||||
'Sending...': 'Küldés...',
|
||||
'Send Request': 'Kérelem küldése',
|
||||
'You can get an invite code from a relay member.':
|
||||
'Meghívókódot kaphat egy relay tagtól.',
|
||||
'You can get an invite code from a relay member.': 'Meghívókódot kaphat egy relay tagtól.',
|
||||
'Enter the invite code you received from a relay member.':
|
||||
'Írja be a relay tagtól kapott meghívókódot.',
|
||||
'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',
|
||||
'Invite code copied to clipboard': 'Meghívókód vágólapra másolva',
|
||||
'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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,11 +517,11 @@ export default {
|
||||
Leave: 'Esci',
|
||||
'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',
|
||||
'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',
|
||||
'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.':
|
||||
"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)',
|
||||
'Enter invite code': 'Inserisci il codice di invito',
|
||||
'Sending...': 'Invio...',
|
||||
@@ -537,12 +537,22 @@ export default {
|
||||
Copy: 'Copia',
|
||||
'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.',
|
||||
'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',
|
||||
'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',
|
||||
'Invite code copied to clipboard': 'Codice di invito copiato negli appunti',
|
||||
'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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,8 +522,10 @@ export default {
|
||||
'Enter invite code': '招待コードを入力',
|
||||
'Sending...': '送信中...',
|
||||
'Send Request': 'リクエストを送信',
|
||||
'You can get an invite code from a relay member.': 'リレーメンバーから招待コードを取得できます。',
|
||||
'Enter the invite code you received from a relay member.': 'リレーメンバーから受け取った招待コードを入力してください。',
|
||||
'You can get an invite code from a relay member.':
|
||||
'リレーメンバーから招待コードを取得できます。',
|
||||
'Enter the invite code you received from a relay member.':
|
||||
'リレーメンバーから受け取った招待コードを入力してください。',
|
||||
'Get Invite Code': '招待コードを取得',
|
||||
'Share this invite code with others to invite them to join this relay.':
|
||||
'この招待コードを他の人と共有して、このリレーへの参加を招待してください。',
|
||||
@@ -537,6 +539,15 @@ export default {
|
||||
'Failed to get invite code': '招待コードの取得に失敗しました',
|
||||
'Invite code copied to clipboard': '招待コードをクリップボードにコピーしました',
|
||||
'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 の議論を表示'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,8 +522,10 @@ export default {
|
||||
'Enter invite code': '초대 코드 입력',
|
||||
'Sending...': '전송 중...',
|
||||
'Send Request': '요청 보내기',
|
||||
'You can get an invite code from a relay member.': '릴레이 회원으로부터 초대 코드를 받을 수 있습니다.',
|
||||
'Enter the invite code you received from a relay member.': '릴레이 회원으로부터 받은 초대 코드를 입력하세요.',
|
||||
'You can get an invite code from a relay member.':
|
||||
'릴레이 회원으로부터 초대 코드를 받을 수 있습니다.',
|
||||
'Enter the invite code you received from a relay member.':
|
||||
'릴레이 회원으로부터 받은 초대 코드를 입력하세요.',
|
||||
'Get Invite Code': '초대 코드 받기',
|
||||
'Share this invite code with others to invite them to join this relay.':
|
||||
'이 초대 코드를 다른 사람과 공유하여 이 릴레이에 초대하세요.',
|
||||
@@ -537,6 +539,15 @@ export default {
|
||||
'Failed to get invite code': '초대 코드 가져오기 실패',
|
||||
'Invite code copied to clipboard': '초대 코드가 클립보드에 복사되었습니다',
|
||||
'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 토론 보기'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,12 +537,22 @@ export default {
|
||||
Copy: 'Kopiuj',
|
||||
'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.',
|
||||
'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',
|
||||
'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',
|
||||
'Invite code copied to clipboard': 'Kod zaproszenia skopiowany do schowka',
|
||||
'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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,6 +540,15 @@ export default {
|
||||
'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',
|
||||
'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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,6 +543,15 @@ export default {
|
||||
'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',
|
||||
'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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,6 +545,15 @@ export default {
|
||||
'Failed to get invite code': 'Не удалось получить код приглашения',
|
||||
'Invite code copied to clipboard': 'Код приглашения скопирован в буфер обмена',
|
||||
'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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,7 +517,8 @@ export default {
|
||||
'Sending...': 'กำลังส่ง...',
|
||||
'Send Request': 'ส่งคำขอ',
|
||||
'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': 'รับรหัสเชิญ',
|
||||
'Share this invite code with others to invite them to join this relay.':
|
||||
'แชร์รหัสเชิญนี้กับผู้อื่นเพื่อเชิญพวกเขาเข้าร่วมรีเลย์นี้',
|
||||
@@ -531,6 +532,15 @@ export default {
|
||||
'Failed to get invite code': 'ไม่สามารถรับรหัสเชิญ',
|
||||
'Invite code copied to clipboard': 'คัดลอกรหัสเชิญไปยังคลิปบอร์ดแล้ว',
|
||||
'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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,14 +520,22 @@ export default {
|
||||
'将此邀请码分享给他人以邀请他们加入此中继器。',
|
||||
'Invite Code': '邀请码',
|
||||
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.': '此中继器没有可用的邀请码。',
|
||||
Close: '关闭',
|
||||
'Failed to get invite code from relay': '从中继器获取邀请码失败',
|
||||
'Failed to get invite code': '获取邀请码失败',
|
||||
'Invite code copied to clipboard': '邀请码已复制到剪贴板',
|
||||
'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 讨论'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LN_INVOICE_REGEX,
|
||||
URL_REGEX,
|
||||
WS_URL_REGEX,
|
||||
X_URL_REGEX,
|
||||
YOUTUBE_URL_REGEX
|
||||
} from '@/constants'
|
||||
import { isImage, isMedia } from './url'
|
||||
@@ -24,6 +25,7 @@ export type TEmbeddedNodeType =
|
||||
| 'emoji'
|
||||
| 'invoice'
|
||||
| 'youtube'
|
||||
| 'x-post'
|
||||
|
||||
export type TEmbeddedNode =
|
||||
| {
|
||||
@@ -96,6 +98,8 @@ export const EmbeddedUrlParser: TContentParser = (content: string) => {
|
||||
type = 'media'
|
||||
} else if (url.match(YOUTUBE_URL_REGEX)) {
|
||||
type = 'youtube'
|
||||
} else if (url.match(X_URL_REGEX)) {
|
||||
type = 'x-post'
|
||||
}
|
||||
|
||||
// Add the match as specific type
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
isProtectedEvent,
|
||||
isReplaceableEvent
|
||||
} from './event'
|
||||
import { determineExternalContentKind } from './external-content'
|
||||
import { randomString } from './random'
|
||||
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
|
||||
export function createRepostDraftEvent(event: Event): TDraftEvent {
|
||||
const isProtected = isProtectedEvent(event)
|
||||
@@ -177,7 +205,7 @@ export function createRelaySetDraftEvent(relaySet: Omit<TRelaySet, 'aTag'>): TDr
|
||||
|
||||
export async function createCommentDraftEvent(
|
||||
content: string,
|
||||
parentEvent: Event,
|
||||
parentStuff: Event | string,
|
||||
mentions: string[],
|
||||
options: {
|
||||
addClientTag?: boolean
|
||||
@@ -193,8 +221,10 @@ export async function createCommentDraftEvent(
|
||||
rootCoordinateTag,
|
||||
rootKind,
|
||||
rootPubkey,
|
||||
rootUrl
|
||||
} = await extractCommentMentions(transformedEmojisContent, parentEvent)
|
||||
rootUrl,
|
||||
parentEvent,
|
||||
externalContent
|
||||
} = await extractCommentMentions(transformedEmojisContent, parentStuff)
|
||||
const hashtags = extractHashtags(transformedEmojisContent)
|
||||
|
||||
const tags = emojiTags
|
||||
@@ -208,7 +238,9 @@ export async function createCommentDraftEvent(
|
||||
}
|
||||
|
||||
tags.push(
|
||||
...mentions.filter((pubkey) => pubkey !== parentEvent.pubkey).map((pubkey) => buildPTag(pubkey))
|
||||
...mentions
|
||||
.filter((pubkey) => pubkey !== parentEvent?.pubkey)
|
||||
.map((pubkey) => buildPTag(pubkey))
|
||||
)
|
||||
|
||||
if (rootCoordinateTag) {
|
||||
@@ -226,14 +258,25 @@ export async function createCommentDraftEvent(
|
||||
tags.push(buildITag(rootUrl, true))
|
||||
}
|
||||
tags.push(
|
||||
...[
|
||||
isReplaceableEvent(parentEvent.kind)
|
||||
? buildATag(parentEvent)
|
||||
: buildETag(parentEvent.id, parentEvent.pubkey),
|
||||
buildKTag(parentEvent.kind),
|
||||
buildPTag(parentEvent.pubkey)
|
||||
]
|
||||
...(parentEvent
|
||||
? [
|
||||
isReplaceableEvent(parentEvent.kind)
|
||||
? buildATag(parentEvent)
|
||||
: buildETag(parentEvent.id, 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) {
|
||||
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 quoteReplaceableCoordinates: string[] = []
|
||||
const isComment = [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(parentEvent.kind)
|
||||
const rootCoordinateTag = isComment
|
||||
? parentEvent.tags.find(tagNameEquals('A'))
|
||||
: isReplaceableEvent(parentEvent.kind)
|
||||
? buildATag(parentEvent, true)
|
||||
: undefined
|
||||
const rootEventId = isComment ? parentEvent.tags.find(tagNameEquals('E'))?.[1] : parentEvent.id
|
||||
const rootKind = isComment ? parentEvent.tags.find(tagNameEquals('K'))?.[1] : parentEvent.kind
|
||||
const rootPubkey = isComment ? parentEvent.tags.find(tagNameEquals('P'))?.[1] : parentEvent.pubkey
|
||||
const rootUrl = isComment ? parentEvent.tags.find(tagNameEquals('I'))?.[1] : undefined
|
||||
const { parentEvent, externalContent } =
|
||||
typeof parentStuff === 'string'
|
||||
? { parentEvent: undefined, externalContent: parentStuff }
|
||||
: { parentEvent: parentStuff, externalContent: undefined }
|
||||
const isComment =
|
||||
parentEvent && [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(parentEvent.kind)
|
||||
const rootCoordinateTag = parentEvent
|
||||
? isComment
|
||||
? parentEvent.tags.find(tagNameEquals('A'))
|
||||
: 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) => {
|
||||
if (!arr.includes(item)) arr.push(item)
|
||||
@@ -626,7 +682,8 @@ async function extractCommentMentions(content: string, parentEvent: Event) {
|
||||
rootKind,
|
||||
rootPubkey,
|
||||
rootUrl,
|
||||
parentEvent
|
||||
parentEvent,
|
||||
externalContent
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,12 +94,23 @@ export function getParentATag(event?: Event) {
|
||||
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) {
|
||||
const tag = getParentETag(event)
|
||||
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.kind === kinds.ShortTextNote) {
|
||||
@@ -114,8 +125,13 @@ export function getParentTag(event?: Event): { type: 'e' | 'a'; tag: string[] }
|
||||
return tag ? { type: 'a', tag } : undefined
|
||||
}
|
||||
|
||||
const tag = getParentETag(event)
|
||||
return tag ? { type: 'e', tag } : undefined
|
||||
const parentETag = getParentETag(event)
|
||||
if (parentETag) {
|
||||
return { type: 'e', tag: parentETag }
|
||||
}
|
||||
|
||||
const parentITag = getParentITag(event)
|
||||
return parentITag ? { type: 'i', tag: parentITag } : undefined
|
||||
}
|
||||
|
||||
export function getParentBech32Id(event?: Event) {
|
||||
@@ -159,12 +175,23 @@ export function getRootATag(event?: Event) {
|
||||
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) {
|
||||
const tag = getRootETag(event)
|
||||
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.kind === kinds.ShortTextNote) {
|
||||
@@ -179,8 +206,13 @@ export function getRootTag(event?: Event): { type: 'e' | 'a'; tag: string[] } |
|
||||
return tag ? { type: 'a', tag } : undefined
|
||||
}
|
||||
|
||||
const tag = getRootETag(event)
|
||||
return tag ? { type: 'e', tag } : undefined
|
||||
const rootETag = getRootETag(event)
|
||||
if (rootETag) {
|
||||
return { type: 'e', tag: rootETag }
|
||||
}
|
||||
|
||||
const rootITag = getRootITag(event)
|
||||
return rootITag ? { type: 'i', tag: rootITag } : undefined
|
||||
}
|
||||
|
||||
export function getRootBech32Id(event?: Event) {
|
||||
@@ -192,13 +224,21 @@ export function getRootBech32Id(event?: Event) {
|
||||
: 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
|
||||
export function getEventKey(event: Event) {
|
||||
return isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id
|
||||
}
|
||||
|
||||
// Only used for e, E, a, A tags
|
||||
export function getEventKeyFromTag([, tagValue]: (string | undefined)[]) {
|
||||
// Only used for e, E, a, A, i, I tags
|
||||
export function getKeyFromTag([, tagValue]: (string | undefined)[]) {
|
||||
return tagValue
|
||||
}
|
||||
|
||||
|
||||
42
src/lib/external-content.ts
Normal file
42
src/lib/external-content.ts
Normal 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`
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,11 @@ export const toNote = (eventOrId: Event | string) => {
|
||||
export const toNoteList = ({
|
||||
hashtag,
|
||||
search,
|
||||
externalContentId,
|
||||
domain,
|
||||
kinds
|
||||
}: {
|
||||
hashtag?: string
|
||||
search?: string
|
||||
externalContentId?: string
|
||||
domain?: string
|
||||
kinds?: number[]
|
||||
}) => {
|
||||
@@ -28,7 +26,6 @@ export const toNoteList = ({
|
||||
kinds.forEach((k) => query.append('k', k.toString()))
|
||||
}
|
||||
if (search) query.set('s', search)
|
||||
if (externalContentId) query.set('i', externalContentId)
|
||||
if (domain) query.set('d', domain)
|
||||
return `${path}?${query.toString()}`
|
||||
}
|
||||
@@ -62,6 +59,7 @@ export const toSearch = (params?: TSearchParams) => {
|
||||
}
|
||||
return `/search?${query.toString()}`
|
||||
}
|
||||
export const toExternalContent = (id: string) => `/external-content?id=${encodeURIComponent(id)}`
|
||||
export const toSettings = () => '/settings'
|
||||
export const toRelaySettings = (tag?: 'mailbox' | 'favorite-relays') => {
|
||||
return '/settings/relays' + (tag ? '#' + tag : '')
|
||||
|
||||
@@ -15,7 +15,12 @@ export function isOnionUrl(url: string): boolean {
|
||||
export function normalizeUrl(url: string): string {
|
||||
try {
|
||||
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
|
||||
} else {
|
||||
url = 'wss://' + url
|
||||
|
||||
41
src/pages/secondary/ExternalContentPage/index.tsx
Normal file
41
src/pages/secondary/ExternalContentPage/index.tsx
Normal 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
|
||||
@@ -2,7 +2,7 @@ import { useSecondaryPage } from '@/PageManager'
|
||||
import ContentPreview from '@/components/ContentPreview'
|
||||
import Note from '@/components/Note'
|
||||
import NoteInteractions from '@/components/NoteInteractions'
|
||||
import NoteStats from '@/components/NoteStats'
|
||||
import StuffStats from '@/components/StuffStats'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
@@ -12,12 +12,12 @@ import { useFetchEvent } from '@/hooks'
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import {
|
||||
getEventKey,
|
||||
getEventKeyFromTag,
|
||||
getKeyFromTag,
|
||||
getParentBech32Id,
|
||||
getParentTag,
|
||||
getRootBech32Id
|
||||
} from '@/lib/event'
|
||||
import { toNote, toNoteList } from '@/lib/link'
|
||||
import { toExternalContent, toNote } from '@/lib/link'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Ellipsis } from 'lucide-react'
|
||||
@@ -102,7 +102,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
|
||||
originalNoteId={id}
|
||||
showFull
|
||||
/>
|
||||
<NoteStats className="mt-3" event={event} fetchIfNotExisting displayTopZapsAndLikes />
|
||||
<StuffStats className="mt-3" stuff={event} fetchIfNotExisting displayTopZapsAndLikes />
|
||||
</div>
|
||||
<Separator className="mt-4" />
|
||||
<NoteInteractions key={`note-interactions-${event.id}`} pageIndex={index} event={event} />
|
||||
@@ -119,7 +119,7 @@ function ExternalRoot({ value }: { value: string }) {
|
||||
<div>
|
||||
<Card
|
||||
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>
|
||||
</Card>
|
||||
@@ -184,5 +184,5 @@ function isConsecutive(rootEvent?: Event, parentEvent?: Event) {
|
||||
const tag = getParentTag(parentEvent)
|
||||
if (!tag) return false
|
||||
|
||||
return getEventKey(rootEvent) === getEventKeyFromTag(tag.tag)
|
||||
return getEventKey(rootEvent) === getKeyFromTag(tag.tag)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import client from '@/services/client.service'
|
||||
import customEmojiService from '@/services/custom-emoji.service'
|
||||
import indexedDb from '@/services/indexed-db.service'
|
||||
import storage from '@/services/local-storage.service'
|
||||
import noteStatsService from '@/services/note-stats.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import {
|
||||
ISigner,
|
||||
TAccount,
|
||||
@@ -369,7 +369,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
limit: 100
|
||||
}
|
||||
])
|
||||
noteStatsService.updateNoteStatsByEvents(events)
|
||||
stuffStatsService.updateStuffStatsByEvents(events)
|
||||
}
|
||||
initInteractions()
|
||||
}, [account])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getEventKey, getEventKeyFromTag, getParentTag } from '@/lib/event'
|
||||
import { getEventKey, getKeyFromTag, getParentTag } from '@/lib/event'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { createContext, useCallback, useContext, useState } from 'react'
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const parentTag = getParentTag(reply)
|
||||
if (parentTag) {
|
||||
const parentKey = getEventKeyFromTag(parentTag.tag)
|
||||
const parentKey = getKeyFromTag(parentTag.tag)
|
||||
if (parentKey) {
|
||||
newReplyEventMap.set(parentKey, [...(newReplyEventMap.get(parentKey) || []), reply])
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TTheme, TThemeSetting } from '@/types'
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: TTheme
|
||||
themeSetting: TThemeSetting
|
||||
setThemeSetting: (themeSetting: TThemeSetting) => void
|
||||
primaryColor: TPrimaryColor
|
||||
@@ -83,6 +84,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProviderContext.Provider
|
||||
value={{
|
||||
theme,
|
||||
themeSetting,
|
||||
setThemeSetting: updateThemeSetting,
|
||||
primaryColor,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AppearanceSettingsPage from '@/pages/secondary/AppearanceSettingsPage'
|
||||
import BookmarkPage from '@/pages/secondary/BookmarkPage'
|
||||
import EmojiPackSettingsPage from '@/pages/secondary/EmojiPackSettingsPage'
|
||||
import ExternalContentPage from '@/pages/secondary/ExternalContentPage'
|
||||
import FollowingListPage from '@/pages/secondary/FollowingListPage'
|
||||
import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage'
|
||||
import MuteListPage from '@/pages/secondary/MuteListPage'
|
||||
@@ -34,6 +35,7 @@ const SECONDARY_ROUTE_CONFIGS = [
|
||||
{ path: '/relays/:url', element: <RelayPage /> },
|
||||
{ path: '/relays/:url/reviews', element: <RelayReviewsPage /> },
|
||||
{ path: '/search', element: <SearchPage /> },
|
||||
{ path: '/external-content', element: <ExternalContentPage /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{ path: '/settings/relays', element: <RelaySettingsPage /> },
|
||||
{ path: '/settings/wallet', element: <WalletPage /> },
|
||||
|
||||
@@ -98,11 +98,11 @@ class ClientService extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
let relays: string[]
|
||||
const relaySet = new Set<string>()
|
||||
if (specifiedRelayUrls?.length) {
|
||||
relays = specifiedRelayUrls
|
||||
specifiedRelayUrls.forEach((url) => relaySet.add(url))
|
||||
} else {
|
||||
const _additionalRelayUrls: string[] = additionalRelayUrls ?? []
|
||||
additionalRelayUrls?.forEach((url) => relaySet.add(url))
|
||||
if (!specifiedRelayUrls?.length && ![kinds.Contacts, kinds.Mutelist].includes(event.kind)) {
|
||||
const mentions: string[] = []
|
||||
event.tags.forEach(([tagName, tagValue]) => {
|
||||
@@ -118,10 +118,14 @@ class ClientService extends EventTarget {
|
||||
if (mentions.length > 0) {
|
||||
const relayLists = await this.fetchRelayLists(mentions)
|
||||
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 (
|
||||
[
|
||||
kinds.RelayList,
|
||||
@@ -131,20 +135,23 @@ class ClientService extends EventTarget {
|
||||
ExtendedKind.RELAY_REVIEW
|
||||
].includes(event.kind)
|
||||
) {
|
||||
_additionalRelayUrls.push(...BIG_RELAY_URLS)
|
||||
BIG_RELAY_URLS.forEach((url) => relaySet.add(url))
|
||||
}
|
||||
|
||||
const relayList = await this.fetchRelayList(event.pubkey)
|
||||
relays = (relayList?.write.slice(0, 10) ?? []).concat(
|
||||
Array.from(new Set(_additionalRelayUrls)) ?? []
|
||||
)
|
||||
if (event.kind === ExtendedKind.COMMENT) {
|
||||
const rootITag = event.tags.find(tagNameEquals('I'))
|
||||
if (rootITag) {
|
||||
// For external content comments, always publish to big relays
|
||||
BIG_RELAY_URLS.forEach((url) => relaySet.add(url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!relays.length) {
|
||||
relays.push(...BIG_RELAY_URLS)
|
||||
if (!relaySet.size) {
|
||||
BIG_RELAY_URLS.forEach((url) => relaySet.add(url))
|
||||
}
|
||||
|
||||
return relays
|
||||
return Array.from(relaySet)
|
||||
}
|
||||
|
||||
async publishEvent(relayUrls: string[], event: NEvent) {
|
||||
|
||||
@@ -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
|
||||
@@ -24,49 +24,53 @@ class PostEditorCacheService {
|
||||
|
||||
getPostContentCache({
|
||||
defaultContent,
|
||||
parentEvent
|
||||
}: { defaultContent?: string; parentEvent?: Event } = {}) {
|
||||
parentStuff
|
||||
}: { defaultContent?: string; parentStuff?: Event | string } = {}) {
|
||||
return (
|
||||
this.postContentCache.get(this.generateCacheKey(defaultContent, parentEvent)) ??
|
||||
this.postContentCache.get(this.generateCacheKey(defaultContent, parentStuff)) ??
|
||||
defaultContent
|
||||
)
|
||||
}
|
||||
|
||||
setPostContentCache(
|
||||
{ defaultContent, parentEvent }: { defaultContent?: string; parentEvent?: Event },
|
||||
{ defaultContent, parentStuff }: { defaultContent?: string; parentStuff?: Event | string },
|
||||
content: Content
|
||||
) {
|
||||
this.postContentCache.set(this.generateCacheKey(defaultContent, parentEvent), content)
|
||||
this.postContentCache.set(this.generateCacheKey(defaultContent, parentStuff), content)
|
||||
}
|
||||
|
||||
getPostSettingsCache({
|
||||
defaultContent,
|
||||
parentEvent
|
||||
}: { defaultContent?: string; parentEvent?: Event } = {}): TPostSettings | undefined {
|
||||
return this.postSettingsCache.get(this.generateCacheKey(defaultContent, parentEvent))
|
||||
parentStuff
|
||||
}: { defaultContent?: string; parentStuff?: Event | string } = {}): TPostSettings | undefined {
|
||||
return this.postSettingsCache.get(this.generateCacheKey(defaultContent, parentStuff))
|
||||
}
|
||||
|
||||
setPostSettingsCache(
|
||||
{ defaultContent, parentEvent }: { defaultContent?: string; parentEvent?: Event },
|
||||
{ defaultContent, parentStuff }: { defaultContent?: string; parentStuff?: Event | string },
|
||||
settings: TPostSettings
|
||||
) {
|
||||
this.postSettingsCache.set(this.generateCacheKey(defaultContent, parentEvent), settings)
|
||||
this.postSettingsCache.set(this.generateCacheKey(defaultContent, parentStuff), settings)
|
||||
}
|
||||
|
||||
clearPostCache({
|
||||
defaultContent,
|
||||
parentEvent
|
||||
parentStuff
|
||||
}: {
|
||||
defaultContent?: string
|
||||
parentEvent?: Event
|
||||
parentStuff?: Event | string
|
||||
}) {
|
||||
const cacheKey = this.generateCacheKey(defaultContent, parentEvent)
|
||||
const cacheKey = this.generateCacheKey(defaultContent, parentStuff)
|
||||
this.postContentCache.delete(cacheKey)
|
||||
this.postSettingsCache.delete(cacheKey)
|
||||
}
|
||||
|
||||
generateCacheKey(defaultContent: string = '', parentEvent?: Event): string {
|
||||
return parentEvent ? parentEvent.id : defaultContent
|
||||
generateCacheKey(defaultContent: string = '', parentStuff?: Event | string): string {
|
||||
return parentStuff
|
||||
? typeof parentStuff === 'string'
|
||||
? parentStuff
|
||||
: parentStuff.id
|
||||
: defaultContent
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
328
src/services/stuff-stats.service.ts
Normal file
328
src/services/stuff-stats.service.ts
Normal 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
|
||||
9
src/types/index.d.ts
vendored
9
src/types/index.d.ts
vendored
@@ -170,7 +170,14 @@ export type TPollCreateData = {
|
||||
endsAt?: number
|
||||
}
|
||||
|
||||
export type TSearchType = 'profile' | 'profiles' | 'notes' | 'note' | 'hashtag' | 'relay'
|
||||
export type TSearchType =
|
||||
| 'profile'
|
||||
| 'profiles'
|
||||
| 'notes'
|
||||
| 'note'
|
||||
| 'hashtag'
|
||||
| 'relay'
|
||||
| 'externalContent'
|
||||
|
||||
export type TSearchParams = {
|
||||
type: TSearchType
|
||||
|
||||
19
src/types/twitter.d.ts
vendored
Normal file
19
src/types/twitter.d.ts
vendored
Normal 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 {}
|
||||
Reference in New Issue
Block a user