Browse Source

change

imwald
Silberengel 1 month ago
parent
commit
1b9d8d136d
  1. 10
      src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx
  2. 23
      src/components/PostEditor/PostTextarea/Mention/suggestion.ts
  3. 63
      src/components/PostEditor/PostTextarea/Preview.tsx
  4. 4
      src/components/PostEditor/PostTextarea/index.tsx
  5. 35
      src/components/TextareaWithMentionAutocomplete/index.tsx
  6. 6
      src/components/ui/dialog.tsx
  7. 8
      src/lib/tiptap.ts
  8. 3
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  9. 2
      src/pages/primary/DiscussionsPage/index.tsx

10
src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx

@ -17,7 +17,7 @@ import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Loader2, Search } from 'lucide-react' import { Loader2, Search } from 'lucide-react'
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
import { OPEN_NEVENT_PICKER_EVENT } from './suggestion' import { OPEN_NEVENT_PICKER_EVENT, extendMentionRangeToEndOfWord } from './suggestion'
type NeventNaddrPickerDialogProps = { type NeventNaddrPickerDialogProps = {
open: boolean open: boolean
@ -88,7 +88,10 @@ export function NeventNaddrPickerDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[80vh] flex flex-col gap-4"> <DialogContent
className="max-w-lg max-h-[80vh] flex flex-col gap-4 z-[10001]"
overlayClassName="z-[10001]"
>
<DialogHeader> <DialogHeader>
<DialogTitle>{t('Search for event or address…')}</DialogTitle> <DialogTitle>{t('Search for event or address…')}</DialogTitle>
</DialogHeader> </DialogHeader>
@ -150,8 +153,9 @@ export function NeventPickerProvider({ children }: { children: React.ReactNode }
useEffect(() => { useEffect(() => {
const handler = (e: Event) => { const handler = (e: Event) => {
const { editor, range } = (e as CustomEvent<{ editor: Editor; range: { from: number; to: number } }>).detail const { editor, range } = (e as CustomEvent<{ editor: Editor; range: { from: number; to: number } }>).detail
const to = extendMentionRangeToEndOfWord(editor, range)
setOnSelectedRef(() => (link: string) => { setOnSelectedRef(() => (link: string) => {
editor.chain().focus().insertContentAt(range, link + ' ').run() editor.chain().focus().insertContentAt({ from: range.from, to }, link + ' ').run()
}) })
setOpen(true) setOpen(true)
} }

23
src/components/PostEditor/PostTextarea/Mention/suggestion.ts

@ -14,6 +14,24 @@ const MENTION_CHAR = '@'
export const OPEN_NEVENT_PICKER_EVENT = 'open-nevent-picker' export const OPEN_NEVENT_PICKER_EVENT = 'open-nevent-picker'
/** Extend range.to to include any trailing word chars (handle, NIP-05) so the full @handle is replaced. Exported for nevent picker. */
export function extendMentionRangeToEndOfWord(editor: Editor, range: { from: number; to: number }): number {
const { doc } = editor.state
let pos = range.to
while (pos < doc.content.size) {
const $pos = doc.resolve(pos)
const node = $pos.nodeAfter
if (!node || !node.isText) break
const text = node.text ?? ''
const offset = pos - $pos.start()
let i = offset
while (i < text.length && /[\w.-]/.test(text[i]!)) i++
if (i === offset) break
pos += i - offset
}
return pos
}
const suggestion = { const suggestion = {
command: ({ editor, range, props }: { editor: Editor; range: { from: number; to: number }; props: { id: string; label?: string } }) => { command: ({ editor, range, props }: { editor: Editor; range: { from: number; to: number }; props: { id: string; label?: string } }) => {
if (props.id === NEVENT_NADDR_PICKER_ID) { if (props.id === NEVENT_NADDR_PICKER_ID) {
@ -23,13 +41,14 @@ const suggestion = {
) )
return return
} }
const to = extendMentionRangeToEndOfWord(editor, range)
const nodeAfter = editor.view.state.selection.$to.nodeAfter const nodeAfter = editor.view.state.selection.$to.nodeAfter
const overrideSpace = nodeAfter?.text?.startsWith(' ') const overrideSpace = nodeAfter?.text?.startsWith(' ')
const to = overrideSpace ? range.to + 1 : range.to const toWithSpace = overrideSpace ? to + 1 : to
editor editor
.chain() .chain()
.focus() .focus()
.insertContentAt({ from: range.from, to }, [ .insertContentAt({ from: range.from, to: toWithSpace }, [
{ type: MENTION_EXTENSION_NAME, attrs: { ...props, mentionSuggestionChar: MENTION_CHAR } }, { type: MENTION_EXTENSION_NAME, attrs: { ...props, mentionSuggestionChar: MENTION_CHAR } },
{ type: 'text', text: ' ' } { type: 'text', text: ' ' }
]) ])

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

@ -165,14 +165,12 @@ export default function Preview({
}) })
}, [processedContent, allTags, kind, mediaUrl]) }, [processedContent, allTags, kind, mediaUrl])
const selectableClass = 'select-text'
// For polls, use ContentPreview to show poll properly // For polls, use ContentPreview to show poll properly
if (kind === ExtendedKind.POLL) { if (kind === ExtendedKind.POLL) {
return ( return (
<Card className={cn('p-3', className)}> <Card className={cn('p-3', className, selectableClass)}>
<ContentPreview <ContentPreview event={fakeEvent} />
event={fakeEvent}
className="pointer-events-none"
/>
</Card> </Card>
) )
} }
@ -180,11 +178,8 @@ export default function Preview({
// For highlights, use the Highlight component for proper formatting // For highlights, use the Highlight component for proper formatting
if (kind === kinds.Highlights) { if (kind === kinds.Highlights) {
return ( return (
<Card className={cn('p-3', className)}> <Card className={cn('p-3', className, selectableClass)}>
<Highlight <Highlight event={fakeEvent} />
event={fakeEvent}
className="pointer-events-none"
/>
</Card> </Card>
) )
} }
@ -193,12 +188,8 @@ export default function Preview({
// This ensures preview matches the final result (no Links section, correct image placement, proper line breaks) // This ensures preview matches the final result (no Links section, correct image placement, proper line breaks)
if (kind === kinds.ShortTextNote || kind === ExtendedKind.COMMENT || kind === ExtendedKind.VOICE_COMMENT) { if (kind === kinds.ShortTextNote || kind === ExtendedKind.COMMENT || kind === ExtendedKind.VOICE_COMMENT) {
return ( return (
<Card className={cn('p-3', className)}> <Card className={cn('p-3', className, selectableClass)}>
<MarkdownArticle <MarkdownArticle event={fakeEvent} hideMetadata={true} />
event={fakeEvent}
className="pointer-events-none"
hideMetadata={true}
/>
</Card> </Card>
) )
} }
@ -206,12 +197,8 @@ export default function Preview({
// For LongFormArticle, use MarkdownArticle // For LongFormArticle, use MarkdownArticle
if (kind === kinds.LongFormArticle) { if (kind === kinds.LongFormArticle) {
return ( return (
<Card className={cn('p-3', className)}> <Card className={cn('p-3', className, selectableClass)}>
<MarkdownArticle <MarkdownArticle event={fakeEvent} hideMetadata={true} />
event={fakeEvent}
className="pointer-events-none"
hideMetadata={true}
/>
</Card> </Card>
) )
} }
@ -219,12 +206,8 @@ export default function Preview({
// For WikiArticle (AsciiDoc), use AsciidocArticle // For WikiArticle (AsciiDoc), use AsciidocArticle
if (kind === ExtendedKind.WIKI_ARTICLE) { if (kind === ExtendedKind.WIKI_ARTICLE) {
return ( return (
<Card className={cn('p-3', className)}> <Card className={cn('p-3', className, selectableClass)}>
<AsciidocArticle <AsciidocArticle event={fakeEvent} hideImagesAndInfo={false} />
event={fakeEvent}
className="pointer-events-none"
hideImagesAndInfo={false}
/>
</Card> </Card>
) )
} }
@ -232,12 +215,8 @@ export default function Preview({
// For WikiArticleMarkdown, use MarkdownArticle // For WikiArticleMarkdown, use MarkdownArticle
if (kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { if (kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) {
return ( return (
<Card className={cn('p-3', className)}> <Card className={cn('p-3', className, selectableClass)}>
<MarkdownArticle <MarkdownArticle event={fakeEvent} hideMetadata={true} />
event={fakeEvent}
className="pointer-events-none"
hideMetadata={true}
/>
</Card> </Card>
) )
} }
@ -245,23 +224,15 @@ export default function Preview({
// For PublicationContent, use AsciidocArticle // For PublicationContent, use AsciidocArticle
if (kind === ExtendedKind.PUBLICATION_CONTENT) { if (kind === ExtendedKind.PUBLICATION_CONTENT) {
return ( return (
<Card className={cn('p-3', className)}> <Card className={cn('p-3', className, selectableClass)}>
<AsciidocArticle <AsciidocArticle event={fakeEvent} hideImagesAndInfo={false} />
event={fakeEvent}
className="pointer-events-none"
hideImagesAndInfo={false}
/>
</Card> </Card>
) )
} }
return ( return (
<Card className={cn('p-3', className)}> <Card className={cn('p-3', className, selectableClass)}>
<Content <Content event={fakeEvent} className="h-full" mustLoadMedia />
event={fakeEvent}
className="pointer-events-none h-full"
mustLoadMedia
/>
</Card> </Card>
) )
} }

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

@ -247,11 +247,11 @@ const PostTextarea = forwardRef<
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="json"> <TabsContent value="json">
<div className="border rounded-lg p-3 bg-muted/40 max-h-96 overflow-auto"> <div className="border rounded-lg p-3 bg-muted/40 max-h-96 overflow-auto select-text">
{isLoadingJson ? ( {isLoadingJson ? (
<div className="text-muted-foreground text-sm">{t('Loading...')}</div> <div className="text-muted-foreground text-sm">{t('Loading...')}</div>
) : ( ) : (
<pre className="text-xs whitespace-pre-wrap break-words font-mono"> <pre className="text-xs whitespace-pre-wrap break-words font-mono select-text">
{draftEventJson || t('No JSON available')} {draftEventJson || t('No JSON available')}
</pre> </pre>
)} )}

35
src/components/TextareaWithMentionAutocomplete/index.tsx

@ -44,7 +44,9 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
const textareaRef = useRef<HTMLTextAreaElement | null>(null) const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const emojiSearchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const emojiSearchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const mentionQueryRef = useRef(mentionQuery)
const neventPicker = useNeventPicker() const neventPicker = useNeventPicker()
mentionQueryRef.current = mentionQuery
const [dropdownRect, setDropdownRect] = useState<{ top: number; left: number; width: number } | null>(null) const [dropdownRect, setDropdownRect] = useState<{ top: number; left: number; width: number } | null>(null)
const closeMention = useCallback(() => { const closeMention = useCallback(() => {
@ -59,27 +61,38 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
setEmojiItems([]) setEmojiItems([])
}, []) }, [])
// When value is cleared or changed from outside (e.g. Clear button), close dropdowns if they're no longer valid // When value is cleared or changed from outside, or @/: segment is gone, close dropdowns so they don't linger
useEffect(() => { useEffect(() => {
if (!value) { if (!value) {
closeMention() closeMention()
closeEmoji() closeEmoji()
return return
} }
if (mentionOpen && (value.length <= mentionStart || value[mentionStart] !== '@')) { if (mentionOpen) {
closeMention() if (value.length <= mentionStart || value[mentionStart] !== '@' || !value.includes('@')) {
closeMention()
}
} }
if (emojiOpen && (value.length <= emojiStart || value[emojiStart] !== ':')) { if (emojiOpen) {
closeEmoji() if (value.length <= emojiStart || value[emojiStart] !== ':') {
closeEmoji()
}
} }
}, [value, mentionOpen, emojiOpen, mentionStart, emojiStart, closeMention, closeEmoji]) }, [value, mentionOpen, emojiOpen, mentionStart, emojiStart, closeMention, closeEmoji])
/** Find end of @-mention segment in value (from start, after the @): alphanumeric, underscore, hyphen, dot (NIP-05). */
const findMentionSegmentEnd = useCallback((val: string, from: number) => {
let i = from + 1
while (i < val.length && /[\w.-]/.test(val[i]!)) i++
return i
}, [])
const insertMention = useCallback( const insertMention = useCallback(
(id: string) => { (id: string) => {
const ta = textareaRef.current const ta = textareaRef.current
if (!ta) return if (!ta) return
const start = mentionStart const start = mentionStart
const end = start + 1 + mentionQuery.length const end = findMentionSegmentEnd(value, start)
const before = value.slice(0, start) const before = value.slice(0, start)
const after = value.slice(end) const after = value.slice(end)
@ -106,7 +119,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
ta.setSelectionRange(newPos, newPos) ta.setSelectionRange(newPos, newPos)
}, 0) }, 0)
}, },
[value, mentionStart, mentionQuery.length, onChange, closeMention, neventPicker] [value, mentionStart, onChange, closeMention, neventPicker, findMentionSegmentEnd]
) )
const insertEmoji = useCallback( const insertEmoji = useCallback(
@ -146,12 +159,20 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
client client
.searchNpubsForMention(mentionQuery.trim(), MENTION_LIMIT) .searchNpubsForMention(mentionQuery.trim(), MENTION_LIMIT)
.then((npubs) => { .then((npubs) => {
const q = mentionQueryRef.current.trim().toLowerCase()
if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) {
return
}
const list = npubs ?? [] const list = npubs ?? []
setMentionItems(list) setMentionItems(list)
setMentionOpen(list.length > 0) setMentionOpen(list.length > 0)
setSelectedIndex(0) setSelectedIndex(0)
}) })
.catch(() => { .catch(() => {
const q = mentionQueryRef.current.trim().toLowerCase()
if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) {
return
}
setMentionItems([]) setMentionItems([])
setMentionOpen(false) setMentionOpen(false)
}) })

6
src/components/ui/dialog.tsx

@ -72,10 +72,12 @@ const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
withoutClose?: boolean withoutClose?: boolean
/** Optional overlay className (e.g. z-[10001] so this dialog appears above other modals). */
overlayClassName?: string
} }
>(({ className, children, withoutClose, ...props }, ref) => ( >(({ className, children, withoutClose, overlayClassName, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(

8
src/lib/tiptap.ts

@ -4,10 +4,10 @@ import { JSONContent } from '@tiptap/react'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
export function parseEditorJsonToText(node?: JSONContent) { export function parseEditorJsonToText(node?: JSONContent) {
const text = _parseEditorJsonToText(node).trim() let text = _parseEditorJsonToText(node).trim()
const regex = /(?:^|\s)(nevent|naddr|nprofile|npub)1[a-zA-Z0-9]+/g const regex = /(?:^|\s)(nevent|naddr|nprofile|npub)1[a-zA-Z0-9]+/g
return text.replace(regex, (match) => { text = text.replace(regex, (match) => {
const trimmed = match.trim() const trimmed = match.trim()
const leadingSpace = match.startsWith(' ') ? ' ' : '' const leadingSpace = match.startsWith(' ') ? ' ' : ''
@ -18,6 +18,10 @@ export function parseEditorJsonToText(node?: JSONContent) {
return match return match
} }
}) })
// Ensure space before nostr: when not already preceded by space (fixes "Like:nostr:npub" and "Like:\nnostr:npub")
text = text.replace(/(.)(?=nostr:)/g, (_, prev) => (prev === ' ' ? prev : prev + ' '))
return text
} }
function _parseEditorJsonToText(node?: JSONContent): string { function _parseEditorJsonToText(node?: JSONContent): string {

3
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

@ -207,6 +207,7 @@ export default function CreateThreadDialog({
const favoriteKey = favoriteRelays.join(',') const favoriteKey = favoriteRelays.join(',')
const blockedKey = blockedRelays.join(',') const blockedKey = blockedRelays.join(',')
const relaySetsKey = relaySets.map(s => `${s.id}:${s.relayUrls.join(',')}`).join(';') const relaySetsKey = relaySets.map(s => `${s.id}:${s.relayUrls.join(',')}`).join(';')
const availableRelaysKey = availableRelays.join(',')
// Initialize selected relays using the centralized relay selection service (once per meaningful change) // Initialize selected relays using the centralized relay selection service (once per meaningful change)
useEffect(() => { useEffect(() => {
@ -249,7 +250,7 @@ export default function CreateThreadDialog({
} }
initializeRelays() initializeRelays()
}, [initialRelay, availableRelays, writeKey, readKey, favoriteKey, blockedKey, relaySetsKey, pubkey]) }, [initialRelay, availableRelaysKey, writeKey, readKey, favoriteKey, blockedKey, relaySetsKey, pubkey])
// Load cached thread draft when dialog opens // Load cached thread draft when dialog opens
useEffect(() => { useEffect(() => {

2
src/pages/primary/DiscussionsPage/index.tsx

@ -1042,7 +1042,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
titlebar={<DiscussionsPageTitlebar />} titlebar={<DiscussionsPageTitlebar />}
displayScrollToTopButton displayScrollToTopButton
> >
<div className="min-w-0 pt-2 flex flex-col gap-4 p-4"> <div className="min-w-0 pt-14 sm:pt-4 flex flex-col gap-4 p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<button <button
onClick={() => setShowCreateDialog(true)} onClick={() => setShowCreateDialog(true)}

Loading…
Cancel
Save