about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/state/index.ts3
-rw-r--r--src/state/lib/api.ts3
-rw-r--r--src/state/models/user-autocomplete-view.ts97
-rw-r--r--src/view/com/composer/Autocomplete.tsx11
-rw-r--r--src/view/com/composer/ComposePost.tsx56
-rw-r--r--src/view/lib/strings.ts8
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],