forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {
2 useCallback,
3 useEffect,
4 useImperativeHandle,
5 useMemo,
6 useRef,
7 useState,
8} from 'react'
9import {StyleSheet, View} from 'react-native'
10import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
11import {AppBskyRichtextFacet, RichText, UnicodeString} from '@atproto/api'
12import {Trans} from '@lingui/react/macro'
13import {Document} from '@tiptap/extension-document'
14import Hardbreak from '@tiptap/extension-hard-break'
15import History from '@tiptap/extension-history'
16import {Mention} from '@tiptap/extension-mention'
17import {Paragraph} from '@tiptap/extension-paragraph'
18import {Placeholder} from '@tiptap/extension-placeholder'
19import {Text as TiptapText} from '@tiptap/extension-text'
20import {generateJSON} from '@tiptap/html'
21import {Fragment, Node, Slice} from '@tiptap/pm/model'
22import {EditorContent, type JSONContent, useEditor} from '@tiptap/react'
23import {splitGraphemes} from 'unicode-segmenter/grapheme'
24
25import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
26import {blobToDataUri, isUriImage} from '#/lib/media/util'
27import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
28import {
29 type LinkFacetMatch,
30 suggestLinkCardUri,
31} from '#/view/com/composer/text-input/text-input-util'
32import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
33import {atoms as a, useAlf} from '#/alf'
34import {normalizeTextStyles} from '#/alf/typography'
35import {Portal} from '#/components/Portal'
36import {Text} from '#/components/Typography'
37import {type TextInputProps} from './TextInput.types'
38import {type AutocompleteRef, createSuggestion} from './web/Autocomplete'
39import {type Emoji} from './web/EmojiPicker'
40import {LinkDecorator} from './web/LinkDecorator'
41import {RichTextDecorator} from './web/RichTextDecorator'
42import {TagDecorator} from './web/TagDecorator'
43
44export function TextInput({
45 ref,
46 richtext,
47 placeholder,
48 webForceMinHeight,
49 hasRightPadding,
50 isActive,
51 setRichText,
52 onPhotoPasted,
53 onPressPublish,
54 onNewLink,
55 onFocus,
56 autoFocus,
57}: TextInputProps) {
58 const {theme: t, fonts} = useAlf()
59 const autocomplete = useActorAutocompleteFn()
60 const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
61
62 const [isDropping, setIsDropping] = useState(false)
63 const autocompleteRef = useRef<AutocompleteRef>(null)
64
65 const extensions = useMemo(
66 () => [
67 Document,
68 RichTextDecorator,
69 LinkDecorator,
70 TagDecorator,
71 Mention.configure({
72 HTMLAttributes: {
73 class: 'mention',
74 },
75 suggestion: createSuggestion({autocomplete, autocompleteRef}),
76 }),
77 Paragraph,
78 Placeholder.configure({
79 placeholder,
80 }),
81 TiptapText,
82 History,
83 Hardbreak,
84 ],
85 [autocomplete, placeholder],
86 )
87
88 useEffect(() => {
89 if (!isActive) {
90 return
91 }
92 textInputWebEmitter.addListener('publish', onPressPublish)
93 return () => {
94 textInputWebEmitter.removeListener('publish', onPressPublish)
95 }
96 }, [onPressPublish, isActive])
97
98 useEffect(() => {
99 if (!isActive) {
100 return
101 }
102 textInputWebEmitter.addListener('media-pasted', onPhotoPasted)
103 return () => {
104 textInputWebEmitter.removeListener('media-pasted', onPhotoPasted)
105 }
106 }, [isActive, onPhotoPasted])
107
108 useEffect(() => {
109 if (!isActive) {
110 return
111 }
112
113 const handleDrop = (event: DragEvent) => {
114 const transfer = event.dataTransfer
115 if (transfer) {
116 const items = transfer.items
117
118 getImageOrVideoFromUri(items, (uri: string) => {
119 textInputWebEmitter.emit('media-pasted', uri)
120 })
121 }
122
123 event.preventDefault()
124 setIsDropping(false)
125 }
126 const handleDragEnter = (event: DragEvent) => {
127 const transfer = event.dataTransfer
128
129 event.preventDefault()
130 if (transfer && transfer.types.includes('Files')) {
131 setIsDropping(true)
132 }
133 }
134 const handleDragLeave = (event: DragEvent) => {
135 event.preventDefault()
136 setIsDropping(false)
137 }
138
139 document.body.addEventListener('drop', handleDrop)
140 document.body.addEventListener('dragenter', handleDragEnter)
141 document.body.addEventListener('dragover', handleDragEnter)
142 document.body.addEventListener('dragleave', handleDragLeave)
143
144 return () => {
145 document.body.removeEventListener('drop', handleDrop)
146 document.body.removeEventListener('dragenter', handleDragEnter)
147 document.body.removeEventListener('dragover', handleDragEnter)
148 document.body.removeEventListener('dragleave', handleDragLeave)
149 }
150 }, [setIsDropping, isActive])
151
152 const pastSuggestedUris = useRef(new Set<string>())
153 const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
154 const editor = useEditor(
155 {
156 extensions,
157 coreExtensionOptions: {
158 clipboardTextSerializer: {
159 blockSeparator: '\n',
160 },
161 },
162 onFocus() {
163 onFocus?.()
164 },
165 editorProps: {
166 attributes: {
167 class: modeClass,
168 },
169 clipboardTextParser: (text, context) => {
170 const blocks = text.split(/(?:\r\n?|\n)/)
171 const nodes: Node[] = blocks.map(line => {
172 return Node.fromJSON(
173 context.doc.type.schema,
174 line.length > 0
175 ? {type: 'paragraph', content: [{type: 'text', text: line}]}
176 : {type: 'paragraph', content: []},
177 )
178 })
179
180 const fragment = Fragment.fromArray(nodes)
181 return Slice.maxOpen(fragment)
182 },
183 handlePaste: (view, event) => {
184 const clipboardData = event.clipboardData
185 let preventDefault = false
186
187 if (clipboardData) {
188 // Check if text is selected and pasted content is a URL
189 const selection = view.state.selection
190 const hasSelection = !selection.empty
191
192 if (hasSelection && clipboardData.types.includes('text/plain')) {
193 const pastedText = clipboardData.getData('text/plain').trim()
194 const urlPattern =
195 /^(?:(?:(?:https?|ftp):)?\/\/)?(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i
196
197 if (urlPattern.test(pastedText)) {
198 const selectedText = view.state.doc.textBetween(
199 selection.from,
200 selection.to,
201 '',
202 )
203
204 if (selectedText) {
205 // Create markdown-style link: [selectedText](url)
206 const markdownLink = `[${selectedText}](${pastedText})`
207 const {from, to} = selection
208
209 view.dispatch(
210 view.state.tr.replaceWith(
211 from,
212 to,
213 view.state.schema.text(markdownLink),
214 ),
215 )
216
217 preventDefault = true
218 return true
219 }
220 }
221 }
222
223 if (clipboardData.types.includes('text/html')) {
224 // Rich-text formatting is pasted, try retrieving plain text
225 const text = clipboardData.getData('text/plain')
226 // `pasteText` will invoke this handler again, but `clipboardData` will be null.
227 view.pasteText(text)
228 preventDefault = true
229 }
230 getImageOrVideoFromUri(clipboardData.items, (uri: string) => {
231 textInputWebEmitter.emit('media-pasted', uri)
232 })
233 if (preventDefault) {
234 // Return `true` to prevent ProseMirror's default paste behavior.
235 return true
236 }
237 }
238 },
239 handleKeyDown: (view, event) => {
240 if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') {
241 textInputWebEmitter.emit('publish')
242 return true
243 }
244
245 if (
246 event.code === 'Backspace' &&
247 !(event.metaKey || event.altKey || event.ctrlKey)
248 ) {
249 const isNotSelection = view.state.selection.empty
250 if (isNotSelection) {
251 const cursorPosition = view.state.selection.$anchor.pos
252 const textBefore = view.state.doc.textBetween(
253 0,
254 cursorPosition,
255 // important - use \n as a block separator, otherwise
256 // all the lines get mushed together -sfn
257 '\n',
258 )
259 const graphemes = [...splitGraphemes(textBefore)]
260
261 if (graphemes.length > 0) {
262 const lastGrapheme = graphemes[graphemes.length - 1]
263 // deleteRange doesn't work on newlines, because tiptap
264 // treats them as separate 'blocks' and we're using \n
265 // as a stand-in. bail out if the last grapheme is a newline
266 // to let the default behavior handle it -sfn
267 if (lastGrapheme !== '\n') {
268 // otherwise, delete the last grapheme using deleteRange,
269 // so that emojis are deleted as a whole
270 const deleteFrom = cursorPosition - lastGrapheme.length
271 editor?.commands.deleteRange({
272 from: deleteFrom,
273 to: cursorPosition,
274 })
275 return true
276 }
277 }
278 }
279 }
280 },
281 },
282 content: generateJSON(richTextToHTML(richtext), extensions, {
283 preserveWhitespace: 'full',
284 }),
285 autofocus: autoFocus ? 'end' : null,
286 editable: true,
287 injectCSS: true,
288 shouldRerenderOnTransaction: false,
289 onUpdate({editor: editorProp}) {
290 const json = editorProp.getJSON()
291 const newText = editorJsonToText(json)
292 const isPaste = window.event?.type === 'paste'
293
294 const newRt = new RichText({text: newText})
295 newRt.detectFacetsWithoutResolution()
296
297 const markdownFacets: AppBskyRichtextFacet.Main[] = []
298 const regex = /\[([^\]]+)\]\s*\(([^)]+)\)/g
299 let match
300 while ((match = regex.exec(newText)) !== null) {
301 const [fullMatch, _linkText, linkUrl] = match
302 const matchStart = match.index
303 const matchEnd = matchStart + fullMatch.length
304 const prefix = newText.slice(0, matchStart)
305 const matchStr = newText.slice(matchStart, matchEnd)
306 const byteStart = new UnicodeString(prefix).length
307 const byteEnd = byteStart + new UnicodeString(matchStr).length
308
309 let validUrl = linkUrl
310 if (
311 !validUrl.startsWith('http://') &&
312 !validUrl.startsWith('https://') &&
313 !validUrl.startsWith('mailto:')
314 ) {
315 validUrl = `https://${validUrl}`
316 }
317
318 markdownFacets.push({
319 index: {byteStart, byteEnd},
320 features: [{$type: 'app.bsky.richtext.facet#link', uri: validUrl}],
321 })
322 }
323
324 if (markdownFacets.length > 0) {
325 const nonOverlapping = (newRt.facets || []).filter(f => {
326 return !markdownFacets.some(mf => {
327 return (
328 (f.index.byteStart >= mf.index.byteStart &&
329 f.index.byteStart < mf.index.byteEnd) ||
330 (f.index.byteEnd > mf.index.byteStart &&
331 f.index.byteEnd <= mf.index.byteEnd) ||
332 (mf.index.byteStart >= f.index.byteStart &&
333 mf.index.byteStart < f.index.byteEnd)
334 )
335 })
336 })
337 newRt.facets = [...nonOverlapping, ...markdownFacets].sort(
338 (a, b) => a.index.byteStart - b.index.byteStart,
339 )
340 }
341
342 setRichText(newRt)
343
344 const nextDetectedUris = new Map<string, LinkFacetMatch>()
345 if (newRt.facets) {
346 for (const facet of newRt.facets) {
347 for (const feature of facet.features) {
348 if (AppBskyRichtextFacet.isLink(feature)) {
349 nextDetectedUris.set(feature.uri, {facet, rt: newRt})
350 }
351 }
352 }
353 }
354
355 const suggestedUri = suggestLinkCardUri(
356 isPaste,
357 nextDetectedUris,
358 prevDetectedUris.current,
359 pastSuggestedUris.current,
360 )
361 prevDetectedUris.current = nextDetectedUris
362 if (suggestedUri) {
363 onNewLink(suggestedUri)
364 }
365 },
366 },
367 [modeClass],
368 )
369
370 const onEmojiInserted = useCallback(
371 (emoji: Emoji) => {
372 editor?.chain().focus().insertContent(emoji.native).run()
373 },
374 [editor],
375 )
376 useEffect(() => {
377 if (!isActive) {
378 return
379 }
380 textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
381 return () => {
382 textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
383 }
384 }, [onEmojiInserted, isActive])
385
386 useImperativeHandle(ref, () => ({
387 focus: () => {
388 editor?.chain().focus()
389 },
390 blur: () => {
391 editor?.chain().blur()
392 },
393 getCursorPosition: () => {
394 const pos = editor?.state.selection.$anchor.pos
395 return pos ? editor?.view.coordsAtPos(pos) : undefined
396 },
397 maybeClosePopup: () => autocompleteRef.current?.maybeClose() ?? false,
398 }))
399
400 const inputStyle = useMemo(() => {
401 const style = normalizeTextStyles(
402 [a.text_lg, a.leading_snug, t.atoms.text],
403 {
404 fontScale: fonts.scaleMultiplier,
405 fontFamily: fonts.family,
406 flags: {},
407 },
408 )
409 /*
410 * TipTap component isn't a RN View and while it seems to convert
411 * `fontSize` to `px`, it doesn't convert `lineHeight`.
412 *
413 * `lineHeight` should always be defined here, this is defensive.
414 */
415 style.lineHeight = style.lineHeight
416 ? ((style.lineHeight + 'px') as unknown as number)
417 : undefined
418 style.minHeight = webForceMinHeight ? 140 : undefined
419 return style
420 }, [t, fonts, webForceMinHeight])
421
422 return (
423 <>
424 <View
425 style={[
426 styles.container,
427 hasRightPadding && styles.rightPadding,
428 {
429 // @ts-ignore
430 '--mention-color': t.palette.primary_500,
431 },
432 ]}>
433 {/* @ts-ignore inputStyle is fine */}
434 <EditorContent editor={editor} style={inputStyle} />
435 </View>
436
437 {isDropping && (
438 <Portal>
439 <Animated.View
440 style={styles.dropContainer}
441 entering={FadeIn.duration(80)}
442 exiting={FadeOut.duration(80)}>
443 <View
444 style={[
445 t.atoms.bg,
446 t.atoms.border_contrast_low,
447 styles.dropModal,
448 ]}>
449 <Text
450 style={[
451 a.text_lg,
452 a.font_semi_bold,
453 t.atoms.text_contrast_medium,
454 t.atoms.border_contrast_high,
455 styles.dropText,
456 ]}>
457 <Trans>Drop to add images</Trans>
458 </Text>
459 </View>
460 </Animated.View>
461 </Portal>
462 )}
463 </>
464 )
465}
466
467/**
468 * Helper function to initialise the editor with RichText, which expects HTML
469 *
470 * All the extensions are able to initialise themselves from plain text, *except*
471 * for the Mention extension - we need to manually convert it into a `<span>` element
472 *
473 * It also escapes HTML characters
474 */
475function richTextToHTML(richtext: RichText): string {
476 let html = ''
477
478 for (const segment of richtext.segments()) {
479 if (segment.mention) {
480 html += `<span data-type="mention" data-id="${escapeHTML(segment.mention.did)}"></span>`
481 } else {
482 html += escapeHTML(segment.text)
483 }
484 }
485
486 return html
487}
488
489function escapeHTML(str: string): string {
490 return str
491 .replace(/&/g, '&')
492 .replace(/</g, '<')
493 .replace(/>/g, '>')
494 .replace(/"/g, '"')
495 .replace(/\n/g, '<br/>')
496}
497
498function editorJsonToText(
499 json: JSONContent,
500 isLastDocumentChild: boolean = false,
501): string {
502 let text = ''
503 if (json.type === 'doc') {
504 if (json.content?.length) {
505 for (let i = 0; i < json.content.length; i++) {
506 const node = json.content[i]
507 const isLastNode = i === json.content.length - 1
508 text += editorJsonToText(node, isLastNode)
509 }
510 }
511 } else if (json.type === 'paragraph') {
512 if (json.content?.length) {
513 for (let i = 0; i < json.content.length; i++) {
514 const node = json.content[i]
515 text += editorJsonToText(node)
516 }
517 }
518 if (!isLastDocumentChild) {
519 text += '\n'
520 }
521 } else if (json.type === 'hardBreak') {
522 text += '\n'
523 } else if (json.type === 'text') {
524 text += json.text || ''
525 } else if (json.type === 'mention') {
526 text += `@${json.attrs?.id || ''}`
527 }
528 return text
529}
530
531const styles = StyleSheet.create({
532 container: {
533 flex: 1,
534 alignSelf: 'flex-start',
535 padding: 5,
536 marginLeft: 8,
537 marginBottom: 10,
538 },
539 rightPadding: {
540 paddingRight: 32,
541 },
542 dropContainer: {
543 backgroundColor: '#0007',
544 pointerEvents: 'none',
545 alignItems: 'center',
546 justifyContent: 'center',
547 // @ts-ignore web only -prf
548 position: 'fixed',
549 padding: 16,
550 top: 0,
551 bottom: 0,
552 left: 0,
553 right: 0,
554 },
555 dropModal: {
556 // @ts-ignore web only
557 boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px',
558 padding: 8,
559 borderWidth: 1,
560 borderRadius: 16,
561 },
562 dropText: {
563 paddingVertical: 44,
564 paddingHorizontal: 36,
565 borderStyle: 'dashed',
566 borderRadius: 8,
567 borderWidth: 2,
568 },
569})
570
571function getImageOrVideoFromUri(
572 items: DataTransferItemList,
573 callback: (uri: string) => void,
574) {
575 for (let index = 0; index < items.length; index++) {
576 const item = items[index]
577 const type = item.type
578
579 if (type === 'text/plain') {
580 item.getAsString(async itemString => {
581 if (isUriImage(itemString)) {
582 const response = await fetch(itemString)
583 const blob = await response.blob()
584
585 if (blob.type.startsWith('image/')) {
586 blobToDataUri(blob).then(callback, err => console.error(err))
587 }
588
589 if (blob.type.startsWith('video/')) {
590 blobToDataUri(blob).then(callback, err => console.error(err))
591 }
592 }
593 })
594 } else if (type.startsWith('image/')) {
595 const file = item.getAsFile()
596
597 if (file) {
598 blobToDataUri(file).then(callback, err => console.error(err))
599 }
600 } else if (type.startsWith('video/')) {
601 const file = item.getAsFile()
602
603 if (file) {
604 blobToDataUri(file).then(callback, err => console.error(err))
605 }
606 }
607 }
608}