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