refactor: post editor

This commit is contained in:
codytseng
2025-05-23 22:47:31 +08:00
parent 3d06421acb
commit 78725d1e88
31 changed files with 1603 additions and 766 deletions

View File

@@ -12,6 +12,7 @@ import {
import { extractEmojiInfosFromTags, isNsfwEvent } from '@/lib/event'
import { extractImageInfoFromTag } from '@/lib/tag'
import { cn } from '@/lib/utils'
import mediaUpload from '@/services/media-upload.service'
import { TImageInfo } from '@/types'
import { Event } from 'nostr-tools'
import { memo } from 'react'
@@ -46,7 +47,11 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
.map((node) => {
if (node.type === 'image') {
const imageInfo = imageInfos.find((image) => image.url === node.data)
return imageInfo ?? { url: node.data }
if (imageInfo) {
return imageInfo
}
const tag = mediaUpload.getImetaTagByUrl(node.data)
return tag ? extractImageInfoFromTag(tag) : { url: node.data }
}
if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]

View File

@@ -1,3 +1,4 @@
import { cn } from '@/lib/utils'
import Username, { SimpleUsername } from '../Username'
export function EmbeddedMention({ userId }: { userId: string }) {
@@ -6,6 +7,8 @@ export function EmbeddedMention({ userId }: { userId: string }) {
)
}
export function EmbeddedMentionText({ userId }: { userId: string }) {
return <SimpleUsername userId={userId} showAt className="inline truncate" withoutSkeleton />
export function EmbeddedMentionText({ userId, className }: { userId: string; className?: string }) {
return (
<SimpleUsername userId={userId} showAt className={cn('inline', className)} withoutSkeleton />
)
}

View File

@@ -1,220 +0,0 @@
import { Button } from '@/components/ui/button'
import { useToast } from '@/hooks/use-toast'
import { createPictureNoteDraftEvent } from '@/lib/draft-event'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import postContentCache from '@/services/post-content-cache.service'
import { ChevronDown, Loader, LoaderCircle, Plus, X } from 'lucide-react'
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Image from '../Image'
import PostTextarea from '../PostTextarea'
import Mentions from './Mentions'
import PostOptions from './PostOptions'
import SendOnlyToSwitch from './SendOnlyToSwitch'
import Uploader from './Uploader'
import { preprocessContent } from './utils'
export default function PicturePostContent({ close }: { close: () => void }) {
const { t } = useTranslation()
const { toast } = useToast()
const { publish, checkLogin } = useNostr()
const [content, setContent] = useState('')
const [processedContent, setProcessedContent] = useState('')
const [pictureInfos, setPictureInfos] = useState<{ url: string; tags: string[][] }[]>([])
const [posting, setPosting] = useState(false)
const [showMoreOptions, setShowMoreOptions] = useState(false)
const [addClientTag, setAddClientTag] = useState(false)
const [mentions, setMentions] = useState<string[]>([])
const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined)
const initializedRef = useRef(false)
const canPost = !!content && !posting && pictureInfos.length > 0
useEffect(() => {
const { content, pictureInfos } = postContentCache.getPicturePostCache()
setContent(content)
setPictureInfos(pictureInfos)
setTimeout(() => {
initializedRef.current = true
}, 100)
}, [])
useEffect(() => {
setProcessedContent(preprocessContent(content))
if (!initializedRef.current) return
postContentCache.setPicturePostCache(content, pictureInfos)
}, [content, pictureInfos])
const post = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!canPost) {
close()
return
}
setPosting(true)
try {
if (!pictureInfos.length) {
throw new Error(t('Picture note requires images'))
}
const draftEvent = await createPictureNoteDraftEvent(
processedContent,
pictureInfos,
mentions,
{
addClientTag,
protectedEvent: !!specifiedRelayUrls
}
)
await publish(draftEvent, { specifiedRelayUrls })
setContent('')
setPictureInfos([])
close()
} catch (error) {
if (error instanceof AggregateError) {
error.errors.forEach((e) =>
toast({
variant: 'destructive',
title: t('Failed to post'),
description: e.message
})
)
} else if (error instanceof Error) {
toast({
variant: 'destructive',
title: t('Failed to post'),
description: error.message
})
}
console.error(error)
return
} finally {
setPosting(false)
}
toast({
title: t('Post successful'),
description: t('Your post has been published')
})
})
}
return (
<div className="space-y-4">
<div className="text-xs text-muted-foreground">
{t('A special note for picture-first clients like Olas')}
</div>
<PictureUploader pictureInfos={pictureInfos} setPictureInfos={setPictureInfos} />
<PostTextarea
className="h-32"
setTextValue={setContent}
textValue={content}
placeholder={t('Write something...')}
/>
<SendOnlyToSwitch
specifiedRelayUrls={specifiedRelayUrls}
setSpecifiedRelayUrls={setSpecifiedRelayUrls}
/>
<div className="flex items-center justify-between">
<Button
variant="link"
className="text-foreground gap-0 px-0"
onClick={() => setShowMoreOptions((pre) => !pre)}
>
{t('More options')}
<ChevronDown className={`transition-transform ${showMoreOptions ? 'rotate-180' : ''}`} />
</Button>
<div className="flex gap-2 items-center">
<Mentions content={processedContent} mentions={mentions} setMentions={setMentions} />
<div className="flex gap-2 items-center max-sm:hidden">
<Button
variant="secondary"
onClick={(e) => {
e.stopPropagation()
close()
}}
>
{t('Cancel')}
</Button>
<Button type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />}
{t('Post')}
</Button>
</div>
</div>
</div>
<PostOptions
show={showMoreOptions}
addClientTag={addClientTag}
setAddClientTag={setAddClientTag}
/>
<div className="flex gap-2 items-center justify-around sm:hidden">
<Button
className="w-full"
variant="secondary"
onClick={(e) => {
e.stopPropagation()
close()
}}
>
{t('Cancel')}
</Button>
<Button className="w-full" type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />}
{t('Post')}
</Button>
</div>
</div>
)
}
function PictureUploader({
pictureInfos,
setPictureInfos
}: {
pictureInfos: { url: string; tags: string[][] }[]
setPictureInfos: Dispatch<
SetStateAction<
{
url: string
tags: string[][]
}[]
>
>
}) {
const [uploading, setUploading] = useState(false)
return (
<div className="grid grid-cols-3 gap-4">
{pictureInfos.map(({ url }, index) => (
<div className="relative" key={`${index}-${url}`}>
<Image image={{ url }} className="aspect-square w-full rounded-lg" />
<Button
variant="destructive"
className="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 rounded-full w-6 h-6 p-0"
onClick={() => {
setPictureInfos((prev) => prev.filter((_, i) => i !== index))
}}
>
<X />
</Button>
</div>
))}
<Uploader
onUploadSuccess={({ url, tags }) => {
setPictureInfos((prev) => [...prev, { url, tags }])
}}
onUploadingChange={setUploading}
>
<div
className={cn(
'flex flex-col gap-2 items-center justify-center aspect-square w-full rounded-lg border border-dashed',
uploading ? 'cursor-not-allowed text-muted-foreground' : 'clickable'
)}
>
{uploading ? <Loader size={36} className="animate-spin" /> : <Plus size={36} />}
</div>
</Uploader>
</div>
)
}

View File

@@ -1,24 +1,22 @@
import Note from '@/components/Note'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/hooks/use-toast'
import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider'
import postContentCache from '@/services/post-content-cache.service'
import { ChevronDown, ImageUp, LoaderCircle } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PostTextarea from '../PostTextarea'
import Mentions from './Mentions'
import { usePostEditor } from './PostEditorProvider'
import PostOptions from './PostOptions'
import Preview from './Preview'
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
import SendOnlyToSwitch from './SendOnlyToSwitch'
import Uploader from './Uploader'
import { preprocessContent } from './utils'
export default function NormalPostContent({
export default function PostContent({
defaultContent = '',
parentEvent,
close
@@ -30,38 +28,15 @@ export default function NormalPostContent({
const { t } = useTranslation()
const { toast } = useToast()
const { publish, checkLogin } = useNostr()
const [content, setContent] = useState('')
const [processedContent, setProcessedContent] = useState('')
const [pictureInfos, setPictureInfos] = useState<{ url: string; tags: string[][] }[]>([])
const { uploadingFiles, setUploadingFiles } = usePostEditor()
const [text, setText] = useState('')
const textareaRef = useRef<TPostTextareaHandle>(null)
const [posting, setPosting] = useState(false)
const [showMoreOptions, setShowMoreOptions] = useState(false)
const [addClientTag, setAddClientTag] = useState(false)
const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined)
const [uploadingPicture, setUploadingPicture] = useState(false)
const [mentions, setMentions] = useState<string[]>([])
const [cursorOffset, setCursorOffset] = useState(0)
const initializedRef = useRef(false)
const canPost = !!content && !posting
useEffect(() => {
const cached = postContentCache.getNormalPostCache({ defaultContent, parentEvent })
if (cached) {
setContent(cached.content || '')
setPictureInfos(cached.pictureInfos || [])
}
if (defaultContent) {
setCursorOffset(defaultContent.length)
}
setTimeout(() => {
initializedRef.current = true
}, 100)
}, [defaultContent, parentEvent])
useEffect(() => {
setProcessedContent(preprocessContent(content))
if (!initializedRef.current) return
postContentCache.setNormalPostCache({ defaultContent, parentEvent }, content, pictureInfos)
}, [content, pictureInfos])
const canPost = !!text && !posting && !uploadingFiles
const post = async (e: React.MouseEvent) => {
e.stopPropagation()
@@ -75,17 +50,17 @@ export default function NormalPostContent({
try {
const draftEvent =
parentEvent && parentEvent.kind !== kinds.ShortTextNote
? await createCommentDraftEvent(processedContent, parentEvent, pictureInfos, mentions, {
? await createCommentDraftEvent(text, parentEvent, mentions, {
addClientTag,
protectedEvent: !!specifiedRelayUrls
})
: await createShortTextNoteDraftEvent(processedContent, pictureInfos, mentions, {
: await createShortTextNoteDraftEvent(text, mentions, {
parentEvent,
addClientTag,
protectedEvent: !!specifiedRelayUrls
})
await publish(draftEvent, { specifiedRelayUrls })
setContent('')
postContentCache.clearPostCache({ defaultContent, parentEvent })
close()
} catch (error) {
if (error instanceof AggregateError) {
@@ -124,29 +99,13 @@ export default function NormalPostContent({
</div>
</ScrollArea>
)}
<Tabs defaultValue="edit" className="space-y-4">
<TabsList>
<TabsTrigger value="edit">{t('Edit')}</TabsTrigger>
<TabsTrigger value="preview">{t('Preview')}</TabsTrigger>
</TabsList>
<TabsContent value="edit">
<PostTextarea
className="h-52"
setTextValue={setContent}
textValue={content}
placeholder={
t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
}
cursorOffset={cursorOffset}
onUploadImage={({ url, tags }) => {
setPictureInfos((prev) => [...prev, { url, tags }])
}}
/>
</TabsContent>
<TabsContent value="preview">
<Preview content={processedContent} />
</TabsContent>
</Tabs>
<PostTextarea
ref={textareaRef}
text={text}
setText={setText}
defaultContent={defaultContent}
parentEvent={parentEvent}
/>
<SendOnlyToSwitch
parentEvent={parentEvent}
specifiedRelayUrls={specifiedRelayUrls}
@@ -155,15 +114,16 @@ export default function NormalPostContent({
<div className="flex items-center justify-between">
<div className="flex gap-2 items-center">
<Uploader
onUploadSuccess={({ url, tags }) => {
setPictureInfos((prev) => [...prev, { url, tags }])
setContent((prev) => (prev === '' ? url : `${prev}\n${url}`))
onUploadSuccess={({ url }) => {
textareaRef.current?.appendText(url)
}}
onUploadingChange={setUploadingPicture}
onUploadingChange={(uploading) =>
setUploadingFiles((prev) => (uploading ? prev + 1 : prev - 1))
}
accept="image/*,video/*,audio/*"
>
<Button variant="secondary" disabled={uploadingPicture}>
{uploadingPicture ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
<Button variant="secondary" disabled={uploadingFiles > 0}>
{uploadingFiles > 0 ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
</Button>
</Uploader>
<Button
@@ -179,7 +139,7 @@ export default function NormalPostContent({
</div>
<div className="flex gap-2 items-center">
<Mentions
content={processedContent}
content={text}
parentEvent={parentEvent}
mentions={mentions}
setMentions={setMentions}

View File

@@ -0,0 +1,26 @@
import { createContext, Dispatch, SetStateAction, useContext, useState } from 'react'
type TPostEditorContext = {
uploadingFiles: number
setUploadingFiles: Dispatch<SetStateAction<number>>
}
const PostEditorContext = createContext<TPostEditorContext | undefined>(undefined)
export const usePostEditor = () => {
const context = useContext(PostEditorContext)
if (!context) {
throw new Error('usePostEditor must be used within a PostEditorProvider')
}
return context
}
export function PostEditorProvider({ children }: { children: React.ReactNode }) {
const [uploadingFiles, setUploadingFiles] = useState(0)
return (
<PostEditorContext.Provider value={{ uploadingFiles, setUploadingFiles }}>
{children}
</PostEditorContext.Provider>
)
}

View File

@@ -0,0 +1,92 @@
import { formatNpub } from '@/lib/pubkey'
import { ExtendedRegExpMatchArray, InputRule, PasteRule, Range, SingleCommands } from '@tiptap/core'
import Mention from '@tiptap/extension-mention'
import { ReactNodeViewRenderer } from '@tiptap/react'
import MentionNode from './MentionNode'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
mention: {
createMention: (id: string) => ReturnType
}
}
}
const MENTION_REGEX = /(nostr:)?(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g
const CustomMention = Mention.extend({
selectable: true,
addNodeView() {
return ReactNodeViewRenderer(MentionNode)
},
addCommands() {
return {
...this.parent?.(),
createMention:
(npub: string) =>
({ chain }) => {
chain()
.focus()
.insertContent([
{
type: 'mention',
attrs: {
id: npub,
label: formatNpub(npub)
}
},
{
type: 'text',
text: ' '
}
])
.run()
return true
}
}
},
addInputRules() {
return [
new InputRule({
find: MENTION_REGEX,
handler: (props) => handler(props)
})
]
},
addPasteRules() {
return [
new PasteRule({
find: MENTION_REGEX,
handler: (props) => handler(props)
})
]
}
})
export default CustomMention
function handler({
range,
match,
commands
}: {
commands: SingleCommands
match: ExtendedRegExpMatchArray
range: Range
}) {
const mention = match[0]
if (!mention) return
const npub = mention.replace('nostr:', '')
const matchLength = mention.length
const end = range.to
const start = Math.max(0, end - matchLength)
commands.deleteRange({ from: start, to: end })
commands.createMention(npub)
}

View File

@@ -0,0 +1,155 @@
import mediaUpload from '@/services/media-upload.service'
import { Extension } from '@tiptap/core'
import { EditorView } from '@tiptap/pm/view'
import { Plugin, TextSelection } from 'prosemirror-state'
const DRAGOVER_CLASS_LIST = [
'outline-2',
'outline-offset-4',
'outline-dashed',
'outline-border',
'rounded-md'
]
export interface FileHandlerOptions {
onUploadStart?: (file: File) => void
onUploadSuccess?: (file: File, result: any) => void
onUploadError?: (file: File, error: any) => void
}
export const FileHandler = Extension.create<FileHandlerOptions>({
name: 'fileHandler',
addOptions() {
return {
onUploadStart: undefined,
onUploadSuccess: undefined,
onUploadError: undefined
}
},
addProseMirrorPlugins() {
const options = this.options
return [
new Plugin({
props: {
handleDOMEvents: {
dragover(view, event) {
event.preventDefault()
view.dom.classList.add(...DRAGOVER_CLASS_LIST)
return false
},
dragleave(view) {
view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
return false
},
drop(view, event) {
event.preventDefault()
view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
const items = Array.from(event.dataTransfer?.files ?? [])
const mediaFile = items.find(
(item) => item.type.includes('image') || item.type.includes('video')
)
if (!mediaFile) return false
uploadFile(view, mediaFile, options)
return true
}
},
handlePaste(view, event) {
const items = Array.from(event.clipboardData?.items ?? [])
const mediaItem = items.find(
(item) => item.type.includes('image') || item.type.includes('video')
)
if (!mediaItem) return false
const file = mediaItem.getAsFile()
if (!file) return false
uploadFile(view, file, options)
return true
}
}
})
]
}
})
async function uploadFile(view: EditorView, file: File, options: FileHandlerOptions) {
const name = file.name
options.onUploadStart?.(file)
const placeholder = `[Uploading "${name}"...]`
const uploadingNode = view.state.schema.text(placeholder)
const tr = view.state.tr.replaceSelectionWith(uploadingNode)
view.dispatch(tr)
mediaUpload
.upload(file)
.then((result) => {
options.onUploadSuccess?.(file, result)
const urlNode = view.state.schema.text(result.url)
const tr = view.state.tr
let didReplace = false
view.state.doc.descendants((node, pos) => {
if (node.isText && node.text && node.text.includes(placeholder) && !didReplace) {
const startPos = node.text.indexOf(placeholder)
const from = pos + startPos
const to = from + placeholder.length
tr.replaceWith(from, to, urlNode)
didReplace = true
return false
}
return true
})
if (didReplace) {
view.dispatch(tr)
} else {
const endPos = view.state.doc.content.size
const paragraphNode = view.state.schema.nodes.paragraph.create(
null,
view.state.schema.text(result.url)
)
const insertTr = view.state.tr.insert(endPos, paragraphNode)
const newPos = endPos + 1 + result.url.length
insertTr.setSelection(TextSelection.near(insertTr.doc.resolve(newPos)))
view.dispatch(insertTr)
}
})
.catch((error) => {
console.error('Upload failed:', error)
options.onUploadError?.(file, error)
const tr = view.state.tr
let didReplace = false
view.state.doc.descendants((node, pos) => {
if (node.isText && node.text && node.text.includes(placeholder) && !didReplace) {
const startPos = node.text.indexOf(placeholder)
const from = pos + startPos
const to = from + placeholder.length
const errorNode = view.state.schema.text(`[Error uploading "${name}"]`)
tr.replaceWith(from, to, errorNode)
didReplace = true
return false
}
return true
})
if (didReplace) {
view.dispatch(tr)
}
throw error
})
}

View File

@@ -0,0 +1,100 @@
import { ScrollArea } from '@/components/ui/scroll-area'
import { formatNpub, userIdToPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import Nip05 from '../../Nip05'
import { SimpleUserAvatar } from '../../UserAvatar'
import { SimpleUsername } from '../../Username'
export interface MentionListProps {
items: string[]
command: (payload: { id: string; label?: string }) => void
}
export interface MentionListHandle {
onKeyDown: (args: SuggestionKeyDownProps) => boolean
}
const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState<number>(0)
const selectItem = (index: number) => {
const item = props.items[index]
if (item) {
props.command({ id: item, label: formatNpub(item) })
}
}
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
}
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length)
}
const enterHandler = () => {
selectItem(selectedIndex)
}
useEffect(() => {
setSelectedIndex(0)
}, [props.items])
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
if (event.key === 'ArrowUp') {
upHandler()
return true
}
if (event.key === 'ArrowDown') {
downHandler()
return true
}
if (event.key === 'Enter') {
enterHandler()
return true
}
return false
}
}))
if (props.items.length === 0) {
return null
}
return (
<ScrollArea
className="border rounded-lg bg-background z-50 pointer-events-auto flex flex-col max-h-80 overflow-y-auto"
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
>
{props.items.map((item, index) => (
<button
className={cn(
'cursor-pointer text-start items-center m-1 p-2 outline-none transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 rounded-md',
selectedIndex === index && 'bg-accent text-accent-foreground'
)}
key={item}
onClick={() => selectItem(index)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="flex gap-2 w-80 items-center truncate pointer-events-none">
<SimpleUserAvatar userId={item} />
<div className="flex-1 w-0">
<SimpleUsername userId={item} className="font-semibold truncate" />
<Nip05 pubkey={userIdToPubkey(item)} />
</div>
</div>
</button>
))}
</ScrollArea>
)
})
MentionList.displayName = 'MentionList'
export default MentionList

View File

@@ -0,0 +1,20 @@
import { useFetchProfile } from '@/hooks'
import { formatUserId } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { NodeViewRendererProps, NodeViewWrapper } from '@tiptap/react'
export default function MentionNode(props: NodeViewRendererProps & { selected: boolean }) {
const { profile } = useFetchProfile(props.node.attrs.id)
return (
<NodeViewWrapper
className={cn(
'inline text-primary bg-primary/10 rounded-md px-1 transition-colors',
props.selected ? 'bg-primary/20' : ''
)}
>
{'@'}
{profile ? profile.username : formatUserId(props.node.attrs.id)}
</NodeViewWrapper>
)
}

View File

@@ -1,6 +1,6 @@
import { Card } from '@/components/ui/card'
import { createFakeEvent } from '@/lib/event'
import Content from '../Content'
import Content from '../../Content'
export default function Preview({ content }: { content: string }) {
return (

View File

@@ -0,0 +1,110 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { parseEditorJsonToText } from '@/lib/tiptap'
import postContentCache from '@/services/post-content-cache.service'
import Document from '@tiptap/extension-document'
import History from '@tiptap/extension-history'
import Paragraph from '@tiptap/extension-paragraph'
import Placeholder from '@tiptap/extension-placeholder'
import Text from '@tiptap/extension-text'
import { TextSelection } from '@tiptap/pm/state'
import { EditorContent, useEditor } from '@tiptap/react'
import { Event } from 'nostr-tools'
import { Dispatch, forwardRef, SetStateAction, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next'
import CustomMention from './CustomMention'
import { FileHandler } from './FileHandler'
import Preview from './Preview'
import suggestion from './suggestion'
import { usePostEditor } from '../PostEditorProvider'
export type TPostTextareaHandle = {
appendText: (text: string) => void
}
const PostTextarea = forwardRef<
TPostTextareaHandle,
{
text: string
setText: Dispatch<SetStateAction<string>>
defaultContent?: string
parentEvent?: Event
}
>(({ text = '', setText, defaultContent, parentEvent }, ref) => {
const { t } = useTranslation()
const { setUploadingFiles } = usePostEditor()
const editor = useEditor({
extensions: [
Document,
Paragraph,
Text,
History,
Placeholder.configure({
placeholder: t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
}),
CustomMention.configure({
suggestion
}),
FileHandler.configure({
onUploadStart: () => setUploadingFiles((prev) => prev + 1),
onUploadSuccess: () => setUploadingFiles((prev) => prev - 1),
onUploadError: () => setUploadingFiles((prev) => prev - 1)
})
],
editorProps: {
attributes: {
class:
'border rounded-lg p-3 min-h-52 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'
}
},
content: postContentCache.getPostCache({ defaultContent, parentEvent }),
onUpdate(props) {
setText(parseEditorJsonToText(props.editor.getJSON()))
postContentCache.setPostCache({ defaultContent, parentEvent }, props.editor.getJSON())
},
onCreate(props) {
setText(parseEditorJsonToText(props.editor.getJSON()))
}
})
useImperativeHandle(ref, () => ({
appendText: (text: string) => {
if (editor) {
editor
.chain()
.focus()
.command(({ tr, dispatch }) => {
if (dispatch) {
const endPos = tr.doc.content.size
const selection = TextSelection.create(tr.doc, endPos)
tr.setSelection(selection)
dispatch(tr)
}
return true
})
.insertContent(text)
.run()
}
}
}))
if (!editor) {
return null
}
return (
<Tabs defaultValue="edit" className="space-y-4">
<TabsList>
<TabsTrigger value="edit">{t('Edit')}</TabsTrigger>
<TabsTrigger value="preview">{t('Preview')}</TabsTrigger>
</TabsList>
<TabsContent value="edit">
<EditorContent className="tiptap" editor={editor} />
</TabsContent>
<TabsContent value="preview">
<Preview content={text} />
</TabsContent>
</Tabs>
)
})
PostTextarea.displayName = 'PostTextarea'
export default PostTextarea

View File

@@ -0,0 +1,101 @@
import client from '@/services/client.service'
import postEditor from '@/services/post-editor.service'
import type { Editor } from '@tiptap/core'
import { ReactRenderer } from '@tiptap/react'
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
import tippy, { GetReferenceClientRect, Instance, Props } from 'tippy.js'
import MentionList, { MentionListHandle, MentionListProps } from './MentionList'
const suggestion = {
items: async ({ query }: { query: string }) => {
return await client.searchNpubs(query, 20)
},
render: () => {
let component: ReactRenderer<MentionListHandle, MentionListProps>
let popup: Instance[]
let touchListener: (e: TouchEvent) => void
let closePopup: () => void
return {
onBeforeStart: () => {
touchListener = (e: TouchEvent) => {
if (popup && popup[0] && postEditor.isSuggestionPopupOpen) {
const popupElement = popup[0].popper
if (popupElement && !popupElement.contains(e.target as Node)) {
popup[0].hide()
}
}
}
document.addEventListener('touchstart', touchListener)
closePopup = () => {
console.log('closePopup')
if (popup && popup[0]) {
popup[0].hide()
}
}
postEditor.addEventListener('closeSuggestionPopup', closePopup)
},
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor
})
if (!props.clientRect) {
return
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
hideOnClick: true,
touch: true,
onShow() {
postEditor.isSuggestionPopupOpen = true
},
onHide() {
postEditor.isSuggestionPopupOpen = false
}
})
},
onUpdate(props: { clientRect?: (() => DOMRect | null) | null | undefined }) {
component.updateProps(props)
if (!props.clientRect) {
return
}
popup[0].setProps({
getReferenceClientRect: props.clientRect
} as Partial<Props>)
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') {
popup[0].hide()
return true
}
return component.ref?.onKeyDown(props) ?? false
},
onExit() {
postEditor.isSuggestionPopupOpen = false
popup[0].destroy()
component.destroy()
document.removeEventListener('touchstart', touchListener)
postEditor.removeEventListener('closeSuggestionPopup', closePopup)
}
}
}
}
export default suggestion

View File

@@ -1,5 +1,5 @@
import { useToast } from '@/hooks/use-toast'
import { useMediaUploadService } from '@/providers/MediaUploadServiceProvider'
import mediaUpload from '@/services/media-upload.service'
import { useRef } from 'react'
export default function Uploader({
@@ -16,7 +16,6 @@ export default function Uploader({
accept?: string
}) {
const { toast } = useToast()
const { upload } = useMediaUploadService()
const fileInputRef = useRef<HTMLInputElement>(null)
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -25,7 +24,7 @@ export default function Uploader({
onUploadingChange?.(true)
try {
for (const file of event.target.files) {
const result = await upload(file)
const result = await mediaUpload.upload(file)
console.log('File uploaded successfully', result)
onUploadSuccess(result)
}

View File

@@ -14,9 +14,11 @@ import {
SheetTitle
} 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 NormalPostContent from './NormalPostContent'
import PostContent from './PostContent'
import { PostEditorProvider } from './PostEditorProvider'
import Title from './Title'
export default function PostEditor({
@@ -34,18 +36,30 @@ export default function PostEditor({
const content = useMemo(() => {
return (
<NormalPostContent
defaultContent={defaultContent}
parentEvent={parentEvent}
close={() => setOpen(false)}
/>
<PostEditorProvider>
<PostContent
defaultContent={defaultContent}
parentEvent={parentEvent}
close={() => setOpen(false)}
/>
</PostEditorProvider>
)
}, [])
if (isSmallScreen) {
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent className="h-full w-full p-0 border-none" side="bottom" hideClose>
<SheetContent
className="h-full w-full p-0 border-none"
side="bottom"
hideClose
onEscapeKeyDown={(e) => {
if (postEditor.isSuggestionPopupOpen) {
e.preventDefault()
postEditor.closeSuggestionPopup()
}
}}
>
<ScrollArea className="px-4 h-full max-h-screen">
<div className="space-y-4 px-2 py-6">
<SheetHeader>
@@ -64,7 +78,16 @@ export default function PostEditor({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="p-0 max-w-2xl" withoutClose>
<DialogContent
className="p-0 max-w-2xl"
withoutClose
onEscapeKeyDown={(e) => {
if (postEditor.isSuggestionPopupOpen) {
e.preventDefault()
postEditor.closeSuggestionPopup()
}
}}
>
<ScrollArea className="px-4 h-full max-h-screen">
<div className="space-y-4 px-2 py-6">
<DialogHeader>

View File

@@ -1,13 +0,0 @@
import { nip19 } from 'nostr-tools'
export function preprocessContent(content: string) {
const regex = /(?<=^|\s)(nevent|naddr|nprofile|npub)[a-zA-Z0-9]+/g
return content.replace(regex, (match) => {
try {
nip19.decode(match)
return `nostr:${match}`
} catch {
return match
}
})
}

View File

@@ -1,279 +0,0 @@
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/hooks'
import { pubkeyToNpub } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useMediaUploadService } from '@/providers/MediaUploadServiceProvider'
import client from '@/services/client.service'
import { TProfile } from '@/types'
import React, {
ComponentProps,
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
useState
} from 'react'
import Nip05 from '../Nip05'
import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username'
import { getCurrentWord, replaceWord } from './utils'
export default function PostTextarea({
textValue,
setTextValue,
cursorOffset = 0,
onUploadImage,
...props
}: ComponentProps<'textarea'> & {
textValue: string
setTextValue: Dispatch<SetStateAction<string>>
cursorOffset?: number
onUploadImage?: ({ url, tags }: { url: string; tags: string[][] }) => void
}) {
const { toast } = useToast()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const { upload } = useMediaUploadService()
const [commandValue, setCommandValue] = useState('')
const [debouncedCommandValue, setDebouncedCommandValue] = useState(commandValue)
const [profiles, setProfiles] = useState<TProfile[]>([])
const [dragover, setDragover] = useState(false)
useEffect(() => {
if (textareaRef.current && cursorOffset !== 0) {
const textarea = textareaRef.current
const newPos = Math.max(0, textarea.selectionStart - cursorOffset)
textarea.setSelectionRange(newPos, newPos)
textarea.focus()
}
}, [cursorOffset])
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedCommandValue(commandValue)
}, 500)
return () => {
clearTimeout(handler)
}
}, [commandValue])
useEffect(() => {
setProfiles([])
if (debouncedCommandValue) {
const fetchProfiles = async () => {
const newProfiles = await client.searchProfilesFromIndex(debouncedCommandValue, 100)
setProfiles(newProfiles)
}
fetchProfiles()
}
}, [debouncedCommandValue])
useEffect(() => {
const dropdown = dropdownRef.current
if (!dropdown) return
if (profiles.length > 0) {
dropdown.classList.remove('hidden')
} else {
dropdown.classList.add('hidden')
}
}, [profiles])
const handleBlur = useCallback(() => {
const dropdown = dropdownRef.current
if (dropdown) {
dropdown.classList.add('hidden')
setCommandValue('')
}
}, [])
const handleKeyDown = useCallback((e: KeyboardEvent) => {
const textarea = textareaRef.current
const input = inputRef.current
const dropdown = dropdownRef.current
if (textarea && input && dropdown) {
const currentWord = getCurrentWord(textarea)
const isDropdownHidden = dropdown.classList.contains('hidden')
if (currentWord.startsWith('@') && !isDropdownHidden) {
if (
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'Enter' ||
e.key === 'Escape'
) {
e.preventDefault()
input.dispatchEvent(new KeyboardEvent('keydown', e))
}
}
}
}, [])
const onTextValueChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value
const textarea = textareaRef.current
const dropdown = dropdownRef.current
if (textarea && dropdown) {
const currentWord = getCurrentWord(textarea)
setTextValue(text)
if (currentWord.startsWith('@') && currentWord.length > 1) {
setCommandValue(currentWord.slice(1))
} else {
// REMINDER: apparently, we need it when deleting
if (commandValue !== '') {
setCommandValue('')
dropdown.classList.add('hidden')
}
}
}
},
[setTextValue, commandValue]
)
const onCommandSelect = useCallback((value: string) => {
const textarea = textareaRef.current
const dropdown = dropdownRef.current
if (textarea && dropdown) {
replaceWord(textarea, `${value} `)
setCommandValue('')
dropdown.classList.add('hidden')
}
}, [])
const handleMouseDown = useCallback((e: Event) => {
e.preventDefault()
e.stopPropagation()
}, [])
const handleSectionChange = useCallback(() => {
const textarea = textareaRef.current
const dropdown = dropdownRef.current
if (textarea && dropdown) {
const currentWord = getCurrentWord(textarea)
if (!currentWord.startsWith('@') && commandValue !== '') {
setCommandValue('')
dropdown.classList.add('hidden')
}
}
}, [commandValue])
useEffect(() => {
const textarea = textareaRef.current
const dropdown = dropdownRef.current
textarea?.addEventListener('keydown', handleKeyDown)
textarea?.addEventListener('blur', handleBlur)
document?.addEventListener('selectionchange', handleSectionChange)
dropdown?.addEventListener('mousedown', handleMouseDown)
return () => {
textarea?.removeEventListener('keydown', handleKeyDown)
textarea?.removeEventListener('blur', handleBlur)
document?.removeEventListener('selectionchange', handleSectionChange)
dropdown?.removeEventListener('mousedown', handleMouseDown)
}
}, [handleBlur, handleKeyDown, handleMouseDown, handleSectionChange])
const uploadImages = async (files: File[]) => {
for (const file of files) {
if (file.type.startsWith('image/') || file.type.startsWith('video/')) {
const placeholder = `[Uploading "${file.name}"...]`
if (textValue.includes(placeholder)) {
continue
}
setTextValue((prev) => (prev === '' ? placeholder : `${prev}\n${placeholder}`))
try {
const result = await upload(file)
setTextValue((prev) => {
if (prev.includes(placeholder)) {
return prev.replace(placeholder, result.url)
} else {
return prev + `\n${result.url}`
}
})
onUploadImage?.(result)
} catch (error) {
console.error('Error uploading file', error)
toast({
variant: 'destructive',
title: 'Failed to upload file',
description: (error as Error).message
})
setTextValue((prev) => prev.replace(placeholder, ''))
}
}
}
}
const handlePast = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
await uploadImages(
Array.from(e.clipboardData.items)
.map((item) => item.getAsFile())
.filter(Boolean) as File[]
)
}
const handleDrop = async (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault()
setDragover(false)
await uploadImages(Array.from(e.dataTransfer.files))
}
return (
<div
className={cn(
'relative w-full',
dragover && 'outline-2 outline-offset-4 outline-dashed outline-border rounded-md'
)}
>
<Textarea
{...props}
ref={textareaRef}
value={textValue}
onChange={onTextValueChange}
onPaste={handlePast}
onDrop={handleDrop}
onDragOver={(e) => {
e.preventDefault()
setDragover(true)
}}
onDragLeave={() => {
setDragover(false)
}}
/>
<Command
ref={dropdownRef}
className={cn(
'sm:fixed hidden translate-y-2 h-auto w-full sm:w-[462px] z-10 border border-popover shadow'
)}
shouldFilter={false}
>
<div className="hidden">
<CommandInput ref={inputRef} value={commandValue} />
</div>
<CommandList scrollAreaClassName="h-44">
{profiles.map((p) => {
return (
<CommandItem
key={p.pubkey}
value={`nostr:${pubkeyToNpub(p.pubkey)}`}
onSelect={onCommandSelect}
>
<div className="flex gap-2 items-center pointer-events-none truncate">
<SimpleUserAvatar userId={p.pubkey} />
<div>
<SimpleUsername userId={p.pubkey} className="font-semibold truncate" />
<Nip05 pubkey={p.pubkey} />
</div>
</div>
</CommandItem>
)
})}
</CommandList>
</Command>
</div>
)
}

View File

@@ -1,67 +0,0 @@
export function getCaretPosition(element: HTMLTextAreaElement) {
return {
caretStartIndex: element.selectionStart || 0,
caretEndIndex: element.selectionEnd || 0
}
}
export function getCurrentWord(element: HTMLTextAreaElement) {
const text = element.value
const { caretStartIndex } = getCaretPosition(element)
// Find the start position of the word
let start = caretStartIndex
while (start > 0 && text[start - 1].match(/\S/)) {
start--
}
// Find the end position of the word
let end = caretStartIndex
while (end < text.length && text[end].match(/\S/)) {
end++
}
const w = text.substring(start, end)
return w
}
export function replaceWord(element: HTMLTextAreaElement, value: string) {
const text = element.value
const caretPos = element.selectionStart
// Find the word that needs to be replaced
const wordRegex = /[\w@#]+/g
let match
let startIndex
let endIndex
while ((match = wordRegex.exec(text)) !== null) {
startIndex = match.index
endIndex = startIndex + match[0].length
if (caretPos >= startIndex && caretPos <= endIndex) {
break
}
}
// Replace the word with a new word using document.execCommand
if (startIndex !== undefined && endIndex !== undefined) {
// Preserve the current selection range
const selectionStart = element.selectionStart
const selectionEnd = element.selectionEnd
// Modify the selected range to encompass the word to be replaced
element.setSelectionRange(startIndex, endIndex)
// REMINDER: Fastest way to include CMD + Z compatibility
// Execute the command to replace the selected text with the new word
document.execCommand('insertText', false, value)
// Restore the original selection range
element.setSelectionRange(
selectionStart - (endIndex - startIndex) + value.length,
selectionEnd - (endIndex - startIndex) + value.length
)
}
}