458 lines
11 KiB
TypeScript
458 lines
11 KiB
TypeScript
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),
|
|
},
|
|
}
|
|
}
|