import {ImagePickerAsset} from 'expo-image-picker' import {AppBskyFeedPostgate, RichText} from '@atproto/api' import {nanoid} from 'nanoid/non-secure' import {SelfLabel} from '#/lib/moderation' import {insertMentionAt} from '#/lib/strings/mention-manip' import {shortenLinks} from '#/lib/strings/rich-text-manip' import { isBskyPostUrl, postUriToRelativePath, toBskyAppUrl, } from '#/lib/strings/url-helpers' import {ComposerImage, createInitialImages} from '#/state/gallery' import {createPostgateRecord} from '#/state/queries/postgate/util' import {Gif} from '#/state/queries/tenor' import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate' import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {ComposerOpts} from '#/state/shell/composer' import {createVideoState, VideoAction, videoReducer, VideoState} from './video' type ImagesMedia = { type: 'images' images: ComposerImage[] } type VideoMedia = { type: 'video' video: VideoState } type GifMedia = { type: 'gif' gif: Gif alt: string } type Link = { type: 'link' uri: string } // This structure doesn't exactly correspond to the data model. // Instead, it maps to how the UI is organized, and how we present a post. export type EmbedDraft = { // We'll always submit quote and actual media (images, video, gifs) chosen by the user. quote: Link | undefined media: ImagesMedia | VideoMedia | GifMedia | undefined // This field may end up ignored if we have more important things to display than a link card: link: Link | undefined } export type PostDraft = { id: string richtext: RichText labels: SelfLabel[] embed: EmbedDraft shortenedGraphemeLength: number } export type PostAction = | {type: 'update_richtext'; richtext: RichText} | {type: 'update_labels'; labels: SelfLabel[]} | {type: 'embed_add_images'; images: ComposerImage[]} | {type: 'embed_update_image'; image: ComposerImage} | {type: 'embed_remove_image'; image: ComposerImage} | { type: 'embed_add_video' asset: ImagePickerAsset abortController: AbortController } | {type: 'embed_remove_video'} | {type: 'embed_update_video'; videoAction: VideoAction} | {type: 'embed_add_uri'; uri: string} | {type: 'embed_remove_quote'} | {type: 'embed_remove_link'} | {type: 'embed_add_gif'; gif: Gif} | {type: 'embed_update_gif'; alt: string} | {type: 'embed_remove_gif'} export type ThreadDraft = { posts: PostDraft[] postgate: AppBskyFeedPostgate.Record threadgate: ThreadgateAllowUISetting[] } export type ComposerState = { thread: ThreadDraft activePostIndex: number // TODO: Add actions to update this. } export type ComposerAction = | {type: 'update_postgate'; postgate: AppBskyFeedPostgate.Record} | {type: 'update_threadgate'; threadgate: ThreadgateAllowUISetting[]} | { type: 'update_post' postId: string postAction: PostAction } export const MAX_IMAGES = 4 export function composerReducer( state: ComposerState, action: ComposerAction, ): ComposerState { switch (action.type) { case 'update_postgate': { return { ...state, thread: { ...state.thread, postgate: action.postgate, }, } } case 'update_threadgate': { return { ...state, thread: { ...state.thread, threadgate: action.threadgate, }, } } case 'update_post': { let nextPosts = state.thread.posts const postIndex = state.thread.posts.findIndex( p => p.id === action.postId, ) if (postIndex !== -1) { nextPosts = state.thread.posts.slice() nextPosts[postIndex] = postReducer( state.thread.posts[postIndex], action.postAction, ) } return { ...state, thread: { ...state.thread, posts: nextPosts, }, } } } } function postReducer(state: PostDraft, action: PostAction): PostDraft { switch (action.type) { case 'update_richtext': { return { ...state, richtext: action.richtext, shortenedGraphemeLength: shortenLinks(action.richtext).graphemeLength, } } case 'update_labels': { return { ...state, labels: action.labels, } } case 'embed_add_images': { if (action.images.length === 0) { return state } const prevMedia = state.embed.media let nextMedia = prevMedia if (!prevMedia) { nextMedia = { type: 'images', images: action.images.slice(0, MAX_IMAGES), } } else if (prevMedia.type === 'images') { nextMedia = { ...prevMedia, images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES), } } return { ...state, embed: { ...state.embed, media: nextMedia, }, } } case 'embed_update_image': { const prevMedia = state.embed.media if (prevMedia?.type === 'images') { const updatedImage = action.image const nextMedia = { ...prevMedia, images: prevMedia.images.map(img => { if (img.source.id === updatedImage.source.id) { return updatedImage } return img }), } return { ...state, embed: { ...state.embed, media: nextMedia, }, } } return state } case 'embed_remove_image': { const prevMedia = state.embed.media let nextLabels = state.labels if (prevMedia?.type === 'images') { const removedImage = action.image let nextMedia: ImagesMedia | undefined = { ...prevMedia, images: prevMedia.images.filter(img => { return img.source.id !== removedImage.source.id }), } if (nextMedia.images.length === 0) { nextMedia = undefined if (!state.embed.link) { nextLabels = [] } } return { ...state, labels: nextLabels, embed: { ...state.embed, media: nextMedia, }, } } return state } case 'embed_add_video': { const prevMedia = state.embed.media let nextMedia = prevMedia if (!prevMedia) { nextMedia = { type: 'video', video: createVideoState(action.asset, action.abortController), } } return { ...state, embed: { ...state.embed, media: nextMedia, }, } } case 'embed_update_video': { const videoAction = action.videoAction const prevMedia = state.embed.media let nextMedia = prevMedia if (prevMedia?.type === 'video') { nextMedia = { ...prevMedia, video: videoReducer(prevMedia.video, videoAction), } } return { ...state, embed: { ...state.embed, media: nextMedia, }, } } case 'embed_remove_video': { const prevMedia = state.embed.media let nextMedia = prevMedia if (prevMedia?.type === 'video') { nextMedia = undefined } let nextLabels = state.labels if (!state.embed.link) { nextLabels = [] } return { ...state, labels: nextLabels, embed: { ...state.embed, media: nextMedia, }, } } case 'embed_add_uri': { const prevQuote = state.embed.quote const prevLink = state.embed.link let nextQuote = prevQuote let nextLink = prevLink if (isBskyPostUrl(action.uri)) { if (!prevQuote) { nextQuote = { type: 'link', uri: action.uri, } } } else { if (!prevLink) { nextLink = { type: 'link', uri: action.uri, } } } return { ...state, embed: { ...state.embed, quote: nextQuote, link: nextLink, }, } } case 'embed_remove_link': { let nextLabels = state.labels if (!state.embed.media) { nextLabels = [] } return { ...state, labels: nextLabels, embed: { ...state.embed, link: undefined, }, } } case 'embed_remove_quote': { return { ...state, embed: { ...state.embed, quote: undefined, }, } } case 'embed_add_gif': { const prevMedia = state.embed.media let nextMedia = prevMedia if (!prevMedia) { nextMedia = { type: 'gif', gif: action.gif, alt: '', } } return { ...state, embed: { ...state.embed, media: nextMedia, }, } } case 'embed_update_gif': { const prevMedia = state.embed.media let nextMedia = prevMedia if (prevMedia?.type === 'gif') { nextMedia = { ...prevMedia, alt: action.alt, } } return { ...state, embed: { ...state.embed, media: nextMedia, }, } } case 'embed_remove_gif': { const prevMedia = state.embed.media let nextMedia = prevMedia if (prevMedia?.type === 'gif') { nextMedia = undefined } return { ...state, embed: { ...state.embed, media: nextMedia, }, } } } } export function createComposerState({ initText, initMention, initImageUris, initQuoteUri, }: { initText: string | undefined initMention: string | undefined initImageUris: ComposerOpts['imageUris'] initQuoteUri: string | undefined }): ComposerState { let media: ImagesMedia | undefined if (initImageUris?.length) { media = { type: 'images', images: createInitialImages(initImageUris), } } let quote: Link | undefined if (initQuoteUri) { // TODO: Consider passing the app url directly. const path = postUriToRelativePath(initQuoteUri) if (path) { quote = { type: 'link', uri: toBskyAppUrl(path), } } } const initRichText = new RichText({ text: initText ? initText : initMention ? insertMentionAt( `@${initMention}`, initMention.length + 1, `${initMention}`, ) : '', }) return { activePostIndex: 0, thread: { posts: [ { id: nanoid(), richtext: initRichText, shortenedGraphemeLength: 0, labels: [], embed: { quote, media, link: undefined, }, }, ], postgate: createPostgateRecord({post: ''}), threadgate: threadgateViewToAllowUISetting(undefined), }, } }