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/ImagePreview.tsx b/src/components/ImagePreview.tsx index bdd48553..a017a67a 100644 --- a/src/components/ImagePreview.tsx +++ b/src/components/ImagePreview.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { cn } from '@/lib/utils'; import { Skeleton } from '@/components/ui/skeleton'; -import { AlertCircle } from 'lucide-react'; +import { AlertCircle, ImageOff } from 'lucide-react'; interface ImagePreviewProps { src: string; @@ -13,6 +13,7 @@ export function ImagePreview({ src, alt = 'Image', className }: ImagePreviewProp const [isLoading, setIsLoading] = useState(true); const [hasError, setHasError] = useState(false); const [imageUrl, setImageUrl] = useState(''); + const [retryCount, setRetryCount] = useState(0); // Process and normalize the URL useEffect(() => { @@ -43,8 +44,14 @@ export function ImagePreview({ src, alt = 'Image', className }: ImagePreviewProp // Remove size parameters for Twitter images url = url.replace(/&name=[^&]+/, ''); } + + // 4. Handle Discord CDN URLs + if (url.includes('cdn.discordapp.com/attachments')) { + // Add cache-busting parameter for Discord images + url = `${url}${url.includes('?') ? '&' : '?'}t=${Date.now()}`; + } - // 4. Handle URLs with unescaped characters + // 5. Handle URLs with unescaped characters if (url.includes(' ')) { url = url.replace(/ /g, '%20'); } @@ -53,6 +60,7 @@ export function ImagePreview({ src, alt = 'Image', className }: ImagePreviewProp setImageUrl(url); setIsLoading(true); setHasError(false); + setRetryCount(0); } catch (error) { console.error('Error processing image URL:', src, error); @@ -65,23 +73,59 @@ export function ImagePreview({ src, alt = 'Image', className }: ImagePreviewProp }; const handleError = () => { - console.error('Failed to load image:', imageUrl, 'Original URL:', src); - setIsLoading(false); - setHasError(true); + console.error(`Failed to load image (attempt ${retryCount + 1}):`, imageUrl, 'Original URL:', src); + + // Max retry attempts + if (retryCount >= 2) { + setIsLoading(false); + setHasError(true); + return; + } + + // Increment retry counter + setRetryCount(prev => prev + 1); - // Try alternative URL formats if the original fails - if (!imageUrl.includes('?format=')) { - // Some services support format parameter - const newUrl = `${imageUrl}?format=jpg`; - console.log('Trying alternative URL format:', newUrl); + // Try alternative formats based on retry count + if (retryCount === 0) { + // First retry: Try different format + if (imageUrl.includes('.png')) { + // Try jpg instead + const newUrl = imageUrl.replace('.png', '.jpg'); + console.log('Trying JPG format:', newUrl); + setImageUrl(newUrl); + setIsLoading(true); + } else if (imageUrl.includes('.jpg') || imageUrl.includes('.jpeg')) { + // Try png instead + const newUrl = imageUrl.replace(/\.(jpg|jpeg)/, '.png'); + console.log('Trying PNG format:', newUrl); + setImageUrl(newUrl); + setIsLoading(true); + } else { + // Add format parameter + const newUrl = `${imageUrl}${imageUrl.includes('?') ? '&' : '?'}format=jpg`; + console.log('Trying with format parameter:', newUrl); + setImageUrl(newUrl); + setIsLoading(true); + } + } else if (retryCount === 1) { + // Second retry: Try with cache busting parameter + const cacheBuster = Date.now(); + const newUrl = `${imageUrl}${imageUrl.includes('?') ? '&' : '?'}_=${cacheBuster}`; + console.log('Trying with cache buster:', newUrl); setImageUrl(newUrl); setIsLoading(true); - setHasError(false); } }; if (!imageUrl || (hasError && !isLoading)) { - return null; + return ( +
+
+ + Image unavailable +
+
+ ); } return ( diff --git a/src/components/LinkPreview.tsx b/src/components/LinkPreview.tsx index 189a96bc..c4bb84a3 100644 --- a/src/components/LinkPreview.tsx +++ b/src/components/LinkPreview.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; -import { ExternalLink } from 'lucide-react'; +import { ExternalLink, Link, Image } from 'lucide-react'; interface LinkPreviewProps { url: string; @@ -14,32 +14,99 @@ interface LinkMetadata { domain: string; } +// Function to extract domain name from a URL +const extractDomain = (url: string): string => { + try { + const urlObj = new URL(url); + return urlObj.hostname.replace('www.', ''); + } catch (error) { + // If URL parsing fails, use a regex fallback + const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?]+)/i); + return match ? match[1] : url; + } +}; + export function LinkPreview({ url }: LinkPreviewProps) { const [metadata, setMetadata] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); + const [fetchTries, setFetchTries] = useState(0); + + // Clean and format the URL for display + const displayUrl = url.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, ''); + const domain = extractDomain(url); useEffect(() => { const fetchMetadata = async () => { - try { + // Reset state for new URL + if (fetchTries === 0) { setLoading(true); setError(false); + } - // Use a proxy service to avoid CORS issues - // In a production app, you would use your own backend proxy or a service like Microlink - const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`; - const response = await fetch(proxyUrl); + try { + // Handle special case domains directly + if ( + url.includes('youtube.com') || + url.includes('youtu.be') || + url.includes('twitter.com') || + url.includes('x.com') + ) { + // For these domains, just display a simplified preview without trying to fetch metadata + setMetadata({ + title: url.includes('youtube') ? 'YouTube Video' : 'Twitter Post', + description: '', + image: '', + domain: domain + }); + setLoading(false); + return; + } + + // Try different proxy services based on retry count + let proxyUrl = ''; + + // On first try, use allorigins + if (fetchTries === 0) { + proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`; + } + // On second try, use another service + else if (fetchTries === 1) { + proxyUrl = `https://cors-anywhere.herokuapp.com/${url}`; + } + // On third try, give up on proxies and just show a clean preview + else { + throw new Error('All proxy attempts failed'); + } + + // Add a timeout to prevent hanging requests + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout + + const response = await fetch(proxyUrl, { + signal: controller.signal + }); + + clearTimeout(timeoutId); if (!response.ok) { - throw new Error('Failed to fetch link metadata'); + throw new Error(`Response not OK: ${response.status}`); } - const data = await response.json(); - const html = data.contents; + let html = ''; + let doc: Document; - // Create a DOM parser to extract metadata - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); + // Parse the response based on the proxy used + if (fetchTries === 0) { + const data = await response.json(); + html = data.contents; + const parser = new DOMParser(); + doc = parser.parseFromString(html, 'text/html'); + } else { + html = await response.text(); + const parser = new DOMParser(); + doc = parser.parseFromString(html, 'text/html'); + } // Extract metadata from Open Graph tags, Twitter cards, or regular meta tags const title = @@ -59,30 +126,42 @@ export function LinkPreview({ url }: LinkPreviewProps) { doc.querySelector('meta[name="twitter:image"]')?.getAttribute('content') || ''; - // Extract domain from URL - const urlObj = new URL(url); - const domain = urlObj.hostname.replace('www.', ''); - setMetadata({ - title, + title: title || url, description, image, domain }); + setLoading(false); } catch (err) { console.error('Error fetching link preview:', err); - setError(true); - } finally { - setLoading(false); + + // If we haven't exceeded max retries, try another method + if (fetchTries < 2) { + setFetchTries(prev => prev + 1); + } else { + // After all retries fail, show fallback + setError(true); + setLoading(false); + + // Still provide basic metadata for fallback display + setMetadata({ + title: '', + description: '', + image: '', + domain + }); + } } }; if (url) { fetchMetadata(); } - }, [url]); + }, [url, fetchTries, domain]); - if (loading) { + // Show loading state only on first attempt + if (loading && fetchTries === 0) { return ( @@ -101,21 +180,43 @@ export function LinkPreview({ url }: LinkPreviewProps) { ); } - if (error || !metadata) { - // Fallback to a simple link display + // Show fallback for errors or when all retries failed + if (error || (loading && fetchTries >= 2)) { + return ( + + {url.includes('youtube.com') || url.includes('youtu.be') ? ( + + ) : url.includes('twitter.com') || url.includes('x.com') ? ( + + ) : ( + + )} + {displayUrl} + + ); + } + + // If metadata has no title but we're not in an error state, show a simplified preview + if (metadata && !metadata.title) { return ( - - {url} + + {displayUrl} ); } + // Full link preview card with metadata return (
- {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 +
+
+ + 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 -
-
- - 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