Add boost action and improve quote rendering

This commit is contained in:
lemon
2026-05-15 00:31:46 -07:00
parent a486de06ba
commit e066bdb482
5 changed files with 134 additions and 38 deletions
+47 -26
View File
@@ -94,30 +94,37 @@ function emojify(
return result.length > 0 ? result : [text]; 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+1F1E6U+1F1FF). */ /** Regex matching flag emoji: pairs of Regional Indicator Symbol letters (U+1F1E6U+1F1FF). */
const FLAG_EMOJI_REGEX = /([\u{1F1E6}-\u{1F1FF}]{2})/gu; 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. * 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. * 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 lineSuffix = nextNewline === -1 ? afterUrl : afterUrl.substring(0, nextNewline);
const isEndOfLine = lineSuffix.trim() === ''; const isEndOfLine = lineSuffix.trim() === '';
// Check if the URL contains an naddr1 identifier → embed as Nostr event + preserve link if (isEndOfLine) {
const naddrFromUrl = extractNaddrFromUrl(url);
if (naddrFromUrl) {
result.push({ type: 'naddr-embed', addr: naddrFromUrl, url });
} else if (isEndOfLine) {
// Standalone URL at end of line → rich embed (YouTube, Tweet, or link preview) // Standalone URL at end of line → rich embed (YouTube, Tweet, or link preview)
result.push({ type: 'link-embed', url }); result.push({ type: 'link-embed', url });
} else { } 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. // 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 // 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. // the content string. Without this, those attachments would be silently dropped.
+22 -2
View File
@@ -1,4 +1,4 @@
import { Quote, Undo2 } from 'lucide-react'; import { Quote, Rocket, Undo2 } from 'lucide-react';
import { RepostIcon } from '@/components/icons/RepostIcon'; import { RepostIcon } from '@/components/icons/RepostIcon';
import { useState } from 'react'; import { useState } from 'react';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
@@ -14,6 +14,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { getRepostKind } from '@/lib/feedUtils'; import { getRepostKind } from '@/lib/feedUtils';
import { DITTO_RELAY } from '@/lib/appRelays'; import { DITTO_RELAY } from '@/lib/appRelays';
import { encodeEventAddress } from '@/lib/encodeEvent';
import type { EventStats } from '@/hooks/useTrending'; import type { EventStats } from '@/hooks/useTrending';
interface RepostMenuProps { interface RepostMenuProps {
@@ -24,6 +25,7 @@ interface RepostMenuProps {
export function RepostMenu({ event, children }: RepostMenuProps) { export function RepostMenu({ event, children }: RepostMenuProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [quoteOpen, setQuoteOpen] = useState(false); const [quoteOpen, setQuoteOpen] = useState(false);
const [quoteInitialContent, setQuoteInitialContent] = useState<string | undefined>(undefined);
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { mutate: publishEvent } = useNostrPublish(); const { mutate: publishEvent } = useNostrPublish();
const { mutate: deleteEvent } = useDeleteEvent(); const { mutate: deleteEvent } = useDeleteEvent();
@@ -140,6 +142,13 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
}; };
const handleQuote = () => { const handleQuote = () => {
setQuoteInitialContent(undefined);
setOpen(false);
setQuoteOpen(true);
};
const handleBoost = () => {
setQuoteInitialContent(`\n\nhttps://agora.spot/${encodeEventAddress(event)}`);
setOpen(false); setOpen(false);
setQuoteOpen(true); 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" className="flex items-center gap-3 w-full px-4 py-3 text-[15px] text-foreground hover:bg-secondary/60 transition-colors"
> >
<Quote className="size-5" /> <Quote className="size-5" />
<span>Quote post</span> <span>Quote</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleBoost();
}}
className="flex items-center gap-3 w-full px-4 py-3 text-[15px] text-foreground hover:bg-secondary/60 transition-colors"
>
<Rocket className="size-5" />
<span>Boost</span>
</button> </button>
</div> </div>
); );
@@ -203,6 +222,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
quotedEvent={event} quotedEvent={event}
open={quoteOpen} open={quoteOpen}
onOpenChange={setQuoteOpen} onOpenChange={setQuoteOpen}
initialContent={quoteInitialContent}
/> />
</> </>
); );
+8
View File
@@ -114,6 +114,10 @@ async function applyFeedFilters(
events: NostrEvent[], events: NostrEvent[],
countryCode: string | undefined countryCode: string | undefined
): Promise<NostrEvent[]> { ): Promise<NostrEvent[]> {
const parseCountryIdentifier = countryCode
? (await import('@/lib/countryIdentifiers')).parseCountryIdentifier
: undefined;
// Filter by kind and tags // Filter by kind and tags
let filteredEvents = events.filter(event => { let filteredEvents = events.filter(event => {
if (event.kind === 36639) { if (event.kind === 36639) {
@@ -124,6 +128,10 @@ async function applyFeedFilters(
return true; return true;
} }
if (event.kind === 1068) 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);
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'); return kTags.includes('iso3166') || kTags.includes('geo') || KTags.includes('36639');
+51 -2
View File
@@ -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 { useNostrPublish } from '@/hooks/useNostrPublish';
import { NKinds, type NostrEvent } from '@nostrify/nostrify'; import { NKinds, type NostrEvent } from '@nostrify/nostrify';
interface PaginatedFeedPage {
events: NostrEvent[];
oldestTimestamp: number | null;
totalFetched: number;
}
interface PostCommentParams { interface PostCommentParams {
root: NostrEvent | URL | `#${string}`; // The root event to comment on root: NostrEvent | URL | `#${string}`; // The root event to comment on
reply?: NostrEvent | URL | `#${string}`; // Optional reply to another comment reply?: NostrEvent | URL | `#${string}`; // Optional reply to another comment
@@ -44,17 +50,60 @@ export function usePostComment() {
return event; return event;
}, },
onSuccess: (_, { root }) => { onSuccess: (event, { root }) => {
const rootKey = root instanceof URL ? root.toString() : typeof root === 'string' ? root : root.id; const rootKey = root instanceof URL ? root.toString() : typeof root === 'string' ? root : root.id;
const countryCode = getCountryCode(root);
// Invalidate and refetch comments // Invalidate and refetch comments
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['nostr', 'comments', rootKey] queryKey: ['nostr', 'comments', rootKey]
}); });
if (countryCode) {
queryClient.setQueriesData<InfiniteData<PaginatedFeedPage>>(
{ 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. */ /** 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[][] { function makeCommentTags(scope: 'root' | 'reply', target: NostrEvent | URL | `#${string}`, hints: Hints): string[][] {
const tags: string[][] = []; const tags: string[][] = [];
+6 -8
View File
@@ -225,20 +225,18 @@ export function ExternalContentPage() {
useSeoMeta({ title: content ? (resolvedTitle ? `${resolvedTitle} | ${config.appName}` : seoTitle(content, config.appName)) : `External Content | ${config.appName}` }); useSeoMeta({ title: content ? (resolvedTitle ? `${resolvedTitle} | ${config.appName}` : seoTitle(content, config.appName)) : `External Content | ${config.appName}` });
// Build the NIP-73 identifier for comments. // Build the NIP-73 identifier for comments. NIP-73 identifiers with schemes
// For URLs, a URL object is used. For others (isbn:, iso3166:, etc.) the raw identifier // (isbn:, iso3166:, bitcoin:, etc.) are URL objects so NIP-22 writes the
// is passed to useComments for querying. The `#${string}` type is a marker for "non-URL // protocol into the k/K tag instead of treating them as hashtags.
// 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.
const commentRootUrl = useMemo((): URL | undefined => { const commentRootUrl = useMemo((): URL | undefined => {
if (!content || content.type !== 'url') return undefined; if (!content || content.type !== 'url') return undefined;
try { return new URL(content.value); } catch { return undefined; } try { return new URL(content.value); } catch { return undefined; }
}, [content]); }, [content]);
const commentRootId = useMemo((): `#${string}` | undefined => { const commentRootId = useMemo((): URL | `#${string}` | undefined => {
if (!content || content.type === 'url') return 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]); }, [content]);
const commentRoot: URL | `#${string}` | undefined = commentRootUrl ?? commentRootId; const commentRoot: URL | `#${string}` | undefined = commentRootUrl ?? commentRootId;