Fix optimistic updates, show user reaction emoji, compact embeds, and unify repost menu

- Add true optimistic updates for reactions and reposts via setQueryData
  instead of only invalidating queries after publish
- Track user's own reaction per event and display their emoji in place of
  the Heart icon across feed, post details, and notifications
- Strip whitespace around block embeds and skipped media URLs to eliminate
  blank lines caused by whitespace-pre-wrap
- Only render link preview cards for standalone URLs; inline URLs in
  sentences render as plain clickable links
- Remove border-b separators and use fixed-height object-cover on preview
  images to eliminate gaps
- Tighten padding on EmbeddedNote and EmbeddedNaddr cards
- Use fixed-height object-cover for ImageGallery grid images
- Unify RepostMenu to use Popover on all screen sizes instead of Drawer
  on mobile
This commit is contained in:
Chad Curtis
2026-02-19 16:19:19 -06:00
parent 7aad043734
commit 35422ec6bb
9 changed files with 169 additions and 66 deletions
+4 -4
View File
@@ -123,11 +123,11 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className?
>
{/* Image */}
{image && (
<div className="w-full overflow-hidden border-b border-border bg-muted">
<div className="w-full overflow-hidden">
<img
src={image}
alt=""
className="w-full h-auto max-h-[260px] object-cover"
className="w-full h-[180px] object-cover"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
@@ -137,7 +137,7 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className?
)}
{/* Text content */}
<div className="px-3.5 py-2.5 space-y-1.5">
<div className="px-3 py-2 space-y-1">
{/* Author row */}
<div className="flex items-center gap-2 min-w-0">
{author.isLoading ? (
@@ -197,7 +197,7 @@ function EmbeddedNaddrCard({ event, className }: { event: NostrEvent; className?
function EmbeddedNaddrSkeleton({ className }: { className?: string }) {
return (
<div className={cn('rounded-2xl border border-border overflow-hidden', className)}>
<Skeleton className="w-full h-[140px] rounded-none" />
<Skeleton className="w-full h-[180px] rounded-none" />
<div className="px-3.5 py-2.5 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-full" />
+4 -4
View File
@@ -142,11 +142,11 @@ function EmbeddedNoteCard({
>
{/* Optional image thumbnail */}
{firstImage && (
<div className="w-full overflow-hidden border-b border-border bg-muted">
<div className="w-full overflow-hidden">
<img
src={firstImage}
alt=""
className="w-full h-auto max-h-[200px] object-cover"
className="w-full h-[160px] object-cover"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
@@ -156,7 +156,7 @@ function EmbeddedNoteCard({
)}
{/* Note content */}
<div className="px-3.5 py-2.5 space-y-1.5">
<div className="px-3 py-2 space-y-1">
{/* Author row */}
<div className="flex items-center gap-2 min-w-0">
{author.isLoading ? (
@@ -212,7 +212,7 @@ function EmbedContentPreview({ text }: { text: string }) {
const segments = useMemo(() => parseEmbedSegments(text), [text]);
return (
<p className="text-sm leading-relaxed text-foreground whitespace-pre-wrap break-words line-clamp-4">
<p className="text-sm leading-relaxed text-foreground whitespace-pre-wrap break-words line-clamp-3">
{segments.map((seg, i) => {
if (seg.type === 'text') {
return <span key={i}>{seg.value}</span>;
+4 -4
View File
@@ -74,10 +74,10 @@ export function ImageGallery({
<img
src={url}
alt=""
className={cn(
'w-full h-auto object-cover transition-transform duration-200 hover:scale-[1.02]',
)}
style={{ maxHeight: maxGridHeight }}
className="w-full object-cover transition-transform duration-200 hover:scale-[1.02]"
style={{
height: visibleImages.length === 1 ? maxGridHeight : visibleImages.length === 3 && i === 0 ? maxGridHeight : `calc(${maxGridHeight} / 2)`,
}}
loading="lazy"
/>
{/* "+N" overlay on last visible image */}
+3 -3
View File
@@ -46,11 +46,11 @@ export function LinkPreview({ url, className }: LinkPreviewProps) {
>
{/* OG image */}
{data.image && (
<div className="w-full overflow-hidden border-b border-border bg-muted">
<div className="w-full overflow-hidden">
<img
src={data.image}
alt=""
className="w-full h-auto max-h-[260px] object-cover"
className="w-full h-[180px] object-cover"
loading="lazy"
onError={(e) => {
// Hide broken images
@@ -100,7 +100,7 @@ export function LinkPreview({ url, className }: LinkPreviewProps) {
function LinkPreviewSkeleton({ className }: { className?: string }) {
return (
<div className={cn('rounded-2xl border border-border overflow-hidden', className)}>
<Skeleton className="w-full h-[140px] rounded-none" />
<Skeleton className="w-full h-[180px] rounded-none" />
<div className="px-3.5 py-2.5 space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-3/4" />
+51 -9
View File
@@ -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 <span key={i}>{token.value}</span>;
case 'link-preview':
return <LinkPreview key={i} url={token.url} className="my-2.5" />;
case 'inline-link':
return (
<a
key={i}
href={token.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-all"
onClick={(e) => e.stopPropagation()}
>
{token.url}
</a>
);
case 'youtube-embed':
return <YouTubeEmbed key={i} videoId={token.videoId} className="my-2.5" />;
case 'nevent-embed':
+24 -1
View File
@@ -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<EventStats>(['event-stats', eventId]);
if (prevStats) {
queryClient.setQueryData<EventStats>(['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<string>(['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<EventStats>(['event-stats', eventId], prevStats);
}
// Remove user reaction
queryClient.removeQueries({ queryKey: ['user-reaction', eventId] });
},
},
);
+13 -3
View File
@@ -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<NodeJS.Timeout | null>(null);
const userReaction = useUserReaction(eventId);
const hasReacted = !!userReaction;
const handleMouseEnter = useCallback(() => {
if (!user) return;
@@ -53,7 +57,9 @@ export function ReactionButton({
<button
className={cn(
'flex items-center gap-1.5 p-2 rounded-full transition-colors',
'text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10',
hasReacted
? 'text-pink-500'
: 'text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10',
className,
)}
title="React"
@@ -65,9 +71,13 @@ export function ReactionButton({
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Heart className="size-5" />
{hasReacted ? (
<span className="size-5 flex items-center justify-center text-base leading-none">{userReaction}</span>
) : (
<Heart className="size-5" />
)}
{reactionCount > 0 && (
<span className="text-sm tabular-nums">{reactionCount}</span>
<span className={cn('text-sm tabular-nums', hasReacted && 'text-pink-500')}>{reactionCount}</span>
)}
</button>
</PopoverTrigger>
+15 -38
View File
@@ -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<EventStats>(['event-stats', event.id]);
if (prevStats) {
queryClient.setQueryData<EventStats>(['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<EventStats>(['event-stats', event.id], prevStats);
}
},
}
);
@@ -85,43 +96,9 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
<Quote className="size-5" />
<span>Quote post</span>
</button>
{isMobile && (
<>
<div className="h-px bg-border my-1" />
<button
onClick={(e) => {
e.stopPropagation();
setOpen(false);
}}
className="flex items-center justify-center w-full px-4 py-3 text-[15px] text-muted-foreground hover:bg-secondary/60 transition-colors"
>
Close
</button>
</>
)}
</div>
);
if (isMobile) {
return (
<>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild onClick={(e) => e.stopPropagation()}>
{children}
</DrawerTrigger>
<DrawerContent className="px-0 pb-2">
{menuContent}
</DrawerContent>
</Drawer>
<ReplyComposeModal
quotedEvent={event}
open={quoteOpen}
onOpenChange={setQuoteOpen}
/>
</>
);
}
return (
<>
<Popover open={open} onOpenChange={setOpen}>
+51
View File
@@ -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<string>(['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;
}