feat: add support for commenting and reacting on external content

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

View File

@@ -386,6 +386,10 @@ Secondary pages appear in the right column (or full screen on mobile) and suppor
On mobile devices or single-column layouts, primary pages occupy the full screen, while secondary pages are accessed via stack navigation. When navigating to another primary page, it will clear the secondary page stack.
### 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/`

View File

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

View File

@@ -28,6 +28,7 @@ import ExternalLink from '../ExternalLink'
import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer'
import WebPreview from '../WebPreview'
import XEmbeddedPost from '../XEmbeddedPost'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
export default function Content({
@@ -156,6 +157,16 @@ export default function Content({
/>
)
}
if (node.type === 'x-post') {
return (
<XEmbeddedPost
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
return null
})}
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}

View File

@@ -0,0 +1,94 @@
import {
EmbeddedHashtagParser,
EmbeddedUrlParser,
EmbeddedWebsocketUrlParser,
parseContent
} from '@/lib/content-parser'
import { cn } from '@/lib/utils'
import { useMemo } from 'react'
import { EmbeddedHashtag, EmbeddedLNInvoice, EmbeddedWebsocketUrl } from '../Embedded'
import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer'
import WebPreview from '../WebPreview'
import XEmbeddedPost from '../XEmbeddedPost'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
export default function ExternalContent({
content,
className,
mustLoadMedia
}: {
content?: string
className?: string
mustLoadMedia?: boolean
}) {
const nodes = useMemo(() => {
if (!content) return []
return parseContent(content, [
EmbeddedUrlParser,
EmbeddedWebsocketUrlParser,
EmbeddedHashtagParser
])
}, [content])
if (!nodes || nodes.length === 0) {
return null
}
const node = nodes[0]
if (node.type === 'text') {
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>{content}</div>
)
}
if (node.type === 'url') {
return <WebPreview url={node.data} className={className} />
}
if (node.type === 'x-post') {
return (
<XEmbeddedPost
url={node.data}
className={className}
mustLoad={mustLoadMedia}
embedded={false}
/>
)
}
if (node.type === 'youtube') {
return <YoutubeEmbeddedPlayer url={node.data} className={className} mustLoad={mustLoadMedia} />
}
if (node.type === 'image' || node.type === 'images') {
const data = Array.isArray(node.data) ? node.data : [node.data]
return (
<ImageGallery
className={className}
images={data.map((url) => ({ url }))}
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'media') {
return <MediaPlayer className={className} src={node.data} mustLoad={mustLoadMedia} />
}
if (node.type === 'invoice') {
return <EmbeddedLNInvoice invoice={node.data} className={className} />
}
if (node.type === 'websocket-url') {
return <EmbeddedWebsocketUrl url={node.data} />
}
if (node.type === 'hashtag') {
return <EmbeddedHashtag hashtag={node.data} />
}
return null
}

View File

@@ -0,0 +1,63 @@
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import { useRef, useEffect, useState } from 'react'
export type TTabValue = 'replies' | 'reactions'
const TABS = [
{ value: 'replies', label: 'Replies' },
{ value: 'reactions', label: 'Reactions' }
] as { value: TTabValue; label: string }[]
export function Tabs({
selectedTab,
onTabChange
}: {
selectedTab: TTabValue
onTabChange: (tab: TTabValue) => void
}) {
const { t } = useTranslation()
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0 })
useEffect(() => {
setTimeout(() => {
const activeIndex = TABS.findIndex((tab) => tab.value === selectedTab)
if (activeIndex >= 0 && tabRefs.current[activeIndex]) {
const activeTab = tabRefs.current[activeIndex]
const { offsetWidth, offsetLeft } = activeTab
const padding = 32 // 16px padding on each side
setIndicatorStyle({
width: offsetWidth - padding,
left: offsetLeft + padding / 2
})
}
}, 20) // ensure tabs are rendered before calculating
}, [selectedTab])
return (
<div className="w-fit">
<div className="flex relative">
{TABS.map((tab, index) => (
<div
key={tab.value}
ref={(el) => (tabRefs.current[index] = el)}
className={cn(
`text-center px-4 py-2 font-semibold clickable cursor-pointer rounded-lg`,
selectedTab === tab.value ? '' : 'text-muted-foreground'
)}
onClick={() => onTabChange(tab.value)}
>
{t(tab.label)}
</div>
))}
<div
className="absolute bottom-0 h-1 bg-primary rounded-full transition-all duration-500"
style={{
width: `${indicatorStyle.width}px`,
left: `${indicatorStyle.left}px`
}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { useState } from 'react'
import HideUntrustedContentButton from '../HideUntrustedContentButton'
import ReplyNoteList from '../ReplyNoteList'
import { Tabs, TTabValue } from './Tabs'
import ReactionList from '../ReactionList'
export default function ExternalContentInteractions({
pageIndex,
externalContent
}: {
pageIndex?: number
externalContent: string
}) {
const [type, setType] = useState<TTabValue>('replies')
let list
switch (type) {
case 'replies':
list = <ReplyNoteList index={pageIndex} stuff={externalContent} />
break
case 'reactions':
list = <ReactionList stuff={externalContent} />
break
default:
break
}
return (
<>
<div className="flex items-center justify-between">
<ScrollArea className="flex-1 w-0">
<Tabs selectedTab={type} onTabChange={setType} />
<ScrollBar orientation="horizontal" className="opacity-0 pointer-events-none" />
</ScrollArea>
<Separator orientation="vertical" className="h-6" />
<div className="size-10 flex items-center justify-center">
<HideUntrustedContentButton type="interactions" />
</div>
</div>
<Separator />
{list}
</>
)
}

View File

@@ -1,20 +1,53 @@
import { useSecondaryPage } from '@/PageManager'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { toExternalContent } from '@/lib/link'
import { truncateUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { ExternalLink as ExternalLinkIcon, MessageSquare } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function ExternalLink({ url, className }: { url: string; className?: string }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const displayUrl = useMemo(() => truncateUrl(url), [url])
const handleOpenLink = (e: React.MouseEvent) => {
e.stopPropagation()
window.open(url, '_blank', 'noreferrer')
}
const handleViewDiscussions = (e: React.MouseEvent) => {
e.stopPropagation()
push(toExternalContent(url))
}
return (
<a
className={cn('text-primary hover:underline', className)}
href={url}
target="_blank"
onClick={(e) => e.stopPropagation()}
rel="noreferrer"
title={url}
>
{displayUrl}
</a>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<span
className={cn('cursor-pointer text-primary hover:underline', className)}
onClick={(e) => e.stopPropagation()}
title={url}
>
{displayUrl}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={handleOpenLink}>
<ExternalLinkIcon />
{t('Open link')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleViewDiscussions}>
<MessageSquare />
{t('View Nostr discussions')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,36 +0,0 @@
import { ExtendedKind } from '@/constants'
import { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function IValue({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()
const iValue = useMemo(() => {
if (event.kind !== ExtendedKind.COMMENT) return undefined
const iTag = event.tags.find(tagNameEquals('i'))
return iTag ? iTag[1] : undefined
}, [event])
if (!iValue) return null
return (
<div className={cn('truncate text-muted-foreground', className)}>
{t('Comment on') + ' '}
{iValue.startsWith('http') ? (
<a
className="hover:text-foreground underline truncate"
href={iValue}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{iValue}
</a>
) : (
<span>{iValue}</span>
)}
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { useSecondaryPage } from '@/PageManager'
import { ExtendedKind, SUPPORTED_KINDS } from '@/constants'
import { getParentBech32Id, isNsfwEvent } from '@/lib/event'
import { toNote } from '@/lib/link'
import { getParentStuff, isNsfwEvent } from '@/lib/event'
import { toExternalContent, toNote } from '@/lib/link'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
@@ -22,7 +22,6 @@ import CommunityDefinition from './CommunityDefinition'
import EmojiPack from './EmojiPack'
import GroupMetadata from './GroupMetadata'
import Highlight from './Highlight'
import IValue from './IValue'
import LiveEvent from './LiveEvent'
import LongFormArticle from './LongFormArticle'
import LongFormArticlePreview from './LongFormArticlePreview'
@@ -51,10 +50,9 @@ export default function Note({
}) {
const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const parentEventId = useMemo(
() => (hideParentNotePreview ? undefined : getParentBech32Id(event)),
[event, hideParentNotePreview]
)
const { parentEventId, parentExternalContent } = useMemo(() => {
return getParentStuff(event)
}, [event])
const { defaultShowNsfw } = useContentPolicy()
const [showNsfw, setShowNsfw] = useState(false)
const { mutePubkeySet } = useMuteList()
@@ -141,17 +139,21 @@ export default function Note({
)}
</div>
</div>
{parentEventId && (
{!hideParentNotePreview && (
<ParentNotePreview
eventId={parentEventId}
externalContent={parentExternalContent}
className="mt-2"
onClick={(e) => {
e.stopPropagation()
push(toNote(parentEventId))
if (parentExternalContent) {
push(toExternalContent(parentExternalContent))
} else if (parentEventId) {
push(toNote(parentEventId))
}
}}
/>
)}
<IValue event={event} className="mt-2" />
{content}
</div>
)

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import ParentNotePreview from '@/components/ParentNotePreview'
import { NOTIFICATION_LIST_STYLE } from '@/constants'
import { getEmbeddedPubkeys, getParentBech32Id } from '@/lib/event'
import { toNote } from '@/lib/link'
import { getEmbeddedPubkeys, getParentStuff } from '@/lib/event'
import { toExternalContent, toNote } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
@@ -27,7 +27,9 @@ export function MentionNotification({
const mentions = getEmbeddedPubkeys(notification)
return mentions.includes(pubkey)
}, [pubkey, notification])
const parentEventId = useMemo(() => getParentBech32Id(notification), [notification])
const { parentEventId, parentExternalContent } = useMemo(() => {
return getParentStuff(notification)
}, [notification])
return (
<Notification
@@ -45,14 +47,18 @@ export function MentionNotification({
sentAt={notification.created_at}
targetEvent={notification}
middle={
notificationListStyle === NOTIFICATION_LIST_STYLE.DETAILED &&
parentEventId && (
notificationListStyle === NOTIFICATION_LIST_STYLE.DETAILED && (
<ParentNotePreview
eventId={parentEventId}
externalContent={parentExternalContent}
className=""
onClick={(e) => {
e.stopPropagation()
push(toNote(parentEventId))
if (parentExternalContent) {
push(toExternalContent(parentExternalContent))
} else if (parentEventId) {
push(toNote(parentEventId))
}
}}
/>
)

View File

@@ -1,6 +1,6 @@
import ContentPreview from '@/components/ContentPreview'
import { FormattedTimestamp } from '@/components/FormattedTimestamp'
import NoteStats from '@/components/NoteStats'
import StuffStats from '@/components/StuffStats'
import { Skeleton } from '@/components/ui/skeleton'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
@@ -120,7 +120,7 @@ export default function Notification({
/>
)}
<FormattedTimestamp timestamp={sentAt} className="shrink-0 text-muted-foreground text-sm" />
{showStats && targetEvent && <NoteStats event={targetEvent} className="mt-1" />}
{showStats && targetEvent && <StuffStats stuff={targetEvent} className="mt-1" />}
</div>
</div>
)

View File

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

View File

@@ -7,16 +7,37 @@ import UserAvatar from '../UserAvatar'
export default function ParentNotePreview({
eventId,
externalContent,
className,
onClick
}: {
eventId: string
eventId?: string
externalContent?: string
className?: string
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
}) {
const { t } = useTranslation()
const { event, isFetching } = useFetchEvent(eventId)
if (externalContent) {
return (
<div
className={cn(
'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground hover:text-foreground cursor-pointer',
className
)}
onClick={onClick}
>
<div className="shrink-0">{t('reply to')}</div>
<div className="truncate">{externalContent}</div>
</div>
)
}
if (!eventId) {
return null
}
if (isFetching) {
return (
<div

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions'
import NoteStats from '../NoteStats'
import StuffStats from '../StuffStats'
import ParentNotePreview from '../ParentNotePreview'
import TranslateButton from '../TranslateButton'
import UserAvatar from '../UserAvatar'
@@ -111,7 +111,7 @@ export default function ReplyNote({
</div>
</div>
</Collapsible>
{show && <NoteStats className="ml-14 pl-1 mr-4 mt-2" event={event} displayTopZapsAndLikes />}
{show && <StuffStats className="ml-14 pl-1 mr-4 mt-2" stuff={event} displayTopZapsAndLikes />}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { useStuff } from '@/hooks/useStuff'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import noteStatsService from '@/services/note-stats.service'
import stuffStatsService from '@/services/stuff-stats.service'
import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
import BookmarkButton from '../BookmarkButton'
@@ -13,14 +14,14 @@ import SeenOnButton from './SeenOnButton'
import TopZaps from './TopZaps'
import ZapButton from './ZapButton'
export default function NoteStats({
event,
export default function StuffStats({
stuff,
className,
classNames,
fetchIfNotExisting = false,
displayTopZapsAndLikes = false
}: {
event: Event
stuff: Event | string
className?: string
classNames?: {
buttonBar?: string
@@ -31,11 +32,12 @@ export default function NoteStats({
const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr()
const [loading, setLoading] = useState(false)
const { event } = useStuff(stuff)
useEffect(() => {
if (!fetchIfNotExisting) return
setLoading(true)
noteStatsService.fetchNoteStats(event, pubkey).finally(() => setLoading(false))
stuffStatsService.fetchStuffStats(stuff, pubkey).finally(() => setLoading(false))
}, [event, fetchIfNotExisting])
if (isSmallScreen) {
@@ -43,8 +45,8 @@ export default function NoteStats({
<div className={cn('select-none', className)}>
{displayTopZapsAndLikes && (
<>
<TopZaps event={event} />
<Likes event={event} />
<TopZaps stuff={stuff} />
<Likes stuff={stuff} />
</>
)}
<div
@@ -55,12 +57,12 @@ export default function NoteStats({
)}
onClick={(e) => e.stopPropagation()}
>
<ReplyButton event={event} />
<RepostButton event={event} />
<LikeButton event={event} />
<ZapButton event={event} />
<BookmarkButton event={event} />
<SeenOnButton event={event} />
<ReplyButton stuff={stuff} />
<RepostButton stuff={stuff} />
<LikeButton stuff={stuff} />
<ZapButton stuff={stuff} />
<BookmarkButton stuff={stuff} />
<SeenOnButton stuff={stuff} />
</div>
</div>
)
@@ -70,8 +72,8 @@ export default function NoteStats({
<div className={cn('select-none', className)}>
{displayTopZapsAndLikes && (
<>
<TopZaps event={event} />
<Likes event={event} />
<TopZaps stuff={stuff} />
<Likes stuff={stuff} />
</>
)}
<div className="flex justify-between h-5 [&_svg]:size-4">
@@ -79,14 +81,14 @@ export default function NoteStats({
className={cn('flex items-center', loading ? 'animate-pulse' : '')}
onClick={(e) => e.stopPropagation()}
>
<ReplyButton event={event} />
<RepostButton event={event} />
<LikeButton event={event} />
<ZapButton event={event} />
<ReplyButton stuff={stuff} />
<RepostButton stuff={stuff} />
<LikeButton stuff={stuff} />
<ZapButton stuff={stuff} />
</div>
<div className="flex items-center" onClick={(e) => e.stopPropagation()}>
<BookmarkButton event={event} />
<SeenOnButton event={event} />
<BookmarkButton stuff={stuff} />
<SeenOnButton stuff={stuff} />
</div>
</div>
</div>

View File

@@ -0,0 +1,149 @@
import { Skeleton } from '@/components/ui/skeleton'
import { toExternalContent } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useTheme } from '@/providers/ThemeProvider'
import { MessageCircle } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ExternalLink from '../ExternalLink'
export default function XEmbeddedPost({
url,
className,
mustLoad = false,
embedded = true
}: {
url: string
className?: string
mustLoad?: boolean
embedded?: boolean
}) {
const { t } = useTranslation()
const { theme } = useTheme()
const { autoLoadMedia } = useContentPolicy()
const { push } = useSecondaryPage()
const [display, setDisplay] = useState(autoLoadMedia)
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState(false)
const { tweetId } = useMemo(() => parseXUrl(url), [url])
const loadingRef = useRef<boolean>(false)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (autoLoadMedia) {
setDisplay(true)
} else {
setDisplay(false)
}
}, [autoLoadMedia])
useEffect(() => {
if (!tweetId || !containerRef.current || (!mustLoad && !display) || loadingRef.current) return
loadingRef.current = true
// Load Twitter widgets script if not already loaded
if (!window.twttr) {
const script = document.createElement('script')
script.src = 'https://platform.twitter.com/widgets.js'
script.async = true
script.onload = () => {
embedTweet()
}
script.onerror = () => {
setError(true)
loadingRef.current = false
}
document.body.appendChild(script)
} else {
embedTweet()
}
function embedTweet() {
if (!containerRef.current || !window.twttr || !tweetId) return
// Clear container
containerRef.current.innerHTML = ''
window.twttr.widgets
.createTweet(tweetId, containerRef.current, {
theme: theme === 'light' ? 'light' : 'dark',
dnt: true, // Do not track
conversation: 'none' // Hide conversation thread
})
.then((element: HTMLElement | undefined) => {
if (element) {
setTimeout(() => setLoaded(true), 100)
} else {
setError(true)
}
})
.catch(() => {
setError(true)
})
.finally(() => {
loadingRef.current = false
})
}
}, [tweetId, display, mustLoad, theme])
const handleViewComments = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
push(toExternalContent(url))
},
[url, push]
)
if (error || !tweetId) {
return <ExternalLink url={url} />
}
if (!mustLoad && !display) {
return (
<div
className="text-primary hover:underline truncate w-fit cursor-pointer"
onClick={(e) => {
e.stopPropagation()
setDisplay(true)
}}
>
[{t('Click to load X post')}]
</div>
)
}
return (
<div
className={cn('relative group', className)}
style={{
maxWidth: '550px',
minHeight: '225px'
}}
>
<div ref={containerRef} className="cursor-pointer" onClick={handleViewComments} />
{!loaded && <Skeleton className="absolute inset-0 w-full h-full rounded-xl" />}
{loaded && embedded && (
/* Hover overlay mask */
<div
className="absolute inset-0 bg-muted/30 backdrop-blur-md opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center cursor-pointer rounded-xl"
onClick={handleViewComments}
>
<div className="flex flex-col items-center gap-3">
<MessageCircle className="size-12" strokeWidth={1.5} />
<span className="text-lg font-medium">{t('View Nostr comments')}</span>
</div>
</div>
)}
</div>
)
}
function parseXUrl(url: string): { tweetId: string | null } {
const pattern = /(?:twitter\.com|x\.com)\/(?:#!\/)?(?:\w+)\/status(?:es)?\/(\d+)/i
const match = url.match(pattern)
return {
tweetId: match ? match[1] : null
}
}

View File

@@ -19,7 +19,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useZap } from '@/providers/ZapProvider'
import lightning from '@/services/lightning.service'
import noteStatsService from '@/services/note-stats.service'
import stuffStatsService from '@/services/stuff-stats.service'
import { Loader } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
@@ -189,7 +189,7 @@ function ZapDialogContent({
return
}
if (event) {
noteStatsService.addZap(pubkey, event.id, zapResult.invoice, sats, comment)
stuffStatsService.addZap(pubkey, event.id, zapResult.invoice, sats, comment)
}
} catch (error) {
toast.error(`${t('Zap failed')}: ${(error as Error).message}`)

View File

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

View File

@@ -68,6 +68,7 @@ export const SEARCHABLE_RELAY_URLS = ['wss://relay.nostr.band/', 'wss://search.n
export const GROUP_METADATA_EVENT_KIND = 39000
export const 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'

View File

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

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

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

View File

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

View File

@@ -519,7 +519,8 @@ export default {
'Sending...': 'جاري الإرسال...',
'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'
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 चर्चाएँ देखें'
}
}

View File

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

View File

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

View File

@@ -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 の議論を表示'
}
}

View File

@@ -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 토론 보기'
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 讨论'
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,13 +11,11 @@ export const toNote = (eventOrId: Event | string) => {
export const toNoteList = ({
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 : '')

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { useSecondaryPage } from '@/PageManager'
import ContentPreview from '@/components/ContentPreview'
import 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)
}

View File

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

View File

@@ -1,4 +1,4 @@
import { getEventKey, getEventKeyFromTag, getParentTag } from '@/lib/event'
import { getEventKey, getKeyFromTag, getParentTag } from '@/lib/event'
import { Event } from 'nostr-tools'
import { 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])
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,49 +24,53 @@ class PostEditorCacheService {
getPostContentCache({
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
}
}

View File

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

View File

@@ -170,7 +170,14 @@ export type TPollCreateData = {
endsAt?: number
}
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
View File

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