my fork of the bluesky client

Move remaining composer state into reducer (#5623)

Co-authored-by: Mary <git@mary.my.id>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>

authored by danabra.mov

Mary
surfdude29
Hailey
and committed by
GitHub
e1ca3ae4 c06040cc

+201 -362
+17 -28
src/lib/api/index.ts
··· 4 4 AppBskyEmbedRecord, 5 5 AppBskyEmbedRecordWithMedia, 6 6 AppBskyEmbedVideo, 7 - AppBskyFeedPostgate, 8 7 AtUri, 9 8 BlobRef, 10 9 BskyAgent, ··· 17 16 import {isNetworkError} from '#/lib/strings/errors' 18 17 import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' 19 18 import {logger} from '#/logger' 20 - import {ComposerImage, compressImage} from '#/state/gallery' 19 + import {compressImage} from '#/state/gallery' 21 20 import {writePostgateRecord} from '#/state/queries/postgate' 22 21 import { 23 22 fetchResolveGifQuery, ··· 25 24 } from '#/state/queries/resolve-link' 26 25 import { 27 26 createThreadgateRecord, 28 - ThreadgateAllowUISetting, 29 27 threadgateAllowUISettingToAllowRecordValue, 30 28 writeThreadgateRecord, 31 29 } from '#/state/queries/threadgate' 32 - import {ComposerState, EmbedDraft} from '#/view/com/composer/state/composer' 30 + import {ComposerDraft, EmbedDraft} from '#/view/com/composer/state/composer' 33 31 import {createGIFDescription} from '../gif-alt-text' 34 - import {LinkMeta} from '../link-meta/link-meta' 35 32 import {uploadBlob} from './upload-blob' 36 33 37 34 export {uploadBlob} 38 35 39 - export interface ExternalEmbedDraft { 40 - uri: string 41 - isLoading: boolean 42 - meta?: LinkMeta 43 - embed?: AppBskyEmbedRecord.Main 44 - localThumb?: ComposerImage 45 - } 46 - 47 36 interface PostOpts { 48 - composerState: ComposerState // TODO: Not used yet. 49 - rawText: string 37 + draft: ComposerDraft 50 38 replyTo?: string 51 - labels?: string[] 52 - threadgate: ThreadgateAllowUISetting[] 53 - postgate: AppBskyFeedPostgate.Record 54 39 onStateChange?: (state: string) => void 55 40 langs?: string[] 56 41 } ··· 60 45 queryClient: QueryClient, 61 46 opts: PostOpts, 62 47 ) { 48 + const draft = opts.draft 63 49 let reply 64 - let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true}) 50 + let rt = new RichText( 51 + {text: draft.richtext.text.trimEnd()}, 52 + {cleanNewlines: true}, 53 + ) 65 54 66 55 opts.onStateChange?.('Processing...') 67 56 ··· 73 62 const embed = await resolveEmbed( 74 63 agent, 75 64 queryClient, 76 - opts.composerState, 65 + draft, 77 66 opts.onStateChange, 78 67 ) 79 68 ··· 98 87 99 88 // set labels 100 89 let labels: ComAtprotoLabelDefs.SelfLabels | undefined 101 - if (opts.labels?.length) { 90 + if (draft.labels.length) { 102 91 labels = { 103 92 $type: 'com.atproto.label.defs#selfLabels', 104 - values: opts.labels.map(val => ({val})), 93 + values: draft.labels.map(val => ({val})), 105 94 } 106 95 } 107 96 ··· 135 124 } 136 125 } 137 126 138 - if (opts.threadgate.some(tg => tg.type !== 'everybody')) { 127 + if (draft.threadgate.some(tg => tg.type !== 'everybody')) { 139 128 try { 140 129 // TODO: this needs to be batch-created with the post! 141 130 await writeThreadgateRecord({ ··· 143 132 postUri: res.uri, 144 133 threadgate: createThreadgateRecord({ 145 134 post: res.uri, 146 - allow: threadgateAllowUISettingToAllowRecordValue(opts.threadgate), 135 + allow: threadgateAllowUISettingToAllowRecordValue(draft.threadgate), 147 136 }), 148 137 }) 149 138 } catch (e: any) { ··· 158 147 } 159 148 160 149 if ( 161 - opts.postgate.embeddingRules?.length || 162 - opts.postgate.detachedEmbeddingUris?.length 150 + draft.postgate.embeddingRules?.length || 151 + draft.postgate.detachedEmbeddingUris?.length 163 152 ) { 164 153 try { 165 154 // TODO: this needs to be batch-created with the post! ··· 167 156 agent, 168 157 postUri: res.uri, 169 158 postgate: { 170 - ...opts.postgate, 159 + ...draft.postgate, 171 160 post: res.uri, 172 161 }, 173 162 }) ··· 188 177 async function resolveEmbed( 189 178 agent: BskyAgent, 190 179 queryClient: QueryClient, 191 - draft: ComposerState, 180 + draft: ComposerDraft, 192 181 onStateChange: ((state: string) => void) | undefined, 193 182 ): Promise< 194 183 | AppBskyEmbedImages.Main
+68 -25
src/lib/api/resolve.ts
··· 1 - import {AppBskyActorDefs, ComAtprotoRepoStrongRef} from '@atproto/api' 1 + import { 2 + AppBskyActorDefs, 3 + AppBskyFeedPost, 4 + AppBskyGraphStarterpack, 5 + ComAtprotoRepoStrongRef, 6 + } from '@atproto/api' 2 7 import {AtUri} from '@atproto/api' 3 8 import {BskyAgent} from '@atproto/api' 4 9 5 10 import {POST_IMG_MAX} from '#/lib/constants' 6 - import { 7 - getFeedAsEmbed, 8 - getListAsEmbed, 9 - getPostAsQuote, 10 - getStarterPackAsEmbed, 11 - } from '#/lib/link-meta/bsky' 12 11 import {getLinkMeta} from '#/lib/link-meta/link-meta' 13 12 import {resolveShortLink} from '#/lib/link-meta/resolve-short-link' 14 13 import {downloadAndResize} from '#/lib/media/manip' 14 + import { 15 + createStarterPackUri, 16 + parseStarterPackUri, 17 + } from '#/lib/strings/starter-pack' 15 18 import { 16 19 isBskyCustomFeedUrl, 17 20 isBskyListUrl, ··· 24 27 import {createComposerImage} from '#/state/gallery' 25 28 import {Gif} from '#/state/queries/tenor' 26 29 import {createGIFDescription} from '../gif-alt-text' 30 + import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' 27 31 28 32 type ResolvedExternalLink = { 29 33 type: 'external' ··· 59 63 | ResolvedExternalLink 60 64 | ResolvedPostRecord 61 65 | ResolvedOtherRecord 66 + 67 + class EmbeddingDisabledError extends Error { 68 + constructor() { 69 + super('Embedding is disabled for this record') 70 + } 71 + } 62 72 63 73 export async function resolveLink( 64 74 agent: BskyAgent, ··· 68 78 uri = await resolveShortLink(uri) 69 79 } 70 80 if (isBskyPostUrl(uri)) { 71 - // TODO: Remove this abstraction. 72 - // TODO: Nice error messages (e.g. EmbeddingDisabledError). 73 - const result = await getPostAsQuote(getPost, uri) 81 + uri = convertBskyAppUrlIfNeeded(uri) 82 + const [_0, user, _1, rkey] = uri.split('/').filter(Boolean) 83 + const recordUri = makeRecordUri(user, 'app.bsky.feed.post', rkey) 84 + const post = await getPost({uri: recordUri}) 85 + if (post.viewer?.embeddingDisabled) { 86 + throw new EmbeddingDisabledError() 87 + } 74 88 return { 75 89 type: 'record', 76 90 record: { 77 - cid: result.cid, 78 - uri: result.uri, 91 + cid: post.cid, 92 + uri: post.uri, 79 93 }, 80 94 kind: 'post', 81 - meta: result, 95 + meta: { 96 + text: AppBskyFeedPost.isRecord(post.record) ? post.record.text : '', 97 + indexedAt: post.indexedAt, 98 + author: post.author, 99 + }, 82 100 } 83 101 } 84 102 if (isBskyCustomFeedUrl(uri)) { 85 - // TODO: Remove this abstraction. 86 - const result = await getFeedAsEmbed(agent, fetchDid, uri) 103 + uri = convertBskyAppUrlIfNeeded(uri) 104 + const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean) 105 + const did = await fetchDid(handleOrDid) 106 + const feed = makeRecordUri(did, 'app.bsky.feed.generator', rkey) 107 + const res = await agent.app.bsky.feed.getFeedGenerator({feed}) 87 108 return { 88 109 type: 'record', 89 - record: result.embed!.record, 110 + record: { 111 + uri: res.data.view.uri, 112 + cid: res.data.view.cid, 113 + }, 90 114 kind: 'other', 91 115 meta: { 92 116 // TODO: Include hydrated content instead. 93 - title: result.meta!.title!, 117 + title: res.data.view.displayName, 94 118 }, 95 119 } 96 120 } 97 121 if (isBskyListUrl(uri)) { 98 - // TODO: Remove this abstraction. 99 - const result = await getListAsEmbed(agent, fetchDid, uri) 122 + uri = convertBskyAppUrlIfNeeded(uri) 123 + const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean) 124 + const did = await fetchDid(handleOrDid) 125 + const list = makeRecordUri(did, 'app.bsky.graph.list', rkey) 126 + const res = await agent.app.bsky.graph.getList({list}) 100 127 return { 101 128 type: 'record', 102 - record: result.embed!.record, 129 + record: { 130 + uri: res.data.list.uri, 131 + cid: res.data.list.cid, 132 + }, 103 133 kind: 'other', 104 134 meta: { 105 135 // TODO: Include hydrated content instead. 106 - title: result.meta!.title!, 136 + title: res.data.list.name, 107 137 }, 108 138 } 109 139 } 110 140 if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) { 111 - // TODO: Remove this abstraction. 112 - const result = await getStarterPackAsEmbed(agent, fetchDid, uri) 141 + const parsed = parseStarterPackUri(uri) 142 + if (!parsed) { 143 + throw new Error( 144 + 'Unexpectedly called getStarterPackAsEmbed with a non-starterpack url', 145 + ) 146 + } 147 + const did = await fetchDid(parsed.name) 148 + const starterPack = createStarterPackUri({did, rkey: parsed.rkey}) 149 + const res = await agent.app.bsky.graph.getStarterPack({starterPack}) 150 + const record = res.data.starterPack.record 113 151 return { 114 152 type: 'record', 115 - record: result.embed!.record, 153 + record: { 154 + uri: res.data.starterPack.uri, 155 + cid: res.data.starterPack.cid, 156 + }, 116 157 kind: 'other', 117 158 meta: { 118 159 // TODO: Include hydrated content instead. 119 - title: result.meta!.title!, 160 + title: AppBskyGraphStarterpack.isRecord(record) 161 + ? record.name 162 + : 'Starter Pack', 120 163 }, 121 164 } 122 165 }
+48 -73
src/view/com/composer/Composer.tsx
··· 42 42 AppBskyFeedGetPostThread, 43 43 BskyAgent, 44 44 } from '@atproto/api' 45 - import {RichText} from '@atproto/api' 46 45 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 47 46 import {msg, Trans} from '@lingui/macro' 48 47 import {useLingui} from '@lingui/react' ··· 57 56 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 58 57 import {logEvent} from '#/lib/statsig/statsig' 59 58 import {cleanError} from '#/lib/strings/errors' 60 - import {insertMentionAt} from '#/lib/strings/mention-manip' 61 59 import {shortenLinks} from '#/lib/strings/rich-text-manip' 62 60 import {colors, s} from '#/lib/styles' 63 61 import {logger} from '#/logger' ··· 73 71 useLanguagePrefs, 74 72 useLanguagePrefsApi, 75 73 } from '#/state/preferences/languages' 76 - import {createPostgateRecord} from '#/state/queries/postgate/util' 77 74 import {useProfileQuery} from '#/state/queries/profile' 78 75 import {Gif} from '#/state/queries/tenor' 79 - import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' 80 - import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util' 81 76 import {useAgent, useSession} from '#/state/session' 82 77 import {useComposerControls} from '#/state/shell/composer' 83 78 import {ComposerOpts} from '#/state/shell/composer' ··· 168 163 const [isProcessing, setIsProcessing] = useState(false) 169 164 const [processingState, setProcessingState] = useState('') 170 165 const [error, setError] = useState('') 171 - const [richtext, setRichText] = useState( 172 - new RichText({ 173 - text: initText 174 - ? initText 175 - : initMention 176 - ? insertMentionAt( 177 - `@${initMention}`, 178 - initMention.length + 1, 179 - `${initMention}`, 180 - ) // insert mention if passed in 181 - : '', 182 - }), 183 - ) 184 - const graphemeLength = useMemo(() => { 185 - return shortenLinks(richtext).graphemeLength 186 - }, [richtext]) 187 166 188 - // TODO: Move more state here. 189 - const [composerState, dispatch] = useReducer( 167 + const [draft, dispatch] = useReducer( 190 168 composerReducer, 191 - {initImageUris, initQuoteUri: initQuote?.uri}, 169 + {initImageUris, initQuoteUri: initQuote?.uri, initText, initMention}, 192 170 createComposerState, 193 171 ) 194 - 172 + const richtext = draft.richtext 173 + let quote: string | undefined 174 + if (draft.embed.quote) { 175 + quote = draft.embed.quote.uri 176 + } 177 + let images = NO_IMAGES 178 + if (draft.embed.media?.type === 'images') { 179 + images = draft.embed.media.images 180 + } 195 181 let videoState: VideoState | NoVideoState = NO_VIDEO 196 - if (composerState.embed.media?.type === 'video') { 197 - videoState = composerState.embed.media.video 182 + if (draft.embed.media?.type === 'video') { 183 + videoState = draft.embed.media.video 184 + } 185 + let extGif: Gif | undefined 186 + let extGifAlt: string | undefined 187 + if (draft.embed.media?.type === 'gif') { 188 + extGif = draft.embed.media.gif 189 + extGifAlt = draft.embed.media.alt 190 + } 191 + let extLink: string | undefined 192 + if (draft.embed.link) { 193 + extLink = draft.embed.link.uri 198 194 } 195 + 196 + const graphemeLength = useMemo(() => { 197 + return shortenLinks(richtext).graphemeLength 198 + }, [richtext]) 199 199 200 200 const selectVideo = React.useCallback( 201 201 (asset: ImagePickerAsset) => { ··· 241 241 ) 242 242 243 243 const hasVideo = Boolean(videoState.asset || videoState.video) 244 - 245 244 const [publishOnUpload, setPublishOnUpload] = useState(false) 246 245 247 - const [labels, setLabels] = useState<string[]>([]) 248 - const [threadgateAllowUISettings, onChangeThreadgateAllowUISettings] = 249 - useState<ThreadgateAllowUISetting[]>( 250 - threadgateViewToAllowUISetting(undefined), 251 - ) 252 - const [postgate, setPostgate] = useState(createPostgateRecord({post: ''})) 253 - 254 - let quote: string | undefined 255 - if (composerState.embed.quote) { 256 - quote = composerState.embed.quote.uri 257 - } 258 - let images = NO_IMAGES 259 - if (composerState.embed.media?.type === 'images') { 260 - images = composerState.embed.media.images 261 - } 262 - let extGif: Gif | undefined 263 - let extGifAlt: string | undefined 264 - if (composerState.embed.media?.type === 'gif') { 265 - extGif = composerState.embed.media.gif 266 - extGifAlt = composerState.embed.media.alt 267 - } 268 - let extLink: string | undefined 269 - if (composerState.embed.link) { 270 - extLink = composerState.embed.link.uri 271 - } 272 - 273 246 const onClose = useCallback(() => { 274 247 closeComposer() 275 248 }, [closeComposer]) ··· 419 392 try { 420 393 postUri = ( 421 394 await apilib.post(agent, queryClient, { 422 - composerState, // TODO: move more state here. 423 - rawText: richtext.text, 395 + draft: draft, 424 396 replyTo: replyTo?.uri, 425 - labels, 426 - threadgate: threadgateAllowUISettings, 427 - postgate, 428 397 onStateChange: setProcessingState, 429 398 langs: toPostLanguages(langPrefs.postLanguage), 430 399 }) ··· 497 466 [ 498 467 _, 499 468 agent, 500 - composerState, 469 + draft, 501 470 extLink, 502 471 images, 503 472 graphemeLength, 504 473 isAltTextRequiredAndMissing, 505 474 isProcessing, 506 - labels, 507 475 langPrefs.postLanguage, 508 476 onClose, 509 477 onPost, 510 - postgate, 511 478 quote, 512 479 initQuote, 513 480 initQuoteCount, 514 481 replyTo, 515 482 richtext.text, 516 483 setLangPrefs, 517 - threadgateAllowUISettings, 518 484 videoState.asset, 519 485 videoState.status, 520 486 queryClient, ··· 615 581 ) : ( 616 582 <View style={[styles.postBtnWrapper]}> 617 583 <LabelsBtn 618 - labels={labels} 619 - onChange={setLabels} 584 + labels={draft.labels} 585 + onChange={nextLabels => { 586 + dispatch({type: 'update_labels', labels: nextLabels}) 587 + }} 620 588 hasMedia={hasMedia || Boolean(extLink)} 621 589 /> 622 590 {canPost ? ( ··· 698 666 richtext={richtext} 699 667 placeholder={selectTextInputPlaceholder} 700 668 autoFocus 701 - setRichText={setRichText} 669 + setRichText={rt => { 670 + dispatch({type: 'update_richtext', richtext: rt}) 671 + }} 702 672 onPhotoPasted={onPhotoPasted} 703 673 onPressPublish={() => onPressPublish()} 704 674 onNewLink={onNewLink} ··· 734 704 </View> 735 705 )} 736 706 737 - {!composerState.embed.media && extLink && ( 707 + {!draft.embed.media && extLink && ( 738 708 <View style={a.relative} key={extLink}> 739 709 <ExternalEmbedLink 740 710 uri={extLink} ··· 815 785 816 786 {replyTo ? null : ( 817 787 <ThreadgateBtn 818 - postgate={postgate} 819 - onChangePostgate={setPostgate} 820 - threadgateAllowUISettings={threadgateAllowUISettings} 821 - onChangeThreadgateAllowUISettings={ 822 - onChangeThreadgateAllowUISettings 823 - } 788 + postgate={draft.postgate} 789 + onChangePostgate={nextPostgate => { 790 + dispatch({type: 'update_postgate', postgate: nextPostgate}) 791 + }} 792 + threadgateAllowUISettings={draft.threadgate} 793 + onChangeThreadgateAllowUISettings={nextThreadgate => { 794 + dispatch({ 795 + type: 'update_threadgate', 796 + threadgate: nextThreadgate, 797 + }) 798 + }} 824 799 style={bottomBarAnimatedStyle} 825 800 Portal={Portal.Portal} 826 801 />
+60 -5
src/view/com/composer/state/composer.ts
··· 1 1 import {ImagePickerAsset} from 'expo-image-picker' 2 + import {AppBskyFeedPostgate, RichText} from '@atproto/api' 2 3 4 + import {insertMentionAt} from '#/lib/strings/mention-manip' 3 5 import { 4 6 isBskyPostUrl, 5 7 postUriToRelativePath, 6 8 toBskyAppUrl, 7 9 } from '#/lib/strings/url-helpers' 8 10 import {ComposerImage, createInitialImages} from '#/state/gallery' 11 + import {createPostgateRecord} from '#/state/queries/postgate/util' 9 12 import {Gif} from '#/state/queries/tenor' 13 + import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate' 14 + import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' 10 15 import {ComposerOpts} from '#/state/shell/composer' 11 16 import {createVideoState, VideoAction, videoReducer, VideoState} from './video' 12 17 ··· 41 46 link: Link | undefined 42 47 } 43 48 44 - export type ComposerState = { 45 - // TODO: Other draft data. 49 + export type ComposerDraft = { 50 + richtext: RichText 51 + labels: string[] 52 + postgate: AppBskyFeedPostgate.Record 53 + threadgate: ThreadgateAllowUISetting[] 46 54 embed: EmbedDraft 47 55 } 48 56 49 57 export type ComposerAction = 58 + | {type: 'update_richtext'; richtext: RichText} 59 + | {type: 'update_labels'; labels: string[]} 60 + | {type: 'update_postgate'; postgate: AppBskyFeedPostgate.Record} 61 + | {type: 'update_threadgate'; threadgate: ThreadgateAllowUISetting[]} 50 62 | {type: 'embed_add_images'; images: ComposerImage[]} 51 63 | {type: 'embed_update_image'; image: ComposerImage} 52 64 | {type: 'embed_remove_image'; image: ComposerImage} ··· 67 79 export const MAX_IMAGES = 4 68 80 69 81 export function composerReducer( 70 - state: ComposerState, 82 + state: ComposerDraft, 71 83 action: ComposerAction, 72 - ): ComposerState { 84 + ): ComposerDraft { 73 85 switch (action.type) { 86 + case 'update_richtext': { 87 + return { 88 + ...state, 89 + richtext: action.richtext, 90 + } 91 + } 92 + case 'update_labels': { 93 + return { 94 + ...state, 95 + labels: action.labels, 96 + } 97 + } 98 + case 'update_postgate': { 99 + return { 100 + ...state, 101 + postgate: action.postgate, 102 + } 103 + } 104 + case 'update_threadgate': { 105 + return { 106 + ...state, 107 + threadgate: action.threadgate, 108 + } 109 + } 74 110 case 'embed_add_images': { 75 111 if (action.images.length === 0) { 76 112 return state ··· 293 329 } 294 330 295 331 export function createComposerState({ 332 + initText, 333 + initMention, 296 334 initImageUris, 297 335 initQuoteUri, 298 336 }: { 337 + initText: string | undefined 338 + initMention: string | undefined 299 339 initImageUris: ComposerOpts['imageUris'] 300 340 initQuoteUri: string | undefined 301 - }): ComposerState { 341 + }): ComposerDraft { 302 342 let media: ImagesMedia | undefined 303 343 if (initImageUris?.length) { 304 344 media = { ··· 317 357 } 318 358 } 319 359 } 360 + const initRichText = new RichText({ 361 + text: initText 362 + ? initText 363 + : initMention 364 + ? insertMentionAt( 365 + `@${initMention}`, 366 + initMention.length + 1, 367 + `${initMention}`, 368 + ) 369 + : '', 370 + }) 320 371 return { 372 + richtext: initRichText, 373 + labels: [], 374 + postgate: createPostgateRecord({post: ''}), 375 + threadgate: threadgateViewToAllowUISetting(undefined), 321 376 embed: { 322 377 quote, 323 378 media,
+1 -1
src/view/com/composer/text-input/TextInput.tsx
··· 43 43 interface TextInputProps extends ComponentProps<typeof RNTextInput> { 44 44 richtext: RichText 45 45 placeholder: string 46 - setRichText: (v: RichText | ((v: RichText) => RichText)) => void 46 + setRichText: (v: RichText) => void 47 47 onPhotoPasted: (uri: string) => void 48 48 onPressPublish: (richtext: RichText) => Promise<void> 49 49 onNewLink: (uri: string) => void