mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-23 16:05:24 +00:00
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:
parent
892ac6c7be
commit
94f9e6d505
81
src/components/admin/ChannelCreator.js
Normal file
81
src/components/admin/ChannelCreator.js
Normal 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;
|
103
src/components/feeds/ChannelMessageInput.js
Normal file
103
src/components/feeds/ChannelMessageInput.js
Normal 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;
|
@ -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}
|
||||
|
@ -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>
|
||||
|
199
src/hooks/nostr/useCommunityChannel.js
Normal file
199
src/hooks/nostr/useCommunityChannel.js
Normal 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
|
||||
};
|
||||
}
|
@ -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
231
src/utils/nip28.js
Normal 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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user