Add live search to autocomplete and only highlight known handles

zio/stable
Paul Frazee 2022-11-17 14:35:12 -06:00
parent 859087f21d
commit 2b98714548
6 changed files with 138 additions and 40 deletions

View File

@ -1,5 +1,6 @@
import {autorun} from 'mobx' import {autorun} from 'mobx'
import {sessionClient as AtpApi} from '../third-party/api' import {sessionClient as AtpApi} from '../third-party/api'
import type {SessionServiceClient} from '../third-party/api/src/index'
import {RootStoreModel} from './models/root-store' import {RootStoreModel} from './models/root-store'
import * as libapi from './lib/api' import * as libapi from './lib/api'
import * as storage from './lib/storage' 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 LOCAL_DEV_SERVICE = 'http://localhost:2583'
export const STAGING_SERVICE = 'https://pds.staging.bsky.dev' export const STAGING_SERVICE = 'https://pds.staging.bsky.dev'
export const PROD_SERVICE = 'https://bsky.social' 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 ROOT_STATE_STORAGE_KEY = 'root'
const STATE_FETCH_INTERVAL = 15e3 const STATE_FETCH_INTERVAL = 15e3

View File

@ -20,6 +20,7 @@ export async function post(
store: RootStoreModel, store: RootStoreModel,
text: string, text: string,
replyTo?: Post.PostRef, replyTo?: Post.PostRef,
knownHandles?: Set<string>,
) { ) {
let reply let reply
if (replyTo) { 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( return await store.api.app.bsky.feed.post.create(
{did: store.me.did || ''}, {did: store.me.did || ''},
{ {

View File

@ -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)
}
})
}
}

View File

@ -13,13 +13,18 @@ import Animated, {
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {colors} from '../../lib/styles' import {colors} from '../../lib/styles'
interface AutocompleteItem {
handle: string
displayName?: string
}
export function Autocomplete({ export function Autocomplete({
active, active,
items, items,
onSelect, onSelect,
}: { }: {
active: boolean active: boolean
items: string[] items: AutocompleteItem[]
onSelect: (item: string) => void onSelect: (item: string) => void
}) { }) {
const winDim = useWindowDimensions() const winDim = useWindowDimensions()
@ -46,8 +51,8 @@ export function Autocomplete({
<TouchableOpacity <TouchableOpacity
key={i} key={i}
style={styles.item} style={styles.item}
onPress={() => onSelect(item)}> onPress={() => onSelect(item.handle)}>
<Text style={styles.itemText}>@{item}</Text> <Text style={styles.itemText}>@{item.handle}</Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</Animated.View> </Animated.View>

View File

@ -1,4 +1,5 @@
import React, {useEffect, useMemo, useState} from 'react' import React, {useEffect, useMemo, useState} from 'react'
import {observer} from 'mobx-react-lite'
import { import {
ActivityIndicator, ActivityIndicator,
KeyboardAvoidingView, KeyboardAvoidingView,
@ -11,7 +12,7 @@ import {
} from 'react-native' } from 'react-native'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 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 {Autocomplete} from './Autocomplete'
import Toast from '../util/Toast' import Toast from '../util/Toast'
import ProgressCircle from '../util/ProgressCircle' import ProgressCircle from '../util/ProgressCircle'
@ -24,7 +25,7 @@ const MAX_TEXT_LENGTH = 256
const WARNING_TEXT_LENGTH = 200 const WARNING_TEXT_LENGTH = 200
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
export function ComposePost({ export const ComposePost = observer(function ComposePost({
replyTo, replyTo,
onPost, onPost,
onClose, onClose,
@ -37,40 +38,24 @@ export function ComposePost({
const [isProcessing, setIsProcessing] = useState(false) const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [text, setText] = useState('') const [text, setText] = useState('')
const [followedUsers, setFollowedUsers] = useState< const autocompleteView = useMemo<UserAutocompleteViewModel>(
undefined | GetFollows.OutputSchema['follows'] () => new UserAutocompleteViewModel(store),
>(undefined) [],
const [autocompleteOptions, setAutocompleteOptions] = useState<string[]>([]) )
useEffect(() => { useEffect(() => {
let aborted = false autocompleteView.setup()
store.api.app.bsky.graph
.getFollows({
user: store.me.did || '',
})
.then(res => {
if (aborted) return
setFollowedUsers(res.data.follows)
})
return () => {
aborted = true
}
}) })
const onChangeText = (newText: string) => { const onChangeText = (newText: string) => {
setText(newText) setText(newText)
const prefix = extractTextAutocompletePrefix(newText) const prefix = extractTextAutocompletePrefix(newText)
if (typeof prefix === 'string' && followedUsers) { if (typeof prefix === 'string') {
setAutocompleteOptions( autocompleteView.setActive(true)
[prefix].concat( autocompleteView.setPrefix(prefix)
followedUsers } else {
.filter(user => user.handle.startsWith(prefix)) autocompleteView.setActive(false)
.map(user => user.handle),
),
)
} else if (autocompleteOptions) {
setAutocompleteOptions([])
} }
} }
const onPressCancel = () => { const onPressCancel = () => {
@ -90,7 +75,7 @@ export function ComposePost({
} }
setIsProcessing(true) setIsProcessing(true)
try { try {
await apilib.post(store, text, replyTo) await apilib.post(store, text, replyTo, autocompleteView.knownHandles)
} catch (e: any) { } catch (e: any) {
console.error(`Failed to create post: ${e.toString()}`) console.error(`Failed to create post: ${e.toString()}`)
setError( setError(
@ -111,7 +96,7 @@ export function ComposePost({
} }
const onSelectAutocompleteItem = (item: string) => { const onSelectAutocompleteItem = (item: string) => {
setText(replaceTextAutocompletePrefix(text, item)) setText(replaceTextAutocompletePrefix(text, item))
setAutocompleteOptions([]) autocompleteView.setActive(false)
} }
const canPost = text.length <= MAX_TEXT_LENGTH const canPost = text.length <= MAX_TEXT_LENGTH
@ -124,7 +109,10 @@ export function ComposePost({
const textDecorated = useMemo(() => { const textDecorated = useMemo(() => {
return (text || '').split(/(\s)/g).map((item, i) => { 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 ( return (
<Text key={i} style={{color: colors.blue3}}> <Text key={i} style={{color: colors.blue3}}>
{item} {item}
@ -198,14 +186,14 @@ export function ComposePost({
</View> </View>
</View> </View>
<Autocomplete <Autocomplete
active={autocompleteOptions.length > 0} active={autocompleteView.isActive}
items={autocompleteOptions} items={autocompleteView.suggestions}
onSelect={onSelectAutocompleteItem} onSelect={onSelectAutocompleteItem}
/> />
</SafeAreaView> </SafeAreaView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
) )
} })
const atPrefixRegex = /@([\S]*)$/i const atPrefixRegex = /@([\S]*)$/i
function extractTextAutocompletePrefix(text: string) { function extractTextAutocompletePrefix(text: string) {

View File

@ -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 match
let ents: Entity[] = [] let ents: Entity[] = []
const re = /(^|\s)(@)([a-zA-Z0-9\.-]+)(\b)/dg const re = /(^|\s)(@)([a-zA-Z0-9\.-]+)(\b)/dg
while ((match = re.exec(text))) { while ((match = re.exec(text))) {
if (knownHandles && !knownHandles.has(match[3])) {
continue // not a known handle
}
ents.push({ ents.push({
type: 'mention', type: 'mention',
value: match[3], value: match[3],