Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Introduce a composer reducer and move image state there (#5547)

* Add composer reducer

* Support adding images

Co-authored-by: Mary <git@mary.my.id>

* Support updating and deleting images

Co-authored-by: Mary <git@mary.my.id>

* Derive images state from composer state

Co-authored-by: Mary <git@mary.my.id>

---------

Co-authored-by: Mary <git@mary.my.id>

authored by

dan
Mary
and committed by
GitHub
d2fd5589 a7ee561e

+162 -16
+2
src/lib/api/index.ts
··· 24 24 threadgateAllowUISettingToAllowRecordValue, 25 25 writeThreadgateRecord, 26 26 } from '#/state/queries/threadgate' 27 + import {ComposerState} from '#/view/com/composer/state' 27 28 import {LinkMeta} from '../link-meta/link-meta' 28 29 import {uploadBlob} from './upload-blob' 29 30 ··· 38 39 } 39 40 40 41 interface PostOpts { 42 + composerState: ComposerState // TODO: Not used yet. 41 43 rawText: string 42 44 replyTo?: string 43 45 quote?: {
+23 -6
src/view/com/composer/Composer.tsx
··· 3 3 useEffect, 4 4 useImperativeHandle, 5 5 useMemo, 6 + useReducer, 6 7 useRef, 7 8 useState, 8 9 } from 'react' ··· 66 67 import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection' 67 68 import {useDialogStateControlContext} from '#/state/dialogs' 68 69 import {emitPostCreated} from '#/state/events' 69 - import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery' 70 + import {ComposerImage, pasteImage} from '#/state/gallery' 70 71 import {useModalControls} from '#/state/modals' 71 72 import {useModals} from '#/state/modals' 72 73 import {useRequireAltTextEnabled} from '#/state/preferences' ··· 119 120 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 120 121 import * as Prompt from '#/components/Prompt' 121 122 import {Text as NewText} from '#/components/Typography' 123 + import {composerReducer, createComposerState} from './state' 122 124 123 125 const MAX_IMAGES = 4 124 126 ··· 126 128 onPressCancel: () => void 127 129 } 128 130 131 + const NO_IMAGES: ComposerImage[] = [] 132 + 129 133 type Props = ComposerOpts 130 134 export const ComposePost = ({ 131 135 replyTo, ··· 213 217 ) 214 218 const [postgate, setPostgate] = useState(createPostgateRecord({post: ''})) 215 219 216 - const [images, setImages] = useState<ComposerImage[]>(() => 217 - createInitialImages(initImageUris), 220 + // TODO: Move more state here. 221 + const [composerState, dispatch] = useReducer( 222 + composerReducer, 223 + {initImageUris}, 224 + createComposerState, 218 225 ) 226 + let images = NO_IMAGES 227 + if (composerState.embed.media?.type === 'images') { 228 + images = composerState.embed.media.images 229 + } 230 + 219 231 const onClose = useCallback(() => { 220 232 closeComposer() 221 233 }, [closeComposer]) ··· 301 313 302 314 const onImageAdd = useCallback( 303 315 (next: ComposerImage[]) => { 304 - setImages(prev => prev.concat(next.slice(0, MAX_IMAGES - prev.length))) 316 + dispatch({ 317 + type: 'embed_add_images', 318 + images: next, 319 + }) 305 320 }, 306 - [setImages], 321 + [dispatch], 307 322 ) 308 323 309 324 const onPhotoPasted = useCallback( ··· 374 389 try { 375 390 postUri = ( 376 391 await apilib.post(agent, { 392 + composerState, // TODO: not used yet. 377 393 rawText: richtext.text, 378 394 replyTo: replyTo?.uri, 379 395 images, ··· 475 491 _, 476 492 agent, 477 493 captions, 494 + composerState, 478 495 extLink, 479 496 images, 480 497 graphemeLength, ··· 717 734 /> 718 735 </View> 719 736 720 - <Gallery images={images} onChange={setImages} /> 737 + <Gallery images={images} dispatch={dispatch} /> 721 738 {images.length === 0 && extLink && ( 722 739 <View style={a.relative}> 723 740 <ExternalEmbed
+6 -10
src/view/com/composer/photos/Gallery.tsx
··· 21 21 import {Text} from '#/view/com/util/text/Text' 22 22 import {useTheme} from '#/alf' 23 23 import * as Dialog from '#/components/Dialog' 24 + import {ComposerAction} from '../state' 24 25 import {EditImageDialog} from './EditImageDialog' 25 26 import {ImageAltTextDialog} from './ImageAltTextDialog' 26 27 ··· 28 29 29 30 interface GalleryProps { 30 31 images: ComposerImage[] 31 - onChange: (next: ComposerImage[]) => void 32 + dispatch: (action: ComposerAction) => void 32 33 } 33 34 34 35 export let Gallery = (props: GalleryProps): React.ReactNode => { ··· 56 57 containerInfo: Dimensions 57 58 } 58 59 59 - const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => { 60 + const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => { 60 61 const {isMobile} = useWebMediaQueries() 61 62 62 63 const {altTextControlStyle, imageControlsStyle, imageStyle} = ··· 96 97 return images.length !== 0 ? ( 97 98 <> 98 99 <View testID="selectedPhotosView" style={styles.gallery}> 99 - {images.map((image, index) => { 100 + {images.map(image => { 100 101 return ( 101 102 <GalleryItem 102 103 key={image.source.id} ··· 105 106 imageControlsStyle={imageControlsStyle} 106 107 imageStyle={imageStyle} 107 108 onChange={next => { 108 - onChange( 109 - images.map(i => (i.source === image.source ? next : i)), 110 - ) 109 + dispatch({type: 'embed_update_image', image: next}) 111 110 }} 112 111 onRemove={() => { 113 - const next = images.slice() 114 - next.splice(index, 1) 115 - 116 - onChange(next) 112 + dispatch({type: 'embed_remove_image', image}) 117 113 }} 118 114 /> 119 115 )
+131
src/view/com/composer/state.ts
··· 1 + import {ComposerImage, createInitialImages} from '#/state/gallery' 2 + import {ComposerOpts} from '#/state/shell/composer' 3 + 4 + type PostRecord = { 5 + uri: string 6 + } 7 + 8 + type ImagesMedia = { 9 + type: 'images' 10 + images: ComposerImage[] 11 + labels: string[] 12 + } 13 + 14 + type ComposerEmbed = { 15 + // TODO: Other record types. 16 + record: PostRecord | undefined 17 + // TODO: Other media types. 18 + media: ImagesMedia | undefined 19 + } 20 + 21 + export type ComposerState = { 22 + // TODO: Other draft data. 23 + embed: ComposerEmbed 24 + } 25 + 26 + export type ComposerAction = 27 + | {type: 'embed_add_images'; images: ComposerImage[]} 28 + | {type: 'embed_update_image'; image: ComposerImage} 29 + | {type: 'embed_remove_image'; image: ComposerImage} 30 + 31 + const MAX_IMAGES = 4 32 + 33 + export function composerReducer( 34 + state: ComposerState, 35 + action: ComposerAction, 36 + ): ComposerState { 37 + switch (action.type) { 38 + case 'embed_add_images': { 39 + const prevMedia = state.embed.media 40 + let nextMedia = prevMedia 41 + if (!prevMedia) { 42 + nextMedia = { 43 + type: 'images', 44 + images: action.images.slice(0, MAX_IMAGES), 45 + labels: [], 46 + } 47 + } else if (prevMedia.type === 'images') { 48 + nextMedia = { 49 + ...prevMedia, 50 + images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES), 51 + } 52 + } 53 + return { 54 + ...state, 55 + embed: { 56 + ...state.embed, 57 + media: nextMedia, 58 + }, 59 + } 60 + } 61 + case 'embed_update_image': { 62 + const prevMedia = state.embed.media 63 + if (prevMedia?.type === 'images') { 64 + const updatedImage = action.image 65 + const nextMedia = { 66 + ...prevMedia, 67 + images: prevMedia.images.map(img => { 68 + if (img.source.id === updatedImage.source.id) { 69 + return updatedImage 70 + } 71 + return img 72 + }), 73 + } 74 + return { 75 + ...state, 76 + embed: { 77 + ...state.embed, 78 + media: nextMedia, 79 + }, 80 + } 81 + } 82 + return state 83 + } 84 + case 'embed_remove_image': { 85 + const prevMedia = state.embed.media 86 + if (prevMedia?.type === 'images') { 87 + const removedImage = action.image 88 + let nextMedia: ImagesMedia | undefined = { 89 + ...prevMedia, 90 + images: prevMedia.images.filter(img => { 91 + return img.source.id !== removedImage.source.id 92 + }), 93 + } 94 + if (nextMedia.images.length === 0) { 95 + nextMedia = undefined 96 + } 97 + return { 98 + ...state, 99 + embed: { 100 + ...state.embed, 101 + media: nextMedia, 102 + }, 103 + } 104 + } 105 + return state 106 + } 107 + default: 108 + return state 109 + } 110 + } 111 + 112 + export function createComposerState({ 113 + initImageUris, 114 + }: { 115 + initImageUris: ComposerOpts['imageUris'] 116 + }): ComposerState { 117 + let media: ImagesMedia | undefined 118 + if (initImageUris?.length) { 119 + media = { 120 + type: 'images', 121 + images: createInitialImages(initImageUris), 122 + labels: [], 123 + } 124 + } 125 + return { 126 + embed: { 127 + record: undefined, 128 + media, 129 + }, 130 + } 131 + }