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 {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

View File

@ -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 || ''},
{

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'
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>

View File

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

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 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],