Merge branch 'opencode/1772318974'
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user