Bluesky app fork with some witchin' additions 馃挮
at readme-update 647 lines 16 kB view raw
1import {type ImagePickerAsset} from 'expo-image-picker' 2import { 3 type AppBskyFeedPostgate, 4 AppBskyRichtextFacet, 5 type BskyPreferences, 6 RichText, 7} from '@atproto/api' 8import {nanoid} from 'nanoid/non-secure' 9 10import {type SelfLabel} from '#/lib/moderation' 11import {insertMentionAt} from '#/lib/strings/mention-manip' 12import { 13 parseMarkdownLinks, 14 shortenLinks, 15} from '#/lib/strings/rich-text-manip' 16import { 17 isBskyPostUrl, 18 postUriToRelativePath, 19 toBskyAppUrl, 20} from '#/lib/strings/url-helpers' 21import { 22 type ComposerImage, 23 createInitialImages, 24} from '#/state/gallery' 25import {createPostgateRecord} from '#/state/queries/postgate/util' 26import {type Gif} from '#/state/queries/tenor' 27import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate' 28import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate' 29import {type ComposerOpts} from '#/state/shell/composer' 30import { 31 type LinkFacetMatch, 32 suggestLinkCardUri, 33} from '#/view/com/composer/text-input/text-input-util' 34import { 35 createRedraftVideoState, 36 createVideoState, 37 type VideoAction, 38 videoReducer, 39 type VideoState, 40} from './video' 41 42type ImagesMedia = { 43 type: 'images' 44 images: ComposerImage[] 45} 46 47type VideoMedia = { 48 type: 'video' 49 video: VideoState 50} 51 52type GifMedia = { 53 type: 'gif' 54 gif: Gif 55 alt: string 56} 57 58type Link = { 59 type: 'link' 60 uri: string 61} 62 63// This structure doesn't exactly correspond to the data model. 64// Instead, it maps to how the UI is organized, and how we present a post. 65export type EmbedDraft = { 66 // We'll always submit quote and actual media (images, video, gifs) chosen by the user. 67 quote: Link | undefined 68 media: ImagesMedia | VideoMedia | GifMedia | undefined 69 // This field may end up ignored if we have more important things to display than a link card: 70 link: Link | undefined 71} 72 73export type PostDraft = { 74 id: string 75 richtext: RichText 76 labels: SelfLabel[] 77 embed: EmbedDraft 78 shortenedGraphemeLength: number 79} 80 81export type PostAction = 82 | {type: 'update_richtext'; richtext: RichText} 83 | {type: 'update_labels'; labels: SelfLabel[]} 84 | {type: 'embed_add_images'; images: ComposerImage[]} 85 | {type: 'embed_update_image'; image: ComposerImage} 86 | {type: 'embed_remove_image'; image: ComposerImage} 87 | { 88 type: 'embed_add_video' 89 asset: ImagePickerAsset 90 abortController: AbortController 91 } 92 | {type: 'embed_remove_video'} 93 | {type: 'embed_update_video'; videoAction: VideoAction} 94 | {type: 'embed_add_uri'; uri: string} 95 | {type: 'embed_remove_quote'} 96 | {type: 'embed_remove_link'} 97 | {type: 'embed_add_gif'; gif: Gif} 98 | {type: 'embed_update_gif'; alt: string} 99 | {type: 'embed_remove_gif'} 100 101export type ThreadDraft = { 102 posts: PostDraft[] 103 postgate: AppBskyFeedPostgate.Record 104 threadgate: ThreadgateAllowUISetting[] 105} 106 107export type ComposerState = { 108 thread: ThreadDraft 109 activePostIndex: number 110 mutableNeedsFocusActive: boolean 111} 112 113export type ComposerAction = 114 | {type: 'update_postgate'; postgate: AppBskyFeedPostgate.Record} 115 | {type: 'update_threadgate'; threadgate: ThreadgateAllowUISetting[]} 116 | { 117 type: 'update_post' 118 postId: string 119 postAction: PostAction 120 } 121 | { 122 type: 'add_post' 123 } 124 | { 125 type: 'remove_post' 126 postId: string 127 } 128 | { 129 type: 'focus_post' 130 postId: string 131 } 132 133export const MAX_IMAGES = 4 134 135export function composerReducer( 136 state: ComposerState, 137 action: ComposerAction, 138): ComposerState { 139 switch (action.type) { 140 case 'update_postgate': { 141 return { 142 ...state, 143 thread: { 144 ...state.thread, 145 postgate: action.postgate, 146 }, 147 } 148 } 149 case 'update_threadgate': { 150 return { 151 ...state, 152 thread: { 153 ...state.thread, 154 threadgate: action.threadgate, 155 }, 156 } 157 } 158 case 'update_post': { 159 let nextPosts = state.thread.posts 160 const postIndex = state.thread.posts.findIndex( 161 p => p.id === action.postId, 162 ) 163 if (postIndex !== -1) { 164 nextPosts = state.thread.posts.slice() 165 nextPosts[postIndex] = postReducer( 166 state.thread.posts[postIndex], 167 action.postAction, 168 ) 169 } 170 return { 171 ...state, 172 thread: { 173 ...state.thread, 174 posts: nextPosts, 175 }, 176 } 177 } 178 case 'add_post': { 179 const activePostIndex = state.activePostIndex 180 const nextPosts = [...state.thread.posts] 181 nextPosts.splice(activePostIndex + 1, 0, { 182 id: nanoid(), 183 richtext: new RichText({text: ''}), 184 shortenedGraphemeLength: 0, 185 labels: [], 186 embed: { 187 quote: undefined, 188 media: undefined, 189 link: undefined, 190 }, 191 }) 192 return { 193 ...state, 194 thread: { 195 ...state.thread, 196 posts: nextPosts, 197 }, 198 } 199 } 200 case 'remove_post': { 201 if (state.thread.posts.length < 2) { 202 return state 203 } 204 let nextActivePostIndex = state.activePostIndex 205 const indexToRemove = state.thread.posts.findIndex( 206 p => p.id === action.postId, 207 ) 208 let nextPosts = [...state.thread.posts] 209 if (indexToRemove !== -1) { 210 const postToRemove = state.thread.posts[indexToRemove] 211 if (postToRemove.embed.media?.type === 'video') { 212 postToRemove.embed.media.video.abortController.abort() 213 } 214 nextPosts.splice(indexToRemove, 1) 215 nextActivePostIndex = Math.max(0, indexToRemove - 1) 216 } 217 return { 218 ...state, 219 activePostIndex: nextActivePostIndex, 220 mutableNeedsFocusActive: true, 221 thread: { 222 ...state.thread, 223 posts: nextPosts, 224 }, 225 } 226 } 227 case 'focus_post': { 228 const nextActivePostIndex = state.thread.posts.findIndex( 229 p => p.id === action.postId, 230 ) 231 if (nextActivePostIndex === -1) { 232 return state 233 } 234 return { 235 ...state, 236 activePostIndex: nextActivePostIndex, 237 } 238 } 239 } 240} 241 242function postReducer(state: PostDraft, action: PostAction): PostDraft { 243 switch (action.type) { 244 case 'update_richtext': { 245 return { 246 ...state, 247 richtext: action.richtext, 248 shortenedGraphemeLength: getShortenedLength(action.richtext), 249 } 250 } 251 case 'update_labels': { 252 return { 253 ...state, 254 labels: action.labels, 255 } 256 } 257 case 'embed_add_images': { 258 if (action.images.length === 0) { 259 return state 260 } 261 const prevMedia = state.embed.media 262 let nextMedia = prevMedia 263 if (!prevMedia) { 264 nextMedia = { 265 type: 'images', 266 images: action.images.slice(0, MAX_IMAGES), 267 } 268 } else if (prevMedia.type === 'images') { 269 nextMedia = { 270 ...prevMedia, 271 images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES), 272 } 273 } 274 return { 275 ...state, 276 embed: { 277 ...state.embed, 278 media: nextMedia, 279 }, 280 } 281 } 282 case 'embed_update_image': { 283 const prevMedia = state.embed.media 284 if (prevMedia?.type === 'images') { 285 const updatedImage = action.image 286 const nextMedia = { 287 ...prevMedia, 288 images: prevMedia.images.map(img => { 289 if (img.source.id === updatedImage.source.id) { 290 return updatedImage 291 } 292 return img 293 }), 294 } 295 return { 296 ...state, 297 embed: { 298 ...state.embed, 299 media: nextMedia, 300 }, 301 } 302 } 303 return state 304 } 305 case 'embed_remove_image': { 306 const prevMedia = state.embed.media 307 let nextLabels = state.labels 308 if (prevMedia?.type === 'images') { 309 const removedImage = action.image 310 let nextMedia: ImagesMedia | undefined = { 311 ...prevMedia, 312 images: prevMedia.images.filter(img => { 313 return img.source.id !== removedImage.source.id 314 }), 315 } 316 if (nextMedia.images.length === 0) { 317 nextMedia = undefined 318 if (!state.embed.link) { 319 nextLabels = [] 320 } 321 } 322 return { 323 ...state, 324 labels: nextLabels, 325 embed: { 326 ...state.embed, 327 media: nextMedia, 328 }, 329 } 330 } 331 return state 332 } 333 case 'embed_add_video': { 334 const prevMedia = state.embed.media 335 let nextMedia = prevMedia 336 if (!prevMedia) { 337 nextMedia = { 338 type: 'video', 339 video: createVideoState(action.asset, action.abortController), 340 } 341 } 342 return { 343 ...state, 344 embed: { 345 ...state.embed, 346 media: nextMedia, 347 }, 348 } 349 } 350 case 'embed_update_video': { 351 const videoAction = action.videoAction 352 const prevMedia = state.embed.media 353 let nextMedia = prevMedia 354 if (prevMedia?.type === 'video') { 355 nextMedia = { 356 ...prevMedia, 357 video: videoReducer(prevMedia.video, videoAction), 358 } 359 } 360 return { 361 ...state, 362 embed: { 363 ...state.embed, 364 media: nextMedia, 365 }, 366 } 367 } 368 case 'embed_remove_video': { 369 const prevMedia = state.embed.media 370 let nextMedia = prevMedia 371 if (prevMedia?.type === 'video') { 372 prevMedia.video.abortController.abort() 373 nextMedia = undefined 374 } 375 let nextLabels = state.labels 376 if (!state.embed.link) { 377 nextLabels = [] 378 } 379 return { 380 ...state, 381 labels: nextLabels, 382 embed: { 383 ...state.embed, 384 media: nextMedia, 385 }, 386 } 387 } 388 case 'embed_add_uri': { 389 const prevQuote = state.embed.quote 390 const prevLink = state.embed.link 391 let nextQuote = prevQuote 392 let nextLink = prevLink 393 if (isBskyPostUrl(action.uri)) { 394 if (!prevQuote) { 395 nextQuote = { 396 type: 'link', 397 uri: action.uri, 398 } 399 } 400 } else { 401 if (!prevLink) { 402 nextLink = { 403 type: 'link', 404 uri: action.uri, 405 } 406 } 407 } 408 return { 409 ...state, 410 embed: { 411 ...state.embed, 412 quote: nextQuote, 413 link: nextLink, 414 }, 415 } 416 } 417 case 'embed_remove_link': { 418 let nextLabels = state.labels 419 if (!state.embed.media) { 420 nextLabels = [] 421 } 422 return { 423 ...state, 424 labels: nextLabels, 425 embed: { 426 ...state.embed, 427 link: undefined, 428 }, 429 } 430 } 431 case 'embed_remove_quote': { 432 return { 433 ...state, 434 embed: { 435 ...state.embed, 436 quote: undefined, 437 }, 438 } 439 } 440 case 'embed_add_gif': { 441 const prevMedia = state.embed.media 442 let nextMedia = prevMedia 443 if (!prevMedia) { 444 nextMedia = { 445 type: 'gif', 446 gif: action.gif, 447 alt: '', 448 } 449 } 450 return { 451 ...state, 452 embed: { 453 ...state.embed, 454 media: nextMedia, 455 }, 456 } 457 } 458 case 'embed_update_gif': { 459 const prevMedia = state.embed.media 460 let nextMedia = prevMedia 461 if (prevMedia?.type === 'gif') { 462 nextMedia = { 463 ...prevMedia, 464 alt: action.alt, 465 } 466 } 467 return { 468 ...state, 469 embed: { 470 ...state.embed, 471 media: nextMedia, 472 }, 473 } 474 } 475 case 'embed_remove_gif': { 476 const prevMedia = state.embed.media 477 let nextMedia = prevMedia 478 if (prevMedia?.type === 'gif') { 479 nextMedia = undefined 480 } 481 return { 482 ...state, 483 embed: { 484 ...state.embed, 485 media: nextMedia, 486 }, 487 } 488 } 489 } 490} 491 492export function createComposerState({ 493 initText, 494 initMention, 495 initImageUris, 496 initQuoteUri, 497 initInteractionSettings, 498 initVideoUri, 499}: { 500 initText: string | undefined 501 initMention: string | undefined 502 initImageUris: ComposerOpts['imageUris'] 503 initQuoteUri: string | undefined 504 initInteractionSettings: 505 | BskyPreferences['postInteractionSettings'] 506 | undefined 507 initVideoUri?: ComposerOpts['videoUri'] 508}): ComposerState { 509 let media: ImagesMedia | VideoMedia | undefined 510 if (initImageUris?.length) { 511 media = { 512 type: 'images', 513 images: createInitialImages(initImageUris), 514 } 515 } else if (initVideoUri?.blobRef) { 516 media = { 517 type: 'video', 518 video: createRedraftVideoState({ 519 blobRef: initVideoUri.blobRef, 520 width: initVideoUri.width, 521 height: initVideoUri.height, 522 altText: initVideoUri.altText || '', 523 playlistUri: initVideoUri.uri, 524 }), 525 } 526 } 527 let quote: Link | undefined 528 if (initQuoteUri) { 529 // TODO: Consider passing the app url directly. 530 const path = postUriToRelativePath(initQuoteUri) 531 if (path) { 532 quote = { 533 type: 'link', 534 uri: toBskyAppUrl(path), 535 } 536 } 537 } 538 const initRichText = new RichText({ 539 text: initText 540 ? initText 541 : initMention 542 ? insertMentionAt( 543 `@${initMention}`, 544 initMention.length + 1, 545 `${initMention}`, 546 ) 547 : '', 548 }) 549 550 let link: Link | undefined 551 552 /** 553 * `initText` atm is only used for compose intents, meaning share links from 554 * external sources. If `initText` is defined, we want to extract links/posts 555 * from `initText` and suggest them as embeds. 556 * 557 * This checks for posts separately from other types of links so that posts 558 * can become quotes. The util `suggestLinkCardUri` is then applied to ensure 559 * we suggest at most 1 of each. 560 */ 561 if (initText) { 562 initRichText.detectFacetsWithoutResolution() 563 const detectedExtUris = new Map<string, LinkFacetMatch>() 564 const detectedPostUris = new Map<string, LinkFacetMatch>() 565 if (initRichText.facets) { 566 for (const facet of initRichText.facets) { 567 for (const feature of facet.features) { 568 if (AppBskyRichtextFacet.isLink(feature)) { 569 if (isBskyPostUrl(feature.uri)) { 570 detectedPostUris.set(feature.uri, {facet, rt: initRichText}) 571 } else { 572 detectedExtUris.set(feature.uri, {facet, rt: initRichText}) 573 } 574 } 575 } 576 } 577 } 578 const pastSuggestedUris = new Set<string>() 579 const suggestedExtUri = suggestLinkCardUri( 580 true, 581 detectedExtUris, 582 new Map(), 583 pastSuggestedUris, 584 ) 585 if (suggestedExtUri) { 586 link = { 587 type: 'link', 588 uri: suggestedExtUri, 589 } 590 } 591 const suggestedPostUri = suggestLinkCardUri( 592 true, 593 detectedPostUris, 594 new Map(), 595 pastSuggestedUris, 596 ) 597 if (suggestedPostUri) { 598 /* 599 * `initQuote` is only populated via in-app user action, but we're being 600 * future-defensive here. 601 */ 602 if (!quote) { 603 quote = { 604 type: 'link', 605 uri: suggestedPostUri, 606 } 607 } 608 } 609 } 610 611 return { 612 activePostIndex: 0, 613 mutableNeedsFocusActive: false, 614 thread: { 615 posts: [ 616 { 617 id: nanoid(), 618 richtext: initRichText, 619 shortenedGraphemeLength: getShortenedLength(initRichText), 620 labels: [], 621 embed: { 622 quote, 623 media, 624 link, 625 }, 626 }, 627 ], 628 postgate: createPostgateRecord({ 629 post: '', 630 embeddingRules: initInteractionSettings?.postgateEmbeddingRules || [], 631 }), 632 threadgate: threadgateRecordToAllowUISetting({ 633 $type: 'app.bsky.feed.threadgate', 634 post: '', 635 createdAt: new Date().toString(), 636 allow: initInteractionSettings?.threadgateAllowRules, 637 }), 638 }, 639 } 640} 641 642function getShortenedLength(rt: RichText) { 643 const {text} = parseMarkdownLinks(rt.text) 644 const newRt = new RichText({text}) 645 newRt.detectFacetsWithoutResolution() 646 return shortenLinks(newRt).graphemeLength 647}