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 && (
- <>
-
-
- >
- )}