refactor: post editor
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
26
src/components/PostEditor/PostEditorProvider.tsx
Normal file
26
src/components/PostEditor/PostEditorProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
src/components/PostEditor/PostTextarea/CustomMention.ts
Normal file
92
src/components/PostEditor/PostTextarea/CustomMention.ts
Normal 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)
|
||||
}
|
||||
155
src/components/PostEditor/PostTextarea/FileHandler.ts
Normal file
155
src/components/PostEditor/PostTextarea/FileHandler.ts
Normal 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
|
||||
})
|
||||
}
|
||||
100
src/components/PostEditor/PostTextarea/MentionList.tsx
Normal file
100
src/components/PostEditor/PostTextarea/MentionList.tsx
Normal 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
|
||||
20
src/components/PostEditor/PostTextarea/MentionNode.tsx
Normal file
20
src/components/PostEditor/PostTextarea/MentionNode.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
110
src/components/PostEditor/PostTextarea/index.tsx
Normal file
110
src/components/PostEditor/PostTextarea/index.tsx
Normal 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
|
||||
101
src/components/PostEditor/PostTextarea/suggestion.ts
Normal file
101
src/components/PostEditor/PostTextarea/suggestion.ts
Normal 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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user