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