diff --git a/CONTEXT.md b/CONTEXT.md index 4373b47d..b320acb0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -389,7 +389,7 @@ Chorus implements [NIP-72](https://github.com/nostr-protocol/nips/blob/master/72 - **Kind 34550**: Community definition events that include community metadata and moderator lists - **Kind 4550**: Post approval events that moderators use to approve posts -- **Kind 11**: Text note events used for posts within communities +- **Kind 1111**: Text note events used for posts within communities **📋 IMPORTANT: This project extends NIP-72 with custom event kinds documented in `NIP.md`. When working with group functionality, always reference this specification and update it when making changes to event kinds or their usage patterns.** diff --git a/NIP.md b/NIP.md index 01fdeecc..f837ed4e 100644 --- a/NIP.md +++ b/NIP.md @@ -4,7 +4,7 @@ **⚠️ DISCLAIMER: This NIP is still under active development and subject to change. The event kinds and specifications described here are experimental and may be modified, deprecated, or replaced in future versions. It is not recommended to implement this NIP in production systems without first discussing it with the Chorus development team.** -This document describes the Chorus platform's extensions to NIP-72 (Moderated Communities) that enhance community management, user moderation, and content organization capabilities. +This document describes the Chorus platform's extensions to NIP-72 (Moderated Communities) that enhance community management, user moderation, and content organization capabilities. Chorus implements NIP-22 (Comment) for all group discussions, treating the community itself as the root event for threaded conversations. ## Background @@ -12,7 +12,10 @@ NIP-72 defines the basic framework for moderated communities on Nostr using: - **Kind 34550**: Community definition events - **Kind 4550**: Post approval events -Chorus extends this foundation with additional event kinds to provide comprehensive community management features including member lists, content pinning, join requests, and enhanced moderation workflows. +NIP-22 defines a comment threading system using: +- **Kind 1111**: Comments scoped to a root event + +Chorus combines these specifications, using NIP-22 comments scoped to NIP-72 communities for all group discussions, and extends this foundation with additional event kinds to provide comprehensive community management features including member lists, content pinning, join requests, and enhanced moderation workflows. ## Core NIP-72 Event Kinds @@ -20,7 +23,16 @@ Chorus extends this foundation with additional event kinds to provide comprehens Defines a community with metadata and moderator lists as specified in NIP-72. ### Kind 4550: Post Approval -Moderator approval events for posts as specified in NIP-72. +Moderator approval events for comments as specified in NIP-72, extended to handle Kind 1111 comments. + +**Tags:** +- `["a", communityId]` - References the target community +- `["e", commentId]` - References the approved comment +- `["p", commentAuthorPubkey]` - References the comment author +- `["k", "1111"]` - Kind of the approved comment + +**Content:** +Contains the full JSON of the approved comment event for redistribution. ## Chorus Extensions @@ -148,9 +160,9 @@ Moderator approval events for posts as specified in NIP-72. **Tags:** - `["a", communityId]` - References the target community -- `["e", eventId]` - References the removed post -- `["p", authorPubkey]` - References the post author -- `["k", originalKind]` - Kind of the original post +- `["e", eventId]` - References the removed comment +- `["p", authorPubkey]` - References the comment author +- `["k", "1111"]` - Kind of the original comment **Content:** The content field can be left blank or optionally include a moderation reason. @@ -165,7 +177,7 @@ The content field can be left blank or optionally include a moderation reason. ["a", "34550:community_creator_pubkey:bitcoin-discussion"], ["e", "removed_post_id"], ["p", "post_author_pubkey"], - ["k", "1"] + ["k", "1111"] ], "content": "Removed for violating community guidelines" } @@ -232,45 +244,63 @@ The content field can be left blank or optionally include a moderation reason. } ``` -### Enhanced Post Events +### Group Discussion Events (NIP-22 Implementation) -#### Kind 11: Group Post -Standard text note posted to a community, extending Kind 1 with community targeting. +All group discussions in Chorus use **Kind 1111** (NIP-22 Comments) scoped to the community as the root event. This provides proper threading while maintaining compatibility with the broader Nostr ecosystem. -**Tags:** -- `["a", communityId]` - References the target community +#### Kind 1111: Group Comment (Top-Level Post) +A top-level post in a community, implemented as a NIP-22 comment scoped to the community. + +**Tags (NIP-22 compliant):** +- `["A", communityId]` - Root scope: the community (uppercase) +- `["K", "34550"]` - Root kind: community (uppercase) +- `["P", communityCreatorPubkey]` - Root author: community creator (uppercase) +- `["a", communityId]` - Parent scope: same as root for top-level posts (lowercase) +- `["k", "34550"]` - Parent kind: community (lowercase) +- `["p", communityCreatorPubkey]` - Parent author: community creator (lowercase) **Example:** ```json { - "kind": 11, + "kind": 1111, "pubkey": "user_pubkey", "created_at": 1234567890, "tags": [ - ["a", "34550:community_creator_pubkey:bitcoin-discussion"] + ["A", "34550:community_creator_pubkey:bitcoin-discussion"], + ["K", "34550"], + ["P", "community_creator_pubkey"], + ["a", "34550:community_creator_pubkey:bitcoin-discussion"], + ["k", "34550"], + ["p", "community_creator_pubkey"] ], "content": "What do you think about the latest Bitcoin price movement?" } ``` -#### Kind 1111: Group Post Reply -Reply to a group post, enabling threaded discussions within communities. +#### Kind 1111: Group Comment (Reply) +A reply to another comment in a community, following NIP-22 threading rules. -**Tags:** -- `["a", communityId]` - References the target community -- `["e", parentPostId]` - References the parent post being replied to -- `["p", parentAuthorPubkey]` - References the author of the parent post +**Tags (NIP-22 compliant):** +- `["A", communityId]` - Root scope: the community (uppercase) +- `["K", "34550"]` - Root kind: community (uppercase) +- `["P", communityCreatorPubkey]` - Root author: community creator (uppercase) +- `["e", parentCommentId]` - Parent event: the comment being replied to (lowercase) +- `["k", "1111"]` - Parent kind: comment (lowercase) +- `["p", parentCommentAuthorPubkey]` - Parent author: comment author (lowercase) **Example:** ```json { "kind": 1111, - "pubkey": "replying_user_pubkey", + "pubkey": "replying_user_pubkey", "created_at": 1234567890, "tags": [ - ["a", "34550:community_creator_pubkey:bitcoin-discussion"], - ["e", "parent_post_id"], - ["p", "parent_post_author_pubkey"] + ["A", "34550:community_creator_pubkey:bitcoin-discussion"], + ["K", "34550"], + ["P", "community_creator_pubkey"], + ["e", "parent_comment_id"], + ["k", "1111"], + ["p", "parent_comment_author_pubkey"] ], "content": "I think the price movement is due to institutional adoption increasing." } @@ -297,25 +327,78 @@ Reply to a group post, enabling threaded discussions within communities. ### Auto-Approval Workflow -Posts from users in the approved members list (Kind 34551) are automatically considered approved without requiring individual Kind 4550 approval events. This reduces moderation overhead for trusted community members. +Comments from users in the approved members list (Kind 34551) are automatically considered approved without requiring individual Kind 4550 approval events. This reduces moderation overhead for trusted community members. + +### NIP-22 Threading Implementation + +Chorus implements NIP-22 threading with the community (Kind 34550) as the root event: + +1. **Top-level comments**: Both uppercase (root) and lowercase (parent) tags point to the community +2. **Nested replies**: Uppercase tags point to the community (root), lowercase tags point to the parent comment +3. **Querying top-level posts**: Filter Kind 1111 events where parent kind (`k` tag) is "34550" +4. **Querying replies**: Filter Kind 1111 events where parent kind (`k` tag) is "1111" + +This approach ensures proper threading while maintaining the community as the central organizing principle. + +### Query Patterns + +**Top-level posts in a community:** +```json +{ + "kinds": [1111], + "#A": ["34550:creator_pubkey:community_identifier"], + "limit": 50 +} +``` +Then filter results where the `k` tag value is "34550". + +**Replies to a specific comment:** +```json +{ + "kinds": [1111], + "#e": ["parent_comment_id"], + "limit": 100 +} +``` + +**All comments in a community (posts + replies):** +```json +{ + "kinds": [1111], + "#A": ["34550:creator_pubkey:community_identifier"], + "limit": 100 +} +``` + +**Approvals for comments in a community:** +```json +{ + "kinds": [4550], + "#a": ["34550:creator_pubkey:community_identifier"], + "#k": ["1111"], + "limit": 50 +} +``` ### Moderation Hierarchy 1. **Community Creator**: Has full control over the community 2. **Moderators**: Listed in the community definition with `["p", pubkey, relay, "moderator"]` tags -3. **Approved Members**: Can post without individual approval -4. **Regular Members**: Posts require moderator approval +3. **Approved Members**: Can post comments without individual approval +4. **Regular Members**: Comments require moderator approval 5. **Banned Users**: Cannot post, all content hidden ### Client Implementation Clients SHOULD: -- Display approved posts by default +- Display approved comments by default - Provide toggles to view pending/unapproved content for moderators - Hide content from banned users - Show visual indicators for pinned posts - Implement join request workflows for private communities -- Support threaded replies within communities +- Support NIP-22 threaded replies within communities +- Properly distinguish between top-level comments (parent kind "34550") and nested replies (parent kind "1111") +- Query using appropriate tag filters (`#A` for root scope, `#e` for parent events) ## Security Considerations @@ -326,4 +409,11 @@ Clients SHOULD: ## Compatibility -These extensions are designed to be backward compatible with NIP-72. Clients that only implement basic NIP-72 will still function but may not display the enhanced moderation and organization features. \ No newline at end of file +These extensions are designed to be compatible with both NIP-72 and NIP-22. Clients that implement: + +- **Basic NIP-72 only**: Will see community definitions and approvals but not threaded discussions +- **NIP-22 only**: Will see threaded comments but may not understand community context +- **Both NIP-72 and NIP-22**: Will have full functionality including threaded community discussions +- **Chorus extensions**: Will have access to enhanced moderation and organization features + +The use of NIP-22 for group discussions ensures broader interoperability with other Nostr clients that support comment threading. \ No newline at end of file diff --git a/README.md b/README.md index 62ec929e..952949c3 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ - Kind 1984: Report ### Groups -- Kind 11: Post in a group -- Kind 1111: Reply to group posts +- Kind 11: Legacy group posts (backwards compatibility) +- Kind 1111: Group comments (NIP-22) - used for all new group discussions - Kind 4550: Post approval - Kind 4551: Post removal - Kind 4552: Join request diff --git a/src/components/EmojiReactionButton.tsx b/src/components/EmojiReactionButton.tsx index b852268f..c7a11270 100644 --- a/src/components/EmojiReactionButton.tsx +++ b/src/components/EmojiReactionButton.tsx @@ -95,7 +95,7 @@ export function EmojiReactionButton({ postId }: EmojiReactionButtonProps) { kind: KINDS.REACTION, tags: [ ["e", postId], - ["k", String(KINDS.GROUP_POST)], // Assuming we're reacting to a kind 11 post + ["k", String(KINDS.GROUP_COMMENT)], // Reacting to a kind 1111 comment ], content: emojiData.emoji, // The emoji character }); diff --git a/src/components/groups/CreatePostForm.tsx b/src/components/groups/CreatePostForm.tsx index 0eeebb48..ef1baa62 100644 --- a/src/components/groups/CreatePostForm.tsx +++ b/src/components/groups/CreatePostForm.tsx @@ -216,15 +216,25 @@ ${mediaUrl}`; ? hashtagMatches.map(hashtag => ["t", hashtag.slice(1).toLowerCase()]) : []; + // NIP-22 compliant tags for top-level group comment const tags = [ + // Root scope: the group itself (uppercase tags) + ["A", communityId], + ["K", "34550"], // Group kind + ["P", parsedId.pubkey], // Group author + + // Parent scope: same as root for top-level posts (lowercase tags) ["a", communityId], - ["subject", `Post in ${parsedId?.identifier || 'group'}`], + ["k", "34550"], // Group kind + ["p", parsedId.pubkey], // Group author + + // Additional tags ...imageTags, ...hashtagTags, ]; await publishEvent({ - kind: KINDS.GROUP_POST, + kind: KINDS.GROUP_COMMENT, tags, content: finalContent, }); diff --git a/src/components/groups/GroupPostItem.tsx b/src/components/groups/GroupPostItem.tsx index 1d953acf..f5f40495 100644 --- a/src/components/groups/GroupPostItem.tsx +++ b/src/components/groups/GroupPostItem.tsx @@ -48,7 +48,7 @@ function ReplyCount({ postId }: { postId: string }) { try { const events = await nostr.query([{ - kinds: [KINDS.GROUP_POST_REPLY], + kinds: [KINDS.GROUP_COMMENT], "#e": [postId], limit: 100, }], { signal: AbortSignal.timeout(3000) }); diff --git a/src/components/groups/PendingRepliesList.tsx b/src/components/groups/PendingRepliesList.tsx index 33aab3e9..a6945cc4 100644 --- a/src/components/groups/PendingRepliesList.tsx +++ b/src/components/groups/PendingRepliesList.tsx @@ -123,7 +123,7 @@ function PendingReplyItem({ reply, communityId, onApproved }: PendingReplyItemPr ["a", communityId], ["e", reply.id], ["p", reply.pubkey], - ["k", "1111"], // Reply kind + ["k", String(reply.kind)], ], content: JSON.stringify(reply), }); diff --git a/src/components/groups/PostList.tsx b/src/components/groups/PostList.tsx index f04cd7ff..fe4a4c38 100644 --- a/src/components/groups/PostList.tsx +++ b/src/components/groups/PostList.tsx @@ -61,9 +61,10 @@ function ReplyCount({ postId }: { postId: string }) { queryFn: async (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]); - // Get all kind 1111 replies that reference this post + // Get all kind 1111 comments that reference this post as parent + // This covers replies to both new kind 1111 posts and legacy kind 11 posts const events = await nostr.query([{ - kinds: [KINDS.GROUP_POST_REPLY], + kinds: [KINDS.GROUP_COMMENT], "#e": [postId], limit: 100, }], { signal }); @@ -93,7 +94,7 @@ export function PostList({ communityId, showOnlyApproved = true, pendingOnly = f const { bannedUsers } = useBannedUsers(communityId); const { pinnedPostIds, isLoading: isLoadingPinnedPostIds } = usePinnedPosts(communityId); - // Query for approved posts + // Query for approved posts (kind 1111 comments that are top-level and legacy kind 11 posts) const { data: approvedPosts, isLoading: isLoadingApproved } = useQuery({ queryKey: ["approved-posts", communityId], queryFn: async (c) => { @@ -105,25 +106,39 @@ export function PostList({ communityId, showOnlyApproved = true, pendingOnly = f limit: 50, }], { signal }); - // Extract the approved posts from the content field and filter out replies - return approvals.map(approval => { + // Extract the approved posts from the content field + const approvedPosts = approvals.map(approval => { try { - // Get the kind tag to check if it's a reply + // Get the kind tag to check what kind of post was approved const kindTag = approval.tags.find(tag => tag[0] === "k"); const kind = kindTag ? Number.parseInt(kindTag[1]) : null; - // Skip this approval if it's for a reply (kind 1111) - if (kind === KINDS.GROUP_POST_REPLY) { + // Process approvals for both kind 1111 comments and legacy kind 11 posts + if (kind !== KINDS.GROUP_COMMENT && kind !== KINDS.GROUP_POST_LEGACY) { return null; } const approvedPost = JSON.parse(approval.content) as NostrEvent; - // Skip if the post itself is a reply - if (approvedPost.kind === KINDS.GROUP_POST_REPLY) { + // Skip if the post itself is not the expected kind + if (approvedPost.kind !== kind) { return null; } + // For kind 1111 comments, check if this is a top-level comment + if (approvedPost.kind === KINDS.GROUP_COMMENT) { + const parentKindTag = approvedPost.tags.find(tag => tag[0] === "k"); + const parentKind = parentKindTag ? parentKindTag[1] : null; + + // Top-level comments have parent kind "34550" (group) + if (parentKind !== "34550") { + return null; + } + } + + // For legacy kind 11 posts, they are always considered top-level + // (no additional filtering needed) + // Add the approval information return { ...approvedPost, @@ -138,11 +153,11 @@ export function PostList({ communityId, showOnlyApproved = true, pendingOnly = f console.error("Error parsing approved post:", error); return null; } - }).filter((post): post is NostrEvent & { + }).filter(post => post !== null) as Array post !== null); + }>; - // Filter out spam posts + // Filter out spam posts using the centralized spam filter const filteredApprovedPosts = filterSpamPosts(approvedPosts); // Debug logging @@ -177,34 +192,30 @@ export function PostList({ communityId, showOnlyApproved = true, pendingOnly = f enabled: !!nostr && !!communityId, }); - // Query for pending posts + // Query for pending posts (kind 1111 comments that are top-level and not approved) const { data: pendingPosts, isLoading: isLoadingPending } = useQuery({ queryKey: ["pending-posts", communityId], queryFn: async (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]); + + // Query for all kind 1111 comments in this group const posts = await nostr.query([{ - kinds: [KINDS.GROUP_POST], - "#a": [communityId], + kinds: [KINDS.GROUP_COMMENT], + "#A": [communityId], // Root scope is the group limit: 50, }], { signal }); - // Filter out replies (kind 1111) and any posts with a parent reference + // Filter to only top-level comments (parent is the group, not another comment) const filteredPosts = posts.filter(post => { - // Exclude posts with kind 1111 (replies) - if (post.kind === KINDS.GROUP_POST_REPLY) { - return false; - } - - // Exclude posts that have an 'e' tag with a 'reply' marker - // This checks for posts that are replies to other posts - const replyTags = post.tags.filter(tag => - tag[0] === 'e' && (tag[3] === 'reply' || tag[3] === 'root') - ); - - return replyTags.length === 0; + // Check if this is a top-level comment (parent is the group, not another comment) + const parentKindTag = post.tags.find(tag => tag[0] === "k"); + const parentKind = parentKindTag ? parentKindTag[1] : null; + + // Top-level comments have parent kind "34550" (group) + return parentKind === "34550"; }); - // Filter out spam posts + // Filter out spam posts using the centralized spam filter const spamFilteredPosts = filterSpamPosts(filteredPosts); // Debug logging @@ -221,6 +232,34 @@ export function PostList({ communityId, showOnlyApproved = true, pendingOnly = f enabled: !!nostr && !!communityId, }); + // Query for legacy kind 11 posts (backwards compatibility) + const { data: legacyPosts, isLoading: isLoadingLegacy } = useQuery({ + queryKey: ["legacy-posts", communityId], + queryFn: async (c) => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]); + + // Query for legacy kind 11 posts that tag this community + const posts = await nostr.query([{ + kinds: [KINDS.GROUP_POST_LEGACY], + "#a": [communityId], // Legacy posts use lowercase "a" tag + limit: 50, + }], { signal }); + + // Filter out spam posts using the centralized spam filter + const spamFilteredPosts = filterSpamPosts(posts); + + // Debug logging + console.log("Legacy posts:", { + totalLegacyPosts: posts.length, + afterSpamFilter: spamFilteredPosts.length, + spamPostsRemoved: posts.length - spamFilteredPosts.length + }); + + return spamFilteredPosts; + }, + enabled: !!nostr && !!communityId, + }); + // Get approved members using the centralized hook const { approvedMembers, moderators: hookModerators } = useApprovedMembers(communityId); @@ -251,9 +290,9 @@ export function PostList({ communityId, showOnlyApproved = true, pendingOnly = f const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]); - // Fetch the actual pinned posts + // Fetch the actual pinned posts (including legacy kind 11 posts) const posts = await nostr.query([{ - kinds: [1, KINDS.GROUP_POST], + kinds: [1, KINDS.GROUP_COMMENT, KINDS.GROUP_POST_LEGACY], ids: pinnedPostIds, }], { signal }); @@ -278,30 +317,39 @@ export function PostList({ communityId, showOnlyApproved = true, pendingOnly = f const isUserModerator = Boolean(user && moderators.includes(user.pubkey)); - const allPosts = [...(approvedPosts || []), ...(pendingPosts || [])]; + const allPosts = [ + ...(approvedPosts || []), + ...(pendingPosts || []), + ...(legacyPosts || []) + ]; const uniquePosts = allPosts.filter((post, index, self) => - index === self.findIndex(p => p.id === post.id) + post && index === self.findIndex(p => p && p.id === post.id) ); const removedPostIds = useMemo(() => removedPosts || [], [removedPosts]); const postsWithoutRemoved = uniquePosts.filter(post => - !removedPostIds.includes(post.id) && + post && !removedPostIds.includes(post.id) && !bannedUsers.includes(post.pubkey) ); const processedPosts = postsWithoutRemoved.map(post => { + if (!post) return post; + // If post already has approval info, return it as is if ('approval' in post) return post; - // Check if this is a reply by looking at the kind or tags - const isReply = post.kind === KINDS.GROUP_POST_REPLY || post.tags.some(tag => - tag[0] === 'e' && (tag[3] === 'reply' || tag[3] === 'root') - ); + // For kind 1111 comments, check if this is a nested reply + if (post.kind === KINDS.GROUP_COMMENT) { + const parentKindTag = post.tags.find(tag => tag[0] === "k"); + const parentKind = parentKindTag ? parentKindTag[1] : null; + const isNestedReply = parentKind === "1111"; - // If it's a reply, don't auto-approve it as a top-level post - if (isReply) return post; + // If it's a nested reply, don't auto-approve it as a top-level post + if (isNestedReply) return post; + } + // For legacy kind 11 posts, they are always considered top-level // Auto-approve for approved members and moderators const isApprovedMember = approvedMembers.includes(post.pubkey); const isModerator = moderators.includes(post.pubkey); @@ -318,7 +366,7 @@ export function PostList({ communityId, showOnlyApproved = true, pendingOnly = f }; } return post; - }); + }).filter(Boolean); // Count approved and pending posts const pendingCount = processedPosts.filter(post => !('approval' in post)).length; @@ -383,14 +431,18 @@ export function PostList({ communityId, showOnlyApproved = true, pendingOnly = f // Separate regular posts (excluding pinned ones) const regularPosts = filteredPosts.filter(post => - !pinnedPostIds.includes(post.id) + post && !pinnedPostIds.includes(post.id) ); // Sort regular posts by creation time - const sortedRegularPosts = regularPosts.sort((a, b) => b.created_at - a.created_at); + const sortedRegularPosts = regularPosts.sort((a, b) => + (b?.created_at || 0) - (a?.created_at || 0) + ); // Sort pinned posts by creation time (most recent pins first) - const sortedPinnedPosts = filteredPinnedPosts.sort((a, b) => b.created_at - a.created_at); + const sortedPinnedPosts = filteredPinnedPosts.sort((a, b) => + (b?.created_at || 0) - (a?.created_at || 0) + ); // Combine pinned posts first, then regular posts return [...sortedPinnedPosts, ...sortedRegularPosts]; @@ -409,7 +461,7 @@ export function PostList({ communityId, showOnlyApproved = true, pendingOnly = f - if (isLoadingApproved || isLoadingPending || isLoadingPinnedPostIds || isLoadingPinnedPosts) { + if (isLoadingApproved || isLoadingPending || isLoadingLegacy || isLoadingPinnedPostIds || isLoadingPinnedPosts) { return (
{[1, 2, 3].map((i) => ( @@ -464,7 +516,7 @@ export function PostList({ communityId, showOnlyApproved = true, pendingOnly = f return (
- {sortedPosts.map((post, index) => ( + {sortedPosts.map((post, index) => post && ( ))}
@@ -601,9 +653,12 @@ function PostItem({ post, communityId, isApproved, isModerator, isLastItem = fal return; } try { + // Determine the correct kind tag based on the post type + const kindTag = post.kind === KINDS.GROUP_POST_LEGACY ? "11" : "1111"; + await publishEvent({ kind: KINDS.GROUP_POST_APPROVAL, - tags: [["a", communityId], ["e", post.id], ["p", post.pubkey], ["k", String(post.kind)]], + tags: [["a", communityId], ["e", post.id], ["p", post.pubkey], ["k", kindTag]], content: JSON.stringify(post), }); toast.success("Post approved successfully!"); @@ -619,9 +674,12 @@ function PostItem({ post, communityId, isApproved, isModerator, isLastItem = fal return; } try { + // Determine the correct kind tag based on the post type + const kindTag = post.kind === KINDS.GROUP_POST_LEGACY ? "11" : "1111"; + await publishEvent({ kind: KINDS.GROUP_POST_REMOVAL, - tags: [["a", communityId], ["e", post.id], ["p", post.pubkey], ["k", String(post.kind)]], + tags: [["a", communityId], ["e", post.id], ["p", post.pubkey], ["k", kindTag]], content: "", // Empty content - do not redistribute removed content }); toast.success("Post removed successfully!"); diff --git a/src/components/groups/ReplyForm.tsx b/src/components/groups/ReplyForm.tsx index b1a4e137..2e5a1ef6 100644 --- a/src/components/groups/ReplyForm.tsx +++ b/src/components/groups/ReplyForm.tsx @@ -238,20 +238,29 @@ ${mediaUrl}`; ? hashtagMatches.map(hashtag => ["t", hashtag.slice(1).toLowerCase()]) : []; - // Create tags for the reply + // Parse community ID to get group details + const parsedCommunityId = communityId.includes(':') + ? (() => { + const [kind, pubkey, identifier] = communityId.split(':'); + return { kind, pubkey, identifier }; + })() + : null; + if (!parsedCommunityId) { + toast.error("Invalid community ID"); + return; + } + + // NIP-22 compliant tags for group comment reply const tags = [ - // Community reference - ["a", communityId], - - // Root post reference (uppercase tags) - ["E", postId], - ["K", "11"], // Original post is kind 11 - ["P", postAuthorPubkey], + // Root scope: the group itself (uppercase tags) + ["A", communityId], + ["K", "34550"], // Group kind + ["P", parsedCommunityId.pubkey], // Group author // Parent reference (lowercase tags) - ["e", replyToId], - ["k", parentId ? "1111" : "11"], // Parent is either a reply (1111) or the original post (11) - ["p", replyToPubkey], + ["e", replyToId], // Parent event ID + ["k", "1111"], // Parent is always a comment (kind 1111) + ["p", replyToPubkey], // Parent author // Media tags ...imageTags, @@ -262,7 +271,7 @@ ${mediaUrl}`; // Publish the reply event (kind 1111) await publishEvent({ - kind: KINDS.GROUP_POST_REPLY, + kind: KINDS.GROUP_COMMENT, tags, content: finalContent, }); diff --git a/src/hooks/useGroupStats.ts b/src/hooks/useGroupStats.ts index f35b9479..5e05a5a5 100644 --- a/src/hooks/useGroupStats.ts +++ b/src/hooks/useGroupStats.ts @@ -38,10 +38,10 @@ export function useGroupStats(communities: NostrEvent[] | undefined, enabled = t stats[communityId] = { posts: 0, participants: new Set() }; } - // 1. Get all posts (Kind 1, 11, 1111) that reference any community + // 1. Get all posts (Kind 1, 1111) that reference any community const posts = await nostr.query([{ - kinds: [KINDS.TEXT_NOTE, KINDS.GROUP_POST, KINDS.GROUP_POST_REPLY], - "#a": communityRefs, + kinds: [KINDS.TEXT_NOTE, KINDS.GROUP_COMMENT], + "#A": communityRefs, // Use uppercase A for root scope limit: 500 }], { signal }); diff --git a/src/hooks/useLikes.ts b/src/hooks/useLikes.ts index ede766bc..3980f08c 100644 --- a/src/hooks/useLikes.ts +++ b/src/hooks/useLikes.ts @@ -53,7 +53,7 @@ export function useLikes(eventId: string) { kind: KINDS.REACTION, tags: [ ["e", eventId], - ["k", String(KINDS.GROUP_POST)], // Assuming we're liking a kind 11 post + ["k", String(KINDS.GROUP_COMMENT)], // Liking a kind 1111 comment ], content: "+", // "+" means like }); diff --git a/src/hooks/useNostrPublish.ts b/src/hooks/useNostrPublish.ts index 5df4b827..3df9e9e5 100644 --- a/src/hooks/useNostrPublish.ts +++ b/src/hooks/useNostrPublish.ts @@ -20,8 +20,7 @@ interface UseNostrPublishOptions { const expirationEventKinds: number[] = [ KINDS.REACTION, // Reactions - KINDS.GROUP_POST, // Posts - KINDS.GROUP_POST_REPLY, // Comments (replies) + KINDS.GROUP_COMMENT, // Comments (posts and replies) ] as const; export function useNostrPublish(options?: UseNostrPublishOptions) { @@ -165,21 +164,15 @@ export function useNostrPublish(options?: UseNostrPublishOptions) { break; } - case KINDS.GROUP_POST: // Post + case KINDS.GROUP_COMMENT: { if (communityId) { queryClient.invalidateQueries({ queryKey: ["pending-posts", communityId] }); queryClient.invalidateQueries({ queryKey: ["pending-posts-count", communityId] }); + queryClient.invalidateQueries({ queryKey: ["pending-replies", communityId] }); } + // Also invalidate user posts queryClient.invalidateQueries({ queryKey: ["user-posts", event.pubkey] }); - break; - - case KINDS.GROUP_POST_REPLY: { - if (communityId) { - queryClient.invalidateQueries({ queryKey: ["pending-posts", communityId] }); - queryClient.invalidateQueries({ queryKey: ["pending-posts-count", communityId] }); - queryClient.invalidateQueries({ queryKey: ["pending-replies", communityId] }); - } // Find the post being replied to const parentPostId = event.tags.find(tag => tag[0] === "e")?.[1]; diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 4d2dfb5e..ee7b2cbf 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -38,9 +38,8 @@ export function useNotifications() { const readNotifications = JSON.parse(localStorage.getItem(`notifications:${user.pubkey}`) || '{}'); const kinds = [ - KINDS.GROUP_POST, + KINDS.GROUP_COMMENT, KINDS.REACTION, - KINDS.GROUP_POST_REPLY, KINDS.GROUP_POST_APPROVAL, KINDS.GROUP_POST_REMOVAL, KINDS.GROUP @@ -61,11 +60,16 @@ export function useNotifications() { const groupId = communityParts && communityParts[0] === String(KINDS.GROUP) ? communityRef : undefined; switch (event.kind) { - case KINDS.GROUP_POST: { + case KINDS.GROUP_COMMENT: { + // Check if this is a top-level post or a reply + const parentKindTag = event.tags.find(tag => tag[0] === "k"); + const parentKind = parentKindTag ? parentKindTag[1] : null; + const isTopLevel = parentKind === "34550"; // Parent is the group + notifications.push({ id: event.id, - type: 'tag_post', - message: `tagged you in a post`, + type: isTopLevel ? 'tag_post' : 'tag_reply', + message: isTopLevel ? `tagged you in a post` : `tagged you in a reply`, createdAt: event.created_at, read: !!readNotifications[event.id], eventId: event.id, @@ -89,19 +93,7 @@ export function useNotifications() { }); break; } - case KINDS.GROUP_POST_REPLY: { - notifications.push({ - id: event.id, - type: 'tag_reply', - message: `tagged you in a reply`, - createdAt: event.created_at, - read: !!readNotifications[event.id], - eventId: event.id, - pubkey: event.pubkey, - groupId - }); - break; - } + // Note: GROUP_POST_REPLY case removed since all comments are now kind 1111 case KINDS.GROUP_POST_APPROVAL: { // For post approval events, we already have the full community reference in the 'a' tag const communityRef = event.tags.find(tag => tag[0] === 'a')?.[1]; diff --git a/src/hooks/usePendingPostsCount.ts b/src/hooks/usePendingPostsCount.ts index 240eae5c..f5d3b592 100644 --- a/src/hooks/usePendingPostsCount.ts +++ b/src/hooks/usePendingPostsCount.ts @@ -2,6 +2,7 @@ import { useNostr } from "@/hooks/useNostr"; import { useQuery } from "@tanstack/react-query"; import { parseNostrAddress } from "@/lib/nostr-utils"; import { KINDS } from "@/lib/nostr-kinds"; +import { filterSpamPosts } from "@/lib/spam-filter"; /** * Hook to fetch the count of pending posts in a community @@ -24,13 +25,32 @@ export function usePendingPostsCount(communityId: string) { if (!parsedId) return 0; - // Get posts that tag the community + // Get posts that tag the community (kind 1111 comments) const posts = await nostr.query([{ - kinds: [KINDS.GROUP_POST], - "#a": [communityId], + kinds: [KINDS.GROUP_COMMENT], + "#A": [communityId], // Root scope is the group limit: 100, }], { signal }); + // Filter to only top-level comments (parent is the group, not another comment) + const topLevelPosts = posts.filter(post => { + const parentKindTag = post.tags.find(tag => tag[0] === "k"); + const parentKind = parentKindTag ? parentKindTag[1] : null; + // Top-level comments have parent kind "34550" (group) + return parentKind === "34550"; + }); + + // Get legacy kind 11 posts that tag the community + const legacyPosts = await nostr.query([{ + kinds: [KINDS.GROUP_POST_LEGACY], + "#a": [communityId], // Legacy posts use lowercase "a" tag + limit: 100, + }], { signal }); + + // Combine both types of posts and filter out spam + const combinedPosts = [...topLevelPosts, ...legacyPosts]; + const allPosts = filterSpamPosts(combinedPosts); + // Get approval events const approvals = await nostr.query([{ kinds: [KINDS.GROUP_POST_APPROVAL], @@ -90,12 +110,7 @@ export function usePendingPostsCount(communityId: string) { // 3. Posted by the community owner (auto-approved) // 4. Posted by approved members (auto-approved) // 5. Posted by moderators (auto-approved) - // 6. Replies (kind 1111) - const pendingPosts = posts.filter(post => { - // Skip if post is a reply - if (post.kind === KINDS.GROUP_POST_REPLY) { - return false; - } + const pendingPosts = allPosts.filter(post => { // Skip if post is already approved if (approvedPostIds.includes(post.id)) { @@ -123,6 +138,8 @@ export function usePendingPostsCount(communityId: string) { // Debug logging // console.log("Pending posts count calculation:", { // totalPosts: posts.length, + // legacyPosts: legacyPosts.length, + // allPosts: allPosts.length, // approvedPostIds, // removedPostIds, // communityOwner: communityOwnerPubkey, diff --git a/src/hooks/usePendingReplies.ts b/src/hooks/usePendingReplies.ts index 08f33f2f..a6fa33cf 100644 --- a/src/hooks/usePendingReplies.ts +++ b/src/hooks/usePendingReplies.ts @@ -22,11 +22,19 @@ export function usePendingReplies(communityId: string) { // Get all replies in the community (kind 1111 with the community tag) const replies = await nostr.query([{ - kinds: [KINDS.GROUP_POST_REPLY], - "#a": [communityId], + kinds: [KINDS.GROUP_COMMENT], + "#A": [communityId], // Root scope is the group limit: 100, }], { signal }); + // Filter to only nested replies (parent is another comment, not the group) + const nestedReplies = replies.filter(reply => { + const parentKindTag = reply.tags.find(tag => tag[0] === "k"); + const parentKind = parentKindTag ? parentKindTag[1] : null; + // Only include replies where parent is another comment (kind 1111) + return parentKind === "1111"; + }); + // Parse the community ID to get the pubkey and identifier const parsedId = communityId.includes(':') ? parseNostrAddress(communityId) @@ -49,7 +57,7 @@ export function usePendingReplies(communityId: string) { // 2. Posted by approved members (auto-approved) // 3. Posted by moderators (auto-approved) // 4. Posted by the community owner (auto-approved) - const pendingReplies = replies.filter(reply => { + const pendingReplies = nestedReplies.filter(reply => { // Skip if reply is already approved if (replyApprovals.includes(reply.id)) { return false; diff --git a/src/hooks/useReplies.ts b/src/hooks/useReplies.ts index 32759615..a0c77e84 100644 --- a/src/hooks/useReplies.ts +++ b/src/hooks/useReplies.ts @@ -18,7 +18,7 @@ export function useReplies(postId: string) { // Get replies using kind 1111 with the post as the parent const replies = await nostr.query([{ - kinds: [KINDS.GROUP_POST_REPLY], + kinds: [KINDS.GROUP_COMMENT], "#e": [postId], limit: 100, }], { signal }); @@ -44,7 +44,7 @@ export function useNestedReplies(replyId: string) { // Get nested replies using kind 1111 with the reply as the parent const replies = await nostr.query([{ - kinds: [KINDS.GROUP_POST_REPLY], + kinds: [KINDS.GROUP_COMMENT], "#e": [replyId], limit: 50, }], { signal }); diff --git a/src/hooks/useReplyApprovals.ts b/src/hooks/useReplyApprovals.ts index 8a5ddae1..4d6c29a7 100644 --- a/src/hooks/useReplyApprovals.ts +++ b/src/hooks/useReplyApprovals.ts @@ -20,7 +20,7 @@ export function useReplyApprovals(communityId: string) { const approvals = await nostr.query([{ kinds: [KINDS.GROUP_POST_APPROVAL], "#a": [communityId], - "#k": [String(KINDS.GROUP_POST_REPLY)], + "#k": ["1111"], // Comments/replies limit: 100, }], { signal }); diff --git a/src/hooks/useTrendingHashtags.ts b/src/hooks/useTrendingHashtags.ts index f461ad89..d0028d97 100644 --- a/src/hooks/useTrendingHashtags.ts +++ b/src/hooks/useTrendingHashtags.ts @@ -26,7 +26,7 @@ export function useTrendingHashtags(limit = 10) { // Query for recent posts that might contain hashtags const events = await nostr.query([ { - kinds: [KINDS.TEXT_NOTE, KINDS.GROUP_POST], // text notes and community posts + kinds: [KINDS.TEXT_NOTE, KINDS.GROUP_COMMENT], // text notes and community comments since, limit: 1000, // Get a good sample size } diff --git a/src/lib/nostr-kinds.ts b/src/lib/nostr-kinds.ts index a252fe95..a59a7089 100644 --- a/src/lib/nostr-kinds.ts +++ b/src/lib/nostr-kinds.ts @@ -6,8 +6,10 @@ export const KINDS = { REACTION: 7, ZAP: 9735, REPORT: 1984, - GROUP_POST: 11, - GROUP_POST_REPLY: 1111, + // Legacy group post format (backwards compatibility) + GROUP_POST_LEGACY: 11, + // NIP-22 Comment system for groups - ALL group discussion uses kind 1111 + GROUP_COMMENT: 1111, GROUP_POST_APPROVAL: 4550, GROUP_POST_REMOVAL: 4551, GROUP_JOIN_REQUEST: 4552, diff --git a/src/pages/GroupPostsFeed.tsx b/src/pages/GroupPostsFeed.tsx index 909132d5..b67860b9 100644 --- a/src/pages/GroupPostsFeed.tsx +++ b/src/pages/GroupPostsFeed.tsx @@ -68,10 +68,18 @@ export default function GroupPostsFeed() { let postEvents: NostrEvent[] = []; if (activeTab === "all") { postEvents = await nostr.query([{ - kinds: [KINDS.GROUP_POST], - "#a": [communityId], + kinds: [KINDS.GROUP_COMMENT], + "#A": [communityId], // Root scope is the group limit: 20, }], { signal: AbortSignal.timeout(5000) }); + + // Filter to only top-level comments (parent is the group, not another comment) + postEvents = postEvents.filter(post => { + const parentKindTag = post.tags.find(tag => tag[0] === "k"); + const parentKind = parentKindTag ? parentKindTag[1] : null; + // Top-level comments have parent kind "34550" (group) + return parentKind === "34550"; + }); } // Fetch removals for this group @@ -124,13 +132,18 @@ export default function GroupPostsFeed() { // Skip if the post is removed if (removedPostIds.has(approvedPost.id)) return null; - // Skip if this is a reply (kind 1111) + // Skip if this is not a comment (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; + if (kind !== KINDS.GROUP_COMMENT) return null; - // Skip if the post itself is a reply - if (approvedPost.kind === KINDS.GROUP_POST_REPLY) return null; + // Skip if the post itself is not a comment + if (approvedPost.kind !== KINDS.GROUP_COMMENT) return null; + + // Skip if this is a nested reply (parent is another comment, not the group) + const parentKindTag = approvedPost.tags.find(tag => tag[0] === "k"); + const parentKind = parentKindTag ? parentKindTag[1] : null; + if (parentKind !== "34550") return null; // Only include top-level comments // Add the community ID and approval information return { @@ -147,15 +160,7 @@ export default function GroupPostsFeed() { 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); + }).filter((post): post is NonNullable => post !== null); // Add approved posts to our result array allPosts = [...allPosts, ...processedApprovedPosts]; @@ -168,14 +173,13 @@ export default function GroupPostsFeed() { // 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; + // Skip if this is not a comment (kind 1111) + if (post.kind !== KINDS.GROUP_COMMENT) return null; + + // Skip if this is a nested reply (already filtered above, but double-check) + const parentKindTag = post.tags.find(tag => tag[0] === "k"); + const parentKind = parentKindTag ? parentKindTag[1] : null; + if (parentKind !== "34550") return null; // Only include top-level comments // Check if the post is already in approved posts const isAlreadyApproved = processedApprovedPosts.some( @@ -205,15 +209,7 @@ export default function GroupPostsFeed() { ...post, communityId }; - }).filter((post): post is NostrEvent & { - communityId: string; - approval?: { - id: string; - pubkey: string; - created_at: number; - kind: number; - } - } => post !== null); + }).filter((post): post is NonNullable => post !== null); // Add all posts to the array allPosts = [...allPosts, ...allGroupPosts]; diff --git a/src/pages/Hashtag.tsx b/src/pages/Hashtag.tsx index dfe7d74a..f0b8561c 100644 --- a/src/pages/Hashtag.tsx +++ b/src/pages/Hashtag.tsx @@ -56,7 +56,7 @@ export default function Hashtag() { const [taggedPosts, contentPosts] = await Promise.all([ // Query for posts with hashtag as a 't' tag nostr.query([{ - kinds: [KINDS.TEXT_NOTE, KINDS.GROUP_POST], // text notes and community posts + kinds: [KINDS.TEXT_NOTE, KINDS.GROUP_COMMENT], // text notes and community comments "#t": [hashtag.toLowerCase()], limit: 50 }], { signal }), @@ -64,7 +64,7 @@ export default function Hashtag() { // Query for posts containing hashtag in content // Note: This is less efficient but catches posts without proper tagging nostr.query([{ - kinds: [KINDS.TEXT_NOTE, KINDS.GROUP_POST], + kinds: [KINDS.TEXT_NOTE, KINDS.GROUP_COMMENT], search: `#${hashtag}`, limit: 30 }], { signal }).catch(() => []) // Some relays may not support search diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 9f8800be..64aa4010 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -841,12 +841,20 @@ export default function Profile() { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]); - // Get posts by this user - const userPosts = await nostr.query([{ - kinds: [KINDS.GROUP_POST], + // Get posts by this user (kind 1111 comments that are top-level) + const userComments = await nostr.query([{ + kinds: [KINDS.GROUP_COMMENT], authors: [pubkey], - limit: 20, + limit: 50, }], { signal }); + + // Filter to only top-level comments (parent is the group, not another comment) + const userPosts = userComments.filter(post => { + const parentKindTag = post.tags.find(tag => tag[0] === "k"); + const parentKind = parentKindTag ? parentKindTag[1] : null; + // Top-level comments have parent kind "34550" (group) + return parentKind === "34550"; + }); return userPosts.sort((a, b) => b.created_at - a.created_at); },