Add live search to autocomplete and only highlight known handles
parent
859087f21d
commit
2b98714548
|
@ -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
|
||||
|
||||
|
|
|
@ -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 || ''},
|
||||
{
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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],
|
||||
|
|
Loading…
Reference in New Issue