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:
@@ -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" />
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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] });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user