Add boost action and improve quote rendering
This commit is contained in:
@@ -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+1F1E6–U+1F1FF). */
|
/** Regex matching flag emoji: pairs of Regional Indicator Symbol letters (U+1F1E6–U+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.
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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[][] = [];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user