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

Configure Feed

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

at 6d68a5bd212dd4eeee816828ffe4e27601cdd7f3 516 lines 15 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} from '@atproto/api' 12import {Trans} from '@lingui/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 if (clipboardData.types.includes('text/html')) { 187 // Rich-text formatting is pasted, try retrieving plain text 188 const text = clipboardData.getData('text/plain') 189 // `pasteText` will invoke this handler again, but `clipboardData` will be null. 190 view.pasteText(text) 191 preventDefault = true 192 } 193 getImageOrVideoFromUri(clipboardData.items, (uri: string) => { 194 textInputWebEmitter.emit('media-pasted', uri) 195 }) 196 if (preventDefault) { 197 // Return `true` to prevent ProseMirror's default paste behavior. 198 return true 199 } 200 } 201 }, 202 handleKeyDown: (view, event) => { 203 if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') { 204 textInputWebEmitter.emit('publish') 205 return true 206 } 207 208 if ( 209 event.code === 'Backspace' && 210 !(event.metaKey || event.altKey || event.ctrlKey) 211 ) { 212 const isNotSelection = view.state.selection.empty 213 if (isNotSelection) { 214 const cursorPosition = view.state.selection.$anchor.pos 215 const textBefore = view.state.doc.textBetween( 216 0, 217 cursorPosition, 218 // important - use \n as a block separator, otherwise 219 // all the lines get mushed together -sfn 220 '\n', 221 ) 222 const graphemes = [...splitGraphemes(textBefore)] 223 224 if (graphemes.length > 0) { 225 const lastGrapheme = graphemes[graphemes.length - 1] 226 // deleteRange doesn't work on newlines, because tiptap 227 // treats them as separate 'blocks' and we're using \n 228 // as a stand-in. bail out if the last grapheme is a newline 229 // to let the default behavior handle it -sfn 230 if (lastGrapheme !== '\n') { 231 // otherwise, delete the last grapheme using deleteRange, 232 // so that emojis are deleted as a whole 233 const deleteFrom = cursorPosition - lastGrapheme.length 234 editor?.commands.deleteRange({ 235 from: deleteFrom, 236 to: cursorPosition, 237 }) 238 return true 239 } 240 } 241 } 242 } 243 }, 244 }, 245 content: generateJSON(richTextToHTML(richtext), extensions, { 246 preserveWhitespace: 'full', 247 }), 248 autofocus: autoFocus ? 'end' : null, 249 editable: true, 250 injectCSS: true, 251 shouldRerenderOnTransaction: false, 252 onUpdate({editor: editorProp}) { 253 const json = editorProp.getJSON() 254 const newText = editorJsonToText(json) 255 const isPaste = window.event?.type === 'paste' 256 257 const newRt = new RichText({text: newText}) 258 newRt.detectFacetsWithoutResolution() 259 setRichText(newRt) 260 261 const nextDetectedUris = new Map<string, LinkFacetMatch>() 262 if (newRt.facets) { 263 for (const facet of newRt.facets) { 264 for (const feature of facet.features) { 265 if (AppBskyRichtextFacet.isLink(feature)) { 266 nextDetectedUris.set(feature.uri, {facet, rt: newRt}) 267 } 268 } 269 } 270 } 271 272 const suggestedUri = suggestLinkCardUri( 273 isPaste, 274 nextDetectedUris, 275 prevDetectedUris.current, 276 pastSuggestedUris.current, 277 ) 278 prevDetectedUris.current = nextDetectedUris 279 if (suggestedUri) { 280 onNewLink(suggestedUri) 281 } 282 }, 283 }, 284 [modeClass], 285 ) 286 287 const onEmojiInserted = useCallback( 288 (emoji: Emoji) => { 289 editor?.chain().focus().insertContent(emoji.native).run() 290 }, 291 [editor], 292 ) 293 useEffect(() => { 294 if (!isActive) { 295 return 296 } 297 textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) 298 return () => { 299 textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) 300 } 301 }, [onEmojiInserted, isActive]) 302 303 useImperativeHandle(ref, () => ({ 304 focus: () => { 305 editor?.chain().focus() 306 }, 307 blur: () => { 308 editor?.chain().blur() 309 }, 310 getCursorPosition: () => { 311 const pos = editor?.state.selection.$anchor.pos 312 return pos ? editor?.view.coordsAtPos(pos) : undefined 313 }, 314 maybeClosePopup: () => autocompleteRef.current?.maybeClose() ?? false, 315 })) 316 317 const inputStyle = useMemo(() => { 318 const style = normalizeTextStyles( 319 [a.text_lg, a.leading_snug, t.atoms.text], 320 { 321 fontScale: fonts.scaleMultiplier, 322 fontFamily: fonts.family, 323 flags: {}, 324 }, 325 ) 326 /* 327 * TipTap component isn't a RN View and while it seems to convert 328 * `fontSize` to `px`, it doesn't convert `lineHeight`. 329 * 330 * `lineHeight` should always be defined here, this is defensive. 331 */ 332 style.lineHeight = style.lineHeight 333 ? ((style.lineHeight + 'px') as unknown as number) 334 : undefined 335 style.minHeight = webForceMinHeight ? 140 : undefined 336 return style 337 }, [t, fonts, webForceMinHeight]) 338 339 return ( 340 <> 341 <View style={[styles.container, hasRightPadding && styles.rightPadding]}> 342 {/* @ts-ignore inputStyle is fine */} 343 <EditorContent editor={editor} style={inputStyle} /> 344 </View> 345 346 {isDropping && ( 347 <Portal> 348 <Animated.View 349 style={styles.dropContainer} 350 entering={FadeIn.duration(80)} 351 exiting={FadeOut.duration(80)}> 352 <View 353 style={[ 354 t.atoms.bg, 355 t.atoms.border_contrast_low, 356 styles.dropModal, 357 ]}> 358 <Text 359 style={[ 360 a.text_lg, 361 a.font_semi_bold, 362 t.atoms.text_contrast_medium, 363 t.atoms.border_contrast_high, 364 styles.dropText, 365 ]}> 366 <Trans>Drop to add images</Trans> 367 </Text> 368 </View> 369 </Animated.View> 370 </Portal> 371 )} 372 </> 373 ) 374} 375 376/** 377 * Helper function to initialise the editor with RichText, which expects HTML 378 * 379 * All the extensions are able to initialise themselves from plain text, *except* 380 * for the Mention extension - we need to manually convert it into a `<span>` element 381 * 382 * It also escapes HTML characters 383 */ 384function richTextToHTML(richtext: RichText): string { 385 let html = '' 386 387 for (const segment of richtext.segments()) { 388 if (segment.mention) { 389 html += `<span data-type="mention" data-id="${escapeHTML(segment.mention.did)}"></span>` 390 } else { 391 html += escapeHTML(segment.text) 392 } 393 } 394 395 return html 396} 397 398function escapeHTML(str: string): string { 399 return str 400 .replace(/&/g, '&amp;') 401 .replace(/</g, '&lt;') 402 .replace(/>/g, '&gt;') 403 .replace(/"/g, '&quot;') 404} 405 406function editorJsonToText( 407 json: JSONContent, 408 isLastDocumentChild: boolean = false, 409): string { 410 let text = '' 411 if (json.type === 'doc') { 412 if (json.content?.length) { 413 for (let i = 0; i < json.content.length; i++) { 414 const node = json.content[i] 415 const isLastNode = i === json.content.length - 1 416 text += editorJsonToText(node, isLastNode) 417 } 418 } 419 } else if (json.type === 'paragraph') { 420 if (json.content?.length) { 421 for (let i = 0; i < json.content.length; i++) { 422 const node = json.content[i] 423 text += editorJsonToText(node) 424 } 425 } 426 if (!isLastDocumentChild) { 427 text += '\n' 428 } 429 } else if (json.type === 'hardBreak') { 430 text += '\n' 431 } else if (json.type === 'text') { 432 text += json.text || '' 433 } else if (json.type === 'mention') { 434 text += `@${json.attrs?.id || ''}` 435 } 436 return text 437} 438 439const styles = StyleSheet.create({ 440 container: { 441 flex: 1, 442 alignSelf: 'flex-start', 443 padding: 5, 444 marginLeft: 8, 445 marginBottom: 10, 446 }, 447 rightPadding: { 448 paddingRight: 32, 449 }, 450 dropContainer: { 451 backgroundColor: '#0007', 452 pointerEvents: 'none', 453 alignItems: 'center', 454 justifyContent: 'center', 455 // @ts-ignore web only -prf 456 position: 'fixed', 457 padding: 16, 458 top: 0, 459 bottom: 0, 460 left: 0, 461 right: 0, 462 }, 463 dropModal: { 464 // @ts-ignore web only 465 boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px', 466 padding: 8, 467 borderWidth: 1, 468 borderRadius: 16, 469 }, 470 dropText: { 471 paddingVertical: 44, 472 paddingHorizontal: 36, 473 borderStyle: 'dashed', 474 borderRadius: 8, 475 borderWidth: 2, 476 }, 477}) 478 479function getImageOrVideoFromUri( 480 items: DataTransferItemList, 481 callback: (uri: string) => void, 482) { 483 for (let index = 0; index < items.length; index++) { 484 const item = items[index] 485 const type = item.type 486 487 if (type === 'text/plain') { 488 item.getAsString(async itemString => { 489 if (isUriImage(itemString)) { 490 const response = await fetch(itemString) 491 const blob = await response.blob() 492 493 if (blob.type.startsWith('image/')) { 494 blobToDataUri(blob).then(callback, err => console.error(err)) 495 } 496 497 if (blob.type.startsWith('video/')) { 498 blobToDataUri(blob).then(callback, err => console.error(err)) 499 } 500 } 501 }) 502 } else if (type.startsWith('image/')) { 503 const file = item.getAsFile() 504 505 if (file) { 506 blobToDataUri(file).then(callback, err => console.error(err)) 507 } 508 } else if (type.startsWith('video/')) { 509 const file = item.getAsFile() 510 511 if (file) { 512 blobToDataUri(file).then(callback, err => console.error(err)) 513 } 514 } 515 } 516}