forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}