diff --git a/.npmrc b/.npmrc index 41583e36..d9bedc97 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,6 @@ -@jsr:registry=https://npm.jsr.io +fetch-timeout=600000 +fetch-retries=5 +fetch-retry-factor=2 +fetch-retry-mintimeout=20000 +fetch-retry-maxtimeout=120000 +registry=https://registry.npmjs.org/ \ No newline at end of file diff --git a/package.json b/package.json index 382eb90c..b8bcca80 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,12 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "npm i && vite", - "build": "npm i && vite build && cp dist/index.html dist/404.html", - "build:dev": "npm i && vite build --mode development", - "ci": "npm i && tsc -p tsconfig.app.json --noEmit && eslint && vite build", - "lint": "npm i && eslint .", - "preview": "npm i && vite preview", + "dev": "npm i --fetch-timeout=300000 && vite", + "build": "npm i --fetch-timeout=300000 && vite build && cp dist/index.html dist/404.html", + "build:dev": "npm i --fetch-timeout=300000 && vite build --mode development", + "ci": "npm i --fetch-timeout=300000 && tsc -p tsconfig.app.json --noEmit && eslint && vite build", + "lint": "npm i --fetch-timeout=300000 && eslint .", + "preview": "npm i --fetch-timeout=300000 && vite preview", "deploy": "npm run build && npx -y surge@latest dist", "dev:https": "vite --config vite.config.https.js" }, @@ -95,4 +95,4 @@ "typescript-eslint": "^8.0.1", "vite": "^6.3.5" } -} +} \ No newline at end of file diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 8e2f72d4..f75d1e8c 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -9,6 +9,7 @@ import GroupDetail from "./pages/GroupDetail"; import Profile from "./pages/Profile"; import Hashtag from "./pages/Hashtag"; import Trending from "./pages/Trending"; +import GroupPostsFeed from "./pages/GroupPostsFeed"; // Lazy load less frequently used pages const NotFound = lazy(() => import("./pages/NotFound")); @@ -47,6 +48,7 @@ export function AppRouter() { } /> } /> } /> + } /> {/* Lazy loaded routes */} ); } -export default AppRouter; +export default AppRouter; \ 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..cddfb375 --- /dev/null +++ b/src/components/groups/GroupPostItem.tsx @@ -0,0 +1,339 @@ +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 */} +
+
+
+ + + + + + + +
+ + View in group + + +
+ + {showZaps && ( +
+ { + // Call the refetch function if available + const refetchFn = (window as any)[`zapRefetch_${post.id}`]; + if (refetchFn) refetchFn(); + }} + /> +
+ )} +
+
+ ); +} \ No newline at end of file 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..44c0e0dd --- /dev/null +++ b/src/components/ui/Icon.tsx @@ -0,0 +1,48 @@ +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 React.SVGProps { + 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 ; + } + + // Otherwise use lucide icons + const LucideIcon = (LucideIcons as any)[name]; + + if (LucideIcon) { + return ; + } + + // Fallback to a simple list icon if icon is not found + return ; +}; \ No newline at end of file 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