Add boost action and improve quote rendering
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<string | undefined>(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 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>
|
||||
</div>
|
||||
);
|
||||
@@ -203,6 +222,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
|
||||
quotedEvent={event}
|
||||
open={quoteOpen}
|
||||
onOpenChange={setQuoteOpen}
|
||||
initialContent={quoteInitialContent}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -114,6 +114,10 @@ async function applyFeedFilters(
|
||||
events: NostrEvent[],
|
||||
countryCode: string | undefined
|
||||
): Promise<NostrEvent[]> {
|
||||
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');
|
||||
|
||||
@@ -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<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. */
|
||||
function makeCommentTags(scope: 'root' | 'reply', target: NostrEvent | URL | `#${string}`, hints: Hints): string[][] {
|
||||
const tags: string[][] = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user