Rework the search UI and add (#174)

* Add search tab and move icon to footer

* Remove subtitles from view header

* Remove unused code

* Clean up UI of search screen

* Search: give better user feedback to UI state and add a cancel button

* Add WhoToFollow section to search

* Add a temporary SuggestedPosts solution using the patented 'bsky team algo'

* Trigger reload of suggested content in search on open

* Wait five min between reloading discovery content

* Reduce weight of solid search icon in footer

* Fix lint

* Fix tests
This commit is contained in:
Paul Frazee
2023-02-08 18:01:29 -06:00
committed by GitHub
parent 3c70bdf791
commit 00d41c9168
22 changed files with 745 additions and 295 deletions
+25 -14
View File
@@ -12,13 +12,20 @@ function genId() {
// we've since decided to pause that idea and do something more traditional
// until we're fully sure what that is, the tabs are being repurposed into a fixed topology
// - Tab 0: The "Default" tab
// - Tab 1: The "Notifications" tab
// - Tab 1: The "Search" tab
// - Tab 2: The "Notifications" tab
// These tabs always retain the first item in their history.
// The default tab is used for basically everything except notifications.
// -prf
export enum TabPurpose {
Default = 0,
Notifs = 1,
Search = 1,
Notifs = 2,
}
export const TabPurposeMainPath: Record<TabPurpose, string> = {
[TabPurpose.Default]: '/',
[TabPurpose.Search]: '/search',
[TabPurpose.Notifs]: '/notifications',
}
interface HistoryItem {
@@ -37,11 +44,9 @@ export class NavigationTabModel {
isNewTab = false
constructor(public fixedTabPurpose: TabPurpose) {
if (fixedTabPurpose === TabPurpose.Notifs) {
this.history = [{url: '/notifications', ts: Date.now(), id: genId()}]
} else {
this.history = [{url: '/', ts: Date.now(), id: genId()}]
}
this.history = [
{url: TabPurposeMainPath[fixedTabPurpose], ts: Date.now(), id: genId()},
]
makeAutoObservable(this, {
serialize: false,
hydrate: false,
@@ -112,8 +117,7 @@ export class NavigationTabModel {
}
// TEMP ensure the tab has its purpose's main view -prf
if (this.history.length < 1) {
const fixedUrl =
this.fixedTabPurpose === TabPurpose.Notifs ? '/notifications' : '/'
const fixedUrl = TabPurposeMainPath[this.fixedTabPurpose]
this.history.push({url: fixedUrl, ts: Date.now(), id: genId()})
}
this.history.push({url, title, ts: Date.now(), id: genId()})
@@ -219,6 +223,7 @@ export class NavigationTabModel {
export class NavigationModel {
tabs: NavigationTabModel[] = [
new NavigationTabModel(TabPurpose.Default),
new NavigationTabModel(TabPurpose.Search),
new NavigationTabModel(TabPurpose.Notifs),
]
tabIndex = 0
@@ -233,6 +238,7 @@ export class NavigationModel {
clear() {
this.tabs = [
new NavigationTabModel(TabPurpose.Default),
new NavigationTabModel(TabPurpose.Search),
new NavigationTabModel(TabPurpose.Notifs),
]
this.tabIndex = 0
@@ -294,10 +300,15 @@ export class NavigationModel {
// fixed tab helper function
// -prf
switchTo(purpose: TabPurpose, reset: boolean) {
if (purpose === TabPurpose.Notifs) {
this.tabIndex = 1
} else {
this.tabIndex = 0
switch (purpose) {
case TabPurpose.Notifs:
this.tabIndex = 2
break
case TabPurpose.Search:
this.tabIndex = 1
break
default:
this.tabIndex = 0
}
if (reset) {
this.tab.fixedTabReset()
+8 -14
View File
@@ -10,6 +10,7 @@ export type SuggestedActor = GetSuggestions.Actor
export class SuggestedActorsViewModel {
// state
pageSize = PAGE_SIZE
isLoading = false
isRefreshing = false
hasLoaded = false
@@ -20,7 +21,10 @@ export class SuggestedActorsViewModel {
// data
suggestions: SuggestedActor[] = []
constructor(public rootStore: RootStoreModel) {
constructor(public rootStore: RootStoreModel, opts?: {pageSize?: number}) {
if (opts?.pageSize) {
this.pageSize = opts.pageSize
}
makeAutoObservable(
this,
{
@@ -63,23 +67,13 @@ export class SuggestedActorsViewModel {
let res
do {
res = await this.rootStore.api.app.bsky.actor.getSuggestions({
limit: PAGE_SIZE,
limit: this.pageSize,
cursor: this.loadMoreCursor,
})
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
items = items.concat(
res.data.actors.filter(actor => {
if (actor.did === this.rootStore.me.did) {
return false // skip self
}
if (actor.myState?.follow) {
return false // skip already-followed users
}
return true
}),
)
} while (items.length < PAGE_SIZE && this.hasMore)
items = items.concat(res.data.actors)
} while (items.length < this.pageSize && this.hasMore)
runInAction(() => {
this.suggestions = items
})
+148
View File
@@ -0,0 +1,148 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
AppBskyFeedFeedViewPost,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
} from '@atproto/api'
type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
import {RootStoreModel} from './root-store'
import {FeedItemModel} from './feed-view'
import {cleanError} from '../../lib/strings'
const TEAM_HANDLES = [
'jay.bsky.social',
'paul.bsky.social',
'dan.bsky.social',
'divy.bsky.social',
'why.bsky.social',
'iamrosewang.bsky.social',
]
export class SuggestedPostsView {
// state
isLoading = false
hasLoaded = false
error = ''
// data
posts: FeedItemModel[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.posts.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async setup() {
this._xLoading()
try {
const responses = await Promise.all(
TEAM_HANDLES.map(handle =>
this.rootStore.api.app.bsky.feed
.getAuthorFeed({author: handle, limit: 10})
.catch(_err => ({success: false, headers: {}, data: {feed: []}})),
),
)
runInAction(() => {
this.posts = mergeAndFilterResponses(this.rootStore, responses)
})
this._xIdle()
} catch (e: any) {
this.rootStore.log.error('SuggestedPostsView: Failed to load posts', {
e,
})
this._xIdle() // dont bubble to the user
}
}
// state transitions
// =
private _xLoading() {
this.isLoading = true
this.error = ''
}
private _xIdle(err?: any) {
this.isLoading = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Failed to fetch suggested posts', err)
}
}
}
function mergeAndFilterResponses(
store: RootStoreModel,
responses: GetAuthorFeed.Response[],
): FeedItemModel[] {
let posts: AppBskyFeedFeedViewPost.Main[] = []
// merge into one array
for (const res of responses) {
if (res.success) {
posts = posts.concat(res.data.feed)
}
}
// filter down to reposts of other users
const now = Date.now()
const uris = new Set()
posts = posts.filter(p => {
if (isARepostOfSomeoneElse(p) && isRecentEnough(now, p)) {
if (uris.has(p.post.uri)) {
return false
}
uris.add(p.post.uri)
return true
}
return false
})
// sort by index time
posts.sort((a, b) => {
return (
Number(new Date(b.post.indexedAt)) - Number(new Date(a.post.indexedAt))
)
})
// hydrate into models and strip the reasons to hide that these are reposts
return posts.map((post, i) => {
delete post.reason
return new FeedItemModel(store, `post-${i}`, post)
})
}
function isARepostOfSomeoneElse(post: AppBskyFeedFeedViewPost.Main): boolean {
return (
post.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost' &&
post.post.author.did !== (post.reason as ReasonRepost).by.did
)
}
const THREE_DAYS = 3 * 24 * 60 * 60 * 1000
function isRecentEnough(
now: number,
post: AppBskyFeedFeedViewPost.Main,
): boolean {
return now - Number(new Date(post.post.indexedAt)) < THREE_DAYS
}
+15 -10
View File
@@ -3,6 +3,7 @@ import {
AppBskyGraphGetFollows as GetFollows,
AppBskyActorSearchTypeahead as SearchTypeahead,
} from '@atproto/api'
import AwaitLock from 'await-lock'
import {RootStoreModel} from './root-store'
export class UserAutocompleteViewModel {
@@ -10,7 +11,7 @@ export class UserAutocompleteViewModel {
isLoading = false
isActive = false
prefix = ''
_searchPromise: Promise<any> | undefined
lock = new AwaitLock()
// data
follows: GetFollows.Follow[] = []
@@ -58,16 +59,20 @@ export class UserAutocompleteViewModel {
}
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
const origPrefix = prefix.trim()
this.prefix = origPrefix
await this.lock.acquireAsync()
try {
if (this.prefix) {
if (this.prefix !== origPrefix) {
return // another prefix was set before we got our chance
}
await this._search()
} else {
this.searchRes = []
}
this._searchPromise = this._search()
} else {
this.searchRes = []
} finally {
this.lock.release()
}
}