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) {
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..b15b6105
--- /dev/null
+++ b/src/hooks/useWallComments.ts
@@ -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({
+ 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,
+ });
+}
diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx
index c2a79e6f..fd663669 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';
@@ -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();
+ 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: ,
showFAB: true,
+ onFabClick: activeTab === 'wall' ? openWallCompose : undefined,
} : {});
if (!pubkey) {
@@ -1400,6 +1457,7 @@ export function ProfilePage() {
setActiveTab('replies')} />
setActiveTab('media')} />
setActiveTab('likes')} />
+ setActiveTab('wall')} />
{/* Pinned posts (only on Posts tab) */}
@@ -1435,7 +1493,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 ? (
@@ -1480,6 +1612,7 @@ export function ProfilePage() {
)}
+ )}
{/* Profile More Menu */}
{pubkey && (