From 9a792bc9daf43f48ee22bca2f0c2dd13f762438e Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 3 Oct 2025 22:56:16 +0200 Subject: [PATCH] public messages --- src/components/ContentPreview/index.tsx | 3 +- src/components/Note/index.tsx | 2 + .../PublicMessageNotification.tsx | 57 +++++ .../NotificationItem/index.tsx | 4 + src/components/NotificationList/index.tsx | 6 +- src/components/PostEditor/Mentions.tsx | 2 +- src/components/PostEditor/PostContent.tsx | 207 ++++++++++++++++-- .../PostEditor/PostTextarea/Preview.tsx | 16 +- .../PostEditor/PostTextarea/index.tsx | 6 +- src/components/PostEditor/Title.tsx | 40 +++- src/components/PostEditor/index.tsx | 13 +- src/components/QuoteList/index.tsx | 3 +- src/components/ReplyNoteList/index.tsx | 8 + src/constants.ts | 2 + src/lib/draft-event.ts | 112 +++++++++- src/lib/notification.ts | 7 + src/providers/NotificationProvider.tsx | 18 +- src/services/client.service.ts | 60 +++-- 18 files changed, 498 insertions(+), 68 deletions(-) create mode 100644 src/components/NotificationList/NotificationItem/PublicMessageNotification.tsx diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index e3e597d..9da3db9 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -62,7 +62,8 @@ export default function ContentPreview({ ExtendedKind.COMMENT, ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT, - ExtendedKind.RELAY_REVIEW + ExtendedKind.RELAY_REVIEW, + ExtendedKind.PUBLIC_MESSAGE ].includes(event.kind) ) { return diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index b094ae5..ed9e150 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -101,6 +101,8 @@ export default function Note({ content = } else if (event.kind === ExtendedKind.RELAY_REVIEW) { content = + } else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { + content = } else { content = } diff --git a/src/components/NotificationList/NotificationItem/PublicMessageNotification.tsx b/src/components/NotificationList/NotificationItem/PublicMessageNotification.tsx new file mode 100644 index 0000000..6ba6739 --- /dev/null +++ b/src/components/NotificationList/NotificationItem/PublicMessageNotification.tsx @@ -0,0 +1,57 @@ +import { ExtendedKind } from '@/constants' +import { useNostr } from '@/providers/NostrProvider' +import { MessageCircle } from 'lucide-react' +import { Event } from 'nostr-tools' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import Notification from './Notification' + +export function PublicMessageNotification({ + notification, + isNew = false +}: { + notification: Event + isNew?: boolean +}) { + const { t } = useTranslation() + const { pubkey } = useNostr() + + const isRecipient = useMemo(() => { + if (!pubkey) return false + // Check if current user is in the 'p' tags (recipients) + return notification.tags.some((tag) => tag[0] === 'p' && tag[1] === pubkey) + }, [pubkey, notification]) + + // Get list of recipients for display + const recipients = useMemo(() => { + return notification.tags + .filter((tag) => tag[0] === 'p') + .map((tag) => tag[1]) + .slice(0, 3) // Show first 3 recipients + }, [notification.tags]) + + const description = useMemo(() => { + if (isRecipient) { + if (recipients.length > 1) { + return t('sent you a public message (along with {{count}} others)', { + count: recipients.length - 1 + }) + } + return t('sent you a public message') + } + return t('sent a public message') + }, [isRecipient, recipients.length, t]) + + return ( + } + sender={notification.pubkey} + sentAt={notification.created_at} + targetEvent={notification} + description={description} + isNew={isNew} + showStats + /> + ) +} diff --git a/src/components/NotificationList/NotificationItem/index.tsx b/src/components/NotificationList/NotificationItem/index.tsx index b3d33ec..766ecc2 100644 --- a/src/components/NotificationList/NotificationItem/index.tsx +++ b/src/components/NotificationList/NotificationItem/index.tsx @@ -8,6 +8,7 @@ import { Event, kinds } from 'nostr-tools' import { useMemo } from 'react' import { MentionNotification } from './MentionNotification' import { PollResponseNotification } from './PollResponseNotification' +import { PublicMessageNotification } from './PublicMessageNotification' import { ReactionNotification } from './ReactionNotification' import { RepostNotification } from './RepostNotification' import { ZapNotification } from './ZapNotification' @@ -43,6 +44,9 @@ export function NotificationItem({ if (notification.kind === kinds.Reaction) { return } + if (notification.kind === ExtendedKind.PUBLIC_MESSAGE) { + return + } if ( notification.kind === kinds.ShortTextNote || notification.kind === ExtendedKind.COMMENT || diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index c98da65..c169cfe 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -55,7 +55,8 @@ const NotificationList = forwardRef((_, ref) => { kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, - ExtendedKind.POLL + ExtendedKind.POLL, + ExtendedKind.PUBLIC_MESSAGE ] case 'reactions': return [kinds.Reaction, kinds.Repost, ExtendedKind.POLL_RESPONSE] @@ -70,7 +71,8 @@ const NotificationList = forwardRef((_, ref) => { ExtendedKind.COMMENT, ExtendedKind.POLL_RESPONSE, ExtendedKind.VOICE_COMMENT, - ExtendedKind.POLL + ExtendedKind.POLL, + ExtendedKind.PUBLIC_MESSAGE ] } }, [notificationType]) diff --git a/src/components/PostEditor/Mentions.tsx b/src/components/PostEditor/Mentions.tsx index 5463003..001533e 100644 --- a/src/components/PostEditor/Mentions.tsx +++ b/src/components/PostEditor/Mentions.tsx @@ -132,7 +132,7 @@ function PopoverCheckboxItem({ ) } -async function extractMentions(content: string, parentEvent?: Event) { +export async function extractMentions(content: string, parentEvent?: Event) { const parentEventPubkey = parentEvent ? parentEvent.pubkey : undefined const pubkeys: string[] = [] const relatedPubkeys: string[] = [] diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 5f2086a..13e9c45 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -4,21 +4,24 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { createCommentDraftEvent, createPollDraftEvent, + createPublicMessageDraftEvent, + createPublicMessageReplyDraftEvent, createShortTextNoteDraftEvent, deleteDraftEventCache } from '@/lib/draft-event' +import { ExtendedKind } from '@/constants' import { isTouchDevice } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import { useReply } from '@/providers/ReplyProvider' import postEditorCache from '@/services/post-editor-cache.service' import { TPollCreateData } from '@/types' -import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react' +import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X } from 'lucide-react' import { Event, kinds } from 'nostr-tools' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import EmojiPickerDialog from '../EmojiPickerDialog' -import Mentions from './Mentions' +import Mentions, { extractMentions } from './Mentions' import PollEditor from './PollEditor' import PostOptions from './PostOptions' import PostRelaySelector from './PostRelaySelector' @@ -50,6 +53,8 @@ export default function PostContent({ const [mentions, setMentions] = useState([]) const [isNsfw, setIsNsfw] = useState(false) const [isPoll, setIsPoll] = useState(false) + const [isPublicMessage, setIsPublicMessage] = useState(false) + const [publicMessageRecipients, setPublicMessageRecipients] = useState([]) const [isProtectedEvent, setIsProtectedEvent] = useState(false) const [additionalRelayUrls, setAdditionalRelayUrls] = useState([]) const [pollCreateData, setPollCreateData] = useState({ @@ -61,14 +66,32 @@ export default function PostContent({ const [minPow, setMinPow] = useState(0) const isFirstRender = useRef(true) const canPost = useMemo(() => { - return ( + const result = ( !!pubkey && !!text && !posting && !uploadProgresses.length && (!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) && + (!isPublicMessage || publicMessageRecipients.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) && (!isProtectedEvent || additionalRelayUrls.length > 0) ) + + // Debug logging for public message replies + if (parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) { + console.log('Public message reply debug:', { + pubkey: !!pubkey, + text: !!text, + posting, + uploadProgresses: uploadProgresses.length, + isPoll, + pollCreateDataValid: !isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2, + publicMessageCheck: !isPublicMessage || publicMessageRecipients.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE, + protectedEventCheck: !isProtectedEvent || additionalRelayUrls.length > 0, + canPost: result + }) + } + + return result }, [ pubkey, text, @@ -76,6 +99,9 @@ export default function PostContent({ uploadProgresses, isPoll, pollCreateData, + isPublicMessage, + publicMessageRecipients, + parentEvent?.kind, isProtectedEvent, additionalRelayUrls ]) @@ -113,37 +139,106 @@ export default function PostContent({ ) }, [defaultContent, parentEvent, isNsfw, isPoll, pollCreateData, addClientTag]) + // Extract mentions from content for public messages + const extractMentionsFromContent = useCallback(async (content: string) => { + try { + // First try to extract nostr: protocol mentions + const { pubkeys: nostrPubkeys } = await extractMentions(content, undefined) + + // Also extract regular @ mentions (simple pattern for now) + const atMentions = content.match(/@[a-zA-Z0-9_]+/g) || [] + + console.log('Nostr mentions:', nostrPubkeys) + console.log('@ mentions:', atMentions) + + // For now, we'll use the nostr mentions and show that we detected @ mentions + // In a real implementation, you'd resolve @ mentions to pubkeys + setPublicMessageRecipients(nostrPubkeys) + } catch (error) { + console.error('Error extracting mentions:', error) + setPublicMessageRecipients([]) + } + }, []) + + useEffect(() => { + if (!isPublicMessage) { + setPublicMessageRecipients([]) + return + } + + if (!text) { + setPublicMessageRecipients([]) + return + } + + // Debounce the mention extraction + const timeoutId = setTimeout(() => { + extractMentionsFromContent(text) + }, 300) + + return () => { + clearTimeout(timeoutId) + } + }, [text, isPublicMessage, extractMentionsFromContent]) + const post = async (e?: React.MouseEvent) => { e?.stopPropagation() checkLogin(async () => { - if (!canPost) return + if (!canPost) { + console.log('❌ Cannot post - canPost is false') + return + } + + // console.log('🚀 Starting post process:', { + // isPublicMessage, + // parentEventKind: parentEvent?.kind, + // parentEventId: parentEvent?.id, + // text: text.substring(0, 50) + '...', + // mentions: mentions.length, + // canPost + // }) setPosting(true) try { - const draftEvent = - parentEvent && parentEvent.kind !== kinds.ShortTextNote - ? await createCommentDraftEvent(text, parentEvent, mentions, { - addClientTag, - protectedEvent: isProtectedEvent, - isNsfw - }) - : isPoll - ? await createPollDraftEvent(pubkey!, text, mentions, pollCreateData, { - addClientTag, - isNsfw - }) - : await createShortTextNoteDraftEvent(text, mentions, { - parentEvent, - addClientTag, - protectedEvent: isProtectedEvent, - isNsfw - }) + + let draftEvent + if (isPublicMessage) { + draftEvent = await createPublicMessageDraftEvent(text, publicMessageRecipients, { + addClientTag, + isNsfw + }) + } else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { + draftEvent = await createPublicMessageReplyDraftEvent(text, parentEvent, mentions, { + addClientTag, + isNsfw + }) + } else if (parentEvent && parentEvent.kind !== kinds.ShortTextNote) { + draftEvent = await createCommentDraftEvent(text, parentEvent, mentions, { + addClientTag, + protectedEvent: isProtectedEvent, + isNsfw + }) + } else if (isPoll) { + draftEvent = await createPollDraftEvent(pubkey!, text, mentions, pollCreateData, { + addClientTag, + isNsfw + }) + } else { + draftEvent = await createShortTextNoteDraftEvent(text, mentions, { + parentEvent, + addClientTag, + protectedEvent: isProtectedEvent, + isNsfw + }) + } + // console.log('Publishing draft event:', draftEvent) const newEvent = await publish(draftEvent, { specifiedRelayUrls: isProtectedEvent ? additionalRelayUrls : undefined, additionalRelayUrls: isPoll ? pollCreateData.relays : additionalRelayUrls, minPow }) + // console.log('Published event:', newEvent) postEditorCache.clearPostCache({ defaultContent, parentEvent }) deleteDraftEventCache(draftEvent) addReplies([newEvent]) @@ -171,6 +266,16 @@ export default function PostContent({ setIsPoll((prev) => !prev) } + const handlePublicMessageToggle = () => { + if (parentEvent) return + + setIsPublicMessage((prev) => !prev) + if (!isPublicMessage) { + // When enabling public message mode, clear other modes + setIsPoll(false) + } + } + const handleUploadStart = (file: File, cancel: () => void) => { setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }]) } @@ -187,6 +292,26 @@ export default function PostContent({ return (
+ {/* Dynamic Title based on mode */} +
+ {parentEvent ? ( +
+
+ {parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE + ? t('Reply to Public Message') + : t('Reply to') + } +
+
+ ) : isPoll ? ( + t('New Poll') + ) : isPublicMessage ? ( + t('New Public Message') + ) : ( + t('New Note') + )} +
+ {parentEvent && (
@@ -205,6 +330,7 @@ export default function PostContent({ onUploadStart={handleUploadStart} onUploadProgress={handleUploadProgress} onUploadEnd={handleUploadEnd} + kind={isPublicMessage ? ExtendedKind.PUBLIC_MESSAGE : isPoll ? ExtendedKind.POLL : kinds.ShortTextNote} /> {isPoll && ( )} + {isPublicMessage && ( +
+
{t('Recipients')}
+
+ + {publicMessageRecipients.length > 0 ? ( +
+ {t('Recipients detected from your message:')} {publicMessageRecipients.length} +
+ ) : ( +
+ {t('Add recipients using nostr: mentions (e.g., nostr:npub1...) or the recipient selector above')} +
+ )} +
+
+ )} {uploadProgresses.length > 0 && uploadProgresses.map(({ file, progress, cancel }, index) => (
@@ -289,6 +437,17 @@ export default function PostContent({ )} + {!parentEvent && ( + + )}
diff --git a/src/components/PostEditor/PostTextarea/Preview.tsx b/src/components/PostEditor/PostTextarea/Preview.tsx index 20a73e8..a727191 100644 --- a/src/components/PostEditor/PostTextarea/Preview.tsx +++ b/src/components/PostEditor/PostTextarea/Preview.tsx @@ -5,7 +5,15 @@ import { cn } from '@/lib/utils' import { useMemo } from 'react' import Content from '../../Content' -export default function Preview({ content, className }: { content: string; className?: string }) { +export default function Preview({ + content, + className, + kind = 1 +}: { + content: string + className?: string + kind?: number +}) { const { content: processedContent, emojiTags } = useMemo( () => transformCustomEmojisInContent(content), [content] @@ -13,7 +21,11 @@ export default function Preview({ content, className }: { content: string; class return ( diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx index 1973d3f..be82728 100644 --- a/src/components/PostEditor/PostTextarea/index.tsx +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -40,6 +40,7 @@ const PostTextarea = forwardRef< onUploadStart?: (file: File, cancel: () => void) => void onUploadProgress?: (file: File, progress: number) => void onUploadEnd?: (file: File) => void + kind?: number } >( ( @@ -52,7 +53,8 @@ const PostTextarea = forwardRef< className, onUploadStart, onUploadProgress, - onUploadEnd + onUploadEnd, + kind = 1 }, ref ) => { @@ -167,7 +169,7 @@ const PostTextarea = forwardRef< - + ) diff --git a/src/components/PostEditor/Title.tsx b/src/components/PostEditor/Title.tsx index 21a67c0..a5adf4d 100644 --- a/src/components/PostEditor/Title.tsx +++ b/src/components/PostEditor/Title.tsx @@ -1,14 +1,38 @@ +import { ExtendedKind } from '@/constants' import { Event } from 'nostr-tools' import { useTranslation } from 'react-i18next' -export default function Title({ parentEvent }: { parentEvent?: Event }) { +export default function Title({ + parentEvent, + isPoll = false, + isPublicMessage = false +}: { + parentEvent?: Event + isPoll?: boolean + isPublicMessage?: boolean +}) { const { t } = useTranslation() - return parentEvent ? ( -
-
{t('Reply to')}
-
- ) : ( - t('New Note') - ) + if (parentEvent) { + return ( +
+
+ {parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE + ? t('Reply to Public Message') + : t('Reply to') + } +
+
+ ) + } + + if (isPoll) { + return t('New Poll') + } + + if (isPublicMessage) { + return t('New Public Message') + } + + return t('New Note') } diff --git a/src/components/PostEditor/index.tsx b/src/components/PostEditor/index.tsx index ac12cae..8a2a42b 100644 --- a/src/components/PostEditor/index.tsx +++ b/src/components/PostEditor/index.tsx @@ -2,23 +2,20 @@ import { Dialog, DialogContent, DialogDescription, - DialogHeader, - DialogTitle + DialogHeader } from '@/components/ui/dialog' import { ScrollArea } from '@/components/ui/scroll-area' import { Sheet, SheetContent, SheetDescription, - SheetHeader, - SheetTitle + SheetHeader } from '@/components/ui/sheet' import { useScreenSize } from '@/providers/ScreenSizeProvider' import postEditor from '@/services/post-editor.service' import { Event } from 'nostr-tools' import { Dispatch, useMemo } from 'react' import PostContent from './PostContent' -import Title from './Title' export default function PostEditor({ defaultContent = '', @@ -63,9 +60,6 @@ export default function PostEditor({
- - - </SheetTitle> <SheetDescription className="hidden" /> </SheetHeader> {content} @@ -91,9 +85,6 @@ export default function PostEditor({ <ScrollArea className="px-4 h-full max-h-screen"> <div className="space-y-4 px-2 py-6"> <DialogHeader> - <DialogTitle> - <Title parentEvent={parentEvent} /> - </DialogTitle> <DialogDescription className="hidden" /> </DialogHeader> {content} diff --git a/src/components/QuoteList/index.tsx b/src/components/QuoteList/index.tsx index e3e192f..bf4c9cf 100644 --- a/src/components/QuoteList/index.tsx +++ b/src/components/QuoteList/index.tsx @@ -51,7 +51,8 @@ export default function QuoteList({ event, className }: { event: Event; classNam kinds.Highlights, kinds.LongFormArticle, ExtendedKind.COMMENT, - ExtendedKind.POLL + ExtendedKind.POLL, + ExtendedKind.PUBLIC_MESSAGE ], limit: LIMIT } diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 2d5bc0f..04c4b21 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -172,6 +172,14 @@ export default function ReplyNoteList({ index, event }: { index?: number; event: limit: LIMIT }) } + // For public messages (kind 24), also look for replies using 'q' tags + if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { + filters.push({ + '#q': [rootInfo.id], + kinds: [ExtendedKind.PUBLIC_MESSAGE], + limit: LIMIT + }) + } } else if (rootInfo.type === 'A') { filters.push( { diff --git a/src/constants.ts b/src/constants.ts index d39c102..8c9c210 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -106,6 +106,7 @@ export const ExtendedKind = { COMMENT: 1111, VOICE: 1222, VOICE_COMMENT: 1244, + PUBLIC_MESSAGE: 24, FAVORITE_RELAYS: 10012, BLOSSOM_SERVER_LIST: 10063, RELAY_REVIEW: 31987, @@ -122,6 +123,7 @@ export const SUPPORTED_KINDS = [ ExtendedKind.COMMENT, ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT, + // ExtendedKind.PUBLIC_MESSAGE, // Excluded - public messages should only appear in notifications kinds.Highlights, kinds.LongFormArticle, ExtendedKind.RELAY_REVIEW diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index f982cd2..1eb3708 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -10,7 +10,7 @@ import { TPollCreateData, TRelaySet } from '@/types' -import { sha256 } from '@noble/hashes/sha2' +import { sha256 } from '@noble/hashes/sha256' import dayjs from 'dayjs' import { Event, kinds, nip19 } from 'nostr-tools' import { @@ -252,6 +252,116 @@ export async function createCommentDraftEvent( content: transformedEmojisContent, tags } + + return setDraftEventCache(baseDraft) +} + +export async function createPublicMessageReplyDraftEvent( + content: string, + parentEvent: Event, + mentions: string[], + options: { + addClientTag?: boolean + isNsfw?: boolean + } = {} +): Promise<TDraftEvent> { + const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content) + const { + quoteEventHexIds, + quoteReplaceableCoordinates + } = await extractCommentMentions(transformedEmojisContent, parentEvent) + const hashtags = extractHashtags(transformedEmojisContent) + + const tags = emojiTags + .concat(hashtags.map((hashtag) => buildTTag(hashtag))) + .concat(quoteEventHexIds.map((eventId) => buildQTag(eventId))) + .concat(quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate))) + + const images = extractImagesFromContent(transformedEmojisContent) + if (images && images.length) { + tags.push(...generateImetaTags(images)) + } + + // For kind 24 replies, we use 'q' tag for the parent event (as per NIP-A4) + tags.push(buildQTag(parentEvent.id)) + + // Add 'p' tags for recipients (original sender and any mentions) + const recipients = new Set([parentEvent.pubkey]) + mentions.forEach(pubkey => recipients.add(pubkey)) + + // console.log('🔧 Creating public message reply draft:', { + // parentEventId: parentEvent.id, + // parentEventPubkey: parentEvent.pubkey, + // mentions, + // recipients: Array.from(recipients), + // finalTags: tags.length + // }) + + tags.push( + ...Array.from(recipients).map((pubkey) => buildPTag(pubkey)) + ) + + if (options.addClientTag) { + tags.push(buildClientTag()) + } + + if (options.isNsfw) { + tags.push(buildNsfwTag()) + } + + // console.log('📝 Final public message reply draft tags:', { + // pTags: tags.filter(tag => tag[0] === 'p'), + // qTags: tags.filter(tag => tag[0] === 'q'), + // allTags: tags + // }) + + const baseDraft = { + kind: ExtendedKind.PUBLIC_MESSAGE, + content: transformedEmojisContent, + tags + } + + return setDraftEventCache(baseDraft) +} + +export async function createPublicMessageDraftEvent( + content: string, + recipients: string[], + options: { + addClientTag?: boolean + isNsfw?: boolean + } = {} +): Promise<TDraftEvent> { + const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content) + const hashtags = extractHashtags(transformedEmojisContent) + + const tags = emojiTags + .concat(hashtags.map((hashtag) => buildTTag(hashtag))) + + const images = extractImagesFromContent(transformedEmojisContent) + if (images && images.length) { + tags.push(...generateImetaTags(images)) + } + + // Add 'p' tags for recipients + tags.push( + ...recipients.map((pubkey) => buildPTag(pubkey)) + ) + + if (options.addClientTag) { + tags.push(buildClientTag()) + } + + if (options.isNsfw) { + tags.push(buildNsfwTag()) + } + + const baseDraft = { + kind: ExtendedKind.PUBLIC_MESSAGE, + content: transformedEmojisContent, + tags + } + return setDraftEventCache(baseDraft) } diff --git a/src/lib/notification.ts b/src/lib/notification.ts index 024d5e1..6446980 100644 --- a/src/lib/notification.ts +++ b/src/lib/notification.ts @@ -1,4 +1,5 @@ import { kinds, NostrEvent } from 'nostr-tools' +import { ExtendedKind } from '@/constants' import { isMentioningMutedUsers } from './event' import { tagNameEquals } from './tag' @@ -31,5 +32,11 @@ export function notificationFilter( if (targetPubkey !== pubkey) return false } + // For PUBLIC_MESSAGE (kind 24) events, ensure the user is in the 'p' tags + if (pubkey && event.kind === ExtendedKind.PUBLIC_MESSAGE) { + const hasUserInPTags = event.tags.some((tag) => tag[0] === 'p' && tag[1] === pubkey) + if (!hasUserInPTags) return false + } + return true } diff --git a/src/providers/NotificationProvider.tsx b/src/providers/NotificationProvider.tsx index 59770b0..83f4fbf 100644 --- a/src/providers/NotificationProvider.tsx +++ b/src/providers/NotificationProvider.tsx @@ -98,8 +98,10 @@ export function NotificationProvider({ children }: { children: React.ReactNode } try { let eosed = false const relayList = await client.fetchRelayList(pubkey) + const notificationRelays = relayList.read.length > 0 ? relayList.read.slice(0, 5) : BIG_RELAY_URLS + console.log('🔔 Notification subscription for', pubkey.substring(0, 8) + '...', 'using relays:', notificationRelays) const subCloser = client.subscribe( - relayList.read.length > 0 ? relayList.read.slice(0, 5) : BIG_RELAY_URLS, + notificationRelays, [ { kinds: [ @@ -110,7 +112,8 @@ export function NotificationProvider({ children }: { children: React.ReactNode } ExtendedKind.COMMENT, ExtendedKind.POLL_RESPONSE, ExtendedKind.VOICE_COMMENT, - ExtendedKind.POLL + ExtendedKind.POLL, + ExtendedKind.PUBLIC_MESSAGE ], '#p': [pubkey], limit: 20 @@ -127,6 +130,17 @@ export function NotificationProvider({ children }: { children: React.ReactNode } }, onevent: (evt) => { if (evt.pubkey !== pubkey) { + // Debug: Log public message notifications + if (evt.kind === ExtendedKind.PUBLIC_MESSAGE) { + const hasUserInPTags = evt.tags.some((tag) => tag[0] === 'p' && tag[1] === pubkey) + console.log(`📨 Public message notification received by ${pubkey.substring(0, 8)}... from ${evt.pubkey.substring(0, 8)}...:`, { + hasUserInPTags, + content: evt.content.substring(0, 50), + tags: evt.tags.map(tag => `${tag[0]}:${tag[1]?.substring(0, 8)}...`), + eventId: evt.id.substring(0, 8) + '...' + }) + } + setNewNotifications((prev) => { if (!eosed) { return [evt, ...prev] diff --git a/src/services/client.service.ts b/src/services/client.service.ts index fb9ba3e..c3d1525 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -113,7 +113,8 @@ class ClientService extends EventTarget { }) if (mentions.length > 0) { const relayLists = await this.fetchRelayLists(mentions) - relayLists.forEach((relayList) => { + relayLists.forEach((relayList, index) => { + const mentionPubkey = mentions[index] _additionalRelayUrls.push(...relayList.read.slice(0, 4)) }) } @@ -131,9 +132,19 @@ class ClientService extends EventTarget { } const relayList = await this.fetchRelayList(event.pubkey) - relays = (relayList?.write.slice(0, 6) ?? []).concat( - Array.from(new Set(_additionalRelayUrls)) ?? [] - ) + const senderWriteRelays = relayList?.write.slice(0, 6) ?? [] + const recipientReadRelays = Array.from(new Set(_additionalRelayUrls)) + relays = senderWriteRelays.concat(recipientReadRelays) + + // Special logging for public messages + if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { + // console.log('🎯 Final relay selection for public message:', { + // eventId: event.id.substring(0, 8) + '...', + // senderWriteRelays: senderWriteRelays.length, + // recipientReadRelays: recipientReadRelays.length, + // finalRelays: relays.length + // }) + } } if (!relays.length) { @@ -145,6 +156,17 @@ class ClientService extends EventTarget { async publishEvent(relayUrls: string[], event: NEvent) { const uniqueRelayUrls = this.optimizeRelaySelection(Array.from(new Set(relayUrls))) + console.log(`Publishing kind ${event.kind} event to ${uniqueRelayUrls.length} relays`) + // if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { + // console.log('Public message event details:', { + // id: event.id, + // pubkey: event.pubkey, + // content: event.content.substring(0, 50), + // tags: event.tags, + // targetRelays: uniqueRelayUrls + // }) + // } + await new Promise<void>((resolve, reject) => { let successCount = 0 let finishedCount = 0 @@ -158,15 +180,18 @@ class ClientService extends EventTarget { return relay .publish(event) .then(() => { + console.log(`✓ Published to ${url}`) this.trackEventSeenOn(event.id, relay) successCount++ }) .catch((error) => { + console.log(`✗ Failed to publish to ${url}:`, error.message) if ( error instanceof Error && error.message.startsWith('auth-required') && !!that.signer ) { + console.log(`Attempting auth for ${url}`) return relay .auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt)) .then(() => relay.publish(event)) @@ -175,23 +200,32 @@ class ClientService extends EventTarget { } }) .finally(() => { + finishedCount++ // If one third of the relays have accepted the event, consider it a success const isSuccess = successCount >= uniqueRelayUrls.length / 3 if (isSuccess) { + console.log(`✓ Publishing successful (${successCount}/${uniqueRelayUrls.length} relays)`) this.emitNewEvent(event) resolve() } - if (++finishedCount >= uniqueRelayUrls.length) { - reject( - new AggregateError( - errors.map( - ({ url, error }) => - new Error( - `${url}: ${error instanceof Error ? error.message : String(error)}` - ) + if (finishedCount >= uniqueRelayUrls.length) { + if (successCount > 0) { + console.log(`✓ Publishing successful (${successCount}/${uniqueRelayUrls.length} relays)`) + this.emitNewEvent(event) + resolve() + } else { + console.log(`✗ Publishing failed (0/${uniqueRelayUrls.length} relays)`) + reject( + new AggregateError( + errors.map( + ({ url, error }) => + new Error( + `${url}: ${error instanceof Error ? error.message : String(error)}` + ) + ) ) ) - ) + } } }) })