diff options
author | Paul Frazee <pfrazee@gmail.com> | 2022-11-17 14:35:12 -0600 |
---|---|---|
committer | Paul Frazee <pfrazee@gmail.com> | 2022-11-17 14:35:12 -0600 |
commit | 2b98714548d585ff14dd09252233144f48b5f4b7 (patch) | |
tree | f200f39732aad5f7da3b554f02d5f78ce96bee12 /src | |
parent | 859087f21d148d52d707b0057458e7dd2cbbea0a (diff) | |
download | voidsky-2b98714548d585ff14dd09252233144f48b5f4b7.tar.zst |
Add live search to autocomplete and only highlight known handles
Diffstat (limited to 'src')
-rw-r--r-- | src/state/index.ts | 3 | ||||
-rw-r--r-- | src/state/lib/api.ts | 3 | ||||
-rw-r--r-- | src/state/models/user-autocomplete-view.ts | 97 | ||||
-rw-r--r-- | src/view/com/composer/Autocomplete.tsx | 11 | ||||
-rw-r--r-- | src/view/com/composer/ComposePost.tsx | 56 | ||||
-rw-r--r-- | src/view/lib/strings.ts | 8 |
6 files changed, 138 insertions, 40 deletions
diff --git a/src/state/index.ts b/src/state/index.ts index 1ff3d3b1d..fd81bc842 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -1,5 +1,6 @@ import {autorun} from 'mobx' import {sessionClient as AtpApi} from '../third-party/api' +import type {SessionServiceClient} from '../third-party/api/src/index' import {RootStoreModel} from './models/root-store' import * as libapi from './lib/api' import * as storage from './lib/storage' @@ -8,7 +9,7 @@ export const IS_PROD_BUILD = true export const LOCAL_DEV_SERVICE = 'http://localhost:2583' export const STAGING_SERVICE = 'https://pds.staging.bsky.dev' export const PROD_SERVICE = 'https://bsky.social' -export const DEFAULT_SERVICE = IS_PROD_BUILD ? PROD_SERVICE : LOCAL_DEV_SERVICE +export const DEFAULT_SERVICE = PROD_SERVICE const ROOT_STATE_STORAGE_KEY = 'root' const STATE_FETCH_INTERVAL = 15e3 diff --git a/src/state/lib/api.ts b/src/state/lib/api.ts index ba2fcd3bb..5f147e01f 100644 --- a/src/state/lib/api.ts +++ b/src/state/lib/api.ts @@ -20,6 +20,7 @@ export async function post( store: RootStoreModel, text: string, replyTo?: Post.PostRef, + knownHandles?: Set<string>, ) { let reply if (replyTo) { @@ -39,7 +40,7 @@ export async function post( } } } - const entities = extractEntities(text) + const entities = extractEntities(text, knownHandles) return await store.api.app.bsky.feed.post.create( {did: store.me.did || ''}, { diff --git a/src/state/models/user-autocomplete-view.ts b/src/state/models/user-autocomplete-view.ts new file mode 100644 index 000000000..3d53e5db7 --- /dev/null +++ b/src/state/models/user-autocomplete-view.ts @@ -0,0 +1,97 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import * as GetFollows from '../../third-party/api/src/client/types/app/bsky/graph/getFollows' +import * as SearchTypeahead from '../../third-party/api/src/client/types/app/bsky/actor/searchTypeahead' +import {RootStoreModel} from './root-store' + +export class UserAutocompleteViewModel { + // state + isLoading = false + isActive = false + prefix = '' + _searchPromise: Promise<any> | undefined + + // data + follows: GetFollows.OutputSchema['follows'] = [] + searchRes: SearchTypeahead.OutputSchema['users'] = [] + knownHandles: Set<string> = new Set() + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + knownHandles: false, + }, + {autoBind: true}, + ) + } + + get suggestions() { + if (!this.isActive) { + return [] + } + if (this.prefix) { + return this.searchRes.map(user => ({ + handle: user.handle, + displayName: user.displayName, + })) + } + return this.follows.map(follow => ({ + handle: follow.handle, + displayName: follow.displayName, + })) + } + + // public api + // = + + async setup() { + await this._getFollows() + } + + setActive(v: boolean) { + this.isActive = v + } + + async setPrefix(prefix: string) { + const origPrefix = prefix + this.prefix = prefix.trim() + if (this.prefix) { + await this._searchPromise + if (this.prefix !== origPrefix) { + return // another prefix was set before we got our chance + } + this._searchPromise = this._search() + } else { + this.searchRes = [] + } + } + + // internal + // = + + private async _getFollows() { + const res = await this.rootStore.api.app.bsky.graph.getFollows({ + user: this.rootStore.me.did || '', + }) + runInAction(() => { + this.follows = res.data.follows + for (const f of this.follows) { + this.knownHandles.add(f.handle) + } + }) + } + + private async _search() { + const res = await this.rootStore.api.app.bsky.actor.searchTypeahead({ + term: this.prefix, + limit: 8, + }) + runInAction(() => { + this.searchRes = res.data.users + for (const u of this.searchRes) { + this.knownHandles.add(u.handle) + } + }) + } +} diff --git a/src/view/com/composer/Autocomplete.tsx b/src/view/com/composer/Autocomplete.tsx index 7f93bede7..ca5b03734 100644 --- a/src/view/com/composer/Autocomplete.tsx +++ b/src/view/com/composer/Autocomplete.tsx @@ -13,13 +13,18 @@ import Animated, { } from 'react-native-reanimated' import {colors} from '../../lib/styles' +interface AutocompleteItem { + handle: string + displayName?: string +} + export function Autocomplete({ active, items, onSelect, }: { active: boolean - items: string[] + items: AutocompleteItem[] onSelect: (item: string) => void }) { const winDim = useWindowDimensions() @@ -46,8 +51,8 @@ export function Autocomplete({ <TouchableOpacity key={i} style={styles.item} - onPress={() => onSelect(item)}> - <Text style={styles.itemText}>@{item}</Text> + onPress={() => onSelect(item.handle)}> + <Text style={styles.itemText}>@{item.handle}</Text> </TouchableOpacity> ))} </Animated.View> diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx index 0a7fadce1..cdd232fb6 100644 --- a/src/view/com/composer/ComposePost.tsx +++ b/src/view/com/composer/ComposePost.tsx @@ -1,4 +1,5 @@ import React, {useEffect, useMemo, useState} from 'react' +import {observer} from 'mobx-react-lite' import { ActivityIndicator, KeyboardAvoidingView, @@ -11,7 +12,7 @@ import { } from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import * as GetFollows from '../../../third-party/api/src/client/types/app/bsky/graph/getFollows' +import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view' import {Autocomplete} from './Autocomplete' import Toast from '../util/Toast' import ProgressCircle from '../util/ProgressCircle' @@ -24,7 +25,7 @@ const MAX_TEXT_LENGTH = 256 const WARNING_TEXT_LENGTH = 200 const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH -export function ComposePost({ +export const ComposePost = observer(function ComposePost({ replyTo, onPost, onClose, @@ -37,40 +38,24 @@ export function ComposePost({ const [isProcessing, setIsProcessing] = useState(false) const [error, setError] = useState('') const [text, setText] = useState('') - const [followedUsers, setFollowedUsers] = useState< - undefined | GetFollows.OutputSchema['follows'] - >(undefined) - const [autocompleteOptions, setAutocompleteOptions] = useState<string[]>([]) + const autocompleteView = useMemo<UserAutocompleteViewModel>( + () => new UserAutocompleteViewModel(store), + [], + ) useEffect(() => { - let aborted = false - store.api.app.bsky.graph - .getFollows({ - user: store.me.did || '', - }) - .then(res => { - if (aborted) return - setFollowedUsers(res.data.follows) - }) - return () => { - aborted = true - } + autocompleteView.setup() }) const onChangeText = (newText: string) => { setText(newText) const prefix = extractTextAutocompletePrefix(newText) - if (typeof prefix === 'string' && followedUsers) { - setAutocompleteOptions( - [prefix].concat( - followedUsers - .filter(user => user.handle.startsWith(prefix)) - .map(user => user.handle), - ), - ) - } else if (autocompleteOptions) { - setAutocompleteOptions([]) + if (typeof prefix === 'string') { + autocompleteView.setActive(true) + autocompleteView.setPrefix(prefix) + } else { + autocompleteView.setActive(false) } } const onPressCancel = () => { @@ -90,7 +75,7 @@ export function ComposePost({ } setIsProcessing(true) try { - await apilib.post(store, text, replyTo) + await apilib.post(store, text, replyTo, autocompleteView.knownHandles) } catch (e: any) { console.error(`Failed to create post: ${e.toString()}`) setError( @@ -111,7 +96,7 @@ export function ComposePost({ } const onSelectAutocompleteItem = (item: string) => { setText(replaceTextAutocompletePrefix(text, item)) - setAutocompleteOptions([]) + autocompleteView.setActive(false) } const canPost = text.length <= MAX_TEXT_LENGTH @@ -124,7 +109,10 @@ export function ComposePost({ const textDecorated = useMemo(() => { return (text || '').split(/(\s)/g).map((item, i) => { - if (/^@[a-zA-Z0-9\.-]+$/g.test(item)) { + if ( + /^@[a-zA-Z0-9\.-]+$/g.test(item) && + autocompleteView.knownHandles.has(item.slice(1)) + ) { return ( <Text key={i} style={{color: colors.blue3}}> {item} @@ -198,14 +186,14 @@ export function ComposePost({ </View> </View> <Autocomplete - active={autocompleteOptions.length > 0} - items={autocompleteOptions} + active={autocompleteView.isActive} + items={autocompleteView.suggestions} onSelect={onSelectAutocompleteItem} /> </SafeAreaView> </KeyboardAvoidingView> ) -} +}) const atPrefixRegex = /@([\S]*)$/i function extractTextAutocompletePrefix(text: string) { diff --git a/src/view/lib/strings.ts b/src/view/lib/strings.ts index df134e37b..6d963b281 100644 --- a/src/view/lib/strings.ts +++ b/src/view/lib/strings.ts @@ -57,11 +57,17 @@ export function ago(date: number | string | Date): string { } } -export function extractEntities(text: string): Entity[] | undefined { +export function extractEntities( + text: string, + knownHandles?: Set<string>, +): Entity[] | undefined { let match let ents: Entity[] = [] const re = /(^|\s)(@)([a-zA-Z0-9\.-]+)(\b)/dg while ((match = re.exec(text))) { + if (knownHandles && !knownHandles.has(match[3])) { + continue // not a known handle + } ents.push({ type: 'mention', value: match[3], |