From dfc439150ba3c8c4919d0871deac7f98422f558b Mon Sep 17 00:00:00 2001 From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com> Date: Thu, 5 Jun 2025 17:24:28 -0500 Subject: [PATCH 01/12] Fix: Solve Feed icon error with custom Icon component --- src/AppRouter.tsx | 4 +- src/components/groups/GroupPostItem.tsx | 309 +++++++++++++++ src/components/ui/Header.tsx | 39 +- src/components/ui/Icon.tsx | 48 +++ src/pages/GroupPostsFeed.tsx | 481 ++++++++++++++++++++++++ 5 files changed, 873 insertions(+), 8 deletions(-) create mode 100644 src/components/groups/GroupPostItem.tsx create mode 100644 src/components/ui/Icon.tsx create mode 100644 src/pages/GroupPostsFeed.tsx 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..ef117e46 --- /dev/null +++ b/src/components/groups/GroupPostItem.tsx @@ -0,0 +1,309 @@ +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 { 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); + + // 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 + }); + } + }; + + // 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 + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx index 6b1823a6..269f3d98 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..7ce3fdfe --- /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 + const approvedMembersResponse = await nostr.query([{ + kinds: [KINDS.GROUP_MEMBER_APPROVAL], + "#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_USER_BANNING], + "#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 From 6be8ea5628b78597de6dd0eda0d06ae41d75ee53 Mon Sep 17 00:00:00 2001 From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com> Date: Thu, 5 Jun 2025 19:03:24 -0500 Subject: [PATCH 02/12] Fix: Update group feed with NutzapButton, fix TypeScript errors, and use Home icon for groups --- src/components/groups/GroupPostItem.tsx | 30 +++++++++++++++++++++++++ src/components/ui/Header.tsx | 2 +- src/pages/GroupPostsFeed.tsx | 6 ++--- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/components/groups/GroupPostItem.tsx b/src/components/groups/GroupPostItem.tsx index ef117e46..cddfb375 100644 --- a/src/components/groups/GroupPostItem.tsx +++ b/src/components/groups/GroupPostItem.tsx @@ -14,6 +14,8 @@ 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"; @@ -72,6 +74,7 @@ export function GroupPostItem({ post }: GroupPost) { const [groupInfo, setGroupInfo] = useState(null); const [isLoading, setIsLoading] = useState(true); const author = useAuthor(post.pubkey); + const [showZaps, setShowZaps] = useState(false); // Fetch group information useEffect(() => { @@ -143,6 +146,11 @@ export function GroupPostItem({ post }: GroupPost) { } }; + // 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); @@ -286,6 +294,13 @@ export function GroupPostItem({ post }: GroupPost) { +
+ + {showZaps && ( +
+ { + // Call the refetch function if available + const refetchFn = (window as any)[`zapRefetch_${post.id}`]; + if (refetchFn) refetchFn(); + }} + /> +
+ )}
); diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx index 269f3d98..b9bef088 100644 --- a/src/components/ui/Header.tsx +++ b/src/components/ui/Header.tsx @@ -46,7 +46,7 @@ const Header: React.FC = ({ className }) => { : 'text-gray-500/60 hover:text-gray-700 dark:text-gray-400/60 dark:hover:text-gray-200'} transition-all flex items-center`} > - + Groups tag[0] === "p" && tag[3] === "moderator") .map(tag => tag[1]) || []; - // Get approved member pubkeys + // Get approved member pubkeys - using GROUP_APPROVED_MEMBERS_LIST instead of GROUP_MEMBER_APPROVAL const approvedMembersResponse = await nostr.query([{ - kinds: [KINDS.GROUP_MEMBER_APPROVAL], + kinds: [KINDS.GROUP_APPROVED_MEMBERS_LIST], "#a": [communityId], limit: 100, }], { signal: AbortSignal.timeout(5000) }); @@ -241,7 +241,7 @@ export default function GroupPostsFeed() { for (const groupId of groupIds) { try { const banEvents = await nostr.query([{ - kinds: [KINDS.GROUP_USER_BANNING], + kinds: [KINDS.GROUP_BANNED_MEMBERS_LIST], "#a": [groupId], limit: 50, }], { signal: AbortSignal.timeout(3000) }); From 1bcfdeb169b4104309d7a739ed0ca9428c3336b1 Mon Sep 17 00:00:00 2001 From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com> Date: Thu, 5 Jun 2025 19:14:59 -0500 Subject: [PATCH 03/12] Fix: Increase npm timeout to fix CI build issues with JSR packages --- .npmrc | 7 ++++++- package.json | 14 +++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) 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 From 6807e4fa468858fd8ed4e0d2b501dde9812fa1ae Mon Sep 17 00:00:00 2001 From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com> Date: Thu, 5 Jun 2025 19:18:55 -0500 Subject: [PATCH 04/12] Fix: Update registry config to fix JSR package resolution --- .npmrc | 7 ++++++- package.json | 4 ++-- vite.config.ts | 4 +++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.npmrc b/.npmrc index d9bedc97..b4e6736c 100644 --- a/.npmrc +++ b/.npmrc @@ -3,4 +3,9 @@ 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 +network-timeout=600000 +registry=https://registry.npmjs.org/ +@jsr:registry=https://npm.jsr.io/ +node-linker=hoisted +legacy-peer-deps=true +prefer-offline=true \ No newline at end of file diff --git a/package.json b/package.json index b8bcca80..3cdc4162 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "@cashu/cashu-ts": "^2.5.2", "@cashu/crypto": "^0.3.4", "@hookform/resolvers": "^3.9.0", - "@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.2", - "@nostrify/react": "npm:@jsr/nostrify__react@^0.2.6", + "@jsr/nostrify__nostrify": "^0.46.2", + "@jsr/nostrify__react": "^0.2.6", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-aspect-ratio": "^1.1.0", diff --git a/vite.config.ts b/vite.config.ts index 6dafad49..b9648c73 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,6 +12,8 @@ export default defineConfig(() => ({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + "@nostrify/nostrify": "@jsr/nostrify__nostrify", + "@nostrify/react": "@jsr/nostrify__react" }, }, -})); +})); \ No newline at end of file From 0563530e14b0bb48ce5a5e3971a48063fc9f4307 Mon Sep 17 00:00:00 2001 From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com> Date: Fri, 6 Jun 2025 10:34:21 -0500 Subject: [PATCH 05/12] Fix View in Group navigation and JSR package imports --- src/components/groups/GroupPostItem.tsx | 6 ++++-- src/components/groups/PostList.tsx | 7 +++++-- src/components/ui/Icon.tsx | 14 ++++++++------ src/pages/GroupDetail.tsx | 21 +++++++++++++++++---- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/components/groups/GroupPostItem.tsx b/src/components/groups/GroupPostItem.tsx index cddfb375..e433c705 100644 --- a/src/components/groups/GroupPostItem.tsx +++ b/src/components/groups/GroupPostItem.tsx @@ -2,7 +2,7 @@ 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 { NostrEvent } from "@jsr/nostrify__nostrify"; import { parseNostrAddress } from "@/lib/nostr-utils"; import { KINDS } from "@/lib/nostr-kinds"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -312,6 +312,7 @@ export function GroupPostItem({ post }: GroupPost) { View in group @@ -327,8 +328,9 @@ export function GroupPostItem({ post }: GroupPost) { relayHint={undefined} onSuccess={() => { // Call the refetch function if available + // eslint-disable-next-line @typescript-eslint/no-explicit-any const refetchFn = (window as any)[`zapRefetch_${post.id}`]; - if (refetchFn) refetchFn(); + if (typeof refetchFn === 'function') refetchFn(); }} /> diff --git a/src/components/groups/PostList.tsx b/src/components/groups/PostList.tsx index 4c5ef73b..8d955c8d 100644 --- a/src/components/groups/PostList.tsx +++ b/src/components/groups/PostList.tsx @@ -16,7 +16,7 @@ import { MessageSquare, Share2, CheckCircle, XCircle, MoreVertical, Ban, Chevron import { EmojiReactionButton } from "@/components/EmojiReactionButton"; import { NutzapButton } from "@/components/groups/NutzapButton"; import { NutzapInterface } from "@/components/groups/NutzapInterface"; -import { NostrEvent } from "@nostrify/nostrify"; +import { NostrEvent } from "@jsr/nostrify__nostrify"; import { nip19 } from 'nostr-tools'; import { NoteContent } from "../NoteContent"; import { Link } from "react-router-dom"; @@ -700,7 +700,10 @@ function PostItem({ post, communityId, isApproved, isModerator, isLastItem = fal }; return ( -
+
diff --git a/src/components/ui/Icon.tsx b/src/components/ui/Icon.tsx index 44c0e0dd..8e04caa7 100644 --- a/src/components/ui/Icon.tsx +++ b/src/components/ui/Icon.tsx @@ -24,7 +24,7 @@ const CustomIcons = { ) }; -interface IconProps extends React.SVGProps { +interface IconProps extends Omit, 'size'> { name: string; size?: number; } @@ -36,13 +36,15 @@ export const Icon = ({ name, size = 24, ...rest }: IconProps) => { return ; } - // Otherwise use lucide icons - const LucideIcon = (LucideIcons as any)[name]; + // 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 (LucideIcon) { - return ; + if (LucideIconComponent) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ; } // Fallback to a simple list icon if icon is not found - return ; + return ; }; \ No newline at end of file diff --git a/src/pages/GroupDetail.tsx b/src/pages/GroupDetail.tsx index a3934c25..85d2e1e2 100644 --- a/src/pages/GroupDetail.tsx +++ b/src/pages/GroupDetail.tsx @@ -34,7 +34,7 @@ import { MemberManagement } from "@/components/groups/MemberManagement"; import { ReportsList } from "@/components/groups/ReportsList"; import { useAuthor } from "@/hooks/useAuthor"; import { toast } from "sonner"; -import { NostrEvent } from "@nostrify/nostrify"; +import { NostrEvent } from "@jsr/nostrify__nostrify"; import { useNostrPublish } from "@/hooks/useNostrPublish"; import { QRCodeModal } from "@/components/QRCodeModal"; @@ -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)) { From 4ab0a3ebae1bb5a707c618033c862af5d5e60646 Mon Sep 17 00:00:00 2001 From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com> Date: Fri, 6 Jun 2025 10:54:35 -0500 Subject: [PATCH 06/12] Improve mobile UI for View in Group button --- src/components/groups/GroupPostItem.tsx | 50 ++++++++++++++++++++----- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/src/components/groups/GroupPostItem.tsx b/src/components/groups/GroupPostItem.tsx index e433c705..f56a036d 100644 --- a/src/components/groups/GroupPostItem.tsx +++ b/src/components/groups/GroupPostItem.tsx @@ -285,7 +285,7 @@ export function GroupPostItem({ post }: GroupPost) { {/* Post Actions */}
-
+
- + + + + + + +

Share post

+
+
+
+ + + + + + +

View in group

+
+
+
+ + {/* Only show text version on desktop */} View in group From f1cfd42ef55a97f1fa906b9e19a8f761c05318bb Mon Sep 17 00:00:00 2001 From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com> Date: Fri, 6 Jun 2025 10:58:45 -0500 Subject: [PATCH 07/12] Fix DOM nesting error and improve image error handling --- src/components/ImagePreview.tsx | 68 ++++-- src/components/groups/GroupCard.tsx | 321 +++++++++++++++------------- 2 files changed, 230 insertions(+), 159 deletions(-) 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/groups/GroupCard.tsx b/src/components/groups/GroupCard.tsx index 7e58b63a..2c857591 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"; @@ -13,7 +14,7 @@ import { useCurrentUser } from "@/hooks/useCurrentUser"; import { useOpenReportsCount } from "@/hooks/useOpenReportsCount"; import { usePendingJoinRequests } from "@/hooks/usePendingJoinRequests"; import { toast } from "sonner"; -import type { NostrEvent } from "@nostrify/nostrify"; +import type { NostrEvent } from "@jsr/nostrify__nostrify"; import { DropdownMenu, DropdownMenuContent, @@ -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,188 @@ 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 && ( -
- + + {userRole && ( +
+ +
+ )} + + {hasPendingRequest && !userRole && ( +
+
+ + Pending
- )} +
+ )} + + {/* 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()} + + +
+ {name} +
+ {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 From bfe957972e50e59579398122f53428de2a3c9887 Mon Sep 17 00:00:00 2001 From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:08:16 -0500 Subject: [PATCH 08/12] Fix GroupCard layout to prevent title and badge overlap --- src/components/groups/GroupCard.tsx | 112 ++++++++++++++-------------- 1 file changed, 57 insertions(+), 55 deletions(-) diff --git a/src/components/groups/GroupCard.tsx b/src/components/groups/GroupCard.tsx index 2c857591..234a8f75 100644 --- a/src/components/groups/GroupCard.tsx +++ b/src/components/groups/GroupCard.tsx @@ -123,21 +123,6 @@ export function GroupCard({ return ( - {userRole && ( -
- -
- )} - - {hasPendingRequest && !userRole && ( -
-
- - Pending -
-
- )} - {/* Notification badges for owners/moderators */} {isOwnerOrModerator && (openReportsCount > 0 || pendingRequestsCount > 0) && ( @@ -184,52 +169,69 @@ export function GroupCard({ )} - -
+ +
{getInitials()} -
- {name} -
- {isLoadingStats ? ( - <> -
- - ... -
-
- - ... -
- - ) : stats ? ( - <> -
- - {stats.posts} -
-
- - {stats.participants.size} -
- - ) : ( - <> -
- - 0 -
-
- - 0 -
- - )} -
+
+
+
+ + {name} + + {userRole && ( +
+ +
+ )} + {hasPendingRequest && !userRole && ( +
+
+ + Pending +
+
+ )} +
+
+ {isLoadingStats ? ( + <> +
+ + ... +
+
+ + ... +
+ + ) : stats ? ( + <> +
+ + {stats.posts} +
+
+ + {stats.participants.size} +
+ + ) : ( + <> +
+ + 0 +
+
+ + 0 +
+ + )}
From e4109573a21e420122a653ef8dc2285abcf88bca Mon Sep 17 00:00:00 2001 From: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:10:50 -0500 Subject: [PATCH 09/12] Fix overlap between group name, role badge, and menu button --- src/components/groups/GroupCard.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/groups/GroupCard.tsx b/src/components/groups/GroupCard.tsx index 234a8f75..b64b3a00 100644 --- a/src/components/groups/GroupCard.tsx +++ b/src/components/groups/GroupCard.tsx @@ -178,19 +178,19 @@ export function GroupCard({
-
+
{/* Added right padding to make room for menu button */}
- + {/* Reduced max-width */} {name} {userRole && (
- + {/* Added right margin */}
)} {hasPendingRequest && !userRole && (
-
+
{/* Added right margin */} Pending
@@ -249,7 +249,7 @@ export function GroupCard({