Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at theme-changes 607 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 {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, '&amp;') 491 .replace(/</g, '&lt;') 492 .replace(/>/g, '&gt;') 493 .replace(/"/g, '&quot;') 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}