Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add live search to autocomplete and only highlight known handles

+138 -40
+2 -1
src/state/index.ts
··· 1 1 import {autorun} from 'mobx' 2 2 import {sessionClient as AtpApi} from '../third-party/api' 3 + import type {SessionServiceClient} from '../third-party/api/src/index' 3 4 import {RootStoreModel} from './models/root-store' 4 5 import * as libapi from './lib/api' 5 6 import * as storage from './lib/storage' ··· 8 9 export const LOCAL_DEV_SERVICE = 'http://localhost:2583' 9 10 export const STAGING_SERVICE = 'https://pds.staging.bsky.dev' 10 11 export const PROD_SERVICE = 'https://bsky.social' 11 - export const DEFAULT_SERVICE = IS_PROD_BUILD ? PROD_SERVICE : LOCAL_DEV_SERVICE 12 + export const DEFAULT_SERVICE = PROD_SERVICE 12 13 const ROOT_STATE_STORAGE_KEY = 'root' 13 14 const STATE_FETCH_INTERVAL = 15e3 14 15
+2 -1
src/state/lib/api.ts
··· 20 20 store: RootStoreModel, 21 21 text: string, 22 22 replyTo?: Post.PostRef, 23 + knownHandles?: Set<string>, 23 24 ) { 24 25 let reply 25 26 if (replyTo) { ··· 39 40 } 40 41 } 41 42 } 42 - const entities = extractEntities(text) 43 + const entities = extractEntities(text, knownHandles) 43 44 return await store.api.app.bsky.feed.post.create( 44 45 {did: store.me.did || ''}, 45 46 {
+97
src/state/models/user-autocomplete-view.ts
··· 1 + import {makeAutoObservable, runInAction} from 'mobx' 2 + import * as GetFollows from '../../third-party/api/src/client/types/app/bsky/graph/getFollows' 3 + import * as SearchTypeahead from '../../third-party/api/src/client/types/app/bsky/actor/searchTypeahead' 4 + import {RootStoreModel} from './root-store' 5 + 6 + export class UserAutocompleteViewModel { 7 + // state 8 + isLoading = false 9 + isActive = false 10 + prefix = '' 11 + _searchPromise: Promise<any> | undefined 12 + 13 + // data 14 + follows: GetFollows.OutputSchema['follows'] = [] 15 + searchRes: SearchTypeahead.OutputSchema['users'] = [] 16 + knownHandles: Set<string> = new Set() 17 + 18 + constructor(public rootStore: RootStoreModel) { 19 + makeAutoObservable( 20 + this, 21 + { 22 + rootStore: false, 23 + knownHandles: false, 24 + }, 25 + {autoBind: true}, 26 + ) 27 + } 28 + 29 + get suggestions() { 30 + if (!this.isActive) { 31 + return [] 32 + } 33 + if (this.prefix) { 34 + return this.searchRes.map(user => ({ 35 + handle: user.handle, 36 + displayName: user.displayName, 37 + })) 38 + } 39 + return this.follows.map(follow => ({ 40 + handle: follow.handle, 41 + displayName: follow.displayName, 42 + })) 43 + } 44 + 45 + // public api 46 + // = 47 + 48 + async setup() { 49 + await this._getFollows() 50 + } 51 + 52 + setActive(v: boolean) { 53 + this.isActive = v 54 + } 55 + 56 + async setPrefix(prefix: string) { 57 + const origPrefix = prefix 58 + this.prefix = prefix.trim() 59 + if (this.prefix) { 60 + await this._searchPromise 61 + if (this.prefix !== origPrefix) { 62 + return // another prefix was set before we got our chance 63 + } 64 + this._searchPromise = this._search() 65 + } else { 66 + this.searchRes = [] 67 + } 68 + } 69 + 70 + // internal 71 + // = 72 + 73 + private async _getFollows() { 74 + const res = await this.rootStore.api.app.bsky.graph.getFollows({ 75 + user: this.rootStore.me.did || '', 76 + }) 77 + runInAction(() => { 78 + this.follows = res.data.follows 79 + for (const f of this.follows) { 80 + this.knownHandles.add(f.handle) 81 + } 82 + }) 83 + } 84 + 85 + private async _search() { 86 + const res = await this.rootStore.api.app.bsky.actor.searchTypeahead({ 87 + term: this.prefix, 88 + limit: 8, 89 + }) 90 + runInAction(() => { 91 + this.searchRes = res.data.users 92 + for (const u of this.searchRes) { 93 + this.knownHandles.add(u.handle) 94 + } 95 + }) 96 + } 97 + }
+8 -3
src/view/com/composer/Autocomplete.tsx
··· 13 13 } from 'react-native-reanimated' 14 14 import {colors} from '../../lib/styles' 15 15 16 + interface AutocompleteItem { 17 + handle: string 18 + displayName?: string 19 + } 20 + 16 21 export function Autocomplete({ 17 22 active, 18 23 items, 19 24 onSelect, 20 25 }: { 21 26 active: boolean 22 - items: string[] 27 + items: AutocompleteItem[] 23 28 onSelect: (item: string) => void 24 29 }) { 25 30 const winDim = useWindowDimensions() ··· 46 51 <TouchableOpacity 47 52 key={i} 48 53 style={styles.item} 49 - onPress={() => onSelect(item)}> 50 - <Text style={styles.itemText}>@{item}</Text> 54 + onPress={() => onSelect(item.handle)}> 55 + <Text style={styles.itemText}>@{item.handle}</Text> 51 56 </TouchableOpacity> 52 57 ))} 53 58 </Animated.View>
+22 -34
src/view/com/composer/ComposePost.tsx
··· 1 1 import React, {useEffect, useMemo, useState} from 'react' 2 + import {observer} from 'mobx-react-lite' 2 3 import { 3 4 ActivityIndicator, 4 5 KeyboardAvoidingView, ··· 11 12 } from 'react-native' 12 13 import LinearGradient from 'react-native-linear-gradient' 13 14 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 14 - import * as GetFollows from '../../../third-party/api/src/client/types/app/bsky/graph/getFollows' 15 + import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view' 15 16 import {Autocomplete} from './Autocomplete' 16 17 import Toast from '../util/Toast' 17 18 import ProgressCircle from '../util/ProgressCircle' ··· 24 25 const WARNING_TEXT_LENGTH = 200 25 26 const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH 26 27 27 - export function ComposePost({ 28 + export const ComposePost = observer(function ComposePost({ 28 29 replyTo, 29 30 onPost, 30 31 onClose, ··· 37 38 const [isProcessing, setIsProcessing] = useState(false) 38 39 const [error, setError] = useState('') 39 40 const [text, setText] = useState('') 40 - const [followedUsers, setFollowedUsers] = useState< 41 - undefined | GetFollows.OutputSchema['follows'] 42 - >(undefined) 43 - const [autocompleteOptions, setAutocompleteOptions] = useState<string[]>([]) 41 + const autocompleteView = useMemo<UserAutocompleteViewModel>( 42 + () => new UserAutocompleteViewModel(store), 43 + [], 44 + ) 44 45 45 46 useEffect(() => { 46 - let aborted = false 47 - store.api.app.bsky.graph 48 - .getFollows({ 49 - user: store.me.did || '', 50 - }) 51 - .then(res => { 52 - if (aborted) return 53 - setFollowedUsers(res.data.follows) 54 - }) 55 - return () => { 56 - aborted = true 57 - } 47 + autocompleteView.setup() 58 48 }) 59 49 60 50 const onChangeText = (newText: string) => { 61 51 setText(newText) 62 52 63 53 const prefix = extractTextAutocompletePrefix(newText) 64 - if (typeof prefix === 'string' && followedUsers) { 65 - setAutocompleteOptions( 66 - [prefix].concat( 67 - followedUsers 68 - .filter(user => user.handle.startsWith(prefix)) 69 - .map(user => user.handle), 70 - ), 71 - ) 72 - } else if (autocompleteOptions) { 73 - setAutocompleteOptions([]) 54 + if (typeof prefix === 'string') { 55 + autocompleteView.setActive(true) 56 + autocompleteView.setPrefix(prefix) 57 + } else { 58 + autocompleteView.setActive(false) 74 59 } 75 60 } 76 61 const onPressCancel = () => { ··· 90 75 } 91 76 setIsProcessing(true) 92 77 try { 93 - await apilib.post(store, text, replyTo) 78 + await apilib.post(store, text, replyTo, autocompleteView.knownHandles) 94 79 } catch (e: any) { 95 80 console.error(`Failed to create post: ${e.toString()}`) 96 81 setError( ··· 111 96 } 112 97 const onSelectAutocompleteItem = (item: string) => { 113 98 setText(replaceTextAutocompletePrefix(text, item)) 114 - setAutocompleteOptions([]) 99 + autocompleteView.setActive(false) 115 100 } 116 101 117 102 const canPost = text.length <= MAX_TEXT_LENGTH ··· 124 109 125 110 const textDecorated = useMemo(() => { 126 111 return (text || '').split(/(\s)/g).map((item, i) => { 127 - if (/^@[a-zA-Z0-9\.-]+$/g.test(item)) { 112 + if ( 113 + /^@[a-zA-Z0-9\.-]+$/g.test(item) && 114 + autocompleteView.knownHandles.has(item.slice(1)) 115 + ) { 128 116 return ( 129 117 <Text key={i} style={{color: colors.blue3}}> 130 118 {item} ··· 198 186 </View> 199 187 </View> 200 188 <Autocomplete 201 - active={autocompleteOptions.length > 0} 202 - items={autocompleteOptions} 189 + active={autocompleteView.isActive} 190 + items={autocompleteView.suggestions} 203 191 onSelect={onSelectAutocompleteItem} 204 192 /> 205 193 </SafeAreaView> 206 194 </KeyboardAvoidingView> 207 195 ) 208 - } 196 + }) 209 197 210 198 const atPrefixRegex = /@([\S]*)$/i 211 199 function extractTextAutocompletePrefix(text: string) {
+7 -1
src/view/lib/strings.ts
··· 57 57 } 58 58 } 59 59 60 - export function extractEntities(text: string): Entity[] | undefined { 60 + export function extractEntities( 61 + text: string, 62 + knownHandles?: Set<string>, 63 + ): Entity[] | undefined { 61 64 let match 62 65 let ents: Entity[] = [] 63 66 const re = /(^|\s)(@)([a-zA-Z0-9\.-]+)(\b)/dg 64 67 while ((match = re.exec(text))) { 68 + if (knownHandles && !knownHandles.has(match[3])) { 69 + continue // not a known handle 70 + } 65 71 ents.push({ 66 72 type: 'mention', 67 73 value: match[3],