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