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

Configure Feed

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

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