Merge branch 'opencode/1772318974'

This commit is contained in:
Alex Gleason
2026-02-28 17:21:20 -06:00
5 changed files with 256 additions and 11 deletions
+42
View File
@@ -7,6 +7,8 @@ import { EmbeddedNote } from '@/components/EmbeddedNote';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAddrEvent, useEvent } from '@/hooks/useEvent';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { EXTRA_KINDS } from '@/lib/extraKinds';
/** Parsed root reference from a kind 1111 comment's uppercase tags. */
@@ -131,6 +133,46 @@ export function CommentContext({ event, className }: CommentContextProps) {
/** Comment context for addressable event roots (A tag). */
function AddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
// Kind 0 (profile) roots get special treatment — show "@DisplayName" with a profile link
if (root.addr?.kind === 0) {
return <ProfileCommentContext pubkey={root.addr.pubkey} className={className} />;
}
return <GenericAddrCommentContext root={root} className={className} />;
}
/** Comment context for kind 0 (profile) roots — shows "Commenting on @Name". */
function ProfileCommentContext({ pubkey, className }: { pubkey: string; className?: string }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(pubkey);
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
if (author.isLoading) {
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1'}>
<span className="shrink-0">Commenting on</span>
<Skeleton className="h-3.5 w-24 inline-block" />
</div>
);
}
return (
<div className={className || 'flex items-center gap-x-1 text-sm text-muted-foreground mt-2 mb-1 min-w-0 overflow-hidden'}>
<span className="shrink-0">Commenting on</span>
<Link
to={`/${npubEncoded}`}
className="text-primary hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
@{displayName}
</Link>
</div>
);
}
/** Comment context for non-profile addressable event roots (A tag). */
function GenericAddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
const { data: event, isLoading } = useAddrEvent(root.addr);
if (isLoading) {
+4 -2
View File
@@ -25,6 +25,8 @@ interface ReplyComposeModalProps {
quotedEvent?: NostrEvent | null;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a post is successfully published. */
onSuccess?: () => void;
}
/** Extracts image URLs from note content. */
@@ -33,7 +35,7 @@ function extractImages(content: string): string[] {
return content.match(urlRegex) || [];
}
export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange }: ReplyComposeModalProps) {
export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSuccess }: ReplyComposeModalProps) {
const isUrl = event instanceof URL;
const isReply = !!event;
const isQuote = !!quotedEvent;
@@ -97,7 +99,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange }: Re
<ComposeBox
replyTo={isQuote ? undefined : (event ?? undefined)}
quotedEvent={quotedEvent ?? undefined}
onSuccess={() => onOpenChange(false)}
onSuccess={() => { onOpenChange(false); onSuccess?.(); }}
placeholder={placeholder}
forceExpanded
hideAvatar
+1 -1
View File
@@ -16,7 +16,7 @@ interface ProfileFeedPage {
const PAGE_SIZE = 15;
export type ProfileTab = 'posts' | 'replies' | 'media' | 'likes';
export type ProfileTab = 'posts' | 'replies' | 'media' | 'likes' | 'wall';
/** Kinds that are inherently media (video/image) content. */
const MEDIA_KINDS = new Set([34236]); // vines
+68
View File
@@ -0,0 +1,68 @@
import { useNostr } from '@nostrify/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
const PAGE_SIZE = 20;
interface WallPage {
comments: NostrEvent[];
oldestTimestamp: number | undefined;
}
/**
* Infinite-scroll hook for wall comments (NIP-22 kind 1111) on a user's kind 0.
*
* Wall comments are filtered by the target user's kind 3 follow list — only
* comments from authors the profile owner follows are shown. If no follow list
* is available, no comments are returned.
*/
export function useWallComments(pubkey: string | undefined, followList: string[] | undefined) {
const { nostr } = useNostr();
const aTag = pubkey ? `0:${pubkey}:` : '';
return useInfiniteQuery<WallPage, Error>({
queryKey: ['wall-comments', pubkey ?? '', followList?.length ?? 0],
queryFn: async ({ pageParam, signal }) => {
if (!pubkey || !followList || followList.length === 0) {
return { comments: [], oldestTimestamp: undefined };
}
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
// Include the profile owner's own pubkey alongside their follow list
const authors = followList.includes(pubkey) ? followList : [pubkey, ...followList];
const filter: NostrFilter = {
kinds: [1111],
'#A': [aTag],
authors,
limit: PAGE_SIZE,
};
if (pageParam) {
filter.until = pageParam as number;
}
const events = await nostr.query([filter], { signal: querySignal });
// Sort newest-first
const sorted = [...events].sort((a, b) => b.created_at - a.created_at);
const oldestTimestamp = sorted.length > 0
? sorted[sorted.length - 1].created_at
: undefined;
return { comments: sorted, oldestTimestamp };
},
getNextPageParam: (lastPage) => {
if (lastPage.comments.length === 0 || lastPage.oldestTimestamp === undefined) {
return undefined;
}
return lastPage.oldestTimestamp - 1;
},
initialPageParam: undefined as number | undefined,
enabled: !!pubkey && !!followList && followList.length > 0,
staleTime: 30 * 1000,
});
}
+141 -8
View File
@@ -5,7 +5,7 @@ import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { Zap, Flame, MoreHorizontal, ClipboardCopy, ExternalLink, VolumeX, Flag, Bitcoin, Users, Pin, X, QrCode, Check, Copy, Loader2, Download, Palette, Trash2, Eye, EyeOff, RefreshCw } from 'lucide-react';
import { Zap, Flame, MoreHorizontal, ClipboardCopy, ExternalLink, VolumeX, Flag, Bitcoin, Users, Pin, X, QrCode, Check, Copy, Loader2, Download, Palette, Trash2, Eye, EyeOff, RefreshCw, MessageSquare } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
@@ -18,6 +18,8 @@ import { Separator } from '@/components/ui/separator';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { ProfileRightSidebar } from '@/components/ProfileRightSidebar';
import { NoteCard } from '@/components/NoteCard';
import { ComposeBox } from '@/components/ComposeBox';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ZapDialog } from '@/components/ZapDialog';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { Nip05Badge, VerifiedNip05Text } from '@/components/Nip05Badge';
@@ -34,6 +36,7 @@ import { useProfileFeed, useProfileLikes as useProfileLikesInfinite, filterByTab
import type { ProfileTab } from '@/hooks/useProfileFeed';
import { useProfileMedia } from '@/hooks/useProfileMedia';
import { useProfileSupplementary } from '@/hooks/useProfileData';
import { useWallComments } from '@/hooks/useWallComments';
import { useNip05Resolve } from '@/hooks/useNip05Resolve';
import { genUserName } from '@/lib/genUserName';
@@ -712,6 +715,35 @@ export function ProfilePage() {
isFetchingNextPage: isFetchingNextLikesPage,
} = useProfileLikesInfinite(pubkey, activeTab === 'likes');
// Wall comments (NIP-22 kind 1111 on user's kind 0, filtered by their follow list)
const wallFollowList = useMemo(() => supplementary?.following, [supplementary?.following]);
const {
data: wallData,
isPending: wallPending,
fetchNextPage: fetchNextWallPage,
hasNextPage: hasNextWallPage,
isFetchingNextPage: isFetchingNextWallPage,
} = useWallComments(pubkey, wallFollowList);
// Synthetic kind 0 event for the ComposeBox replyTo (NIP-22 comments on the profile)
const wallReplyTarget = useMemo((): NostrEvent | undefined => {
if (!pubkey) return undefined;
// Use the real kind 0 event if available, otherwise build a minimal synthetic one
if (metadataEvent) return metadataEvent;
return {
id: '',
kind: 0,
pubkey,
content: '',
created_at: 0,
sig: '',
tags: [],
};
}, [pubkey, metadataEvent]);
// Wall compose modal state (for FAB on wall tab)
const [wallComposeOpen, setWallComposeOpen] = useState(false);
// Follow list (cached, for display checks only)
const { data: followData } = useFollowList();
@@ -979,6 +1011,23 @@ export function ProfilePage() {
return items;
}, [likesData?.pages]);
// Flatten wall pages and deduplicate
const wallComments = useMemo(() => {
if (!wallData?.pages) return [];
const seen = new Set<string>();
const items: NostrEvent[] = [];
for (const page of wallData.pages) {
for (const comment of page.comments) {
if (!seen.has(comment.id)) {
seen.add(comment.id);
if (muteItems.length > 0 && isEventMuted(comment, muteItems)) continue;
items.push(comment);
}
}
}
return items;
}, [wallData?.pages, muteItems]);
const streak = useMemo(() => {
if (!feedData?.pages) return 0;
const events: NostrEvent[] = [];
@@ -1005,12 +1054,16 @@ export function ProfilePage() {
if (hasNextMediaPage && !isFetchingNextMediaPage) {
fetchNextMediaPage();
}
} else if (activeTab === 'wall') {
if (hasNextWallPage && !isFetchingNextWallPage) {
fetchNextWallPage();
}
} else {
if (hasNextFeedPage && !isFetchingNextFeedPage) {
fetchNextFeedPage();
}
}
}, [inView, activeTab, hasNextFeedPage, isFetchingNextFeedPage, fetchNextFeedPage, hasNextLikesPage, isFetchingNextLikesPage, fetchNextLikesPage, hasNextMediaPage, isFetchingNextMediaPage, fetchNextMediaPage]);
}, [inView, activeTab, hasNextFeedPage, isFetchingNextFeedPage, fetchNextFeedPage, hasNextLikesPage, isFetchingNextLikesPage, fetchNextLikesPage, hasNextMediaPage, isFetchingNextMediaPage, fetchNextMediaPage, hasNextWallPage, isFetchingNextWallPage, fetchNextWallPage]);
const authorEvent = metadataEvent;
@@ -1026,10 +1079,10 @@ export function ProfilePage() {
[mediaEvents]
);
const currentItems = activeTab === 'likes' ? likedFeedItems : activeTab === 'media' ? mediaFeedItems : filterByTab(feedItems, activeTab);
const currentLoading = activeTab === 'likes' ? likesPending : activeTab === 'media' ? mediaPending : feedPending;
const hasMore = activeTab === 'likes' ? hasNextLikesPage : activeTab === 'media' ? hasNextMediaPage : hasNextFeedPage;
const isFetchingMore = activeTab === 'likes' ? isFetchingNextLikesPage : activeTab === 'media' ? isFetchingNextMediaPage : isFetchingNextFeedPage;
const currentItems = activeTab === 'wall' ? [] : activeTab === 'likes' ? likedFeedItems : activeTab === 'media' ? mediaFeedItems : filterByTab(feedItems, activeTab);
const currentLoading = activeTab === 'wall' ? wallPending : activeTab === 'likes' ? likesPending : activeTab === 'media' ? mediaPending : feedPending;
const hasMore = activeTab === 'wall' ? hasNextWallPage : activeTab === 'likes' ? hasNextLikesPage : activeTab === 'media' ? hasNextMediaPage : hasNextFeedPage;
const isFetchingMore = activeTab === 'wall' ? isFetchingNextWallPage : activeTab === 'likes' ? isFetchingNextLikesPage : activeTab === 'media' ? isFetchingNextMediaPage : isFetchingNextFeedPage;
const handleRefresh = useCallback(async () => {
if (!pubkey) return;
@@ -1044,15 +1097,19 @@ export function ProfilePage() {
(tag === 'profile-feed' && key[1] === pubkey) ||
(tag === 'profile-media' && key[1] === pubkey) ||
(tag === 'profile-likes-infinite' && key[1] === pubkey) ||
(tag === 'profile-pinned-events' && key[1] === pubkey)
(tag === 'profile-pinned-events' && key[1] === pubkey) ||
(tag === 'wall-comments' && key[1] === pubkey)
);
},
});
}, [queryClient, pubkey]);
const openWallCompose = useCallback(() => setWallComposeOpen(true), []);
useLayoutOptions(pubkey ? {
rightSidebar: <ProfileRightSidebar fields={fields} mediaEvents={mediaEvents} mediaLoading={mediaPending} />,
showFAB: true,
onFabClick: activeTab === 'wall' ? openWallCompose : undefined,
} : {});
if (!pubkey) {
@@ -1400,6 +1457,7 @@ export function ProfilePage() {
<TabButton label="Posts & replies" active={activeTab === 'replies'} onClick={() => setActiveTab('replies')} />
<TabButton label="Media" active={activeTab === 'media'} onClick={() => setActiveTab('media')} />
<TabButton label="Likes" active={activeTab === 'likes'} onClick={() => setActiveTab('likes')} />
<TabButton label="Wall" active={activeTab === 'wall'} onClick={() => setActiveTab('wall')} />
</div>
{/* Pinned posts (only on Posts tab) */}
@@ -1435,7 +1493,81 @@ export function ProfilePage() {
</div>
)}
{/* Tab content */}
{/* Wall tab content */}
{activeTab === 'wall' && (
<div>
{/* Inline compose box for wall comments */}
{wallReplyTarget && (
<ComposeBox
compact
replyTo={wallReplyTarget}
onSuccess={() => queryClient.invalidateQueries({ queryKey: ['wall-comments', pubkey] })}
/>
)}
{/* Wall compose modal (for FAB) */}
{wallReplyTarget && (
<ReplyComposeModal
event={wallReplyTarget}
open={wallComposeOpen}
onOpenChange={setWallComposeOpen}
onSuccess={() => queryClient.invalidateQueries({ queryKey: ['wall-comments', pubkey] })}
/>
)}
{!wallFollowList || wallFollowList.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm">
<MessageSquare className="size-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium mb-2">No wall posts yet</p>
<p>{displayName} doesn't follow anyone yet, so there are no wall posts to show.</p>
</div>
) : wallPending ? (
<div className="divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-28" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
</div>
))}
</div>
) : wallComments.length > 0 ? (
<div>
{wallComments.map((comment) => (
<NoteCard key={comment.id} event={comment} />
))}
{/* Infinite scroll sentinel */}
{hasNextWallPage && (
<div ref={scrollRef} className="flex justify-center py-6">
{isFetchingNextWallPage && (
<Loader2 className="size-5 animate-spin text-muted-foreground" />
)}
</div>
)}
</div>
) : (
<div className="py-12 text-center text-muted-foreground text-sm">
<MessageSquare className="size-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium mb-2">No wall posts yet</p>
<p>Be the first to write on {displayName}'s wall!</p>
</div>
)}
</div>
)}
{/* Tab content (non-wall tabs) */}
{activeTab !== 'wall' && (
<div>
{currentLoading ? (
<div className="space-y-0">
@@ -1480,6 +1612,7 @@ export function ProfilePage() {
</div>
)}
</div>
)}
{/* Profile More Menu */}
{pubkey && (