diff --git a/package-lock.json b/package-lock.json index 2c9d3903..dcdb36fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3062,6 +3062,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3075,6 +3076,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3088,6 +3090,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3101,6 +3104,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3114,6 +3118,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3127,6 +3132,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3140,6 +3146,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3153,6 +3160,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3166,6 +3174,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3179,6 +3188,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3192,6 +3202,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3205,6 +3216,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3218,6 +3230,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3231,6 +3244,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3244,6 +3258,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3257,6 +3272,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3270,6 +3286,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3283,6 +3300,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3296,6 +3314,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3309,6 +3328,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/src/components/ComposeBox.tsx b/src/components/ComposeBox.tsx index 5877b243..73defc81 100644 --- a/src/components/ComposeBox.tsx +++ b/src/components/ComposeBox.tsx @@ -11,6 +11,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { EmojiPicker } from '@/components/EmojiPicker'; import { EmbeddedNote } from '@/components/EmbeddedNote'; +import { MentionAutocomplete } from '@/components/MentionAutocomplete'; import { NoteContent } from '@/components/NoteContent'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -235,10 +236,15 @@ export function ComposeBox({ return content.match(urlRegex) || []; }, [content]); - // Check if content has any previewable content (link previews, images, or videos) + // Detect nostr:npub/nprofile mentions in content + const hasMentions = useMemo(() => { + return /nostr:(npub1|nprofile1)[023456789acdefghjklmnpqrstuvwxyz]+/.test(content); + }, [content]); + + // Check if content has any previewable content (link previews, images, videos, or mentions) const hasPreviewableContent = useMemo(() => { - return visibleEmbeds.length > 0 || previewImages.length > 0 || previewVideos.length > 0; - }, [visibleEmbeds, previewImages, previewVideos]); + return visibleEmbeds.length > 0 || previewImages.length > 0 || previewVideos.length > 0 || hasMentions; + }, [visibleEmbeds, previewImages, previewVideos, hasMentions]); // Notify parent of previewable content changes useEffect(() => { @@ -289,6 +295,19 @@ export function ComposeBox({ expand(); }, [content, expand]); + const handleInsertMention = useCallback(({ start, end, replacement }: { start: number; end: number; replacement: string }) => { + const newContent = content.slice(0, start) + replacement + content.slice(end); + setContent(newContent); + requestAnimationFrame(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.focus(); + const pos = start + replacement.length; + textarea.setSelectionRange(pos, pos); + } + }); + }, [content]); + const handleFileUpload = useCallback(async (file: File) => { try { const tags = await uploadFile(file); @@ -367,6 +386,25 @@ export function ComposeBox({ const hashtags = content.match(/#\w+/g)?.map((t) => t.slice(1)) || []; const tags: string[][] = hashtags.map((t) => ['t', t.toLowerCase()]); + // NIP-27 mention p tags — extract nostr:npub1... from content + const mentionMatches = content.matchAll(/nostr:(npub1[023456789acdefghjklmnpqrstuvwxyz]+)/g); + const mentionedPubkeys = new Set(); + for (const match of mentionMatches) { + try { + const decoded = nip19.decode(match[1]); + if (decoded.type === 'npub') { + mentionedPubkeys.add(decoded.data); + } + } catch { + // Invalid bech32, skip + } + } + // Don't include ourselves + mentionedPubkeys.delete(user.pubkey); + for (const pk of mentionedPubkeys) { + tags.push(['p', pk]); + } + // NIP-10 reply tags if (replyTo) { // Determine root of the thread @@ -381,13 +419,15 @@ export function ComposeBox({ } // Add p tags: original author + all existing p tags from the parent + // Skip pubkeys already added by mention detection above const pPubkeys = new Set(); pPubkeys.add(replyTo.pubkey); for (const tag of replyTo.tags) { if (tag[0] === 'p' && tag[1]) pPubkeys.add(tag[1]); } - // Don't include ourselves + // Don't include ourselves or already-mentioned pubkeys if (user.pubkey) pPubkeys.delete(user.pubkey); + for (const pk of mentionedPubkeys) pPubkeys.delete(pk); for (const pk of pPubkeys) { tags.push(['p', pk]); } @@ -558,30 +598,37 @@ export function ComposeBox({
{!previewMode ? ( /* Edit mode - Textarea */ -