@@ -66,10 +88,35 @@ const NostrFeed = ({ searchQuery }) => {
);
}
- if (error) {
+ // Show channel empty state if no channel is available
+ if (!hasChannel) {
+ const mode = channelError ? 'error' : 'no-channel';
+ return (
+
+
+
+ );
+ }
+
+ // Show error for message loading issues (not channel issues)
+ if (error && hasChannel) {
return (
- Failed to load messages. Please try again later.
+ Failed to load messages. Channel is available but message loading failed.
+
+
+
);
}
diff --git a/src/config/appConfig.js b/src/config/appConfig.js
index a8325ba..d431e29 100644
--- a/src/config/appConfig.js
+++ b/src/config/appConfig.js
@@ -9,10 +9,30 @@ const appConfig = {
'wss://purplerelay.com/',
'wss://relay.devs.tools/',
],
- authorPubkeys: [
- 'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741',
- 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345'
- ],
+ // NIP-28 Public Chat Channel Configuration
+ nip28: {
+ channelMetadata: {
+ name: 'DIRKTEST',
+ about: 'DIRKTEST',
+ picture: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQLUv_PyVbALqgHomnxSbLzfjM50mV_q6ZHKQ&s',
+ relays: [
+ 'wss://nos.lol/',
+ 'wss://relay.damus.io/',
+ 'wss://relay.snort.social/',
+ 'wss://relay.primal.net/'
+ ]
+ },
+ requireChannel: true,
+ adminPubkeys: [
+ 'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741',
+ 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345',
+ '6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4'
+ ]
+ },
+ // Legacy compatibility - same as admin pubkeys
+ get authorPubkeys() {
+ return this.nip28.adminPubkeys;
+ },
customLightningAddresses: [
{
// todo remove need for lowercase
diff --git a/src/hooks/nostr/useCommunityNotes.js b/src/hooks/nostr/useCommunityNotes.js
index 4f623ad..0b01b33 100644
--- a/src/hooks/nostr/useCommunityNotes.js
+++ b/src/hooks/nostr/useCommunityNotes.js
@@ -1,12 +1,35 @@
import { useState, useEffect, useCallback } from 'react';
import { useNDKContext } from '@/context/NDKContext';
import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk';
+import { useNip28Channel } from './useNip28Channel';
+import { useMessageModeration } from './useMessageModeration';
+import { useUserModeration } from './useUserModeration';
+import { parseChannelMessageEvent } from '@/utils/nostr';
+import appConfig from '@/config/appConfig';
export function useCommunityNotes() {
const [communityNotes, setCommunityNotes] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const { ndk } = useNDKContext();
+
+ // NIP-28 integration
+ const {
+ channelId,
+ hasChannel,
+ isLoading: channelLoading,
+ error: channelError
+ } = useNip28Channel();
+
+ const {
+ filterHiddenMessages,
+ isLoading: moderationLoading
+ } = useMessageModeration();
+
+ const {
+ filterMutedUsers,
+ isLoading: userModerationLoading
+ } = useUserModeration();
const addNote = useCallback(noteEvent => {
setCommunityNotes(prevNotes => {
@@ -17,6 +40,21 @@ export function useCommunityNotes() {
});
}, []);
+ /**
+ * Apply moderation filters to the community notes
+ */
+ const getFilteredNotes = useCallback(() => {
+ let filteredNotes = communityNotes;
+
+ // Filter out hidden messages
+ filteredNotes = filterHiddenMessages(filteredNotes);
+
+ // Filter out messages from muted users
+ filteredNotes = filterMutedUsers(filteredNotes);
+
+ return filteredNotes;
+ }, [communityNotes, filterHiddenMessages, filterMutedUsers]);
+
useEffect(() => {
let subscription;
const noteIds = new Set();
@@ -25,12 +63,35 @@ export function useCommunityNotes() {
async function subscribeToNotes() {
if (!ndk) return;
+ // Don't start subscription until channel initialization is complete
+ if (channelLoading) {
+ return;
+ }
+
+ // Start subscription even if moderation is still loading - it will filter later
+
try {
await ndk.connect();
+ console.log('Channel ID check:', {
+ channelId,
+ hasChannel,
+ channelIdType: typeof channelId,
+ channelIdLength: channelId?.length
+ });
+
+ if (!channelId) {
+ // No channel available - this is normal, not an error
+ console.log('No channel available for community notes');
+ setIsLoading(false);
+ return;
+ }
+
+ // Use NIP-28 channel messages (kind 42) only
+ console.log('Using NIP-28 channel mode for community notes, channel:', channelId);
const filter = {
- kinds: [1],
- '#t': ['plebdevs'],
+ kinds: [42],
+ '#e': [channelId],
};
subscription = ndk.subscribe(filter, {
@@ -39,11 +100,30 @@ export function useCommunityNotes() {
});
subscription.on('event', noteEvent => {
- if (!noteIds.has(noteEvent.id)) {
- noteIds.add(noteEvent.id);
- addNote(noteEvent);
- setIsLoading(false);
- clearTimeout(timeoutId);
+ try {
+ const parsedMessage = parseChannelMessageEvent(noteEvent);
+ console.log('Received channel message:', parsedMessage);
+
+ if (!noteIds.has(parsedMessage.id)) {
+ noteIds.add(parsedMessage.id);
+ // Add both parsed data and original event
+ const enrichedEvent = {
+ ...noteEvent,
+ ...parsedMessage
+ };
+ addNote(enrichedEvent);
+ setIsLoading(false);
+ clearTimeout(timeoutId);
+ }
+ } catch (err) {
+ console.warn('Error parsing channel message:', err);
+ // Fallback to original event if parsing fails
+ if (!noteIds.has(noteEvent.id)) {
+ noteIds.add(noteEvent.id);
+ addNote(noteEvent);
+ setIsLoading(false);
+ clearTimeout(timeoutId);
+ }
}
});
@@ -68,9 +148,11 @@ export function useCommunityNotes() {
}
}
+ // Reset state when dependencies change
setCommunityNotes([]);
setIsLoading(true);
- setError(null);
+ setError(channelError); // Propagate channel errors
+
subscribeToNotes();
return () => {
@@ -79,7 +161,25 @@ export function useCommunityNotes() {
}
clearTimeout(timeoutId);
};
- }, [ndk, addNote]);
+ }, [
+ ndk,
+ addNote,
+ channelId,
+ hasChannel,
+ channelLoading,
+ channelError
+ ]); // Removed moderation loading deps to prevent unnecessary re-subs
- return { communityNotes, isLoading, error };
+ return {
+ communityNotes: getFilteredNotes(),
+ rawCommunityNotes: communityNotes,
+ isLoading: isLoading || channelLoading,
+ error,
+ // NIP-28 specific info
+ channelId,
+ hasChannel,
+ channelMode: 'nip28',
+ // Moderation state
+ isModerationLoading: moderationLoading || userModerationLoading
+ };
}
diff --git a/src/hooks/nostr/useMessageModeration.js b/src/hooks/nostr/useMessageModeration.js
new file mode 100644
index 0000000..de3d4a8
--- /dev/null
+++ b/src/hooks/nostr/useMessageModeration.js
@@ -0,0 +1,216 @@
+/**
+ * useMessageModeration - Hook for managing NIP-28 message moderation (kind 43)
+ *
+ * Handles hiding individual messages using kind 43 events according to NIP-28 spec.
+ * Provides functionality to hide messages and filter out hidden messages from display.
+ *
+ * @returns {Object} Message moderation state and functions
+ */
+import { useState, useEffect, useCallback } from 'react';
+import { useNDKContext } from '@/context/NDKContext';
+import { NDKEvent, NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk';
+
+export function useMessageModeration() {
+ const [hiddenMessages, setHiddenMessages] = useState(new Set());
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const { ndk } = useNDKContext();
+
+ /**
+ * Get current user's public key
+ */
+ const getCurrentUserPubkey = useCallback(async () => {
+ if (!ndk?.signer) return null;
+
+ try {
+ const user = await ndk.signer.user();
+ return user.pubkey;
+ } catch (err) {
+ console.error('Error getting current user pubkey:', err);
+ return null;
+ }
+ }, [ndk]);
+
+ /**
+ * Subscribe to the current user's hide message events (kind 43)
+ */
+ const subscribeToHiddenMessages = useCallback(async () => {
+ if (!ndk) return;
+
+ const userPubkey = await getCurrentUserPubkey();
+ if (!userPubkey) {
+ setIsLoading(false);
+ return;
+ }
+
+ try {
+ await ndk.connect();
+
+ const filter = {
+ kinds: [43],
+ authors: [userPubkey],
+ };
+
+ const subscription = ndk.subscribe(filter, {
+ closeOnEose: false,
+ cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
+ });
+
+ subscription.on('event', (event) => {
+ try {
+ // Extract the message ID being hidden from the 'e' tag
+ const messageIdTag = event.tags.find(tag => tag[0] === 'e');
+ if (messageIdTag && messageIdTag[1]) {
+ const messageId = messageIdTag[1];
+ setHiddenMessages(prev => new Set([...prev, messageId]));
+ console.log('Message hidden:', messageId);
+ }
+ } catch (err) {
+ console.error('Error processing hide message event:', err);
+ }
+ });
+
+ subscription.on('eose', () => {
+ setIsLoading(false);
+ });
+
+ subscription.on('close', () => {
+ setIsLoading(false);
+ });
+
+ await subscription.start();
+
+ return subscription;
+ } catch (err) {
+ console.error('Error subscribing to hidden messages:', err);
+ setError(err.message);
+ setIsLoading(false);
+ }
+ }, [ndk, getCurrentUserPubkey]);
+
+ /**
+ * Hide a specific message (create kind 43 event)
+ *
+ * @param {string} messageId - The ID of the message to hide
+ * @param {string} reason - Optional reason for hiding the message
+ */
+ const hideMessage = useCallback(async (messageId, reason = '') => {
+ if (!ndk?.signer || !messageId) {
+ throw new Error('Cannot hide message: No signer available or missing message ID');
+ }
+
+ try {
+ const event = new NDKEvent(ndk);
+ event.kind = 43;
+ event.content = reason ? JSON.stringify({ reason }) : '';
+ event.tags = [
+ ['e', messageId]
+ ];
+
+ await event.sign();
+ await event.publish();
+
+ // Immediately update local state
+ setHiddenMessages(prev => new Set([...prev, messageId]));
+
+ console.log('Message hidden successfully:', messageId, reason ? `Reason: ${reason}` : '');
+ return event;
+ } catch (err) {
+ console.error('Error hiding message:', err);
+ throw new Error(`Failed to hide message: ${err.message}`);
+ }
+ }, [ndk]);
+
+ /**
+ * Unhide a message (currently not part of NIP-28 spec, but useful for client-side management)
+ * Note: This only removes from local state, doesn't create deletion events
+ *
+ * @param {string} messageId - The ID of the message to unhide
+ */
+ const unhideMessage = useCallback((messageId) => {
+ setHiddenMessages(prev => {
+ const newSet = new Set(prev);
+ newSet.delete(messageId);
+ return newSet;
+ });
+ console.log('Message unhidden locally:', messageId);
+ }, []);
+
+ /**
+ * Check if a specific message is hidden
+ *
+ * @param {string} messageId - The ID of the message to check
+ * @returns {boolean} True if the message is hidden
+ */
+ const isMessageHidden = useCallback((messageId) => {
+ return hiddenMessages.has(messageId);
+ }, [hiddenMessages]);
+
+ /**
+ * Filter an array of messages to exclude hidden ones
+ *
+ * @param {Array} messages - Array of message objects with 'id' property
+ * @returns {Array} Filtered array excluding hidden messages
+ */
+ const filterHiddenMessages = useCallback((messages) => {
+ if (!Array.isArray(messages)) return messages;
+ return messages.filter(message => !hiddenMessages.has(message.id));
+ }, [hiddenMessages]);
+
+ /**
+ * Get all hidden message IDs
+ *
+ * @returns {Array} Array of hidden message IDs
+ */
+ const getHiddenMessageIds = useCallback(() => {
+ return Array.from(hiddenMessages);
+ }, [hiddenMessages]);
+
+ /**
+ * Clear all hidden messages from local state
+ */
+ const clearHiddenMessages = useCallback(() => {
+ setHiddenMessages(new Set());
+ console.log('All hidden messages cleared from local state');
+ }, []);
+
+ // Initialize subscription on mount and NDK changes
+ useEffect(() => {
+ setHiddenMessages(new Set());
+ setIsLoading(true);
+ setError(null);
+
+ let subscription;
+ let isMounted = true;
+
+ subscribeToHiddenMessages().then(sub => {
+ if (isMounted) {
+ subscription = sub;
+ } else if (sub) {
+ // Component unmounted before subscription completed
+ sub.stop();
+ }
+ });
+
+ return () => {
+ isMounted = false;
+ if (subscription) {
+ subscription.stop();
+ }
+ };
+ }, [subscribeToHiddenMessages]);
+
+ return {
+ hiddenMessages: Array.from(hiddenMessages),
+ hiddenMessageIds: hiddenMessages,
+ isLoading,
+ error,
+ // Actions
+ hideMessage,
+ unhideMessage,
+ isMessageHidden,
+ filterHiddenMessages,
+ getHiddenMessageIds,
+ clearHiddenMessages
+ };
+}
\ No newline at end of file
diff --git a/src/hooks/nostr/useNip28Channel.js b/src/hooks/nostr/useNip28Channel.js
new file mode 100644
index 0000000..bf1f59b
--- /dev/null
+++ b/src/hooks/nostr/useNip28Channel.js
@@ -0,0 +1,391 @@
+/**
+ * useNip28Channel - Hook for managing NIP-28 public chat channels
+ *
+ * Handles channel discovery, creation, and metadata management according to NIP-28 spec.
+ * Provides fallback mechanisms for graceful degradation when channels are unavailable.
+ *
+ * @returns {Object} Channel state and management functions
+ */
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { useNDKContext } from '@/context/NDKContext';
+import { NDKEvent, NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk';
+import { useIsAdmin } from '@/hooks/useIsAdmin';
+import { parseChannelEvent, parseChannelMetadataEvent, getEventId } from '@/utils/nostr';
+import appConfig from '@/config/appConfig';
+
+export function useNip28Channel() {
+ const [channel, setChannel] = useState(null);
+ const [channelMetadata, setChannelMetadata] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const { ndk, addSigner } = useNDKContext();
+ const { isAdmin: canCreateChannel } = useIsAdmin();
+ const initializationRef = useRef(false);
+ const mountedRef = useRef(true);
+
+
+
+ /**
+ * Search for existing PlebDevs channel (kind 40)
+ */
+ const discoverChannel = useCallback(async () => {
+ if (!ndk) return null;
+
+ try {
+ await ndk.connect();
+
+ // Search for existing channels with our metadata
+ const filter = {
+ kinds: [40],
+ authors: appConfig.nip28.adminPubkeys,
+ limit: 10
+ };
+
+ console.log('Searching for channels with filter:', filter);
+ const events = await ndk.fetchEvents(filter);
+ console.log('Found channel events:', events.size);
+
+ // Find channel that matches our community
+ for (const event of events) {
+ try {
+ // Use the parsing function to get proper event data
+ console.log('yayaya', event);
+ const parsedChannel = parseChannelEvent(event);
+ console.log('Parsed channel:', parsedChannel);
+
+ if (parsedChannel.metadata?.name === appConfig.nip28.channelMetadata.name) {
+ console.log('Found matching PlebDevs channel:', parsedChannel.id);
+
+ // Return the original event but with proper ID handling
+ const channelEvent = {
+ ...event,
+ id: parsedChannel.id
+ };
+
+ return channelEvent;
+ }
+ } catch (err) {
+ console.warn('Error parsing channel event:', err);
+ }
+ }
+
+ console.log('No matching channel found');
+ return null;
+ } catch (err) {
+ console.error('Error discovering channel:', err);
+ throw err;
+ }
+ }, [ndk]);
+
+ /**
+ * Fetch the latest channel metadata (kind 41)
+ */
+ const fetchChannelMetadata = useCallback(async (channelId) => {
+ if (!ndk || !channelId) return null;
+
+ try {
+ const filter = {
+ kinds: [41],
+ '#e': [channelId],
+ authors: appConfig.nip28.adminPubkeys,
+ limit: 1
+ };
+
+ const events = await ndk.fetchEvents(filter);
+ const latestMetadata = Array.from(events).sort((a, b) => b.created_at - a.created_at)[0];
+
+ if (latestMetadata) {
+ try {
+ const parsedMetadata = parseChannelMetadataEvent(latestMetadata);
+ console.log('Found channel metadata:', parsedMetadata.metadata);
+ return parsedMetadata.metadata;
+ } catch (err) {
+ console.warn('Error parsing channel metadata:', err);
+ }
+ }
+
+ return null;
+ } catch (err) {
+ console.error('Error fetching channel metadata:', err);
+ return null;
+ }
+ }, [ndk]);
+
+ /**
+ * Create a new PlebDevs channel (kind 40)
+ */
+ const createChannel = useCallback(async () => {
+ console.log('createChannel called - Debug info:', {
+ hasNdk: !!ndk,
+ hasSigner: !!ndk?.signer,
+ canCreateChannel,
+ ndkStatus: ndk ? 'available' : 'missing'
+ });
+
+ if (!canCreateChannel) {
+ throw new Error('Not authorized to create channels - admin permissions required');
+ }
+
+ if (!ndk) {
+ throw new Error('NDK not available - please refresh the page');
+ }
+
+ // Ensure NDK is connected
+ try {
+ await ndk.connect();
+ } catch (connectErr) {
+ console.warn('NDK connect failed:', connectErr);
+ }
+
+ // Ensure signer is available
+ if (!ndk.signer) {
+ try {
+ await addSigner();
+ } catch (signerErr) {
+ throw new Error('Nostr extension not connected - please connect your wallet extension');
+ }
+ }
+
+ if (!ndk.signer) {
+ throw new Error('Nostr signer unavailable - please connect your Nostr extension (Alby, nos2x, etc.)');
+ }
+
+ try {
+ const event = new NDKEvent(ndk);
+ event.kind = 40;
+ event.content = JSON.stringify(appConfig.nip28.channelMetadata);
+ event.tags = [
+ ['t', 'plebdevs'],
+ ['t', 'bitcoin'],
+ ['t', 'lightning'],
+ ['t', 'development']
+ ];
+
+ await event.sign();
+
+ // The ID should be available after signing
+ console.log('Signed channel event - ID:', event.id);
+
+ await event.publish();
+
+ console.log('Created new PlebDevs channel:', event.id);
+ return event;
+ } catch (err) {
+ console.error('Error creating channel:', err);
+ throw err;
+ }
+ }, [ndk, canCreateChannel, addSigner]);
+
+ /**
+ * Update channel metadata (kind 41)
+ */
+ const updateChannelMetadata = useCallback(async (channelId, newMetadata) => {
+ if (!canCreateChannel) {
+ throw new Error('Not authorized to update channel metadata - admin permissions required');
+ }
+
+ if (!ndk) {
+ throw new Error('NDK not available - please refresh the page');
+ }
+
+ if (!channelId) {
+ throw new Error('Channel ID is required to update metadata');
+ }
+
+ // Ensure signer is available
+ if (!ndk.signer) {
+ try {
+ await addSigner();
+ } catch (signerErr) {
+ throw new Error('Nostr extension not connected - please connect your wallet extension');
+ }
+ }
+
+ try {
+ const event = new NDKEvent(ndk);
+ event.kind = 41;
+ event.content = JSON.stringify(newMetadata);
+ event.tags = [
+ ['e', channelId, '', 'root'],
+ ['t', 'plebdevs']
+ ];
+
+ await event.sign();
+ await event.publish();
+
+ console.log('Updated channel metadata for:', channelId);
+ setChannelMetadata(newMetadata);
+ return event;
+ } catch (err) {
+ console.error('Error updating channel metadata:', err);
+ throw err;
+ }
+ }, [ndk, canCreateChannel, addSigner]);
+
+ /**
+ * Initialize channel - discover existing or create new
+ */
+ const initializeChannel = useCallback(async () => {
+ if (!ndk) return;
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ // Try to discover existing channel
+ let channelEvent = await discoverChannel();
+
+ // If no channel exists and user can create, create one
+ if (!channelEvent && canCreateChannel && appConfig.nip28.requireChannel) {
+ console.log('No channel found, creating new one...');
+ try {
+ channelEvent = await createChannel();
+ } catch (createError) {
+ console.warn('Failed to create channel:', createError);
+ // Continue with fallback logic
+ }
+ }
+
+ if (channelEvent) {
+ setChannel(channelEvent);
+
+ // Fetch latest metadata
+ const metadata = await fetchChannelMetadata(channelEvent.id);
+ if (metadata) {
+ setChannelMetadata(metadata);
+ } else {
+ // Use channel creation content as fallback
+ try {
+ const content = JSON.parse(channelEvent.content);
+ setChannelMetadata(content);
+ } catch (err) {
+ setChannelMetadata(appConfig.nip28.channelMetadata);
+ }
+ }
+
+ console.log('Channel initialized successfully:', channelEvent.id);
+ } else {
+ // No channel available - this is expected for new communities
+ console.log('No channel found - awaiting admin creation');
+ // Don't set error - this is a normal state, not an error
+ setChannelMetadata(appConfig.nip28.channelMetadata); // For display purposes
+ }
+
+ } catch (err) {
+ console.error('Error initializing channel:', err);
+ setError(err.message);
+ initializationRef.current = false; // Allow retry on error
+ setChannelMetadata(appConfig.nip28.channelMetadata); // For display purposes
+ } finally {
+ setIsLoading(false);
+ }
+ }, [ndk, canCreateChannel, discoverChannel, createChannel, fetchChannelMetadata]);
+
+ /**
+ * Get channel ID for message posting
+ */
+ const getChannelId = useCallback(() => {
+ return channel?.id || null;
+ }, [channel]);
+
+ /**
+ * Check if channel is available
+ */
+ const hasChannel = useCallback(() => {
+ return !!channel;
+ }, [channel]);
+
+ // Initialize channel on mount and NDK changes only
+ useEffect(() => {
+ if (ndk && !initializationRef.current && mountedRef.current) {
+ console.log('Initializing channel for the first time...');
+ initializationRef.current = true;
+ // Call initializeChannel directly without dependency on the callback
+ (async () => {
+ if (!ndk) return;
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ // Try to discover existing channel
+ let channelEvent = await discoverChannel();
+
+ // If no channel exists and user can create, create one
+ if (!channelEvent && canCreateChannel && appConfig.nip28.requireChannel) {
+ console.log('No channel found, creating new one...');
+ try {
+ channelEvent = await createChannel();
+ } catch (createError) {
+ console.warn('Failed to create channel:', createError);
+ }
+ }
+
+ if (channelEvent) {
+ if (!mountedRef.current) return; // Component unmounted
+ setChannel(channelEvent);
+
+ // Fetch latest metadata
+ const metadata = await fetchChannelMetadata(channelEvent.id);
+ if (!mountedRef.current) return; // Component unmounted
+ if (metadata) {
+ setChannelMetadata(metadata);
+ } else {
+ // Use channel creation content as fallback
+ try {
+ const content = JSON.parse(channelEvent.content);
+ setChannelMetadata(content);
+ } catch (err) {
+ setChannelMetadata(appConfig.nip28.channelMetadata);
+ }
+ }
+
+ console.log('Channel initialized successfully:', channelEvent.id);
+ } else {
+ // No channel available - this is expected for new communities
+ console.log('No channel found - awaiting admin creation');
+ if (!mountedRef.current) return; // Component unmounted
+ setChannelMetadata(appConfig.nip28.channelMetadata);
+ }
+
+ } catch (err) {
+ console.error('Error initializing channel:', err);
+ if (!mountedRef.current) return; // Component unmounted
+ setError(err.message);
+ initializationRef.current = false; // Allow retry on error
+ setChannelMetadata(appConfig.nip28.channelMetadata);
+ } finally {
+ if (mountedRef.current) {
+ setIsLoading(false);
+ }
+ }
+ })();
+ }
+
+ return () => {
+ mountedRef.current = false;
+ };
+ }, [ndk]); // Only depend on ndk to prevent multiple initializations
+
+ /**
+ * Refresh channel (force re-initialization)
+ */
+ const refreshChannel = useCallback(() => {
+ initializationRef.current = false;
+ return initializeChannel();
+ }, [initializeChannel]);
+
+ return {
+ channel,
+ channelMetadata,
+ channelId: getChannelId(),
+ isLoading,
+ error,
+ canCreateChannel,
+ hasChannel: hasChannel(),
+ // Actions
+ createChannel,
+ updateChannelMetadata,
+ refreshChannel
+ };
+}
\ No newline at end of file
diff --git a/src/hooks/nostr/useUserModeration.js b/src/hooks/nostr/useUserModeration.js
new file mode 100644
index 0000000..deac246
--- /dev/null
+++ b/src/hooks/nostr/useUserModeration.js
@@ -0,0 +1,235 @@
+/**
+ * useUserModeration - Hook for managing NIP-28 user moderation (kind 44)
+ *
+ * Handles muting users using kind 44 events according to NIP-28 spec.
+ * Provides functionality to mute users and filter out messages from muted users.
+ *
+ * @returns {Object} User moderation state and functions
+ */
+import { useState, useEffect, useCallback } from 'react';
+import { useNDKContext } from '@/context/NDKContext';
+import { NDKEvent, NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk';
+
+export function useUserModeration() {
+ const [mutedUsers, setMutedUsers] = useState(new Set());
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const { ndk } = useNDKContext();
+
+ /**
+ * Get current user's public key
+ */
+ const getCurrentUserPubkey = useCallback(async () => {
+ if (!ndk?.signer) return null;
+
+ try {
+ const user = await ndk.signer.user();
+ return user.pubkey;
+ } catch (err) {
+ console.error('Error getting current user pubkey:', err);
+ return null;
+ }
+ }, [ndk]);
+
+ /**
+ * Subscribe to the current user's mute user events (kind 44)
+ */
+ const subscribeToMutedUsers = useCallback(async () => {
+ if (!ndk) return;
+
+ const userPubkey = await getCurrentUserPubkey();
+ if (!userPubkey) {
+ setIsLoading(false);
+ return;
+ }
+
+ try {
+ await ndk.connect();
+
+ const filter = {
+ kinds: [44],
+ authors: [userPubkey],
+ };
+
+ const subscription = ndk.subscribe(filter, {
+ closeOnEose: false,
+ cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
+ });
+
+ subscription.on('event', (event) => {
+ try {
+ // Extract the user pubkey being muted from the 'p' tag
+ const userPubkeyTag = event.tags.find(tag => tag[0] === 'p');
+ if (userPubkeyTag && userPubkeyTag[1]) {
+ const mutedPubkey = userPubkeyTag[1];
+ setMutedUsers(prev => new Set([...prev, mutedPubkey]));
+ console.log('User muted:', mutedPubkey);
+ }
+ } catch (err) {
+ console.error('Error processing mute user event:', err);
+ }
+ });
+
+ subscription.on('eose', () => {
+ setIsLoading(false);
+ });
+
+ subscription.on('close', () => {
+ setIsLoading(false);
+ });
+
+ await subscription.start();
+
+ return subscription;
+ } catch (err) {
+ console.error('Error subscribing to muted users:', err);
+ setError(err.message);
+ setIsLoading(false);
+ }
+ }, [ndk, getCurrentUserPubkey]);
+
+ /**
+ * Mute a specific user (create kind 44 event)
+ *
+ * @param {string} userPubkey - The pubkey of the user to mute
+ * @param {string} reason - Optional reason for muting the user
+ */
+ const muteUser = useCallback(async (userPubkey, reason = '') => {
+ if (!ndk?.signer || !userPubkey) {
+ throw new Error('Cannot mute user: No signer available or missing user pubkey');
+ }
+
+ // Don't allow muting yourself
+ const currentUserPubkey = await getCurrentUserPubkey();
+ if (currentUserPubkey === userPubkey) {
+ throw new Error('Cannot mute yourself');
+ }
+
+ try {
+ const event = new NDKEvent(ndk);
+ event.kind = 44;
+ event.content = reason ? JSON.stringify({ reason }) : '';
+ event.tags = [
+ ['p', userPubkey]
+ ];
+
+ await event.sign();
+ await event.publish();
+
+ // Immediately update local state
+ setMutedUsers(prev => new Set([...prev, userPubkey]));
+
+ console.log('User muted successfully:', userPubkey, reason ? `Reason: ${reason}` : '');
+ return event;
+ } catch (err) {
+ console.error('Error muting user:', err);
+ throw new Error(`Failed to mute user: ${err.message}`);
+ }
+ }, [ndk, getCurrentUserPubkey]);
+
+ /**
+ * Unmute a user (currently not part of NIP-28 spec, but useful for client-side management)
+ * Note: This only removes from local state, doesn't create deletion events
+ *
+ * @param {string} userPubkey - The pubkey of the user to unmute
+ */
+ const unmuteUser = useCallback((userPubkey) => {
+ setMutedUsers(prev => {
+ const newSet = new Set(prev);
+ newSet.delete(userPubkey);
+ return newSet;
+ });
+ console.log('User unmuted locally:', userPubkey);
+ }, []);
+
+ /**
+ * Check if a specific user is muted
+ *
+ * @param {string} userPubkey - The pubkey of the user to check
+ * @returns {boolean} True if the user is muted
+ */
+ const isUserMuted = useCallback((userPubkey) => {
+ return mutedUsers.has(userPubkey);
+ }, [mutedUsers]);
+
+ /**
+ * Filter an array of messages to exclude those from muted users
+ *
+ * @param {Array} messages - Array of message objects with 'pubkey' property
+ * @returns {Array} Filtered array excluding messages from muted users
+ */
+ const filterMutedUsers = useCallback((messages) => {
+ if (!Array.isArray(messages)) return messages;
+ return messages.filter(message => !mutedUsers.has(message.pubkey));
+ }, [mutedUsers]);
+
+ /**
+ * Get all muted user pubkeys
+ *
+ * @returns {Array} Array of muted user pubkeys
+ */
+ const getMutedUserPubkeys = useCallback(() => {
+ return Array.from(mutedUsers);
+ }, [mutedUsers]);
+
+ /**
+ * Clear all muted users from local state
+ */
+ const clearMutedUsers = useCallback(() => {
+ setMutedUsers(new Set());
+ console.log('All muted users cleared from local state');
+ }, []);
+
+ /**
+ * Get mute statistics
+ *
+ * @returns {Object} Statistics about muted users
+ */
+ const getMuteStats = useCallback(() => {
+ return {
+ mutedUserCount: mutedUsers.size,
+ mutedUsers: Array.from(mutedUsers)
+ };
+ }, [mutedUsers]);
+
+ // Initialize subscription on mount and NDK changes
+ useEffect(() => {
+ setMutedUsers(new Set());
+ setIsLoading(true);
+ setError(null);
+
+ let subscription;
+ let isMounted = true;
+
+ subscribeToMutedUsers().then(sub => {
+ if (isMounted) {
+ subscription = sub;
+ } else if (sub) {
+ // Component unmounted before subscription completed
+ sub.stop();
+ }
+ });
+
+ return () => {
+ isMounted = false;
+ if (subscription) {
+ subscription.stop();
+ }
+ };
+ }, [subscribeToMutedUsers]);
+
+ return {
+ mutedUsers: Array.from(mutedUsers),
+ mutedUserPubkeys: mutedUsers,
+ isLoading,
+ error,
+ // Actions
+ muteUser,
+ unmuteUser,
+ isUserMuted,
+ filterMutedUsers,
+ getMutedUserPubkeys,
+ clearMutedUsers,
+ getMuteStats
+ };
+}
\ No newline at end of file
diff --git a/src/hooks/useIsAdmin.js b/src/hooks/useIsAdmin.js
index 7d8c6b1..5ab1b94 100644
--- a/src/hooks/useIsAdmin.js
+++ b/src/hooks/useIsAdmin.js
@@ -1,24 +1,48 @@
import { useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
+import { useNDKContext } from '@/context/NDKContext';
+import appConfig from '@/config/appConfig';
export function useIsAdmin() {
const { data: session, status } = useSession();
+ const { ndk } = useNDKContext();
const [isAdmin, setIsAdmin] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
- if (status === 'loading') {
- setIsLoading(true);
- return;
- }
- if (status === 'authenticated') {
- setIsAdmin(session?.user?.role?.admin || false);
+ const checkAdminStatus = async () => {
+ if (status === 'loading') {
+ setIsLoading(true);
+ return;
+ }
+
+ let isSessionAdmin = false;
+ let isNostrAdmin = false;
+
+ // Check session-based admin
+ if (status === 'authenticated') {
+ isSessionAdmin = session?.user?.role?.admin || false;
+ }
+
+ // Check Nostr pubkey admin
+ if (ndk?.signer) {
+ try {
+ const user = await ndk.signer.user();
+ if (user?.pubkey) {
+ isNostrAdmin = appConfig.nip28.adminPubkeys.includes(user.pubkey);
+ }
+ } catch (err) {
+ console.warn('Could not get Nostr user for admin check:', err);
+ }
+ }
+
+ // User is admin if they're admin by either method
+ setIsAdmin(isSessionAdmin || isNostrAdmin);
setIsLoading(false);
- } else if (status === 'unauthenticated') {
- setIsAdmin(false);
- setIsLoading(false);
- }
- }, [session, status]);
+ };
+
+ checkAdminStatus();
+ }, [session, status, ndk]);
return { isAdmin, isLoading };
}
diff --git a/src/utils/nostr.js b/src/utils/nostr.js
index 6ae2205..7016b7a 100644
--- a/src/utils/nostr.js
+++ b/src/utils/nostr.js
@@ -1,4 +1,5 @@
import { nip19 } from 'nostr-tools';
+import { getEventHash } from 'nostr-tools/pure';
export const findKind0Fields = async kind0 => {
let fields = {};
@@ -277,3 +278,238 @@ export function validateEvent(event) {
return true;
}
+
+/**
+ * Parse NIP-28 Channel Creation Event (kind 40)
+ *
+ * @param {Object} event - The NDK event object
+ * @returns {Object} - Parsed channel data
+ */
+export const parseChannelEvent = event => {
+
+ console.log('Parsing channel event:', event);
+
+ // Try to get ID from the actual event data
+ let eventId = '';
+
+ // First try the direct properties
+ if (event.id && event.id !== '') {
+ eventId = event.id;
+ } else if (event.eventId && event.eventId !== '') {
+ eventId = event.eventId;
+ } else if (typeof event.tagId === 'function') {
+ const tagId = event.tagId();
+ if (tagId && tagId !== '') eventId = tagId;
+ }
+
+ // Try to decode the nevent if available (skip if malformed)
+ if (!eventId && event.encode) {
+ try {
+ const neventString = typeof event.encode === 'function' ? event.encode() : event.encode;
+ if (neventString && typeof neventString === 'string') {
+ const decoded = nip19.decode(neventString);
+ if (decoded.type === 'nevent' && decoded.data?.id) {
+ eventId = decoded.data.id;
+ console.log('Decoded event ID from nevent:', eventId);
+ }
+ }
+ } catch (err) {
+ // Nevent is malformed, will fallback to generating ID from event data
+ console.log('Nevent malformed, will generate ID from event data');
+ }
+ }
+
+ // Try the rawEvent - call it if it's a function
+ if (!eventId && event.rawEvent) {
+ try {
+ const rawEventData = typeof event.rawEvent === 'function' ? event.rawEvent() : event.rawEvent;
+ if (rawEventData?.id) {
+ eventId = rawEventData.id;
+ console.log('Found ID in raw event data:', eventId);
+ }
+ } catch (err) {
+ console.warn('Error accessing raw event:', err);
+ }
+ }
+
+ // Generate event ID from event data if we have all required fields
+ if (!eventId && event.pubkey && event.kind && event.created_at && event.content && event.tags) {
+ try {
+ console.log('Generating event ID from event data:', {
+ pubkey: event.pubkey?.slice(0, 8) + '...',
+ kind: event.kind,
+ created_at: event.created_at,
+ contentLength: event.content?.length,
+ tagsLength: event.tags?.length
+ });
+
+ const eventForHashing = {
+ pubkey: event.pubkey,
+ kind: event.kind,
+ created_at: event.created_at,
+ tags: event.tags,
+ content: event.content
+ };
+
+ eventId = getEventHash(eventForHashing);
+ console.log('â
Generated event ID from data:', eventId);
+ } catch (err) {
+ console.error('â Error generating event ID:', err);
+ }
+ } else if (!eventId) {
+ console.log('Cannot generate event ID - missing required fields:', {
+ hasPubkey: !!event.pubkey,
+ hasKind: !!event.kind,
+ hasCreatedAt: !!event.created_at,
+ hasContent: !!event.content,
+ hasTags: !!event.tags
+ });
+ }
+
+ // Last resort - generate temporary ID
+ if (!eventId) {
+ console.warn('No event ID found - generating temporary ID');
+ if (event.pubkey && event.created_at) {
+ eventId = `temp_${event.pubkey.slice(0, 8)}_${event.created_at}`;
+ }
+ }
+
+ const eventData = {
+ id: eventId,
+ pubkey: event.pubkey || '',
+ content: event.content || '',
+ kind: event.kind || 40,
+ created_at: event.created_at || 0,
+ type: 'channel',
+ metadata: null,
+ tags: event.tags || []
+ };
+
+ // Parse channel metadata from content
+ try {
+ if (eventData.content) {
+ eventData.metadata = JSON.parse(eventData.content);
+ }
+ } catch (err) {
+ console.warn('Error parsing channel metadata:', err);
+ eventData.metadata = {};
+ }
+
+ // Extract additional data from tags
+ event.tags.forEach(tag => {
+ switch (tag[0]) {
+ case 't':
+ if (!eventData.topics) eventData.topics = [];
+ eventData.topics.push(tag[1]);
+ break;
+ case 'r':
+ if (!eventData.relays) eventData.relays = [];
+ eventData.relays.push(tag[1]);
+ break;
+ default:
+ break;
+ }
+ });
+
+ return eventData;
+};
+
+/**
+ * Parse NIP-28 Channel Metadata Event (kind 41)
+ *
+ * @param {Object} event - The NDK event object
+ * @returns {Object} - Parsed channel metadata
+ */
+export const parseChannelMetadataEvent = event => {
+ const eventData = {
+ id: event.id || event.eventId || event.tagId() || '',
+ pubkey: event.pubkey || '',
+ content: event.content || '',
+ kind: event.kind || 41,
+ created_at: event.created_at || 0,
+ type: 'channel-metadata',
+ channelId: null,
+ metadata: null,
+ tags: event.tags || []
+ };
+
+ // Find channel reference
+ event.tags.forEach(tag => {
+ if (tag[0] === 'e' && tag[3] === 'root') {
+ eventData.channelId = tag[1];
+ }
+ });
+
+ // Parse metadata from content
+ try {
+ if (eventData.content) {
+ eventData.metadata = JSON.parse(eventData.content);
+ }
+ } catch (err) {
+ console.warn('Error parsing channel metadata:', err);
+ eventData.metadata = {};
+ }
+
+ return eventData;
+};
+
+/**
+ * Parse NIP-28 Channel Message Event (kind 42)
+ *
+ * @param {Object} event - The NDK event object
+ * @returns {Object} - Parsed channel message
+ */
+export const parseChannelMessageEvent = event => {
+ const eventData = {
+ id: event.id || event.eventId || event.tagId() || '',
+ pubkey: event.pubkey || '',
+ content: event.content || '',
+ kind: event.kind || 42,
+ created_at: event.created_at || 0,
+ type: 'channel-message',
+ channelId: null,
+ replyTo: null,
+ mentions: [],
+ tags: event.tags || []
+ };
+
+ // Parse NIP-10 threading and channel references
+ event.tags.forEach(tag => {
+ switch (tag[0]) {
+ case 'e':
+ if (tag[3] === 'root') {
+ eventData.channelId = tag[1];
+ } else if (tag[3] === 'reply') {
+ eventData.replyTo = tag[1];
+ }
+ break;
+ case 'p':
+ eventData.mentions.push(tag[1]);
+ break;
+ default:
+ break;
+ }
+ });
+
+ return eventData;
+};
+
+/**
+ * Generate a proper event ID from NDK event
+ *
+ * @param {Object} event - The NDK event object
+ * @returns {string} - The event ID
+ */
+export const getEventId = event => {
+ // Try different methods to get the ID
+ if (event.id && event.id !== '') return event.id;
+ if (event.eventId && event.eventId !== '') return event.eventId;
+ if (typeof event.tagId === 'function') {
+ const tagId = event.tagId();
+ if (tagId && tagId !== '') return tagId;
+ }
+
+ // If no ID available, generate one or return null
+ console.warn('No event ID found for event:', event);
+ return null;
+};