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' @@ -17,7 +17,7 @@ import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Loader2, Search } from 'lucide-react'
import type { Editor } from '@tiptap/core'
import { OPEN_NEVENT_PICKER_EVENT } from './suggestion'
import { OPEN_NEVENT_PICKER_EVENT, extendMentionRangeToEndOfWord } from './suggestion'
type NeventNaddrPickerDialogProps = {
open: boolean
@ -88,7 +88,10 @@ export function NeventNaddrPickerDialog({ @@ -88,7 +88,10 @@ export function NeventNaddrPickerDialog({
return (
<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>
<DialogTitle>{t('Search for event or address…')}</DialogTitle>
</DialogHeader>
@ -150,8 +153,9 @@ export function NeventPickerProvider({ children }: { children: React.ReactNode } @@ -150,8 +153,9 @@ export function NeventPickerProvider({ children }: { children: React.ReactNode }
useEffect(() => {
const handler = (e: Event) => {
const { editor, range } = (e as CustomEvent<{ editor: Editor; range: { from: number; to: number } }>).detail
const to = extendMentionRangeToEndOfWord(editor, range)
setOnSelectedRef(() => (link: string) => {
editor.chain().focus().insertContentAt(range, link + ' ').run()
editor.chain().focus().insertContentAt({ from: range.from, to }, link + ' ').run()
})
setOpen(true)
}

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

@ -14,6 +14,24 @@ const MENTION_CHAR = '@' @@ -14,6 +14,24 @@ const MENTION_CHAR = '@'
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 = {
command: ({ editor, range, props }: { editor: Editor; range: { from: number; to: number }; props: { id: string; label?: string } }) => {
if (props.id === NEVENT_NADDR_PICKER_ID) {
@ -23,13 +41,14 @@ const suggestion = { @@ -23,13 +41,14 @@ const suggestion = {
)
return
}
const to = extendMentionRangeToEndOfWord(editor, range)
const nodeAfter = editor.view.state.selection.$to.nodeAfter
const overrideSpace = nodeAfter?.text?.startsWith(' ')
const to = overrideSpace ? range.to + 1 : range.to
const toWithSpace = overrideSpace ? to + 1 : to
editor
.chain()
.focus()
.insertContentAt({ from: range.from, to }, [
.insertContentAt({ from: range.from, to: toWithSpace }, [
{ type: MENTION_EXTENSION_NAME, attrs: { ...props, mentionSuggestionChar: MENTION_CHAR } },
{ type: 'text', text: ' ' }
])

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

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

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

@ -247,11 +247,11 @@ const PostTextarea = forwardRef< @@ -247,11 +247,11 @@ const PostTextarea = forwardRef<
</div>
</TabsContent>
<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 ? (
<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')}
</pre>
)}

35
src/components/TextareaWithMentionAutocomplete/index.tsx

@ -44,7 +44,9 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea @@ -44,7 +44,9 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const emojiSearchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const mentionQueryRef = useRef(mentionQuery)
const neventPicker = useNeventPicker()
mentionQueryRef.current = mentionQuery
const [dropdownRect, setDropdownRect] = useState<{ top: number; left: number; width: number } | null>(null)
const closeMention = useCallback(() => {
@ -59,27 +61,38 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea @@ -59,27 +61,38 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
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(() => {
if (!value) {
closeMention()
closeEmoji()
return
}
if (mentionOpen && (value.length <= mentionStart || value[mentionStart] !== '@')) {
closeMention()
if (mentionOpen) {
if (value.length <= mentionStart || value[mentionStart] !== '@' || !value.includes('@')) {
closeMention()
}
}
if (emojiOpen && (value.length <= emojiStart || value[emojiStart] !== ':')) {
closeEmoji()
if (emojiOpen) {
if (value.length <= emojiStart || value[emojiStart] !== ':') {
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(
(id: string) => {
const ta = textareaRef.current
if (!ta) return
const start = mentionStart
const end = start + 1 + mentionQuery.length
const end = findMentionSegmentEnd(value, start)
const before = value.slice(0, start)
const after = value.slice(end)
@ -106,7 +119,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea @@ -106,7 +119,7 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
ta.setSelectionRange(newPos, newPos)
}, 0)
},
[value, mentionStart, mentionQuery.length, onChange, closeMention, neventPicker]
[value, mentionStart, onChange, closeMention, neventPicker, findMentionSegmentEnd]
)
const insertEmoji = useCallback(
@ -146,12 +159,20 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea @@ -146,12 +159,20 @@ const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, Textarea
client
.searchNpubsForMention(mentionQuery.trim(), MENTION_LIMIT)
.then((npubs) => {
const q = mentionQueryRef.current.trim().toLowerCase()
if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) {
return
}
const list = npubs ?? []
setMentionItems(list)
setMentionOpen(list.length > 0)
setSelectedIndex(0)
})
.catch(() => {
const q = mentionQueryRef.current.trim().toLowerCase()
if (q === 'nevent' || q === 'naddr' || q.startsWith('nevent') || q.startsWith('naddr')) {
return
}
setMentionItems([])
setMentionOpen(false)
})

6
src/components/ui/dialog.tsx

@ -72,10 +72,12 @@ const DialogContent = React.forwardRef< @@ -72,10 +72,12 @@ const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
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>
<DialogOverlay />
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
className={cn(

8
src/lib/tiptap.ts

@ -4,10 +4,10 @@ import { JSONContent } from '@tiptap/react' @@ -4,10 +4,10 @@ import { JSONContent } from '@tiptap/react'
import { nip19 } from 'nostr-tools'
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
return text.replace(regex, (match) => {
text = text.replace(regex, (match) => {
const trimmed = match.trim()
const leadingSpace = match.startsWith(' ') ? ' ' : ''
@ -18,6 +18,10 @@ export function parseEditorJsonToText(node?: JSONContent) { @@ -18,6 +18,10 @@ export function parseEditorJsonToText(node?: JSONContent) {
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 {

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

@ -207,6 +207,7 @@ export default function CreateThreadDialog({ @@ -207,6 +207,7 @@ export default function CreateThreadDialog({
const favoriteKey = favoriteRelays.join(',')
const blockedKey = blockedRelays.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)
useEffect(() => {
@ -249,7 +250,7 @@ export default function CreateThreadDialog({ @@ -249,7 +250,7 @@ export default function CreateThreadDialog({
}
initializeRelays()
}, [initialRelay, availableRelays, writeKey, readKey, favoriteKey, blockedKey, relaySetsKey, pubkey])
}, [initialRelay, availableRelaysKey, writeKey, readKey, favoriteKey, blockedKey, relaySetsKey, pubkey])
// Load cached thread draft when dialog opens
useEffect(() => {

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

@ -1042,7 +1042,7 @@ const DiscussionsPage = forwardRef((_, ref) => { @@ -1042,7 +1042,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
titlebar={<DiscussionsPageTitlebar />}
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">
<button
onClick={() => setShowCreateDialog(true)}

Loading…
Cancel
Save