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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user