diff --git a/src/components/NoteContent.tsx b/src/components/NoteContent.tsx index ab268a31..3cc68aa8 100644 --- a/src/components/NoteContent.tsx +++ b/src/components/NoteContent.tsx @@ -94,30 +94,37 @@ function emojify( return result.length > 0 ? result : [text]; } -/** Bech32 charset used by NIP-19 identifiers. */ -const BECH32_CHARS = '023456789acdefghjklmnpqrstuvwxyz'; - -/** Regex to extract an naddr1 identifier from a URL path. */ -const NADDR_IN_URL_REGEX = new RegExp(`naddr1[${BECH32_CHARS}]{10,}`, 'i'); - -/** Try to extract naddr coordinates from a URL containing an naddr1 identifier. */ -function extractNaddrFromUrl(url: string): AddrCoords | null { - const match = url.match(NADDR_IN_URL_REGEX); - if (!match) return null; - try { - const decoded = nip19.decode(match[0]); - if (decoded.type === 'naddr') { - return decoded.data as AddrCoords; - } - } catch { - // invalid naddr - } - return null; -} - /** Regex matching flag emoji: pairs of Regional Indicator Symbol letters (U+1F1E6–U+1F1FF). */ const FLAG_EMOJI_REGEX = /([\u{1F1E6}-\u{1F1FF}]{2})/gu; +function parseAddressCoordinate(value: string): AddrCoords | null { + const parts = value.split(':'); + if (parts.length < 3) return null; + + const kind = Number(parts[0]); + const pubkey = parts[1]; + const identifier = parts.slice(2).join(':'); + + if (!Number.isInteger(kind) || !/^[0-9a-f]{64}$/i.test(pubkey)) return null; + return { kind, pubkey, identifier }; +} + +function tokenMatchesQTag(token: ContentToken, qTagValue: string): boolean { + if (token.type === 'nevent-embed') { + return token.eventId === qTagValue; + } + + if (token.type === 'naddr-embed') { + const addr = parseAddressCoordinate(qTagValue); + return !!addr + && token.addr.kind === addr.kind + && token.addr.pubkey === addr.pubkey + && token.addr.identifier === addr.identifier; + } + + return false; +} + /** * Convert a flag emoji (pair of Regional Indicator Symbols) to an ISO 3166-1 alpha-2 code. * Returns the code if it maps to a known country, otherwise null. @@ -357,11 +364,7 @@ export function NoteContent({ const lineSuffix = nextNewline === -1 ? afterUrl : afterUrl.substring(0, nextNewline); const isEndOfLine = lineSuffix.trim() === ''; - // Check if the URL contains an naddr1 identifier → embed as Nostr event + preserve link - const naddrFromUrl = extractNaddrFromUrl(url); - if (naddrFromUrl) { - result.push({ type: 'naddr-embed', addr: naddrFromUrl, url }); - } else if (isEndOfLine) { + if (isEndOfLine) { // Standalone URL at end of line → rich embed (YouTube, Tweet, or link preview) result.push({ type: 'link-embed', url }); } else { @@ -440,6 +443,24 @@ export function NoteContent({ } } + // Quote posts should render from their q tags even when the content only + // contains a normal web URL for the target (for example, Boost links). + for (const [qTagValue, qInfo] of qTagMap) { + if (result.some((token) => tokenMatchesQTag(token, qTagValue))) continue; + + const addr = parseAddressCoordinate(qTagValue); + if (addr) { + result.push({ type: 'naddr-embed', addr }); + } else if (/^[0-9a-f]{64}$/i.test(qTagValue)) { + result.push({ + type: 'nevent-embed', + eventId: qTagValue, + relays: qInfo.relay ? [qInfo.relay] : undefined, + author: qInfo.author, + }); + } + } + // Append media-embed tokens for imeta-declared media URLs not found in the content. // Some clients attach audio/video/webxdc via imeta tags without including the URL in // the content string. Without this, those attachments would be silently dropped. diff --git a/src/components/RepostMenu.tsx b/src/components/RepostMenu.tsx index e58b79aa..53e75bdb 100644 --- a/src/components/RepostMenu.tsx +++ b/src/components/RepostMenu.tsx @@ -1,4 +1,4 @@ -import { Quote, Undo2 } from 'lucide-react'; +import { Quote, Rocket, Undo2 } from 'lucide-react'; import { RepostIcon } from '@/components/icons/RepostIcon'; import { useState } from 'react'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -14,6 +14,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useToast } from '@/hooks/useToast'; import { getRepostKind } from '@/lib/feedUtils'; import { DITTO_RELAY } from '@/lib/appRelays'; +import { encodeEventAddress } from '@/lib/encodeEvent'; import type { EventStats } from '@/hooks/useTrending'; interface RepostMenuProps { @@ -24,6 +25,7 @@ interface RepostMenuProps { export function RepostMenu({ event, children }: RepostMenuProps) { const [open, setOpen] = useState(false); const [quoteOpen, setQuoteOpen] = useState(false); + const [quoteInitialContent, setQuoteInitialContent] = useState(undefined); const { user } = useCurrentUser(); const { mutate: publishEvent } = useNostrPublish(); const { mutate: deleteEvent } = useDeleteEvent(); @@ -140,6 +142,13 @@ export function RepostMenu({ event, children }: RepostMenuProps) { }; const handleQuote = () => { + setQuoteInitialContent(undefined); + setOpen(false); + setQuoteOpen(true); + }; + + const handleBoost = () => { + setQuoteInitialContent(`\n\nhttps://agora.spot/${encodeEventAddress(event)}`); setOpen(false); setQuoteOpen(true); }; @@ -177,7 +186,17 @@ export function RepostMenu({ event, children }: RepostMenuProps) { className="flex items-center gap-3 w-full px-4 py-3 text-[15px] text-foreground hover:bg-secondary/60 transition-colors" > - Quote post + Quote + + ); @@ -203,6 +222,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) { quotedEvent={event} open={quoteOpen} onOpenChange={setQuoteOpen} + initialContent={quoteInitialContent} /> ); diff --git a/src/hooks/usePaginatedFeed.ts b/src/hooks/usePaginatedFeed.ts index f53dec6b..cd068904 100644 --- a/src/hooks/usePaginatedFeed.ts +++ b/src/hooks/usePaginatedFeed.ts @@ -114,6 +114,10 @@ async function applyFeedFilters( events: NostrEvent[], countryCode: string | undefined ): Promise { + const parseCountryIdentifier = countryCode + ? (await import('@/lib/countryIdentifiers')).parseCountryIdentifier + : undefined; + // Filter by kind and tags let filteredEvents = events.filter(event => { if (event.kind === 36639) { @@ -124,6 +128,10 @@ async function applyFeedFilters( return true; } if (event.kind === 1068) return true; + if (countryCode) { + const iTag = event.tags.find(([name]) => name === 'i')?.[1]; + return !!iTag && parseCountryIdentifier?.(iTag)?.toUpperCase() === countryCode.toUpperCase(); + } const kTags = event.tags.filter(([name]) => name === 'k').map(([, v]) => v); const KTags = event.tags.filter(([name]) => name === 'K').map(([, v]) => v); return kTags.includes('iso3166') || kTags.includes('geo') || KTags.includes('36639'); diff --git a/src/hooks/usePostComment.ts b/src/hooks/usePostComment.ts index 680f4114..0e0b2dae 100644 --- a/src/hooks/usePostComment.ts +++ b/src/hooks/usePostComment.ts @@ -1,7 +1,13 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient, type InfiniteData } from '@tanstack/react-query'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { NKinds, type NostrEvent } from '@nostrify/nostrify'; +interface PaginatedFeedPage { + events: NostrEvent[]; + oldestTimestamp: number | null; + totalFetched: number; +} + interface PostCommentParams { root: NostrEvent | URL | `#${string}`; // The root event to comment on reply?: NostrEvent | URL | `#${string}`; // Optional reply to another comment @@ -44,17 +50,60 @@ export function usePostComment() { return event; }, - onSuccess: (_, { root }) => { + onSuccess: (event, { root }) => { const rootKey = root instanceof URL ? root.toString() : typeof root === 'string' ? root : root.id; + const countryCode = getCountryCode(root); // Invalidate and refetch comments queryClient.invalidateQueries({ queryKey: ['nostr', 'comments', rootKey] }); + + if (countryCode) { + queryClient.setQueriesData>( + { queryKey: ['agora-feed-paginated', countryCode] }, + (data) => { + if (!data || data.pages.length === 0) return data; + if (data.pages.some((page) => page.events.some((item) => item.id === event.id))) return data; + + const [firstPage, ...restPages] = data.pages; + return { + ...data, + pages: [ + { + ...firstPage, + events: [event, ...firstPage.events], + totalFetched: firstPage.totalFetched + 1, + }, + ...restPages, + ], + }; + }, + ); + + queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', countryCode] }); + queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', countryCode] }); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', countryCode] }); + queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', countryCode] }); + }, 3000); + } }, }); } +function getCountryCode(root: NostrEvent | URL | `#${string}`): string | undefined { + if (root instanceof URL && root.protocol === 'iso3166:') { + return root.pathname.toUpperCase(); + } + + if (typeof root === 'string' && root.toLowerCase().startsWith('iso3166:')) { + return root.slice('iso3166:'.length).toUpperCase(); + } + + return undefined; +} + /** Build NIP-22 comment tags for a given scope and target, enriched with hints when available. */ function makeCommentTags(scope: 'root' | 'reply', target: NostrEvent | URL | `#${string}`, hints: Hints): string[][] { const tags: string[][] = []; diff --git a/src/pages/ExternalContentPage.tsx b/src/pages/ExternalContentPage.tsx index 7226e119..9a5f25d3 100644 --- a/src/pages/ExternalContentPage.tsx +++ b/src/pages/ExternalContentPage.tsx @@ -225,20 +225,18 @@ export function ExternalContentPage() { useSeoMeta({ title: content ? (resolvedTitle ? `${resolvedTitle} | ${config.appName}` : seoTitle(content, config.appName)) : `External Content | ${config.appName}` }); - // Build the NIP-73 identifier for comments. - // For URLs, a URL object is used. For others (isbn:, iso3166:, etc.) the raw identifier - // is passed to useComments for querying. The `#${string}` type is a marker for "non-URL - // external identifier" — the runtime value is the plain identifier (e.g. `iso3166:VE`), - // matching the format used in NIP-73 `I`/`i` tag values and consistent with PostDetailPage - // and ComposeBox. ComposeBox/ReplyComposeModal do not accept this type. + // Build the NIP-73 identifier for comments. NIP-73 identifiers with schemes + // (isbn:, iso3166:, bitcoin:, etc.) are URL objects so NIP-22 writes the + // protocol into the k/K tag instead of treating them as hashtags. const commentRootUrl = useMemo((): URL | undefined => { if (!content || content.type !== 'url') return undefined; try { return new URL(content.value); } catch { return undefined; } }, [content]); - const commentRootId = useMemo((): `#${string}` | undefined => { + const commentRootId = useMemo((): URL | `#${string}` | undefined => { if (!content || content.type === 'url') return undefined; - return content.value as `#${string}`; + if (content.value.startsWith('#')) return content.value as `#${string}`; + try { return new URL(content.value); } catch { return content.value as `#${string}`; } }, [content]); const commentRoot: URL | `#${string}` | undefined = commentRootUrl ?? commentRootId;