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

Configure Feed

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

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