diff --git a/src/components/admin/ChannelCreator.js b/src/components/admin/ChannelCreator.js new file mode 100644 index 0000000..c34bbf6 --- /dev/null +++ b/src/components/admin/ChannelCreator.js @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { Button } from 'primereact/button'; +import { useNDKContext } from '@/context/NDKContext'; +import { useSession } from 'next-auth/react'; +import { createChannel, PLEBDEVS_CHANNEL_CONFIG } from '@/utils/nip28'; +import { useToast } from '@/hooks/useToast'; +import appConfig from '@/config/appConfig'; + +const ChannelCreator = ({ onChannelCreated }) => { + const [isCreating, setIsCreating] = useState(false); + const { ndk } = useNDKContext(); + const { data: session } = useSession(); + const { showToast } = useToast(); + + // Check if current user is authorized to create channels + const isAuthorized = session?.user?.pubkey && + appConfig.authorPubkeys.includes(session.user.pubkey); + + const handleCreateChannel = async () => { + if (!ndk?.signer) { + showToast('error', 'Please connect your Nostr account first'); + return; + } + + if (!isAuthorized) { + showToast('error', 'You are not authorized to create channels'); + return; + } + + setIsCreating(true); + + try { + const channelEvent = await createChannel(ndk, PLEBDEVS_CHANNEL_CONFIG); + showToast('success', `Channel created successfully! ID: ${channelEvent.id.substring(0, 12)}...`); + + if (onChannelCreated) { + onChannelCreated(channelEvent); + } + } catch (error) { + console.error('Error creating channel:', error); + showToast('error', 'Failed to create channel. Please try again.'); + } finally { + setIsCreating(false); + } + }; + + if (!isAuthorized) { + return null; // Don't show to unauthorized users + } + + return ( +
+
+

Initialize PlebDevs Channel

+

+ No NIP-28 channel found for PlebDevs community. Create the official channel to enable + structured community discussions. +

+ +
+
Channel Configuration:
+
    +
  • Name: {PLEBDEVS_CHANNEL_CONFIG.name}
  • +
  • About: {PLEBDEVS_CHANNEL_CONFIG.about}
  • +
  • Tags: {PLEBDEVS_CHANNEL_CONFIG.tags.map(tag => tag[1]).join(', ')}
  • +
+
+ +
+
+ ); +}; + +export default ChannelCreator; \ No newline at end of file diff --git a/src/components/feeds/ChannelMessageInput.js b/src/components/feeds/ChannelMessageInput.js new file mode 100644 index 0000000..4e76709 --- /dev/null +++ b/src/components/feeds/ChannelMessageInput.js @@ -0,0 +1,103 @@ +import React, { useState } from 'react'; +import { InputTextarea } from 'primereact/inputtextarea'; +import { Button } from 'primereact/button'; +import { useNDKContext } from '@/context/NDKContext'; +import { useSession } from 'next-auth/react'; +import { sendChannelMessage } from '@/utils/nip28'; +import { useToast } from '@/hooks/useToast'; + +const ChannelMessageInput = ({ channelId, onMessageSent, disabled = false }) => { + const [message, setMessage] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const { ndk } = useNDKContext(); + const { data: session } = useSession(); + const { showToast } = useToast(); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!message.trim()) return; + + if (!ndk?.signer) { + showToast('error', 'Please connect your Nostr account to post messages'); + return; + } + + if (!channelId) { + showToast('error', 'No channel available for posting'); + return; + } + + setIsSubmitting(true); + + try { + await sendChannelMessage(ndk, channelId, message.trim()); + setMessage(''); + showToast('success', 'Message posted successfully!'); + + if (onMessageSent) { + onMessageSent(); + } + } catch (error) { + console.error('Error posting message:', error); + showToast('error', 'Failed to post message. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }; + + if (!session) { + return ( +
+

Please sign in to participate in the community chat

+
+ ); + } + + return ( +
+
+ setMessage(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Share your thoughts with the PlebDevs community..." + rows={3} + className="w-full bg-gray-700 text-white border-gray-600 focus:border-blue-400" + disabled={disabled || isSubmitting} + maxLength={2000} + /> + +
+ + {message.length}/2000 characters + + +
+ + + {!ndk?.signer && ( +
+ Connect your Nostr account to post messages to the community +
+ )} +
+ ); +}; + +export default ChannelMessageInput; \ No newline at end of file diff --git a/src/components/feeds/GlobalFeed.js b/src/components/feeds/GlobalFeed.js index a684c9c..4c5815b 100644 --- a/src/components/feeds/GlobalFeed.js +++ b/src/components/feeds/GlobalFeed.js @@ -4,7 +4,7 @@ import { useDiscordQuery } from '@/hooks/communityQueries/useDiscordQuery'; import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; import { useRouter } from 'next/router'; -import { useCommunityNotes } from '@/hooks/nostr/useCommunityNotes'; +import { useCommunityChannel } from '@/hooks/nostr/useCommunityChannel'; import { useNDKContext } from '@/context/NDKContext'; import { findKind0Fields } from '@/utils/nostr'; import NostrIcon from '../../../public/images/nostr.png'; @@ -54,10 +54,10 @@ const GlobalFeed = ({ searchQuery }) => { isLoading: stackerNewsLoading, } = useQuery({ queryKey: ['stackerNews'], queryFn: fetchStackerNews }); const { - communityNotes: nostrData, + channelMessages: nostrData, error: nostrError, isLoading: nostrLoading, - } = useCommunityNotes(); + } = useCommunityChannel(); const { ndk } = useNDKContext(); const { returnImageProxy } = useImageProxy(); const windowWidth = useWindowWidth(); @@ -172,7 +172,7 @@ const GlobalFeed = ({ searchQuery }) => { ? item.channel : item.type === 'stackernews' ? '~devs' - : '#plebdevs', + : 'PlebDevs Community', }} searchQuery={searchQuery} windowWidth={windowWidth} diff --git a/src/components/feeds/NostrFeed.js b/src/components/feeds/NostrFeed.js index f64ff12..0eb8129 100644 --- a/src/components/feeds/NostrFeed.js +++ b/src/components/feeds/NostrFeed.js @@ -7,20 +7,27 @@ import NostrIcon from '../../../public/images/nostr.png'; import Image from 'next/image'; import useWindowWidth from '@/hooks/useWindowWidth'; import { nip19 } from 'nostr-tools'; -import { useCommunityNotes } from '@/hooks/nostr/useCommunityNotes'; +import { useCommunityChannel } from '@/hooks/nostr/useCommunityChannel'; import CommunityMessage from '@/components/feeds/messages/CommunityMessage'; +import ChannelMessageInput from '@/components/feeds/ChannelMessageInput'; +import ChannelCreator from '@/components/admin/ChannelCreator'; +import appConfig from '@/config/appConfig'; const NostrFeed = ({ searchQuery }) => { - const { communityNotes, isLoading, error } = useCommunityNotes(); + const { channelMessages, channelMetadata, currentChannelId, isLoading, error } = useCommunityChannel(); const { ndk } = useNDKContext(); const { data: session } = useSession(); const [authorData, setAuthorData] = useState({}); const windowWidth = useWindowWidth(); + // Check if current user is authorized to create channels + const isAuthorized = session?.user?.pubkey && + appConfig.authorPubkeys.includes(session.user.pubkey); + useEffect(() => { const fetchAuthors = async () => { - for (const message of communityNotes) { + for (const message of channelMessages) { if (!authorData[message.pubkey]) { const author = await fetchAuthor(message.pubkey); setAuthorData(prevData => ({ @@ -31,10 +38,10 @@ const NostrFeed = ({ searchQuery }) => { } }; - if (communityNotes && communityNotes.length > 0) { + if (channelMessages && channelMessages.length > 0) { fetchAuthors(); } - }, [communityNotes, authorData]); + }, [channelMessages, authorData]); const fetchAuthor = async pubkey => { try { @@ -74,7 +81,7 @@ const NostrFeed = ({ searchQuery }) => { ); } - const filteredNotes = communityNotes + const filteredMessages = channelMessages .filter(message => searchQuery ? message.content.toLowerCase().includes(searchQuery.toLowerCase()) : true ) @@ -82,9 +89,31 @@ const NostrFeed = ({ searchQuery }) => { return (
+ {channelMetadata && ( +
+

{channelMetadata.name}

+ {channelMetadata.about && ( +

{channelMetadata.about}

+ )} +
+ )} + + {!currentChannelId && isAuthorized && !isLoading && ( +
+ +
+ )} + +
+ +
+
- {filteredNotes.length > 0 ? ( - filteredNotes.map(message => ( + {filteredMessages.length > 0 ? ( + filteredMessages.map(message => ( { avatar: authorData[message.pubkey]?.avatar, content: message.content, timestamp: message.created_at * 1000, - channel: 'plebdevs', + channel: channelMetadata?.name || 'PlebDevs Community', }} searchQuery={searchQuery} windowWidth={windowWidth} @@ -106,7 +135,9 @@ const NostrFeed = ({ searchQuery }) => { /> )) ) : ( -
No messages available.
+
+ {isLoading ? 'Loading channel messages...' : 'No messages available in this channel.'} +
)}
diff --git a/src/hooks/nostr/useCommunityChannel.js b/src/hooks/nostr/useCommunityChannel.js new file mode 100644 index 0000000..4817832 --- /dev/null +++ b/src/hooks/nostr/useCommunityChannel.js @@ -0,0 +1,199 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useNDKContext } from '@/context/NDKContext'; +import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk'; +import { validateChannelMessage } from '@/utils/nip28'; + +// Default PlebDevs channel ID - this would be set after creating the channel +// For now, we'll look for any existing channels or create one +const DEFAULT_CHANNEL_ID = null; + +export function useCommunityChannel(channelId = DEFAULT_CHANNEL_ID) { + const [channelMessages, setChannelMessages] = useState([]); + const [channelMetadata, setChannelMetadata] = useState(null); + const [currentChannelId, setCurrentChannelId] = useState(channelId); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const { ndk } = useNDKContext(); + + const addMessage = useCallback(messageEvent => { + if (!validateChannelMessage(messageEvent)) return; + + setChannelMessages(prevMessages => { + if (prevMessages.some(msg => msg.id === messageEvent.id)) return prevMessages; + const newMessages = [messageEvent, ...prevMessages]; + newMessages.sort((a, b) => b.created_at - a.created_at); + return newMessages; + }); + }, []); + + const updateChannelMetadata = useCallback(metadataEvent => { + try { + const metadata = JSON.parse(metadataEvent.content); + setChannelMetadata({ + id: metadataEvent.id, + name: metadata.name, + about: metadata.about, + picture: metadata.picture, + created_at: metadataEvent.created_at, + author: metadataEvent.pubkey, + tags: metadataEvent.tags + }); + } catch (error) { + console.error('Error parsing channel metadata:', error); + } + }, []); + + // Find or create PlebDevs channel + useEffect(() => { + let findChannelSub; + + async function findOrCreateChannel() { + if (!ndk) return; + + try { + await ndk.connect(); + + // First, try to find existing PlebDevs channel + const channelFilter = { + kinds: [40, 41], + '#t': ['plebdevs'], + limit: 10 + }; + + findChannelSub = ndk.subscribe(channelFilter, { + closeOnEose: true, + cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST, + }); + + const foundChannels = []; + + findChannelSub.on('event', (event) => { + foundChannels.push(event); + }); + + findChannelSub.on('eose', () => { + // Find the most recent PlebDevs channel + if (foundChannels.length > 0) { + const latestChannel = foundChannels.sort((a, b) => b.created_at - a.created_at)[0]; + + // Check if it's a kind 40 (creation) or kind 41 (update) + let channelId; + if (latestChannel.kind === 40) { + channelId = latestChannel.id; + } else if (latestChannel.kind === 41) { + // For kind 41, the channel ID is in the 'e' tag + const eTag = latestChannel.tags.find(tag => tag[0] === 'e' && tag[3] === 'root'); + channelId = eTag ? eTag[1] : null; + } + + if (channelId) { + setCurrentChannelId(channelId); + updateChannelMetadata(latestChannel); + } + } else { + console.log('No existing PlebDevs NIP-28 channel found'); + // Set a fallback channel metadata for display purposes + setChannelMetadata({ + name: 'PlebDevs Community', + about: 'Bitcoin and Lightning developer discussions (transitioning to NIP-28)', + created_at: Date.now() / 1000, + tags: [['t', 'plebdevs']] + }); + // We'll still show the message input for when a channel gets created + } + }); + + await findChannelSub.start(); + + } catch (err) { + console.error('Error finding channel:', err); + setError(err.message); + } + } + + findOrCreateChannel(); + + return () => { + if (findChannelSub) { + findChannelSub.stop(); + } + }; + }, [ndk, updateChannelMetadata]); + + // Subscribe to channel messages + useEffect(() => { + let messageSubscription; + const messageIds = new Set(); + let timeoutId; + + async function subscribeToChannelMessages() { + if (!ndk || !currentChannelId) return; + + try { + await ndk.connect(); + + // Subscribe to messages in this channel + const filter = { + kinds: [42], + '#e': [currentChannelId], + }; + + messageSubscription = ndk.subscribe(filter, { + closeOnEose: false, + cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST, + }); + + messageSubscription.on('event', messageEvent => { + if (!messageIds.has(messageEvent.id)) { + messageIds.add(messageEvent.id); + addMessage(messageEvent); + setIsLoading(false); + clearTimeout(timeoutId); + } + }); + + messageSubscription.on('close', () => { + setIsLoading(false); + }); + + messageSubscription.on('eose', () => { + setIsLoading(false); + }); + + await messageSubscription.start(); + + // Set a 4-second timeout to stop loading state if no messages are received + timeoutId = setTimeout(() => { + setIsLoading(false); + }, 4000); + + } catch (err) { + console.error('Error subscribing to channel messages:', err); + setError(err.message); + setIsLoading(false); + } + } + + // Reset messages when channel changes + setChannelMessages([]); + setIsLoading(true); + setError(null); + + subscribeToChannelMessages(); + + return () => { + if (messageSubscription) { + messageSubscription.stop(); + } + clearTimeout(timeoutId); + }; + }, [ndk, currentChannelId, addMessage]); + + return { + channelMessages, + channelMetadata, + currentChannelId, + isLoading, + error + }; +} \ No newline at end of file diff --git a/src/hooks/useCommunitySearch.js b/src/hooks/useCommunitySearch.js index 6df3340..693c570 100644 --- a/src/hooks/useCommunitySearch.js +++ b/src/hooks/useCommunitySearch.js @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useDiscordQuery } from '@/hooks/communityQueries/useDiscordQuery'; -import { useCommunityNotes } from '@/hooks/nostr/useCommunityNotes'; +import { useCommunityChannel } from '@/hooks/nostr/useCommunityChannel'; import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; @@ -12,7 +12,7 @@ const fetchStackerNews = async () => { export const useCommunitySearch = () => { const [searchResults, setSearchResults] = useState([]); const { data: discordData } = useDiscordQuery({ page: 1 }); - const { communityNotes: nostrData } = useCommunityNotes(); + const { channelMessages: nostrData } = useCommunityChannel(); const { data: stackerNewsData } = useQuery({ queryKey: ['stackerNews'], queryFn: fetchStackerNews, diff --git a/src/utils/nip28.js b/src/utils/nip28.js new file mode 100644 index 0000000..b4c65b7 --- /dev/null +++ b/src/utils/nip28.js @@ -0,0 +1,231 @@ +import { NDKEvent } from '@nostr-dev-kit/ndk'; + +// PlebDevs community channel configuration +export const PLEBDEVS_CHANNEL_CONFIG = { + name: "PlebDevs Community", + about: "Bitcoin and Lightning developer discussions, learning, and collaboration", + picture: "https://plebdevs.com/images/plebdevs-icon.png", + tags: [ + ["t", "developer"], + ["t", "bitcoin"], + ["t", "lightning"], + ["t", "plebdevs"] + ] +}; + +/** + * Create a NIP-28 channel (kind 40) + * @param {NDK} ndk - NDK instance + * @param {Object} config - Channel configuration + * @returns {Promise} Channel creation event + */ +export async function createChannel(ndk, config = PLEBDEVS_CHANNEL_CONFIG) { + if (!ndk?.signer) { + throw new Error('NDK signer required to create channel'); + } + + const channelEvent = new NDKEvent(ndk); + channelEvent.kind = 40; + channelEvent.content = JSON.stringify({ + name: config.name, + about: config.about, + picture: config.picture + }); + channelEvent.tags = config.tags; + + await channelEvent.publish(); + return channelEvent; +} + +/** + * Update channel metadata (kind 41) + * @param {NDK} ndk - NDK instance + * @param {string} channelId - Channel ID to update + * @param {Object} metadata - Updated metadata + * @param {string} relay - Relay URL + * @returns {Promise} Channel update event + */ +export async function updateChannelMetadata(ndk, channelId, metadata, relay = 'wss://nos.lol') { + if (!ndk?.signer) { + throw new Error('NDK signer required to update channel'); + } + + const updateEvent = new NDKEvent(ndk); + updateEvent.kind = 41; + updateEvent.content = JSON.stringify(metadata); + updateEvent.tags = [ + ["e", channelId, relay, "root"] + ]; + + await updateEvent.publish(); + return updateEvent; +} + +/** + * Send a message to a channel (kind 42) + * @param {NDK} ndk - NDK instance + * @param {string} channelId - Channel ID + * @param {string} content - Message content + * @param {string} relay - Relay URL + * @param {string} replyToId - ID of message being replied to (optional) + * @param {string} replyToAuthor - Pubkey of message author being replied to (optional) + * @returns {Promise} Message event + */ +export async function sendChannelMessage(ndk, channelId, content, relay = 'wss://nos.lol', replyToId = null, replyToAuthor = null) { + if (!ndk?.signer) { + throw new Error('NDK signer required to send message'); + } + + const messageEvent = new NDKEvent(ndk); + messageEvent.kind = 42; + messageEvent.content = content; + + // Base channel reference + const tags = [ + ["e", channelId, relay, "root"] + ]; + + // Add reply tags if this is a reply + if (replyToId && replyToAuthor) { + tags.push(["e", replyToId, relay, "reply"]); + tags.push(["p", replyToAuthor, relay]); + } + + messageEvent.tags = tags; + + await messageEvent.publish(); + return messageEvent; +} + +/** + * Hide a message (kind 43) - client-side moderation + * @param {NDK} ndk - NDK instance + * @param {string} messageId - Message ID to hide + * @param {string} reason - Reason for hiding + * @param {string} relay - Relay URL + * @returns {Promise} Hide event + */ +export async function hideMessage(ndk, messageId, reason = 'spam', relay = 'wss://nos.lol') { + if (!ndk?.signer) { + throw new Error('NDK signer required to hide message'); + } + + const hideEvent = new NDKEvent(ndk); + hideEvent.kind = 43; + hideEvent.content = reason; + hideEvent.tags = [ + ["e", messageId, relay] + ]; + + await hideEvent.publish(); + return hideEvent; +} + +/** + * Mute a user in a channel (kind 44) - client-side moderation + * @param {NDK} ndk - NDK instance + * @param {string} userPubkey - User pubkey to mute + * @param {string} channelId - Channel ID + * @param {string} reason - Reason for muting + * @param {string} relay - Relay URL + * @returns {Promise} Mute event + */ +export async function muteUser(ndk, userPubkey, channelId, reason = 'spam', relay = 'wss://nos.lol') { + if (!ndk?.signer) { + throw new Error('NDK signer required to mute user'); + } + + const muteEvent = new NDKEvent(ndk); + muteEvent.kind = 44; + muteEvent.content = reason; + muteEvent.tags = [ + ["p", userPubkey, relay], + ["e", channelId, relay] + ]; + + await muteEvent.publish(); + return muteEvent; +} + +/** + * Get channel metadata from a kind 40 or 41 event + * @param {NDKEvent} event - Channel creation or update event + * @returns {Object} Parsed channel metadata + */ +export function parseChannelMetadata(event) { + try { + const metadata = JSON.parse(event.content); + return { + id: event.id, + name: metadata.name, + about: metadata.about, + picture: metadata.picture, + created_at: event.created_at, + author: event.pubkey, + tags: event.tags + }; + } catch (error) { + console.error('Error parsing channel metadata:', error); + return null; + } +} + +/** + * Build message threads from channel messages + * @param {Array} messages - Array of kind 42 messages + * @returns {Map} Map of parent message ID to replies + */ +export function buildMessageThreads(messages) { + const threads = new Map(); + + messages.forEach(msg => { + const replyTag = msg.tags.find(tag => + tag[0] === 'e' && tag[3] === 'reply' + ); + + if (replyTag) { + const parentId = replyTag[1]; + if (!threads.has(parentId)) { + threads.set(parentId, []); + } + threads.get(parentId).push(msg); + } + }); + + return threads; +} + +/** + * Validate a channel message event + * @param {NDKEvent} event - Message event to validate + * @returns {boolean} Whether the event is valid + */ +export function validateChannelMessage(event) { + if (event.kind !== 42) return false; + if (!event.tags.find(tag => tag[0] === 'e')) return false; + if (!event.content || event.content.length > 2000) return false; + return true; +} + +/** + * Find the channel ID from a message event + * @param {NDKEvent} messageEvent - Kind 42 message event + * @returns {string|null} Channel ID or null if not found + */ +export function getChannelIdFromMessage(messageEvent) { + const rootTag = messageEvent.tags.find(tag => + tag[0] === 'e' && tag[3] === 'root' + ); + return rootTag ? rootTag[1] : null; +} + +/** + * Check if user has admin permissions (placeholder for future implementation) + * @param {string} userPubkey - User's public key + * @returns {boolean} Whether user has admin permissions + */ +export function hasChannelAdminPermissions(userPubkey) { + // For now, allow all users to post + // In future, this could check against a list of authorized pubkeys + return true; +} \ No newline at end of file