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 {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}