diff --git a/src/components/EmbeddedNaddr.tsx b/src/components/EmbeddedNaddr.tsx index 4bb21b06..ed611a88 100644 --- a/src/components/EmbeddedNaddr.tsx +++ b/src/components/EmbeddedNaddr.tsx @@ -123,11 +123,11 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className? > {/* Image */} {image && ( -
+
{ (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; @@ -137,7 +137,7 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className? )} {/* Text content */} -
+
{/* Author row */}
{author.isLoading ? ( @@ -197,7 +197,7 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className? function EmbeddedNaddrSkeleton({ className }: { className?: string }) { return (
- +
diff --git a/src/components/EmbeddedNote.tsx b/src/components/EmbeddedNote.tsx index e8126bc4..24709c54 100644 --- a/src/components/EmbeddedNote.tsx +++ b/src/components/EmbeddedNote.tsx @@ -142,11 +142,11 @@ function EmbeddedNoteCard({ > {/* Optional image thumbnail */} {firstImage && ( -
+
{ (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; @@ -156,7 +156,7 @@ function EmbeddedNoteCard({ )} {/* Note content */} -
+
{/* Author row */}
{author.isLoading ? ( @@ -212,7 +212,7 @@ function EmbedContentPreview({ text }: { text: string }) { const segments = useMemo(() => parseEmbedSegments(text), [text]); return ( -

+

{segments.map((seg, i) => { if (seg.type === 'text') { return {seg.value}; diff --git a/src/components/ImageGallery.tsx b/src/components/ImageGallery.tsx index ddd62304..0715a504 100644 --- a/src/components/ImageGallery.tsx +++ b/src/components/ImageGallery.tsx @@ -74,10 +74,10 @@ export function ImageGallery({ {/* "+N" overlay on last visible image */} diff --git a/src/components/LinkPreview.tsx b/src/components/LinkPreview.tsx index 79bad76f..dc76d194 100644 --- a/src/components/LinkPreview.tsx +++ b/src/components/LinkPreview.tsx @@ -46,11 +46,11 @@ export function LinkPreview({ url, className }: LinkPreviewProps) { > {/* OG image */} {data.image && ( -

+
{ // Hide broken images @@ -100,7 +100,7 @@ export function LinkPreview({ url, className }: LinkPreviewProps) { function LinkPreviewSkeleton({ className }: { className?: string }) { return (
- +
diff --git a/src/components/NoteContent.tsx b/src/components/NoteContent.tsx index b08f0f75..6843fdc2 100644 --- a/src/components/NoteContent.tsx +++ b/src/components/NoteContent.tsx @@ -72,6 +72,7 @@ function extractNaddrFromUrl(url: string): AddrCoords | null { type ContentToken = | { type: 'text'; value: string } | { type: 'link-preview'; url: string } + | { type: 'inline-link'; url: string } | { type: 'youtube-embed'; videoId: string } | { type: 'mention'; pubkey: string } | { type: 'nevent-embed'; eventId: string } @@ -131,25 +132,53 @@ export function NoteContent({ // The punctuation will be part of the next text token } } - // Skip media URLs — rendered as embedded previews by the parent + // Skip media URLs — rendered as embedded media by the parent. + // Trim trailing whitespace from the preceding text token so that + // the removed URL doesn't leave blank lines under pre-wrap. if (MEDIA_URL_REGEX.test(url)) { + if (result.length > 0) { + const prev = result[result.length - 1]; + if (prev.type === 'text') { + prev.value = prev.value.replace(/\s+$/, ''); + } + } lastIndex = index + fullMatch.length; + // Also strip leading whitespace that follows the skipped URL + const remaining = text.substring(lastIndex); + const leadingWs = remaining.match(/^\s+/); + if (leadingWs) { + lastIndex += leadingWs[0].length; + } continue; } + // Determine if this URL stands alone on its own line (not mid-sentence). + // A URL is "standalone" when it's only preceded/followed by whitespace + // (or start/end of string) on the same line. + const textBefore = text.substring(lastIndex, index); + const lastNewline = textBefore.lastIndexOf('\n'); + const linePrefix = lastNewline === -1 ? textBefore : textBefore.substring(lastNewline + 1); + const afterUrl = text.substring(index + fullMatch.length); + const nextNewline = afterUrl.indexOf('\n'); + const lineSuffix = nextNewline === -1 ? afterUrl : afterUrl.substring(0, nextNewline); + const isStandalone = linePrefix.trim() === '' && 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 { + } else if (isStandalone) { // YouTube → playable embed const ytId = extractYouTubeId(url); if (ytId) { result.push({ type: 'youtube-embed', videoId: ytId }); } else { - // Other non-media URL → link preview card + // Standalone URL → link preview card result.push({ type: 'link-preview', url }); } + } else { + // Inline URL mid-sentence → plain clickable link + result.push({ type: 'inline-link', url }); } } else if ((nostrPrefix && nostrData) || (barePrefix && bareData)) { // Handle both nostr:-prefixed and bare NIP-19 identifiers @@ -200,20 +229,20 @@ export function NoteContent({ || token.type === 'naddr-embed'; if (isBlock) { - // Trim trailing whitespace from the preceding text token (before the block) + // Strip all trailing whitespace from the preceding text token. + // The block's own margin (my-2.5) handles spacing, so preserved + // newlines just add redundant blank lines under whitespace-pre-wrap. if (i > 0) { const prev = result[i - 1]; if (prev.type === 'text') { - // Collapse multiple trailing newlines to max 2, trim trailing spaces - prev.value = prev.value.replace(/[ \t]+$/gm, '').replace(/\n{3,}$/, '\n\n'); + prev.value = prev.value.replace(/\s+$/, ''); } } - // After the block, collapse multiple leading newlines but preserve one if present + // Strip all leading whitespace from the following text token. if (i < result.length - 1) { const next = result[i + 1]; if (next.type === 'text') { - // Collapse multiple leading newlines to max 2, trim leading spaces on each line - next.value = next.value.replace(/^[ \t]+/gm, '').replace(/^\n{3,}/, '\n\n'); + next.value = next.value.replace(/^\s+/, ''); } } } @@ -246,6 +275,19 @@ export function NoteContent({ return {token.value}; case 'link-preview': return ; + case 'inline-link': + return ( + e.stopPropagation()} + > + {token.url} + + ); case 'youtube-embed': return ; case 'nevent-embed': diff --git a/src/components/QuickReactMenu.tsx b/src/components/QuickReactMenu.tsx index 16f49edf..d3931792 100644 --- a/src/components/QuickReactMenu.tsx +++ b/src/components/QuickReactMenu.tsx @@ -7,6 +7,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useEmojiUsage } from '@/hooks/useEmojiUsage'; import { cn } from '@/lib/utils'; +import type { EventStats } from '@/hooks/useTrending'; interface QuickReactMenuProps { /** The event ID being reacted to. */ @@ -48,6 +49,22 @@ export function QuickReactMenu({ // Track emoji usage trackEmojiUsage(emoji); + // Optimistically update stats cache immediately + const displayEmoji = (emoji === '+' || emoji === '') ? '👍' : emoji; + const prevStats = queryClient.getQueryData(['event-stats', eventId]); + if (prevStats) { + queryClient.setQueryData(['event-stats', eventId], { + ...prevStats, + reactions: prevStats.reactions + 1, + reactionEmojis: prevStats.reactionEmojis.includes(displayEmoji) + ? prevStats.reactionEmojis + : [...prevStats.reactionEmojis, displayEmoji], + }); + } + + // Store user's own reaction for this event + queryClient.setQueryData(['user-reaction', eventId], displayEmoji); + // Publish kind 7 reaction publishEvent( { @@ -62,13 +79,19 @@ export function QuickReactMenu({ }, { onSuccess: () => { - // Invalidate stats to refetch real counts + // Invalidate stats to refetch real counts (will reconcile with optimistic data) queryClient.invalidateQueries({ queryKey: ['event-stats', eventId] }); queryClient.invalidateQueries({ queryKey: ['event-interactions', eventId] }); }, onError: () => { // Revert optimistic update on failure setSelectedEmoji(null); + // Revert stats + if (prevStats) { + queryClient.setQueryData(['event-stats', eventId], prevStats); + } + // Remove user reaction + queryClient.removeQueries({ queryKey: ['user-reaction', eventId] }); }, }, ); diff --git a/src/components/ReactionButton.tsx b/src/components/ReactionButton.tsx index bb8f676b..46934dd5 100644 --- a/src/components/ReactionButton.tsx +++ b/src/components/ReactionButton.tsx @@ -4,6 +4,7 @@ import { Heart } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { QuickReactMenu } from '@/components/QuickReactMenu'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useUserReaction } from '@/hooks/useUserReaction'; import { cn } from '@/lib/utils'; interface ReactionButtonProps { @@ -29,6 +30,9 @@ export function ReactionButton({ const { user } = useCurrentUser(); const [menuOpen, setMenuOpen] = useState(false); const closeTimeoutRef = useRef(null); + const userReaction = useUserReaction(eventId); + + const hasReacted = !!userReaction; const handleMouseEnter = useCallback(() => { if (!user) return; @@ -53,7 +57,9 @@ export function ReactionButton({ diff --git a/src/components/RepostMenu.tsx b/src/components/RepostMenu.tsx index 5f214a8d..cbe4b72e 100644 --- a/src/components/RepostMenu.tsx +++ b/src/components/RepostMenu.tsx @@ -4,13 +4,12 @@ import { useState } from 'react'; import type { NostrEvent } from '@nostrify/nostrify'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; -import { useIsMobile } from '@/hooks/useIsMobile'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useQueryClient } from '@tanstack/react-query'; import { useToast } from '@/hooks/useToast'; +import type { EventStats } from '@/hooks/useTrending'; interface RepostMenuProps { event: NostrEvent; @@ -18,7 +17,6 @@ interface RepostMenuProps { } export function RepostMenu({ event, children }: RepostMenuProps) { - const isMobile = useIsMobile(); const [open, setOpen] = useState(false); const [quoteOpen, setQuoteOpen] = useState(false); const { user } = useCurrentUser(); @@ -32,6 +30,15 @@ export function RepostMenu({ event, children }: RepostMenuProps) { return; } + // Optimistically update stats cache immediately + const prevStats = queryClient.getQueryData(['event-stats', event.id]); + if (prevStats) { + queryClient.setQueryData(['event-stats', event.id], { + ...prevStats, + reposts: prevStats.reposts + 1, + }); + } + // Kind 6 repost publishEvent( { @@ -47,12 +54,16 @@ export function RepostMenu({ event, children }: RepostMenuProps) { onSuccess: () => { toast({ title: 'Reposted!' }); setOpen(false); - // Invalidate stats to refetch counts + // Invalidate stats to refetch real counts queryClient.invalidateQueries({ queryKey: ['event-stats', event.id] }); queryClient.invalidateQueries({ queryKey: ['event-interactions', event.id] }); }, onError: () => { toast({ title: 'Failed to repost', variant: 'destructive' }); + // Revert optimistic update + if (prevStats) { + queryClient.setQueryData(['event-stats', event.id], prevStats); + } }, } ); @@ -85,43 +96,9 @@ export function RepostMenu({ event, children }: RepostMenuProps) { Quote post - {isMobile && ( - <> -
- - - )}
); - if (isMobile) { - return ( - <> - - e.stopPropagation()}> - {children} - - - {menuContent} - - - - - ); - } - return ( <> diff --git a/src/hooks/useUserReaction.ts b/src/hooks/useUserReaction.ts new file mode 100644 index 00000000..40e1eb8b --- /dev/null +++ b/src/hooks/useUserReaction.ts @@ -0,0 +1,51 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; + +/** + * Returns the current user's reaction emoji for a given event, if any. + * + * Checks the optimistic cache first (set by QuickReactMenu on react), + * then falls back to querying the relay for the user's kind 7 events. + * + * Returns undefined while loading, null if no reaction, or the emoji string. + */ +export function useUserReaction(eventId: string | undefined): string | null | undefined { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const queryClient = useQueryClient(); + + // Check optimistic cache first + const optimistic = queryClient.getQueryData(['user-reaction', eventId ?? '']); + + const { data } = useQuery({ + queryKey: ['user-reaction', eventId ?? ''], + queryFn: async ({ signal }) => { + if (!eventId || !user) return null; + + const events = await nostr.query( + [{ + kinds: [7], + authors: [user.pubkey], + '#e': [eventId], + limit: 1, + }], + { signal: AbortSignal.any([signal, AbortSignal.timeout(3000)]) }, + ); + + if (events.length === 0) return null; + + const content = events[0].content.trim(); + if (content === '+' || content === '') return '👍'; + if (content === '-') return null; + return content; + }, + enabled: !!eventId && !!user && !optimistic, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); + + // Prefer optimistic value, then query result + if (optimistic) return optimistic; + return data; +}