- {metadata.image && (
+ {metadata?.image && (
{
+ // Hide the image div if it fails to load
+ (e.target as HTMLDivElement).style.display = 'none';
+ }}
/>
)}
-
-
{metadata.title}
-
{metadata.description}
+
+
{metadata?.title}
+ {metadata?.description && (
+
{metadata.description}
+ )}
- {metadata.domain}
+ {metadata?.domain}
diff --git a/src/components/NoteContent.tsx b/src/components/NoteContent.tsx
index f92500e6..79898837 100644
--- a/src/components/NoteContent.tsx
+++ b/src/components/NoteContent.tsx
@@ -314,7 +314,32 @@ export function NoteContent({
// We need to determine the link URL first before processing text content
const allMediaUrls = [...extractedImages, ...extractedVideos, ...extractedAudios];
const firstUrl = getFirstUrl(event.content);
- const determinedLinkUrl = firstUrl && !allMediaUrls.includes(firstUrl) ? firstUrl : null;
+
+ // Only create link preview for certain types of URLs (avoid API/data URLs)
+ const shouldCreateLinkPreview = (url: string): boolean => {
+ // Skip preview for these domains/URL patterns
+ const skipPatterns = [
+ 'api.', // API endpoint
+ 'data:', // Data URL
+ '.json', // JSON file
+ '.csv', // CSV file
+ '.pdf', // PDF file
+ '.xml', // XML file
+ 'localhost', // Local development
+ '127.0.0.1', // Local IP
+ 'blockstream.info' // Bitcoin explorer (often used for transaction links)
+ ];
+
+ // Skip preview if the URL is already displayed as media
+ if (allMediaUrls.includes(url)) return false;
+
+ // Skip if it matches any of the patterns
+ if (skipPatterns.some(pattern => url.includes(pattern))) return false;
+
+ return true;
+ };
+
+ const determinedLinkUrl = firstUrl && shouldCreateLinkPreview(firstUrl) ? firstUrl : null;
// Process the content and update state in one go to prevent multiple renders
const processed = processTextContent(event.content, extractedImages, extractedVideos, extractedAudios, determinedLinkUrl);
diff --git a/src/components/groups/GroupCard.tsx b/src/components/groups/GroupCard.tsx
index 7e58b63a..c249eb13 100644
--- a/src/components/groups/GroupCard.tsx
+++ b/src/components/groups/GroupCard.tsx
@@ -1,4 +1,5 @@
-import { Link } from "react-router-dom";
+import { useState } from "react";
+import { Link, useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Pin, PinOff, MessageSquare, Activity, MoreVertical, UserPlus, AlertTriangle, Clock } from "lucide-react";
@@ -51,6 +52,8 @@ export function GroupCard({
isLoadingStats,
}: GroupCardProps) {
const { user } = useCurrentUser();
+ const navigate = useNavigate();
+ const [preventNavigation, setPreventNavigation] = useState(false);
// Extract community data from tags
const nameTag = community.tags.find((tag) => tag[0] === "name");
@@ -77,6 +80,7 @@ export function GroupCard({
const handleTogglePin = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
+ setPreventNavigation(true);
if (!user) {
toast.error("Please log in to pin/unpin groups.");
@@ -105,165 +109,190 @@ export function GroupCard({
isUserMember && "bg-primary/5", // Subtle highlight for groups the user is a member of
hasPendingRequest && !isUserMember && "bg-gray-50/50" // Different background for pending requests
);
+
+ // Handle card click to navigate
+ const handleCardClick = (e: React.MouseEvent) => {
+ if (preventNavigation) {
+ setPreventNavigation(false);
+ return;
+ }
+
+ e.preventDefault();
+ navigate(`/group/${encodeURIComponent(communityId)}`);
+ };
return (
-
-
- {userRole && (
-
-
-
- )}
+
+ {/* Notification badges for owners/moderators */}
+ {isOwnerOrModerator && (openReportsCount > 0 || pendingRequestsCount > 0) && (
+
+
+
+
+ {openReportsCount > 0 && (
+
+
+ {openReportsCount > 99 ? '99+' : openReportsCount}
+
+ )}
+ {pendingRequestsCount > 0 && (
+
+
+ {pendingRequestsCount > 99 ? '99+' : pendingRequestsCount}
+
+ )}
+
+
+
+
+ {openReportsCount > 0 && (
+
+ {openReportsCount} open report{openReportsCount !== 1 ? 's' : ''}
+
+ )}
+ {pendingRequestsCount > 0 && (
+
+ {pendingRequestsCount} pending join request{pendingRequestsCount !== 1 ? 's' : ''}
+
+ )}
+
+ Click to manage group
+
+
+
+
+
+ )}
- {hasPendingRequest && !userRole && (
-
-
-
- Pending
-
+
+
+
+
+
+ {getInitials()}
+
+
+
+ {/* Added right padding to make room for menu button */}
+
+
{/* Reduced max-width */}
+ {name}
+
+ {userRole && (
+
+ {/* Added right margin */}
+
+ )}
+ {hasPendingRequest && !userRole && (
+
+
{/* Added right margin */}
+
+ Pending
+
+
+ )}
- )}
+
+ {isLoadingStats ? (
+ <>
+
+
+ ...
+
+
+ >
+ ) : stats ? (
+ <>
+
+
+ {stats.posts}
+
+
+
+ {stats.participants.size}
+
+ >
+ ) : (
+ <>
+
+
+ 0
+
+
+ >
+ )}
+
+
+
- {/* Notification badges for owners/moderators */}
- {isOwnerOrModerator && (openReportsCount > 0 || pendingRequestsCount > 0) && (
+
+ {description}
+
+
+ {user && (
+
-
- {openReportsCount > 0 && (
-
-
- {openReportsCount > 99 ? '99+' : openReportsCount}
-
- )}
- {pendingRequestsCount > 0 && (
-
-
- {pendingRequestsCount > 99 ? '99+' : pendingRequestsCount}
-
- )}
-
+
+
+
-
- {openReportsCount > 0 && (
-
- {openReportsCount} open report{openReportsCount !== 1 ? 's' : ''}
-
- )}
- {pendingRequestsCount > 0 && (
-
- {pendingRequestsCount} pending join request{pendingRequestsCount !== 1 ? 's' : ''}
-
- )}
-
- Click to manage group
-
-
+ Group options
- )}
-
-
-
-
-
-
- {getInitials()}
-
-
-
-
{name}
-
- {isLoadingStats ? (
- <>
-
-
- ...
-
-
- >
- ) : stats ? (
- <>
-
-
- {stats.posts}
-
-
-
- {stats.participants.size}
-
- >
- ) : (
- <>
-
-
- 0
-
-
- >
- )}
-
-
-
-
-
-
- {description}
-
-
- {user && (
-
-
-
-
-
-
-
-
-
- Group options
-
-
-
- e.stopPropagation()}>
- {isPinned ? (
-
-
- Unpin group
-
- ) : (
-
-
- Pin group
-
- )}
- {!isUserMember && }
-
-
- )}
-
-
+ {
+ e.stopPropagation();
+ setPreventNavigation(true);
+ }}
+ onCloseAutoFocus={(e) => {
+ e.preventDefault();
+ setPreventNavigation(false);
+ }}
+ >
+ {isPinned ? (
+
+
+ Unpin group
+
+ ) : (
+
+
+ Pin group
+
+ )}
+ {!isUserMember && }
+
+
+ )}
+
);
}
\ No newline at end of file
diff --git a/src/components/groups/GroupPostItem.tsx b/src/components/groups/GroupPostItem.tsx
new file mode 100644
index 00000000..3549d0e0
--- /dev/null
+++ b/src/components/groups/GroupPostItem.tsx
@@ -0,0 +1,371 @@
+import { useState, useEffect } from "react";
+import { useNostr } from "@/hooks/useNostr";
+import { Link } from "react-router-dom";
+import { Badge } from "@/components/ui/badge";
+import { NostrEvent } from "@nostrify/nostrify";
+import { parseNostrAddress } from "@/lib/nostr-utils";
+import { KINDS } from "@/lib/nostr-kinds";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Button } from "@/components/ui/button";
+import { Icon } from "@/components/ui/Icon";
+import { formatRelativeTime } from "@/lib/utils";
+import { useAuthor } from "@/hooks/useAuthor";
+import { nip19 } from "nostr-tools";
+import { NoteContent } from "../NoteContent";
+import { EmojiReactionButton } from "@/components/EmojiReactionButton";
+import { NutzapButton } from "@/components/groups/NutzapButton";
+import { NutzapInterface } from "@/components/groups/NutzapInterface";
+import { shareContent } from "@/lib/share";
+import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
+
+interface GroupPost {
+ post: NostrEvent & {
+ communityId: string;
+ approval?: {
+ id: string;
+ pubkey: string;
+ created_at: number;
+ kind: number;
+ };
+ };
+}
+
+interface GroupInfo {
+ id: string;
+ name: string;
+ avatar?: string;
+}
+
+// Function to count replies
+function ReplyCount({ postId }: { postId: string }) {
+ const { nostr } = useNostr();
+ const [replyCount, setReplyCount] = useState
(null);
+
+ useEffect(() => {
+ const fetchReplyCount = async () => {
+ if (!nostr) return;
+
+ try {
+ const events = await nostr.query([{
+ kinds: [KINDS.GROUP_POST_REPLY],
+ "#e": [postId],
+ limit: 100,
+ }], { signal: AbortSignal.timeout(3000) });
+
+ setReplyCount(events?.length || 0);
+ } catch (error) {
+ console.error("Error fetching reply count:", error);
+ }
+ };
+
+ fetchReplyCount();
+ }, [nostr, postId]);
+
+ if (replyCount === null || replyCount === 0) {
+ return null;
+ }
+
+ return {replyCount};
+}
+
+export function GroupPostItem({ post }: GroupPost) {
+ const { nostr } = useNostr();
+ const [groupInfo, setGroupInfo] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const author = useAuthor(post.pubkey);
+ const [showZaps, setShowZaps] = useState(false);
+
+ // Fetch group information
+ useEffect(() => {
+ const fetchGroupInfo = async () => {
+ setIsLoading(true);
+
+ try {
+ const communityId = post.communityId;
+ const parsedId = communityId.includes(':') ? parseNostrAddress(communityId) : null;
+
+ if (!parsedId || !nostr) {
+ setIsLoading(false);
+ return;
+ }
+
+ const events = await nostr.query([{
+ kinds: [KINDS.GROUP],
+ authors: [parsedId.pubkey],
+ "#d": [parsedId.identifier],
+ }], { signal: AbortSignal.timeout(3000) });
+
+ if (events && events.length > 0) {
+ const nameTag = events[0].tags.find(tag => tag[0] === "name");
+ const pictureTag = events[0].tags.find(tag => tag[0] === "picture");
+ setGroupInfo({
+ id: communityId,
+ name: nameTag ? nameTag[1] : parsedId.identifier,
+ avatar: pictureTag ? pictureTag[1] : undefined,
+ });
+ }
+ } catch (error) {
+ console.error("Error fetching group info:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchGroupInfo();
+ }, [nostr, post.communityId]);
+
+ const handleSharePost = async () => {
+ try {
+ // Create nevent identifier for the post with relay hint
+ const nevent = nip19.neventEncode({
+ id: post.id,
+ author: post.pubkey,
+ kind: post.kind,
+ relays: ["wss://relay.chorus.community"],
+ });
+
+ // Create njump.me URL
+ const shareUrl = `https://njump.me/${nevent}`;
+
+ await shareContent({
+ title: "Check out this post",
+ text: post.content.slice(0, 100) + (post.content.length > 100 ? "..." : ""),
+ url: shareUrl
+ });
+ } catch (error) {
+ console.error("Error creating share URL:", error);
+ // Fallback to the original URL format
+ const shareUrl = `${window.location.origin}/group/${encodeURIComponent(post.communityId)}#${post.id}`;
+
+ await shareContent({
+ title: "Check out this post",
+ text: post.content.slice(0, 100) + (post.content.length > 100 ? "..." : ""),
+ url: shareUrl
+ });
+ }
+ };
+
+ // Handle toggle between replies and zaps
+ const handleZapToggle = (isOpen: boolean) => {
+ setShowZaps(isOpen);
+ };
+
+ // Get author information for display
+ const metadata = author.data?.metadata;
+ const displayName = metadata?.name || post.pubkey.slice(0, 12);
+ const profileImage = metadata?.picture;
+
+ const authorNip05 = metadata?.nip05;
+ let authorIdentifier = authorNip05 || post.pubkey;
+ if (!authorNip05 && post.pubkey.match(/^[0-9a-fA-F]{64}$/)) {
+ try {
+ const npub = nip19.npubEncode(post.pubkey);
+ authorIdentifier = `${npub.slice(0,10)}...${npub.slice(-4)}`;
+ } catch (e) {
+ authorIdentifier = `${post.pubkey.slice(0,8)}...${post.pubkey.slice(-4)}`;
+ }
+ } else if (!authorNip05) {
+ authorIdentifier = `${post.pubkey.slice(0,8)}...${post.pubkey.slice(-4)}`;
+ }
+
+ // Format the timestamp as relative time
+ const relativeTime = formatRelativeTime(post.created_at);
+
+ // Keep the absolute time as a tooltip
+ const postDate = new Date(post.created_at * 1000);
+ const formattedAbsoluteTime = `${postDate.toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })} ${postDate.toLocaleTimeString(undefined, {
+ hour: 'numeric',
+ minute: '2-digit'
+ })}`;
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Group Badge - links to the group */}
+
+
+
+ {groupInfo?.avatar ? (
+
+ ) : (
+
+ {(groupInfo?.name || 'G').charAt(0).toUpperCase()}
+
+ )}
+
+
+ {groupInfo ? groupInfo.name : 'Unknown Group'}
+
+
+
+
+ {/* Author and Post Info */}
+
+
+
+
+ {displayName.slice(0, 1).toUpperCase()}
+
+
+
+
+
+
+
+
+ {displayName}
+
+ {post.approval ? (
+
+ Approved
+
+ ) : (
+
+ Pending
+
+ )}
+
+
+
+ {authorIdentifier}
+
+
ยท
+
+
+
+ {relativeTime}
+
+
+ {formattedAbsoluteTime}
+
+
+
+
+
+
+
+
+
+ {/* Post Content */}
+
+
+ {/* Post Actions */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Share post
+
+
+
+
+
+
+
+
+
+ View in group
+
+
+
+
+
+ {/* Only show text version on desktop */}
+
+
View in group
+
+
+
+
+ {showZaps && (
+
+ {
+ // Call the refetch function if available
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const refetchFn = (window as any)[`zapRefetch_${post.id}`];
+ if (typeof refetchFn === 'function') refetchFn();
+ }}
+ />
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/groups/PostList.tsx b/src/components/groups/PostList.tsx
index 4c5ef73b..4bd2d1d5 100644
--- a/src/components/groups/PostList.tsx
+++ b/src/components/groups/PostList.tsx
@@ -700,7 +700,10 @@ function PostItem({ post, communityId, isApproved, isModerator, isLastItem = fal
};
return (
-
+
diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx
index 6b1823a6..b9bef088 100644
--- a/src/components/ui/Header.tsx
+++ b/src/components/ui/Header.tsx
@@ -4,9 +4,9 @@ import { ClaimOnboardingTokenButton } from "@/components/ClaimOnboardingTokenBut
import { useCashuStore } from "@/stores/cashuStore";
import { useOnboardingStore } from "@/stores/onboardingStore";
import { useCurrentUser } from "@/hooks/useCurrentUser";
+import { Icon } from "@/components/ui/Icon";
import type React from "react";
-import { Link } from "react-router-dom";
-import { Home } from "lucide-react";
+import { Link, useLocation } from "react-router-dom";
interface HeaderProps {
className?: string;
@@ -16,15 +16,21 @@ const Header: React.FC = ({ className }) => {
const cashuStore = useCashuStore();
const onboardingStore = useOnboardingStore();
const { user } = useCurrentUser();
+ const location = useLocation();
// Check if we should show the claim button
const pendingToken = cashuStore.getPendingOnboardingToken();
const hasClaimedToken = onboardingStore.isTokenClaimed();
const showClaimButton = user && pendingToken && !hasClaimedToken;
+ // Helper to determine active link
+ const isActive = (path: string) => {
+ return location.pathname === path;
+ };
+
return (
-
+
+
@@ -32,9 +38,28 @@ const Header: React.FC = ({ className }) => {
{user && (
-
-
-
+
+
+
+ Groups
+
+
+
+ Feed
+
+
)}
@@ -45,4 +70,4 @@ const Header: React.FC
= ({ className }) => {
);
};
-export default Header;
+export default Header;
\ No newline at end of file
diff --git a/src/components/ui/Icon.tsx b/src/components/ui/Icon.tsx
new file mode 100644
index 00000000..8e04caa7
--- /dev/null
+++ b/src/components/ui/Icon.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import * as LucideIcons from 'lucide-react';
+
+// Custom SVG icons for icons not available in the current lucide-react version
+const CustomIcons = {
+ Feed: (props: React.SVGProps) => (
+
+ )
+};
+
+interface IconProps extends Omit, 'size'> {
+ name: string;
+ size?: number;
+}
+
+export const Icon = ({ name, size = 24, ...rest }: IconProps) => {
+ // First check if it's one of our custom icons
+ if (name in CustomIcons) {
+ const CustomIcon = CustomIcons[name as keyof typeof CustomIcons];
+ return ;
+ }
+
+ // Use type assertion to silence TypeScript errors - this is how lucide-react components are used
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const LucideIconComponent = (LucideIcons as any)[name];
+
+ if (LucideIconComponent) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return ;
+ }
+
+ // Fallback to a simple list icon if icon is not found
+ return ;
+};
\ No newline at end of file
diff --git a/src/pages/GroupDetail.tsx b/src/pages/GroupDetail.tsx
index a3934c25..95c5b47b 100644
--- a/src/pages/GroupDetail.tsx
+++ b/src/pages/GroupDetail.tsx
@@ -397,7 +397,7 @@ export default function GroupDetail() {
}
};
- // Set active tab based on URL hash only
+ // Set active tab based on URL hash, handle post anchoring
useEffect(() => {
// Define valid tab values
const validTabs = ["posts", "members", "ecash", "manage"];
@@ -405,12 +405,25 @@ export default function GroupDetail() {
if (hash && validTabs.includes(hash)) {
setActiveTab(hash);
}
- // If the hash references an invalid tab, default to "posts"
+ // If the hash references a post ID (not a tab name), scroll to it
else if (hash) {
- // Only update if not already on posts tab to avoid unnecessary re-renders
+ // Set active tab to posts to show the post content
if (activeTab !== "posts") {
setActiveTab("posts");
}
+
+ // Wait for content to render before attempting to scroll
+ setTimeout(() => {
+ const postElement = document.getElementById(hash);
+ if (postElement) {
+ postElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ // Optional: add a highlight effect on the post
+ postElement.classList.add('bg-amber-50', 'dark:bg-amber-900/20');
+ setTimeout(() => {
+ postElement.classList.remove('bg-amber-50', 'dark:bg-amber-900/20');
+ }, 2000);
+ }
+ }, 500);
}
// Only set these fallbacks on initial mount to avoid constantly resetting
else if (!activeTab || !validTabs.includes(activeTab)) {
diff --git a/src/pages/GroupPostsFeed.tsx b/src/pages/GroupPostsFeed.tsx
new file mode 100644
index 00000000..ebdb76e6
--- /dev/null
+++ b/src/pages/GroupPostsFeed.tsx
@@ -0,0 +1,481 @@
+import { useState, useEffect, useMemo } from "react";
+import { useNostr } from "@/hooks/useNostr";
+import { useCurrentUser } from "@/hooks/useCurrentUser";
+import { useUserGroups } from "@/hooks/useUserGroups";
+import { useQuery } from "@tanstack/react-query";
+import Header from "@/components/ui/Header";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Card, CardContent, CardHeader, CardDescription, CardTitle } from "@/components/ui/card";
+import { parseNostrAddress } from "@/lib/nostr-utils";
+import { KINDS } from "@/lib/nostr-kinds";
+import { NostrEvent } from "@nostrify/nostrify";
+import { GroupPostItem } from "@/components/groups/GroupPostItem";
+import { Button } from "@/components/ui/button";
+import { Link } from "react-router-dom";
+import { Icon } from "@/components/ui/Icon";
+
+export default function GroupPostsFeed() {
+ const { nostr } = useNostr();
+ const { user } = useCurrentUser();
+ const { data: userGroups } = useUserGroups();
+ const [activeTab, setActiveTab] = useState<"all" | "approved">("approved");
+ const [refreshTrigger, setRefreshTrigger] = useState(0);
+
+ // Get a list of group IDs the user is a member of
+ const groupIds = useMemo(() => {
+ if (!userGroups?.allGroups) return [];
+
+ return userGroups.allGroups.map(group => {
+ const dTag = group.tags.find((tag) => tag[0] === "d");
+ return `${KINDS.GROUP}:${group.pubkey}:${dTag ? dTag[1] : ""}`;
+ });
+ }, [userGroups]);
+
+ // Create a list to track banned users from all groups
+ const [bannedUsersList, setBannedUsersList] = useState>(new Set());
+
+ // Fetch posts from all groups the user is a member of
+ const { data: groupPosts, isLoading: isPostsLoading, refetch } = useQuery({
+ queryKey: ["group-posts-feed", groupIds, activeTab, refreshTrigger],
+ queryFn: async () => {
+ if (!nostr || !groupIds.length) return [];
+
+ // Array to hold all posts
+ let allPosts: Array = [];
+
+ // Process each group sequentially to avoid overwhelming relays
+ for (const communityId of groupIds) {
+ try {
+ const parsedId = communityId.includes(':') ? parseNostrAddress(communityId) : null;
+ if (!parsedId) continue;
+
+ // Fetch approved posts for this group
+ const approvalEvents = await nostr.query([{
+ kinds: [KINDS.GROUP_POST_APPROVAL],
+ "#a": [communityId],
+ limit: 20,
+ }], { signal: AbortSignal.timeout(5000) });
+
+ // Only fetch all posts if we're viewing the "all" tab
+ let postEvents: NostrEvent[] = [];
+ if (activeTab === "all") {
+ postEvents = await nostr.query([{
+ kinds: [KINDS.GROUP_POST],
+ "#a": [communityId],
+ limit: 20,
+ }], { signal: AbortSignal.timeout(5000) });
+ }
+
+ // Fetch removals for this group
+ const removalEvents = await nostr.query([{
+ kinds: [KINDS.GROUP_POST_REMOVAL],
+ "#a": [communityId],
+ limit: 50,
+ }], { signal: AbortSignal.timeout(5000) });
+
+ // Fetch community details to get moderators
+ const communityEvents = await nostr.query([{
+ kinds: [KINDS.GROUP],
+ authors: [parsedId.pubkey],
+ "#d": [parsedId.identifier],
+ }], { signal: AbortSignal.timeout(5000) });
+
+ const communityEvent = communityEvents?.[0];
+
+ // Get moderators from community event
+ const moderators = communityEvent?.tags
+ .filter(tag => tag[0] === "p" && tag[3] === "moderator")
+ .map(tag => tag[1]) || [];
+
+ // Get approved member pubkeys - using GROUP_APPROVED_MEMBERS_LIST instead of GROUP_MEMBER_APPROVAL
+ const approvedMembersResponse = await nostr.query([{
+ kinds: [KINDS.GROUP_APPROVED_MEMBERS_LIST],
+ "#a": [communityId],
+ limit: 100,
+ }], { signal: AbortSignal.timeout(5000) });
+
+ // Extract approved member pubkeys from events
+ const approvedMembers = approvedMembersResponse.map(event => {
+ const pTag = event.tags.find(tag => tag[0] === "p");
+ return pTag?.[1];
+ }).filter((pubkey): pubkey is string => !!pubkey);
+
+ // Create a set of removed post IDs
+ const removedPostIds = new Set(
+ removalEvents.map(removal => {
+ const eventTag = removal.tags.find(tag => tag[0] === "e");
+ return eventTag ? eventTag[1] : null;
+ }).filter((id): id is string => id !== null)
+ );
+
+ // Process approved posts
+ const processedApprovedPosts = approvalEvents.map(approval => {
+ try {
+ const approvedPost = JSON.parse(approval.content) as NostrEvent;
+
+ // Skip if the post is removed
+ if (removedPostIds.has(approvedPost.id)) return null;
+
+ // Skip if this is a reply (kind 1111)
+ const kindTag = approval.tags.find(tag => tag[0] === "k");
+ const kind = kindTag ? Number.parseInt(kindTag[1]) : null;
+ if (kind === KINDS.GROUP_POST_REPLY) return null;
+
+ // Skip if the post itself is a reply
+ if (approvedPost.kind === KINDS.GROUP_POST_REPLY) return null;
+
+ // Add the community ID and approval information
+ return {
+ ...approvedPost,
+ communityId,
+ approval: {
+ id: approval.id,
+ pubkey: approval.pubkey,
+ created_at: approval.created_at,
+ kind: kind || approvedPost.kind
+ }
+ };
+ } catch (error) {
+ console.error("Error parsing approved post:", error);
+ return null;
+ }
+ }).filter((post): post is NostrEvent & {
+ communityId: string;
+ approval: {
+ id: string;
+ pubkey: string;
+ created_at: number;
+ kind: number;
+ }
+ } => post !== null);
+
+ // Add approved posts to our result array
+ allPosts = [...allPosts, ...processedApprovedPosts];
+
+ // If we only want approved posts, skip processing regular posts
+ if (activeTab === "approved") continue;
+
+ // Process all posts (for the "all" tab)
+ const allGroupPosts = postEvents.map(post => {
+ // Skip if removed
+ if (removedPostIds.has(post.id)) return null;
+
+ // Skip if this is a reply (kind 1111)
+ if (post.kind === KINDS.GROUP_POST_REPLY) return null;
+
+ // Skip if it has a reply marker in tags
+ const hasReplyTag = post.tags.some(tag =>
+ tag[0] === 'e' && (tag[3] === 'reply' || tag[3] === 'root')
+ );
+ if (hasReplyTag) return null;
+
+ // Check if the post is already in approved posts
+ const isAlreadyApproved = processedApprovedPosts.some(
+ approvedPost => approvedPost.id === post.id
+ );
+ if (isAlreadyApproved) return null;
+
+ // Auto-approve for approved members and moderators
+ const isApprovedMember = approvedMembers.includes(post.pubkey);
+ const isModerator = moderators.includes(post.pubkey);
+
+ if (isApprovedMember || isModerator) {
+ return {
+ ...post,
+ communityId,
+ approval: {
+ id: `auto-approved-${post.id}`,
+ pubkey: post.pubkey,
+ created_at: post.created_at,
+ kind: post.kind
+ }
+ };
+ }
+
+ // For non-approved posts, just add the communityId
+ return {
+ ...post,
+ communityId
+ };
+ }).filter((post): post is NostrEvent & {
+ communityId: string;
+ approval?: {
+ id: string;
+ pubkey: string;
+ created_at: number;
+ kind: number;
+ }
+ } => post !== null);
+
+ // Add all posts to the array
+ allPosts = [...allPosts, ...allGroupPosts];
+ } catch (error) {
+ console.error(`Error fetching posts for group ${communityId}:`, error);
+ }
+ }
+
+ return allPosts;
+ },
+ enabled: !!nostr && groupIds.length > 0,
+ refetchOnWindowFocus: true,
+ staleTime: 60000, // 1 minute
+ gcTime: 300000, // 5 minutes
+ });
+
+ // Fetch banned users from all groups
+ useEffect(() => {
+ if (!groupIds.length || !nostr) return;
+
+ const fetchBannedUsers = async () => {
+ const allBannedUsers = new Set();
+
+ for (const groupId of groupIds) {
+ try {
+ const banEvents = await nostr.query([{
+ kinds: [KINDS.GROUP_BANNED_MEMBERS_LIST],
+ "#a": [groupId],
+ limit: 50,
+ }], { signal: AbortSignal.timeout(3000) });
+
+ banEvents.forEach(event => {
+ const pTag = event.tags.find(tag => tag[0] === "p");
+ if (pTag && pTag[1]) {
+ allBannedUsers.add(pTag[1]);
+ }
+ });
+ } catch (error) {
+ console.error(`Error getting banned users for ${groupId}:`, error);
+ }
+ }
+
+ setBannedUsersList(allBannedUsers);
+ };
+
+ fetchBannedUsers();
+ }, [groupIds, nostr]);
+
+ // Filter out posts from banned users
+ const filteredPosts = useMemo(() => {
+ const posts = groupPosts || [];
+ return posts.filter(post => !bannedUsersList.has(post.pubkey));
+ }, [groupPosts, bannedUsersList]);
+
+ // Sort posts by timestamp (most recent first)
+ const sortedPosts = useMemo(() => {
+ return [...filteredPosts].sort((a, b) => b.created_at - a.created_at);
+ }, [filteredPosts]);
+
+ // Handle manual refresh
+ const handleRefresh = () => {
+ setRefreshTrigger(prev => prev + 1);
+ refetch();
+ };
+
+ // Show a loading state when fetching posts
+ if (isPostsLoading) {
+ return (
+
+
+
+
+
+
Group Posts
+
+ Approved
+ All
+
+
+
+
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+
+
+
+
+
+ );
+ }
+
+ // Check if user is a member of any groups
+ if (!groupIds.length) {
+ return (
+
+
+
+
+
+
+ No Groups Found
+ You need to join groups to see posts.
+
+
+
+
Browse available groups and join ones that interest you
+
+
+
+
+
+
+ );
+ }
+
+ // Show empty state if no posts found
+ if (!sortedPosts.length) {
+ return (
+
+
+
+
setActiveTab(val as "all" | "approved")}
+ className="w-full mt-2"
+ >
+
+
Group Posts
+
+ Approved
+ All
+
+
+
+
+
+
+
+ No Posts Found
+
+
+
+ {activeTab === "approved"
+ ? "There are no approved posts in your groups yet."
+ : "There are no posts in your groups yet."}
+
+
+
+
+
+ Join more groups or start posting in your existing groups!
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
setActiveTab(val as "all" | "approved")}
+ className="w-full mt-2"
+ >
+
+
Group Posts
+
+ Approved
+ All
+
+
+
+
+
+
+
+ Recent Posts from Your Groups
+
+
+
+ {activeTab === "approved"
+ ? "Showing approved posts from groups you've joined"
+ : "Showing all posts from groups you've joined"}
+
+
+
+
+ {sortedPosts.map((post) => (
+
+ ))}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file