Browse Source

public messages

imwald
Silberengel 5 months ago
parent
commit
9a792bc9da
  1. 3
      src/components/ContentPreview/index.tsx
  2. 2
      src/components/Note/index.tsx
  3. 57
      src/components/NotificationList/NotificationItem/PublicMessageNotification.tsx
  4. 4
      src/components/NotificationList/NotificationItem/index.tsx
  5. 6
      src/components/NotificationList/index.tsx
  6. 2
      src/components/PostEditor/Mentions.tsx
  7. 207
      src/components/PostEditor/PostContent.tsx
  8. 16
      src/components/PostEditor/PostTextarea/Preview.tsx
  9. 6
      src/components/PostEditor/PostTextarea/index.tsx
  10. 40
      src/components/PostEditor/Title.tsx
  11. 13
      src/components/PostEditor/index.tsx
  12. 3
      src/components/QuoteList/index.tsx
  13. 8
      src/components/ReplyNoteList/index.tsx
  14. 2
      src/constants.ts
  15. 112
      src/lib/draft-event.ts
  16. 7
      src/lib/notification.ts
  17. 18
      src/providers/NotificationProvider.tsx
  18. 60
      src/services/client.service.ts

3
src/components/ContentPreview/index.tsx

@ -62,7 +62,8 @@ export default function ContentPreview({ @@ -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 <NormalContentPreview event={event} className={className} />

2
src/components/Note/index.tsx

@ -101,6 +101,8 @@ export default function Note({ @@ -101,6 +101,8 @@ export default function Note({
content = <VideoNote className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.RELAY_REVIEW) {
content = <RelayReview className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) {
content = <Content className="mt-2" event={event} />
} else {
content = <Content className="mt-2" event={event} />
}

57
src/components/NotificationList/NotificationItem/PublicMessageNotification.tsx

@ -0,0 +1,57 @@ @@ -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 (
<Notification
notificationId={notification.id}
icon={<MessageCircle size={24} className="text-purple-400" />}
sender={notification.pubkey}
sentAt={notification.created_at}
targetEvent={notification}
description={description}
isNew={isNew}
showStats
/>
)
}

4
src/components/NotificationList/NotificationItem/index.tsx

@ -8,6 +8,7 @@ import { Event, kinds } from 'nostr-tools' @@ -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({ @@ -43,6 +44,9 @@ export function NotificationItem({
if (notification.kind === kinds.Reaction) {
return <ReactionNotification notification={notification} isNew={isNew} />
}
if (notification.kind === ExtendedKind.PUBLIC_MESSAGE) {
return <PublicMessageNotification notification={notification} isNew={isNew} />
}
if (
notification.kind === kinds.ShortTextNote ||
notification.kind === ExtendedKind.COMMENT ||

6
src/components/NotificationList/index.tsx

@ -55,7 +55,8 @@ const NotificationList = forwardRef((_, ref) => { @@ -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) => { @@ -70,7 +71,8 @@ const NotificationList = forwardRef((_, ref) => {
ExtendedKind.COMMENT,
ExtendedKind.POLL_RESPONSE,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.POLL
ExtendedKind.POLL,
ExtendedKind.PUBLIC_MESSAGE
]
}
}, [notificationType])

2
src/components/PostEditor/Mentions.tsx

@ -132,7 +132,7 @@ function PopoverCheckboxItem({ @@ -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[] = []

207
src/components/PostEditor/PostContent.tsx

@ -4,21 +4,24 @@ import { ScrollArea } from '@/components/ui/scroll-area' @@ -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({ @@ -50,6 +53,8 @@ export default function PostContent({
const [mentions, setMentions] = useState<string[]>([])
const [isNsfw, setIsNsfw] = useState(false)
const [isPoll, setIsPoll] = useState(false)
const [isPublicMessage, setIsPublicMessage] = useState(false)
const [publicMessageRecipients, setPublicMessageRecipients] = useState<string[]>([])
const [isProtectedEvent, setIsProtectedEvent] = useState(false)
const [additionalRelayUrls, setAdditionalRelayUrls] = useState<string[]>([])
const [pollCreateData, setPollCreateData] = useState<TPollCreateData>({
@ -61,14 +66,32 @@ export default function PostContent({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -187,6 +292,26 @@ export default function PostContent({
return (
<div className="space-y-2">
{/* Dynamic Title based on mode */}
<div className="text-lg font-semibold">
{parentEvent ? (
<div className="flex gap-2 items-center w-full">
<div className="shrink-0">
{parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE
? t('Reply to Public Message')
: t('Reply to')
}
</div>
</div>
) : isPoll ? (
t('New Poll')
) : isPublicMessage ? (
t('New Public Message')
) : (
t('New Note')
)}
</div>
{parentEvent && (
<ScrollArea className="flex max-h-48 flex-col overflow-y-auto rounded-lg border bg-muted/40">
<div className="p-2 sm:p-3 pointer-events-none">
@ -205,6 +330,7 @@ export default function PostContent({ @@ -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 && (
<PollEditor
@ -213,6 +339,28 @@ export default function PostContent({ @@ -213,6 +339,28 @@ export default function PostContent({
setIsPoll={setIsPoll}
/>
)}
{isPublicMessage && (
<div className="rounded-lg border bg-muted/40 p-3">
<div className="mb-2 text-sm font-medium">{t('Recipients')}</div>
<div className="space-y-2">
<Mentions
content={text}
parentEvent={undefined}
mentions={publicMessageRecipients}
setMentions={setPublicMessageRecipients}
/>
{publicMessageRecipients.length > 0 ? (
<div className="text-sm text-muted-foreground">
{t('Recipients detected from your message:')} {publicMessageRecipients.length}
</div>
) : (
<div className="text-sm text-muted-foreground">
{t('Add recipients using nostr: mentions (e.g., nostr:npub1...) or the recipient selector above')}
</div>
)}
</div>
</div>
)}
{uploadProgresses.length > 0 &&
uploadProgresses.map(({ file, progress, cancel }, index) => (
<div key={`${file.name}-${index}`} className="mt-2 flex items-end gap-2">
@ -289,6 +437,17 @@ export default function PostContent({ @@ -289,6 +437,17 @@ export default function PostContent({
<ListTodo />
</Button>
)}
{!parentEvent && (
<Button
variant="ghost"
size="icon"
title={t('Send Public Message')}
className={isPublicMessage ? 'bg-accent' : ''}
onClick={handlePublicMessageToggle}
>
<MessageCircle />
</Button>
)}
<Button
variant="ghost"
size="icon"
@ -317,7 +476,7 @@ export default function PostContent({ @@ -317,7 +476,7 @@ export default function PostContent({
</Button>
<Button type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />}
{parentEvent ? t('Reply') : t('Post')}
{parentEvent ? t('Reply') : isPublicMessage ? t('Send Public Message') : t('Post')}
</Button>
</div>
</div>

16
src/components/PostEditor/PostTextarea/Preview.tsx

@ -5,7 +5,15 @@ import { cn } from '@/lib/utils' @@ -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 @@ -13,7 +21,11 @@ export default function Preview({ content, className }: { content: string; class
return (
<Card className={cn('p-3', className)}>
<Content
event={createFakeEvent({ content: processedContent, tags: emojiTags })}
event={createFakeEvent({
content: processedContent,
tags: emojiTags,
kind
})}
className="pointer-events-none h-full"
mustLoadMedia
/>

6
src/components/PostEditor/PostTextarea/index.tsx

@ -40,6 +40,7 @@ const PostTextarea = forwardRef< @@ -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< @@ -52,7 +53,8 @@ const PostTextarea = forwardRef<
className,
onUploadStart,
onUploadProgress,
onUploadEnd
onUploadEnd,
kind = 1
},
ref
) => {
@ -167,7 +169,7 @@ const PostTextarea = forwardRef< @@ -167,7 +169,7 @@ const PostTextarea = forwardRef<
<EditorContent className="tiptap" editor={editor} />
</TabsContent>
<TabsContent value="preview">
<Preview content={text} className={className} />
<Preview content={text} className={className} kind={kind} />
</TabsContent>
</Tabs>
)

40
src/components/PostEditor/Title.tsx

@ -1,14 +1,38 @@ @@ -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 ? (
<div className="flex gap-2 items-center w-full">
<div className="shrink-0">{t('Reply to')}</div>
</div>
) : (
t('New Note')
)
if (parentEvent) {
return (
<div className="flex gap-2 items-center w-full">
<div className="shrink-0">
{parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE
? t('Reply to Public Message')
: t('Reply to')
}
</div>
</div>
)
}
if (isPoll) {
return t('New Poll')
}
if (isPublicMessage) {
return t('New Public Message')
}
return t('New Note')
}

13
src/components/PostEditor/index.tsx

@ -2,23 +2,20 @@ import { @@ -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({ @@ -63,9 +60,6 @@ export default function PostEditor({
<ScrollArea className="px-4 h-full max-h-screen">
<div className="space-y-4 px-2 py-6">
<SheetHeader>
<SheetTitle className="text-start">
<Title parentEvent={parentEvent} />
</SheetTitle>
<SheetDescription className="hidden" />
</SheetHeader>
{content}
@ -91,9 +85,6 @@ export default function PostEditor({ @@ -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}

3
src/components/QuoteList/index.tsx

@ -51,7 +51,8 @@ export default function QuoteList({ event, className }: { event: Event; classNam @@ -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
}

8
src/components/ReplyNoteList/index.tsx

@ -172,6 +172,14 @@ export default function ReplyNoteList({ index, event }: { index?: number; event: @@ -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(
{

2
src/constants.ts

@ -106,6 +106,7 @@ export const ExtendedKind = { @@ -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 = [ @@ -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

112
src/lib/draft-event.ts

@ -10,7 +10,7 @@ import { @@ -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( @@ -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)
}

7
src/lib/notification.ts

@ -1,4 +1,5 @@ @@ -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( @@ -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
}

18
src/providers/NotificationProvider.tsx

@ -98,8 +98,10 @@ export function NotificationProvider({ children }: { children: React.ReactNode } @@ -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 } @@ -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 } @@ -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]

60
src/services/client.service.ts

@ -113,7 +113,8 @@ class ClientService extends EventTarget { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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)}`
)
)
)
)
)
}
}
})
})

Loading…
Cancel
Save