From 0bb62dd3fb3767be74bd3ad1e954e9f6a48bfdac Mon Sep 17 00:00:00 2001 From: codytseng Date: Sat, 15 Nov 2025 16:26:19 +0800 Subject: [PATCH] feat: add support for commenting and reacting on external content --- AGENTS.md | 4 + src/components/BookmarkButton/index.tsx | 14 +- src/components/Content/index.tsx | 11 + src/components/ExternalContent/index.tsx | 94 +++++ .../ExternalContentInteractions/Tabs.tsx | 63 ++++ .../ExternalContentInteractions/index.tsx | 45 +++ src/components/ExternalLink/index.tsx | 53 ++- src/components/Note/IValue.tsx | 36 -- src/components/Note/index.tsx | 22 +- src/components/NoteCard/MainNoteCard.tsx | 4 +- src/components/NoteInteractions/index.tsx | 4 +- src/components/NoteList/index.tsx | 9 +- .../NotificationItem/MentionNotification.tsx | 18 +- .../NotificationItem/Notification.tsx | 4 +- src/components/NotificationList/index.tsx | 6 +- src/components/ParentNotePreview/index.tsx | 23 +- src/components/PostEditor/PostContent.tsx | 39 ++- .../PostEditor/PostTextarea/index.tsx | 8 +- src/components/PostEditor/Title.tsx | 7 +- src/components/PostEditor/index.tsx | 10 +- src/components/ReactionList/index.tsx | 10 +- src/components/ReplyNote/index.tsx | 4 +- src/components/ReplyNoteList/index.tsx | 89 +++-- src/components/RepostList/index.tsx | 4 +- src/components/SearchBar/index.tsx | 85 ++++- .../{NoteStats => StuffStats}/LikeButton.tsx | 26 +- .../{NoteStats => StuffStats}/Likes.tsx | 24 +- .../{NoteStats => StuffStats}/ReplyButton.tsx | 13 +- .../RepostButton.tsx | 30 +- .../SeenOnButton.tsx | 15 +- .../{NoteStats => StuffStats}/TopZaps.tsx | 10 +- .../{NoteStats => StuffStats}/ZapButton.tsx | 46 +-- .../{NoteStats => StuffStats}/index.tsx | 44 +-- .../{NoteStats => StuffStats}/utils.ts | 0 src/components/XEmbeddedPost/index.tsx | 149 ++++++++ src/components/ZapDialog/index.tsx | 4 +- src/components/ZapList/index.tsx | 4 +- src/constants.ts | 3 + src/hooks/useNoteStatsById.tsx | 9 - src/hooks/useStuff.tsx | 15 + src/hooks/useStuffStatsById.tsx | 9 + src/i18n/locales/ar.ts | 14 +- src/i18n/locales/de.ts | 11 +- src/i18n/locales/en.ts | 11 +- src/i18n/locales/es.ts | 17 +- src/i18n/locales/fa.ts | 14 +- src/i18n/locales/fr.ts | 11 +- src/i18n/locales/hi.ts | 11 +- src/i18n/locales/hu.ts | 14 +- src/i18n/locales/it.ts | 20 +- src/i18n/locales/ja.ts | 17 +- src/i18n/locales/ko.ts | 17 +- src/i18n/locales/pl.ts | 14 +- src/i18n/locales/pt-BR.ts | 11 +- src/i18n/locales/pt-PT.ts | 11 +- src/i18n/locales/ru.ts | 11 +- src/i18n/locales/th.ts | 14 +- src/i18n/locales/zh.ts | 14 +- src/lib/content-parser.ts | 4 + src/lib/draft-event.ts | 103 ++++-- src/lib/event.ts | 56 ++- src/lib/external-content.ts | 42 +++ src/lib/link.ts | 4 +- src/lib/url.ts | 7 +- .../secondary/ExternalContentPage/index.tsx | 41 +++ src/pages/secondary/NotePage/index.tsx | 12 +- src/providers/NostrProvider/index.tsx | 4 +- src/providers/ReplyProvider.tsx | 4 +- src/providers/ThemeProvider.tsx | 2 + src/routes/secondary.tsx | 2 + src/services/client.service.ts | 31 +- src/services/note-stats.service.ts | 273 --------------- src/services/post-editor-cache.service.ts | 34 +- src/services/stuff-stats.service.ts | 328 ++++++++++++++++++ src/types/index.d.ts | 9 +- src/types/twitter.d.ts | 19 + 76 files changed, 1635 insertions(+), 639 deletions(-) create mode 100644 src/components/ExternalContent/index.tsx create mode 100644 src/components/ExternalContentInteractions/Tabs.tsx create mode 100644 src/components/ExternalContentInteractions/index.tsx delete mode 100644 src/components/Note/IValue.tsx rename src/components/{NoteStats => StuffStats}/LikeButton.tsx (82%) rename src/components/{NoteStats => StuffStats}/Likes.tsx (87%) rename src/components/{NoteStats => StuffStats}/ReplyButton.tsx (83%) rename src/components/{NoteStats => StuffStats}/RepostButton.tsx (84%) rename src/components/{NoteStats => StuffStats}/SeenOnButton.tsx (89%) rename src/components/{NoteStats => StuffStats}/TopZaps.tsx (85%) rename src/components/{NoteStats => StuffStats}/ZapButton.tsx (83%) rename src/components/{NoteStats => StuffStats}/index.tsx (67%) rename src/components/{NoteStats => StuffStats}/utils.ts (100%) create mode 100644 src/components/XEmbeddedPost/index.tsx delete mode 100644 src/hooks/useNoteStatsById.tsx create mode 100644 src/hooks/useStuff.tsx create mode 100644 src/hooks/useStuffStatsById.tsx create mode 100644 src/lib/external-content.ts create mode 100644 src/pages/secondary/ExternalContentPage/index.tsx delete mode 100644 src/services/note-stats.service.ts create mode 100644 src/services/stuff-stats.service.ts create mode 100644 src/types/twitter.d.ts diff --git a/AGENTS.md b/AGENTS.md index 6016ce3..1e2a67a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/` diff --git a/src/components/BookmarkButton/index.tsx b/src/components/BookmarkButton/index.tsx index cda0422..fb59416 100644 --- a/src/components/BookmarkButton/index.tsx +++ b/src/components/BookmarkButton/index.tsx @@ -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 }) { )} - {!parentEvent && ( + {!parentStuff && ( @@ -345,7 +356,7 @@ export default function PostContent({ diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx index ef3dba9..81ac1dc 100644 --- a/src/components/PostEditor/PostTextarea/index.tsx +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -34,7 +34,7 @@ const PostTextarea = forwardRef< text: string setText: Dispatch> 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())) diff --git a/src/components/PostEditor/Title.tsx b/src/components/PostEditor/Title.tsx index 21a67c0..e6aa971 100644 --- a/src/components/PostEditor/Title.tsx +++ b/src/components/PostEditor/Title.tsx @@ -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 ? (
{t('Reply to')}
+ {typeof parentStuff === 'string' && ( +
{parentStuff}
+ )}
) : ( t('New Note') diff --git a/src/components/PostEditor/index.tsx b/src/components/PostEditor/index.tsx index ac12cae..9668be1 100644 --- a/src/components/PostEditor/index.tsx +++ b/src/components/PostEditor/index.tsx @@ -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 openFrom?: string[] @@ -39,7 +39,7 @@ export default function PostEditor({ return ( setOpen(false)} openFrom={openFrom} /> @@ -64,7 +64,7 @@ export default function PostEditor({
- + <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> diff --git a/src/components/ReactionList/index.tsx b/src/components/ReactionList/index.tsx index 3b359f8..e506d2a 100644 --- a/src/components/ReactionList/index.tsx +++ b/src/components/ReactionList/index.tsx @@ -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) diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index 915173d..c0b82d1 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -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> ) } diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index bd4cf0e..5df8356 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -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} /> diff --git a/src/components/RepostList/index.tsx b/src/components/RepostList/index.tsx index 84c19c8..232c480 100644 --- a/src/components/RepostList/index.tsx +++ b/src/components/RepostList/index.tsx @@ -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)) diff --git a/src/components/SearchBar/index.tsx b/src/components/SearchBar/index.tsx index dde72a5..4f69e0c 100644 --- a/src/components/SearchBar/index.tsx +++ b/src/components/SearchBar/index.tsx @@ -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> ) } diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/StuffStats/LikeButton.tsx similarity index 82% rename from src/components/NoteStats/LikeButton.tsx rename to src/components/StuffStats/LikeButton.tsx index 25e0722..9f5c88d 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/StuffStats/LikeButton.tsx @@ -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 { diff --git a/src/components/NoteStats/Likes.tsx b/src/components/StuffStats/Likes.tsx similarity index 87% rename from src/components/NoteStats/Likes.tsx rename to src/components/StuffStats/Likes.tsx index 87143f5..7cead52 100644 --- a/src/components/NoteStats/Likes.tsx +++ b/src/components/StuffStats/Likes.tsx @@ -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 { diff --git a/src/components/NoteStats/ReplyButton.tsx b/src/components/StuffStats/ReplyButton.tsx similarity index 83% rename from src/components/NoteStats/ReplyButton.tsx rename to src/components/StuffStats/ReplyButton.tsx index 7faaf8f..e39be9b 100644 --- a/src/components/NoteStats/ReplyButton.tsx +++ b/src/components/StuffStats/ReplyButton.tsx @@ -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} /> </> ) } diff --git a/src/components/NoteStats/RepostButton.tsx b/src/components/StuffStats/RepostButton.tsx similarity index 84% rename from src/components/NoteStats/RepostButton.tsx rename to src/components/StuffStats/RepostButton.tsx index 7ba7dc7..0ca3be4 100644 --- a/src/components/NoteStats/RepostButton.tsx +++ b/src/components/StuffStats/RepostButton.tsx @@ -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} diff --git a/src/components/NoteStats/SeenOnButton.tsx b/src/components/StuffStats/SeenOnButton.tsx similarity index 89% rename from src/components/NoteStats/SeenOnButton.tsx rename to src/components/StuffStats/SeenOnButton.tsx index 42598a4..84d3b49 100644 --- a/src/components/NoteStats/SeenOnButton.tsx +++ b/src/components/StuffStats/SeenOnButton.tsx @@ -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> diff --git a/src/components/NoteStats/TopZaps.tsx b/src/components/StuffStats/TopZaps.tsx similarity index 85% rename from src/components/NoteStats/TopZaps.tsx rename to src/components/StuffStats/TopZaps.tsx index 65d6d5a..294cfeb 100644 --- a/src/components/NoteStats/TopZaps.tsx +++ b/src/components/StuffStats/TopZaps.tsx @@ -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"> diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/StuffStats/ZapButton.tsx similarity index 83% rename from src/components/NoteStats/ZapButton.tsx rename to src/components/StuffStats/ZapButton.tsx index 6ffbe93..061bc5c 100644 --- a/src/components/NoteStats/ZapButton.tsx +++ b/src/components/StuffStats/ZapButton.tsx @@ -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} + /> + )} </> ) } diff --git a/src/components/NoteStats/index.tsx b/src/components/StuffStats/index.tsx similarity index 67% rename from src/components/NoteStats/index.tsx rename to src/components/StuffStats/index.tsx index bb27cb1..6512357 100644 --- a/src/components/NoteStats/index.tsx +++ b/src/components/StuffStats/index.tsx @@ -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> diff --git a/src/components/NoteStats/utils.ts b/src/components/StuffStats/utils.ts similarity index 100% rename from src/components/NoteStats/utils.ts rename to src/components/StuffStats/utils.ts diff --git a/src/components/XEmbeddedPost/index.tsx b/src/components/XEmbeddedPost/index.tsx new file mode 100644 index 0000000..83b17b4 --- /dev/null +++ b/src/components/XEmbeddedPost/index.tsx @@ -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 + } +} diff --git a/src/components/ZapDialog/index.tsx b/src/components/ZapDialog/index.tsx index 65cd514..5a1e2c2 100644 --- a/src/components/ZapDialog/index.tsx +++ b/src/components/ZapDialog/index.tsx @@ -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}`) diff --git a/src/components/ZapList/index.tsx b/src/components/ZapList/index.tsx index 6a5eaca..8e15dfe 100644 --- a/src/components/ZapList/index.tsx +++ b/src/components/ZapList/index.tsx @@ -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]) diff --git a/src/constants.ts b/src/constants.ts index 7ab4cc2..57612ab 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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' diff --git a/src/hooks/useNoteStatsById.tsx b/src/hooks/useNoteStatsById.tsx deleted file mode 100644 index 4d4ef74..0000000 --- a/src/hooks/useNoteStatsById.tsx +++ /dev/null @@ -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) - ) -} diff --git a/src/hooks/useStuff.tsx b/src/hooks/useStuff.tsx new file mode 100644 index 0000000..951a8ad --- /dev/null +++ b/src/hooks/useStuff.tsx @@ -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 +} diff --git a/src/hooks/useStuffStatsById.tsx b/src/hooks/useStuffStatsById.tsx new file mode 100644 index 0000000..f3076a3 --- /dev/null +++ b/src/hooks/useStuffStatsById.tsx @@ -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) + ) +} diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index fcd359f..d69d24b 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -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' } } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 0ff71ab..5e8a44f 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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' } } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e711503..2914639 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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' } } diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 74d92e8..5cf54a5 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -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' } } diff --git a/src/i18n/locales/fa.ts b/src/i18n/locales/fa.ts index ba9f349..ed0370d 100644 --- a/src/i18n/locales/fa.ts +++ b/src/i18n/locales/fa.ts @@ -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' } } diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index d776a1b..38f5050 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -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' } } diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts index 0f22d71..6141c38 100644 --- a/src/i18n/locales/hi.ts +++ b/src/i18n/locales/hi.ts @@ -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 चर्चाएँ देखें' } } diff --git a/src/i18n/locales/hu.ts b/src/i18n/locales/hu.ts index 409f04c..15fbc0b 100644 --- a/src/i18n/locales/hu.ts +++ b/src/i18n/locales/hu.ts @@ -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' } } diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index 7cf0d17..e999856 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -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' } } diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 9a49546..5ff8d49 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -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 の議論を表示' } } diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 2a3c735..3ef0406 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -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 토론 보기' } } diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index f40de91..fecd782 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -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' } } diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts index 7d7edcf..7b80587 100644 --- a/src/i18n/locales/pt-BR.ts +++ b/src/i18n/locales/pt-BR.ts @@ -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' } } diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts index 74419f4..6a96196 100644 --- a/src/i18n/locales/pt-PT.ts +++ b/src/i18n/locales/pt-PT.ts @@ -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' } } diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index f5457ad..99d3568 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -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' } } diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index 5c78ee9..0563a40 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -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' } } diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index a6343c1..131e56b 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -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 讨论' } } diff --git a/src/lib/content-parser.ts b/src/lib/content-parser.ts index 8312fa6..0e26d09 100644 --- a/src/lib/content-parser.ts +++ b/src/lib/content-parser.ts @@ -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 diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 9039f70..6195ca7 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -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 } } diff --git a/src/lib/event.ts b/src/lib/event.ts index 6deae61..f4a64e8 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -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 } diff --git a/src/lib/external-content.ts b/src/lib/external-content.ts new file mode 100644 index 0000000..7f5019a --- /dev/null +++ b/src/lib/external-content.ts @@ -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` + } +} diff --git a/src/lib/link.ts b/src/lib/link.ts index 8c1a2af..b6d2e85 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -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 : '') diff --git a/src/lib/url.ts b/src/lib/url.ts index 9218b36..9019625 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -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 diff --git a/src/pages/secondary/ExternalContentPage/index.tsx b/src/pages/secondary/ExternalContentPage/index.tsx new file mode 100644 index 0000000..3490be3 --- /dev/null +++ b/src/pages/secondary/ExternalContentPage/index.tsx @@ -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 diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 32df84d..29eed99 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -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) } diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 47bf336..6407f0a 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -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]) diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx index f20ec85..16eede9 100644 --- a/src/providers/ReplyProvider.tsx +++ b/src/providers/ReplyProvider.tsx @@ -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]) } diff --git a/src/providers/ThemeProvider.tsx b/src/providers/ThemeProvider.tsx index 4efbba9..a74b879 100644 --- a/src/providers/ThemeProvider.tsx +++ b/src/providers/ThemeProvider.tsx @@ -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, diff --git a/src/routes/secondary.tsx b/src/routes/secondary.tsx index af788ee..d5313d1 100644 --- a/src/routes/secondary.tsx +++ b/src/routes/secondary.tsx @@ -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 /> }, diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 6be6796..868da3f 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -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) { diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts deleted file mode 100644 index 9403d52..0000000 --- a/src/services/note-stats.service.ts +++ /dev/null @@ -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 diff --git a/src/services/post-editor-cache.service.ts b/src/services/post-editor-cache.service.ts index f2ac915..515180b 100644 --- a/src/services/post-editor-cache.service.ts +++ b/src/services/post-editor-cache.service.ts @@ -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 } } diff --git a/src/services/stuff-stats.service.ts b/src/services/stuff-stats.service.ts new file mode 100644 index 0000000..9a057e2 --- /dev/null +++ b/src/services/stuff-stats.service.ts @@ -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 diff --git a/src/types/index.d.ts b/src/types/index.d.ts index bef490e..418a9fc 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -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 diff --git a/src/types/twitter.d.ts b/src/types/twitter.d.ts new file mode 100644 index 0000000..6db3a93 --- /dev/null +++ b/src/types/twitter.d.ts @@ -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 {}