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