feat: implement NIP-28 public chat to replace hashtag-based Nostr feed

- Add NIP-28 channel management utilities (kinds 40-44)
- Replace hashtag filtering with channel-based message subscription  
- Add channel message posting with authentication
- Include admin tools for channel creation
- Update all dependent components for backward compatibility
- Maintain existing UI while enabling proper chat infrastructure

Addresses #39

Co-authored-by: AustinKelsay <AustinKelsay@users.noreply.github.com>
This commit is contained in:
claude[bot] 2025-06-08 19:54:09 +00:00 committed by GitHub
parent 892ac6c7be
commit 94f9e6d505
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 661 additions and 16 deletions

View File

@ -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 (
<div className="w-full p-4 bg-blue-900 border border-blue-700 rounded-lg">
<div className="flex flex-col gap-3">
<h4 className="text-lg font-semibold text-white">Initialize PlebDevs Channel</h4>
<p className="text-sm text-blue-200">
No NIP-28 channel found for PlebDevs community. Create the official channel to enable
structured community discussions.
</p>
<div className="bg-blue-800 p-3 rounded">
<h5 className="font-medium text-white mb-2">Channel Configuration:</h5>
<ul className="text-sm text-blue-200 space-y-1">
<li><strong>Name:</strong> {PLEBDEVS_CHANNEL_CONFIG.name}</li>
<li><strong>About:</strong> {PLEBDEVS_CHANNEL_CONFIG.about}</li>
<li><strong>Tags:</strong> {PLEBDEVS_CHANNEL_CONFIG.tags.map(tag => tag[1]).join(', ')}</li>
</ul>
</div>
<Button
label={isCreating ? 'Creating Channel...' : 'Create PlebDevs Channel'}
icon={isCreating ? 'pi pi-spin pi-spinner' : 'pi pi-plus'}
onClick={handleCreateChannel}
disabled={isCreating || !ndk?.signer}
className="bg-blue-600 hover:bg-blue-700"
/>
</div>
</div>
);
};
export default ChannelCreator;

View File

@ -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 (
<div className="w-full p-4 bg-gray-800 rounded-lg text-center">
<p className="text-gray-300">Please sign in to participate in the community chat</p>
</div>
);
}
return (
<div className="w-full p-4 bg-gray-800 rounded-lg">
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<InputTextarea
value={message}
onChange={(e) => 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}
/>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-400">
{message.length}/2000 characters
</span>
<Button
type="submit"
label={isSubmitting ? 'Posting...' : 'Post Message'}
icon={isSubmitting ? 'pi pi-spin pi-spinner' : 'pi pi-send'}
size="small"
disabled={!message.trim() || disabled || isSubmitting || !channelId}
className="bg-blue-500 hover:bg-blue-600"
/>
</div>
</form>
{!ndk?.signer && (
<div className="mt-2 p-2 bg-yellow-900 border border-yellow-600 rounded text-yellow-200 text-sm">
Connect your Nostr account to post messages to the community
</div>
)}
</div>
);
};
export default ChannelMessageInput;

View File

@ -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}

View File

@ -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 (
<div className="h-full w-full">
{channelMetadata && (
<div className="mx-0 mt-2 mb-4 p-3 bg-gray-800 rounded-lg">
<h3 className="text-lg font-semibold text-white">{channelMetadata.name}</h3>
{channelMetadata.about && (
<p className="text-sm text-gray-300 mt-1">{channelMetadata.about}</p>
)}
</div>
)}
{!currentChannelId && isAuthorized && !isLoading && (
<div className="mx-0 mb-4">
<ChannelCreator />
</div>
)}
<div className="mx-0 mb-4">
<ChannelMessageInput
channelId={currentChannelId}
disabled={!currentChannelId || isLoading}
/>
</div>
<div className="mx-0 mt-4">
{filteredNotes.length > 0 ? (
filteredNotes.map(message => (
{filteredMessages.length > 0 ? (
filteredMessages.map(message => (
<CommunityMessage
key={message.id}
message={{
@ -94,7 +123,7 @@ const NostrFeed = ({ searchQuery }) => {
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 }) => {
/>
))
) : (
<div className="text-gray-400 text-center p-4">No messages available.</div>
<div className="text-gray-400 text-center p-4">
{isLoading ? 'Loading channel messages...' : 'No messages available in this channel.'}
</div>
)}
</div>
</div>

View File

@ -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
};
}

View File

@ -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,

231
src/utils/nip28.js Normal file
View File

@ -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<NDKEvent>} 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<NDKEvent>} 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<NDKEvent>} 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<NDKEvent>} 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<NDKEvent>} 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<NDKEvent>} 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;
}