From 866178c5d9c46d944a368de1007199162193257b Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 14 Nov 2023 09:59:43 -0800 Subject: [PATCH] Rework composer to use RQ --- src/lib/api/index.ts | 13 +++-- src/lib/media/picker.tsx | 11 +--- src/lib/media/picker.web.tsx | 11 +--- src/state/models/media/gallery.ts | 9 ++-- src/state/models/media/image.ts | 9 ++-- src/state/queries/actor-autocomplete.ts | 54 ++++++++++++++++++- src/view/com/composer/Composer.tsx | 30 ++++------- .../com/composer/text-input/TextInput.tsx | 21 ++++---- .../com/composer/text-input/TextInput.web.tsx | 20 +++++-- .../text-input/mobile/Autocomplete.tsx | 18 ++++--- .../composer/text-input/web/Autocomplete.tsx | 11 ++-- 11 files changed, 120 insertions(+), 87 deletions(-) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index a988348883..92620c459d 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -82,12 +82,11 @@ interface PostOpts { extLink?: ExternalEmbedDraft images?: ImageModel[] labels?: string[] - knownHandles?: Set onStateChange?: (state: string) => void langs?: string[] } -export async function post(store: RootStoreModel, opts: PostOpts) { +export async function post(agent: BskyAgent, opts: PostOpts) { let embed: | AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main @@ -103,7 +102,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { ) opts.onStateChange?.('Processing...') - await rt.detectFacets(store.agent) + await rt.detectFacets(agent) rt = shortenLinks(rt) // filter out any mention facets that didn't map to a user @@ -136,7 +135,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { await image.compress() const path = image.compressed?.path ?? image.path const {width, height} = image.compressed || image - const res = await uploadBlob(store.agent, path, 'image/jpeg') + const res = await uploadBlob(agent, path, 'image/jpeg') images.push({ image: res.data.blob, alt: image.altText ?? '', @@ -186,7 +185,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { } if (encoding) { const thumbUploadRes = await uploadBlob( - store.agent, + agent, opts.extLink.localThumb.path, encoding, ) @@ -225,7 +224,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { // add replyTo if post is a reply to another post if (opts.replyTo) { const replyToUrip = new AtUri(opts.replyTo) - const parentPost = await store.agent.getPost({ + const parentPost = await agent.getPost({ repo: replyToUrip.host, rkey: replyToUrip.rkey, }) @@ -258,7 +257,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { try { opts.onStateChange?.('Posting...') - return await store.agent.post({ + return await agent.post({ text: rt.text, facets: rt.facets, reply, diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx index d0ee1ae223..e91a5b8dd8 100644 --- a/src/lib/media/picker.tsx +++ b/src/lib/media/picker.tsx @@ -3,7 +3,6 @@ import { openCropper as openCropperFn, Image as RNImage, } from 'react-native-image-crop-picker' -import {RootStoreModel} from 'state/index' import {CameraOpts, CropperOptions} from './types' export {openPicker} from './picker.shared' @@ -16,10 +15,7 @@ export {openPicker} from './picker.shared' * -prf */ -export async function openCamera( - _store: RootStoreModel, - opts: CameraOpts, -): Promise { +export async function openCamera(opts: CameraOpts): Promise { const item = await openCameraFn({ width: opts.width, height: opts.height, @@ -39,10 +35,7 @@ export async function openCamera( } } -export async function openCropper( - _store: RootStoreModel, - opts: CropperOptions, -) { +export async function openCropper(opts: CropperOptions) { const item = await openCropperFn({ ...opts, forceJpg: true, // ios only diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx index 50b9c73e94..995a0c95f9 100644 --- a/src/lib/media/picker.web.tsx +++ b/src/lib/media/picker.web.tsx @@ -1,23 +1,16 @@ /// import {CameraOpts, CropperOptions} from './types' -import {RootStoreModel} from 'state/index' import {Image as RNImage} from 'react-native-image-crop-picker' export {openPicker} from './picker.shared' import {unstable__openModal} from '#/state/modals' -export async function openCamera( - _store: RootStoreModel, - _opts: CameraOpts, -): Promise { +export async function openCamera(_opts: CameraOpts): Promise { // const mediaType = opts.mediaType || 'photo' TODO throw new Error('TODO') } -export async function openCropper( - _store: RootStoreModel, - opts: CropperOptions, -): Promise { +export async function openCropper(opts: CropperOptions): Promise { // TODO handle more opts return new Promise((resolve, reject) => { unstable__openModal({ diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts index f9c3efcad8..04023bf820 100644 --- a/src/state/models/media/gallery.ts +++ b/src/state/models/media/gallery.ts @@ -1,5 +1,4 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {RootStoreModel} from 'state/index' import {ImageModel} from './image' import {Image as RNImage} from 'react-native-image-crop-picker' import {openPicker} from 'lib/media/picker' @@ -8,10 +7,8 @@ import {getImageDim} from 'lib/media/manip' export class GalleryModel { images: ImageModel[] = [] - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, { - rootStore: false, - }) + constructor() { + makeAutoObservable(this) } get isEmpty() { @@ -33,7 +30,7 @@ export class GalleryModel { // Temporarily enforce uniqueness but can eventually also use index if (!this.images.some(i => i.path === image_.path)) { - const image = new ImageModel(this.rootStore, image_) + const image = new ImageModel(image_) // Initial resize image.manipulate({}) diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts index b3796060cd..6a226484e1 100644 --- a/src/state/models/media/image.ts +++ b/src/state/models/media/image.ts @@ -1,5 +1,4 @@ import {Image as RNImage} from 'react-native-image-crop-picker' -import {RootStoreModel} from 'state/index' import {makeAutoObservable, runInAction} from 'mobx' import {POST_IMG_MAX} from 'lib/constants' import * as ImageManipulator from 'expo-image-manipulator' @@ -42,10 +41,8 @@ export class ImageModel implements Omit { } prevAttributes: ImageManipulationAttributes = {} - constructor(public rootStore: RootStoreModel, image: Omit) { - makeAutoObservable(this, { - rootStore: false, - }) + constructor(image: Omit) { + makeAutoObservable(this) this.path = image.path this.width = image.width @@ -178,7 +175,7 @@ export class ImageModel implements Omit { height: this.height, }) - const cropped = await openCropper(this.rootStore, { + const cropped = await openCropper({ mediaType: 'photo', path: this.path, freeStyleCropEnabled: true, diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index 18abb6314f..62c4781c42 100644 --- a/src/state/queries/actor-autocomplete.ts +++ b/src/state/queries/actor-autocomplete.ts @@ -1,7 +1,8 @@ -import {AppBskyActorDefs} from '@atproto/api' +import {AppBskyActorDefs, BskyAgent} from '@atproto/api' import {useQuery} from '@tanstack/react-query' import {useSession} from '../session' import {useMyFollowsQuery} from './my-follows' +import AwaitLock from 'await-lock' export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix] @@ -21,6 +22,57 @@ export function useActorAutocompleteQuery(prefix: string) { }) } +export class ActorAutocomplete { + // state + isLoading = false + isActive = false + prefix = '' + lock = new AwaitLock() + + // data + suggestions: AppBskyActorDefs.ProfileViewBasic[] = [] + + constructor( + public agent: BskyAgent, + public follows?: AppBskyActorDefs.ProfileViewBasic[] | undefined, + ) {} + + setFollows(follows: AppBskyActorDefs.ProfileViewBasic[]) { + this.follows = follows + } + + async query(prefix: string) { + const origPrefix = prefix.trim().toLocaleLowerCase() + this.prefix = origPrefix + await this.lock.acquireAsync() + try { + if (this.prefix) { + if (this.prefix !== origPrefix) { + return // another prefix was set before we got our chance + } + + // start with follow results + this.suggestions = computeSuggestions(this.prefix, this.follows) + + // ask backend + const res = await this.agent.searchActorsTypeahead({ + term: this.prefix, + limit: 8, + }) + this.suggestions = computeSuggestions( + this.prefix, + this.follows, + res.data.actors, + ) + } else { + this.suggestions = computeSuggestions(this.prefix, this.follows) + } + } finally { + this.lock.release() + } + } +} + function computeSuggestions( prefix: string, follows: AppBskyActorDefs.ProfileViewBasic[] = [], diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 37b657c145..4db9a3a328 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -16,7 +16,6 @@ import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {RichText} from '@atproto/api' import {useAnalytics} from 'lib/analytics/analytics' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible' import {ExternalEmbed} from './ExternalEmbed' import {Text} from '../util/text/Text' @@ -57,6 +56,9 @@ import { useLanguagePrefsApi, toPostLanguages, } from '#/state/preferences/languages' +import {useSession} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' +import {useComposerControls} from '#/state/shell/composer' type Props = ComposerOpts export const ComposePost = observer(function ComposePost({ @@ -65,12 +67,14 @@ export const ComposePost = observer(function ComposePost({ quote: initQuote, mention: initMention, }: Props) { + const {agent, currentAccount} = useSession() + const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) const {activeModals} = useModals() const {openModal, closeModal} = useModalControls() + const {closeComposer} = useComposerControls() const {track} = useAnalytics() const pal = usePalette('default') const {isDesktop, isMobile} = useWebMediaQueries() - const store = useStores() const {_} = useLingui() const requireAltTextEnabled = useRequireAltTextEnabled() const langPrefs = useLanguagePrefs() @@ -100,15 +104,10 @@ export const ComposePost = observer(function ComposePost({ const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const [labels, setLabels] = useState([]) const [suggestedLinks, setSuggestedLinks] = useState>(new Set()) - const gallery = useMemo(() => new GalleryModel(store), [store]) + const gallery = useMemo(() => new GalleryModel(), []) const onClose = useCallback(() => { - store.shell.closeComposer() - }, [store]) - - const autocompleteView = useMemo( - () => new UserAutocompleteModel(store), - [store], - ) + closeComposer() + }, [closeComposer]) const insets = useSafeAreaInsets() const viewStyles = useMemo( @@ -161,11 +160,6 @@ export const ComposePost = observer(function ComposePost({ } }, [onPressCancel]) - // initial setup - useEffect(() => { - autocompleteView.setup() - }, [autocompleteView]) - // listen to escape key on desktop web const onEscape = useCallback( (e: KeyboardEvent) => { @@ -215,7 +209,7 @@ export const ComposePost = observer(function ComposePost({ setIsProcessing(true) try { - await apilib.post(store, { + await apilib.post(agent, { rawText: richtext.text, replyTo: replyTo?.uri, images: gallery.images, @@ -223,7 +217,6 @@ export const ComposePost = observer(function ComposePost({ extLink, labels, onStateChange: setProcessingState, - knownHandles: autocompleteView.knownHandles, langs: toPostLanguages(langPrefs.postLanguage), }) } catch (e: any) { @@ -380,13 +373,12 @@ export const ComposePost = observer(function ComposePost({ styles.textInputLayout, isNative && styles.textInputLayoutMobile, ]}> - + { richtext: RichText placeholder: string suggestedLinks: Set - autocompleteView: UserAutocompleteModel setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void onPressPublish: (richtext: RichText) => Promise @@ -56,7 +55,6 @@ export const TextInput = forwardRef(function TextInputImpl( richtext, placeholder, suggestedLinks, - autocompleteView, setRichText, onPhotoPasted, onSuggestedLinksChanged, @@ -69,6 +67,7 @@ export const TextInput = forwardRef(function TextInputImpl( const textInput = useRef(null) const textInputSelection = useRef({start: 0, end: 0}) const theme = useTheme() + const [autocompletePrefix, setAutocompletePrefix] = useState('') React.useImperativeHandle(ref, () => ({ focus: () => textInput.current?.focus(), @@ -99,10 +98,9 @@ export const TextInput = forwardRef(function TextInputImpl( textInputSelection.current?.start || 0, ) if (prefix) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(prefix.value) - } else { - autocompleteView.setActive(false) + setAutocompletePrefix(prefix.value) + } else if (autocompletePrefix) { + setAutocompletePrefix('') } const set: Set = new Set() @@ -139,7 +137,8 @@ export const TextInput = forwardRef(function TextInputImpl( }, [ setRichText, - autocompleteView, + autocompletePrefix, + setAutocompletePrefix, suggestedLinks, onSuggestedLinksChanged, onPhotoPasted, @@ -179,9 +178,9 @@ export const TextInput = forwardRef(function TextInputImpl( item, ), ) - autocompleteView.setActive(false) + setAutocompletePrefix('') }, - [onChangeText, richtext, autocompleteView], + [onChangeText, richtext, setAutocompletePrefix], ) const textDecorated = useMemo(() => { @@ -221,7 +220,7 @@ export const TextInput = forwardRef(function TextInputImpl( {textDecorated} diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 35482bc704..7690a5876a 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -11,13 +11,15 @@ import {Paragraph} from '@tiptap/extension-paragraph' import {Placeholder} from '@tiptap/extension-placeholder' import {Text} from '@tiptap/extension-text' import isEqual from 'lodash.isequal' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {createSuggestion} from './web/Autocomplete' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {isUriImage, blobToDataUri} from 'lib/media/util' import {Emoji} from './web/EmojiPicker.web' import {LinkDecorator} from './web/LinkDecorator' import {generateJSON} from '@tiptap/html' +import {ActorAutocomplete} from '#/state/queries/actor-autocomplete' +import {useSession} from '#/state/session' +import {useMyFollowsQuery} from '#/state/queries/my-follows' export interface TextInputRef { focus: () => void @@ -28,7 +30,6 @@ interface TextInputProps { richtext: RichText placeholder: string suggestedLinks: Set - autocompleteView: UserAutocompleteModel setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void onPressPublish: (richtext: RichText) => Promise @@ -43,7 +44,6 @@ export const TextInput = React.forwardRef(function TextInputImpl( richtext, placeholder, suggestedLinks, - autocompleteView, setRichText, onPhotoPasted, onPressPublish, @@ -52,6 +52,16 @@ export const TextInput = React.forwardRef(function TextInputImpl( TextInputProps, ref, ) { + const {agent} = useSession() + const autocomplete = React.useMemo( + () => new ActorAutocomplete(agent), + [agent], + ) + const {data: follows} = useMyFollowsQuery() + if (follows) { + autocomplete.setFollows(follows) + } + const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') const extensions = React.useMemo( () => [ @@ -61,7 +71,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( HTMLAttributes: { class: 'mention', }, - suggestion: createSuggestion({autocompleteView}), + suggestion: createSuggestion({autocomplete}), }), Paragraph, Placeholder.configure({ @@ -71,7 +81,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( History, Hardbreak, ], - [autocompleteView, placeholder], + [autocomplete, placeholder], ) React.useEffect(() => { diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx index f8335d4b94..9ccd717fb2 100644 --- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -1,31 +1,33 @@ import React, {useEffect} from 'react' import {Animated, TouchableOpacity, StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {usePalette} from 'lib/hooks/usePalette' import {Text} from 'view/com/util/text/Text' import {UserAvatar} from 'view/com/util/UserAvatar' import {useGrapheme} from '../hooks/useGrapheme' +import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' export const Autocomplete = observer(function AutocompleteImpl({ - view, + prefix, onSelect, }: { - view: UserAutocompleteModel + prefix: string onSelect: (item: string) => void }) { const pal = usePalette('default') const positionInterp = useAnimatedValue(0) const {getGraphemeString} = useGrapheme() + const isActive = !!prefix + const {data: suggestions} = useActorAutocompleteQuery(prefix) useEffect(() => { Animated.timing(positionInterp, { - toValue: view.isActive ? 1 : 0, + toValue: isActive ? 1 : 0, duration: 200, useNativeDriver: true, }).start() - }, [positionInterp, view.isActive]) + }, [positionInterp, isActive]) const topAnimStyle = { transform: [ @@ -40,10 +42,10 @@ export const Autocomplete = observer(function AutocompleteImpl({ return ( - {view.isActive ? ( + {isActive ? ( - {view.suggestions.length > 0 ? ( - view.suggestions.slice(0, 5).map(item => { + {suggestions?.length ? ( + suggestions.slice(0, 5).map(item => { // Eventually use an average length const MAX_CHARS = 40 const MAX_HANDLE_CHARS = 20 diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index bbed26d48c..c6b773d862 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -12,7 +12,7 @@ import { SuggestionProps, SuggestionKeyDownProps, } from '@tiptap/suggestion' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' +import {ActorAutocomplete} from '#/state/queries/actor-autocomplete' import {usePalette} from 'lib/hooks/usePalette' import {Text} from 'view/com/util/text/Text' import {UserAvatar} from 'view/com/util/UserAvatar' @@ -23,15 +23,14 @@ interface MentionListRef { } export function createSuggestion({ - autocompleteView, + autocomplete, }: { - autocompleteView: UserAutocompleteModel + autocomplete: ActorAutocomplete }): Omit { return { async items({query}) { - autocompleteView.setActive(true) - await autocompleteView.setPrefix(query) - return autocompleteView.suggestions.slice(0, 8) + await autocomplete.query(query) + return autocomplete.suggestions.slice(0, 8) }, render: () => {