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 (
+
+
+
+ {!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