Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
120
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 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}