From a03045ec853a49edaea76c8cfd9ac9f11105439c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 28 Feb 2026 17:10:06 -0600 Subject: [PATCH 1/3] Add Wall tab to user profiles with NIP-22 comments on kind 0 Wall posts are kind 1111 comments targeting the profile owner's kind 0 event (A tag: 0:pubkey:). Comments are filtered by the target user's kind 3 follow list to show only posts from people they follow. Uses the same ComposeBox/ReplyComposeModal pattern as /i/:uri routes. If the profile owner has no follow list, an empty state is shown. --- src/components/ReplyComposeModal.tsx | 6 +- src/hooks/useProfileFeed.ts | 2 +- src/hooks/useWallComments.ts | 65 ++++++++++++ src/pages/ProfilePage.tsx | 149 +++++++++++++++++++++++++-- 4 files changed, 211 insertions(+), 11 deletions(-) create mode 100644 src/hooks/useWallComments.ts diff --git a/src/components/ReplyComposeModal.tsx b/src/components/ReplyComposeModal.tsx index 5a6880c0..d01deb53 100644 --- a/src/components/ReplyComposeModal.tsx +++ b/src/components/ReplyComposeModal.tsx @@ -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 onOpenChange(false)} + onSuccess={() => { onOpenChange(false); onSuccess?.(); }} placeholder={placeholder} forceExpanded hideAvatar diff --git a/src/hooks/useProfileFeed.ts b/src/hooks/useProfileFeed.ts index e43ea03b..8acfba08 100644 --- a/src/hooks/useProfileFeed.ts +++ b/src/hooks/useProfileFeed.ts @@ -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 diff --git a/src/hooks/useWallComments.ts b/src/hooks/useWallComments.ts new file mode 100644 index 00000000..22276acd --- /dev/null +++ b/src/hooks/useWallComments.ts @@ -0,0 +1,65 @@ +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({ + 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)]); + + const filter: NostrFilter = { + kinds: [1111], + '#A': [aTag], + authors: followList, + 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, + }); +} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 92c7c28e..f03dcb49 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -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'; @@ -33,6 +35,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'; @@ -710,6 +713,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(); @@ -977,6 +1009,23 @@ export function ProfilePage() { return items; }, [likesData?.pages]); + // Flatten wall pages and deduplicate + const wallComments = useMemo(() => { + if (!wallData?.pages) return []; + const seen = new Set(); + 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[] = []; @@ -1003,12 +1052,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; @@ -1024,10 +1077,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; @@ -1042,15 +1095,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: , showFAB: true, + onFabClick: activeTab === 'wall' ? openWallCompose : undefined, } : {}); if (!pubkey) { @@ -1398,6 +1455,7 @@ export function ProfilePage() { setActiveTab('replies')} /> setActiveTab('media')} /> setActiveTab('likes')} /> + setActiveTab('wall')} /> {/* Pinned posts (only on Posts tab) */} @@ -1433,7 +1491,81 @@ export function ProfilePage() { )} - {/* Tab content */} + {/* Wall tab content */} + {activeTab === 'wall' && ( +
+ {/* Inline compose box for wall comments */} + {wallReplyTarget && ( + queryClient.invalidateQueries({ queryKey: ['wall-comments', pubkey] })} + /> + )} + + {/* Wall compose modal (for FAB) */} + {wallReplyTarget && ( + queryClient.invalidateQueries({ queryKey: ['wall-comments', pubkey] })} + /> + )} + + {!wallFollowList || wallFollowList.length === 0 ? ( +
+ +

No wall posts yet

+

{displayName} doesn't follow anyone yet, so there are no wall posts to show.

+
+ ) : wallPending ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ ))} +
+ ) : wallComments.length > 0 ? ( +
+ {wallComments.map((comment) => ( + + ))} + + {/* Infinite scroll sentinel */} + {hasNextWallPage && ( +
+ {isFetchingNextWallPage && ( + + )} +
+ )} +
+ ) : ( +
+ +

No wall posts yet

+

Be the first to write on {displayName}'s wall!

+
+ )} +
+ )} + + {/* Tab content (non-wall tabs) */} + {activeTab !== 'wall' && (
{currentLoading ? (
@@ -1478,6 +1610,7 @@ export function ProfilePage() {
)}
+ )} {/* Profile More Menu */} {pubkey && ( From daa54305dc43fe7d72f5d1cf9435974444727a23 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 28 Feb 2026 17:16:42 -0600 Subject: [PATCH 2/3] Include profile owner's pubkey in wall comments authors filter --- src/hooks/useWallComments.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/hooks/useWallComments.ts b/src/hooks/useWallComments.ts index 22276acd..b15b6105 100644 --- a/src/hooks/useWallComments.ts +++ b/src/hooks/useWallComments.ts @@ -30,10 +30,13 @@ export function useWallComments(pubkey: string | undefined, followList: string[] 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: followList, + authors, limit: PAGE_SIZE, }; From 92af2954769a447eea477bccebe32e7dc5d39a37 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 28 Feb 2026 17:20:05 -0600 Subject: [PATCH 3/3] Show 'Commenting on @Name' for wall comments instead of 'a post' --- src/components/CommentContext.tsx | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index f2afa03b..aee6f8c8 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -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 ; + } + + return ; +} + +/** 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 ( +
+ Commenting on + +
+ ); + } + + return ( +
+ Commenting on + e.stopPropagation()} + > + @{displayName} + +
+ ); +} + +/** 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) {