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
+47 -46
View File
@@ -15,7 +15,7 @@ describe('NavigationModel', () => {
it('should clear() to the correct base state', async () => { it('should clear() to the correct base state', async () => {
await model.clear() await model.clear()
expect(model.tabCount).toBe(2) expect(model.tabCount).toBe(3)
expect(model.tab).toEqual({ expect(model.tab).toEqual({
fixedTabPurpose: 0, fixedTabPurpose: 0,
history: [ history: [
@@ -64,7 +64,7 @@ describe('NavigationModel', () => {
}) })
it('should call the tabCount getter', () => { it('should call the tabCount getter', () => {
expect(model.tabCount).toBe(2) expect(model.tabCount).toBe(3)
}) })
describe('tabs not enabled', () => { describe('tabs not enabled', () => {
@@ -87,7 +87,7 @@ describe('NavigationModel', () => {
it('should not change the active tab', () => { it('should not change the active tab', () => {
// @ts-expect-error // @ts-expect-error
flags.TABS_ENABLED = false flags.TABS_ENABLED = false
model.setActiveTab(2) model.setActiveTab(3)
expect(model.tabIndex).toBe(0) expect(model.tabIndex).toBe(0)
}) })
@@ -95,57 +95,58 @@ describe('NavigationModel', () => {
// @ts-expect-error // @ts-expect-error
flags.TABS_ENABLED = false flags.TABS_ENABLED = false
model.closeTab(0) model.closeTab(0)
expect(model.tabCount).toBe(2) expect(model.tabCount).toBe(3)
}) })
}) })
describe('tabs enabled', () => { // TODO restore when tabs get re-enabled
jest.mock('../../../src/build-flags', () => ({ // describe('tabs enabled', () => {
TABS_ENABLED: true, // jest.mock('../../../src/build-flags', () => ({
})) // TABS_ENABLED: true,
// }))
afterAll(() => { // afterAll(() => {
jest.clearAllMocks() // jest.clearAllMocks()
}) // })
it('should create new tabs', () => { // it('should create new tabs', () => {
// @ts-expect-error // // @ts-expect-error
flags.TABS_ENABLED = true // flags.TABS_ENABLED = true
model.newTab('testurl', 'title') // model.newTab('testurl', 'title')
expect(model.tab.isNewTab).toBe(true) // expect(model.tab.isNewTab).toBe(true)
expect(model.tabIndex).toBe(2) // expect(model.tabIndex).toBe(2)
}) // })
it('should change the current tab', () => { // it('should change the current tab', () => {
// @ts-expect-error // // @ts-expect-error
flags.TABS_ENABLED = true // flags.TABS_ENABLED = true
model.setActiveTab(0) // model.setActiveTab(0)
expect(model.tabIndex).toBe(0) // expect(model.tabIndex).toBe(0)
}) // })
it('should close tabs', () => { // it('should close tabs', () => {
// @ts-expect-error // // @ts-expect-error
flags.TABS_ENABLED = true // flags.TABS_ENABLED = true
model.closeTab(0) // model.closeTab(0)
expect(model.tabs).toEqual([ // expect(model.tabs).toEqual([
{ // {
fixedTabPurpose: 1, // fixedTabPurpose: 1,
history: [ // history: [
{ // {
id: expect.anything(), // id: expect.anything(),
ts: expect.anything(), // ts: expect.anything(),
url: '/notifications', // url: '/notifications',
}, // },
], // ],
id: expect.anything(), // id: expect.anything(),
index: 0, // index: 0,
isNewTab: false, // isNewTab: false,
}, // },
]) // ])
expect(model.tabIndex).toBe(0) // expect(model.tabIndex).toBe(0)
}) // })
}) // })
}) })
+13
View File
@@ -36,6 +36,19 @@ describe('rootStore', () => {
}, },
{ {
fixedTabPurpose: 1, fixedTabPurpose: 1,
history: [
{
id: expect.anything(),
ts: expect.anything(),
url: '/search',
},
],
id: expect.anything(),
index: 0,
isNewTab: false,
},
{
fixedTabPurpose: 2,
history: [ history: [
{ {
id: expect.anything(), id: expect.anything(),
-30
View File
@@ -1,30 +0,0 @@
import React from 'react'
import {Search} from '../../../src/view/screens/Search'
import {cleanup, fireEvent, render} from '../../../jest/test-utils'
describe('Search', () => {
jest.useFakeTimers()
const mockedProps = {
navIdx: [0, 0] as [number, number],
params: {
name: 'test name',
},
visible: true,
}
afterAll(() => {
jest.clearAllMocks()
cleanup()
})
it('renders with query', async () => {
const {findByTestId} = render(<Search {...mockedProps} />)
const searchTextInput = await findByTestId('searchTextInput')
expect(searchTextInput).toBeTruthy()
fireEvent.changeText(searchTextInput, 'test')
const searchScrollView = await findByTestId('searchScrollView')
expect(searchScrollView).toBeTruthy()
})
})
+2 -3
View File
@@ -41,8 +41,7 @@ describe('Menu', () => {
fireEvent.press(searchBtn) fireEvent.press(searchBtn)
expect(onCloseMock).toHaveBeenCalled() expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(0, true) expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(1, true)
expect(mockedNavigationStore.navigate).toHaveBeenCalledWith('/search')
}) })
it("presses notifications menu item' button", () => { it("presses notifications menu item' button", () => {
@@ -52,6 +51,6 @@ describe('Menu', () => {
fireEvent.press(menuItemButton) fireEvent.press(menuItemButton)
expect(onCloseMock).toHaveBeenCalled() expect(onCloseMock).toHaveBeenCalled()
expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(1, true) expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(2, true)
}) })
}) })
+2 -1
View File
@@ -7,6 +7,7 @@ import SplashScreen from 'react-native-splash-screen'
import {SafeAreaProvider} from 'react-native-safe-area-context' import {SafeAreaProvider} from 'react-native-safe-area-context'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {SegmentClient, AnalyticsProvider} from '@segment/analytics-react-native' import {SegmentClient, AnalyticsProvider} from '@segment/analytics-react-native'
import {TabPurpose} from './state/models/navigation'
import {ThemeProvider} from './view/lib/ThemeContext' import {ThemeProvider} from './view/lib/ThemeContext'
import * as view from './view/index' import * as view from './view/index'
import {RootStoreModel, setupState, RootStoreProvider} from './state' import {RootStoreModel, setupState, RootStoreProvider} from './state'
@@ -44,7 +45,7 @@ const App = observer(() => {
store.log.debug('Notifee foreground event', {type}) store.log.debug('Notifee foreground event', {type})
if (type === EventType.PRESS) { if (type === EventType.PRESS) {
store.log.debug('User pressed a notifee, opening notifications') store.log.debug('User pressed a notifee, opening notifications')
store.nav.switchTo(1, true) store.nav.switchTo(TabPurpose.Notifs, true)
} }
}) })
}) })
+25 -14
View File
@@ -12,13 +12,20 @@ function genId() {
// we've since decided to pause that idea and do something more traditional // 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 // until we're fully sure what that is, the tabs are being repurposed into a fixed topology
// - Tab 0: The "Default" tab // - 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. // These tabs always retain the first item in their history.
// The default tab is used for basically everything except notifications.
// -prf // -prf
export enum TabPurpose { export enum TabPurpose {
Default = 0, Default = 0,
Notifs = 1, Search = 1,
Notifs = 2,
}
export const TabPurposeMainPath: Record<TabPurpose, string> = {
[TabPurpose.Default]: '/',
[TabPurpose.Search]: '/search',
[TabPurpose.Notifs]: '/notifications',
} }
interface HistoryItem { interface HistoryItem {
@@ -37,11 +44,9 @@ export class NavigationTabModel {
isNewTab = false isNewTab = false
constructor(public fixedTabPurpose: TabPurpose) { constructor(public fixedTabPurpose: TabPurpose) {
if (fixedTabPurpose === TabPurpose.Notifs) { this.history = [
this.history = [{url: '/notifications', ts: Date.now(), id: genId()}] {url: TabPurposeMainPath[fixedTabPurpose], ts: Date.now(), id: genId()},
} else { ]
this.history = [{url: '/', ts: Date.now(), id: genId()}]
}
makeAutoObservable(this, { makeAutoObservable(this, {
serialize: false, serialize: false,
hydrate: false, hydrate: false,
@@ -112,8 +117,7 @@ export class NavigationTabModel {
} }
// TEMP ensure the tab has its purpose's main view -prf // TEMP ensure the tab has its purpose's main view -prf
if (this.history.length < 1) { if (this.history.length < 1) {
const fixedUrl = const fixedUrl = TabPurposeMainPath[this.fixedTabPurpose]
this.fixedTabPurpose === TabPurpose.Notifs ? '/notifications' : '/'
this.history.push({url: fixedUrl, ts: Date.now(), id: genId()}) this.history.push({url: fixedUrl, ts: Date.now(), id: genId()})
} }
this.history.push({url, title, 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 { export class NavigationModel {
tabs: NavigationTabModel[] = [ tabs: NavigationTabModel[] = [
new NavigationTabModel(TabPurpose.Default), new NavigationTabModel(TabPurpose.Default),
new NavigationTabModel(TabPurpose.Search),
new NavigationTabModel(TabPurpose.Notifs), new NavigationTabModel(TabPurpose.Notifs),
] ]
tabIndex = 0 tabIndex = 0
@@ -233,6 +238,7 @@ export class NavigationModel {
clear() { clear() {
this.tabs = [ this.tabs = [
new NavigationTabModel(TabPurpose.Default), new NavigationTabModel(TabPurpose.Default),
new NavigationTabModel(TabPurpose.Search),
new NavigationTabModel(TabPurpose.Notifs), new NavigationTabModel(TabPurpose.Notifs),
] ]
this.tabIndex = 0 this.tabIndex = 0
@@ -294,10 +300,15 @@ export class NavigationModel {
// fixed tab helper function // fixed tab helper function
// -prf // -prf
switchTo(purpose: TabPurpose, reset: boolean) { switchTo(purpose: TabPurpose, reset: boolean) {
if (purpose === TabPurpose.Notifs) { switch (purpose) {
this.tabIndex = 1 case TabPurpose.Notifs:
} else { this.tabIndex = 2
this.tabIndex = 0 break
case TabPurpose.Search:
this.tabIndex = 1
break
default:
this.tabIndex = 0
} }
if (reset) { if (reset) {
this.tab.fixedTabReset() this.tab.fixedTabReset()
+8 -14
View File
@@ -10,6 +10,7 @@ export type SuggestedActor = GetSuggestions.Actor
export class SuggestedActorsViewModel { export class SuggestedActorsViewModel {
// state // state
pageSize = PAGE_SIZE
isLoading = false isLoading = false
isRefreshing = false isRefreshing = false
hasLoaded = false hasLoaded = false
@@ -20,7 +21,10 @@ export class SuggestedActorsViewModel {
// data // data
suggestions: SuggestedActor[] = [] suggestions: SuggestedActor[] = []
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel, opts?: {pageSize?: number}) {
if (opts?.pageSize) {
this.pageSize = opts.pageSize
}
makeAutoObservable( makeAutoObservable(
this, this,
{ {
@@ -63,23 +67,13 @@ export class SuggestedActorsViewModel {
let res let res
do { do {
res = await this.rootStore.api.app.bsky.actor.getSuggestions({ res = await this.rootStore.api.app.bsky.actor.getSuggestions({
limit: PAGE_SIZE, limit: this.pageSize,
cursor: this.loadMoreCursor, cursor: this.loadMoreCursor,
}) })
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
items = items.concat( items = items.concat(res.data.actors)
res.data.actors.filter(actor => { } while (items.length < this.pageSize && this.hasMore)
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)
runInAction(() => { runInAction(() => {
this.suggestions = items 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, AppBskyGraphGetFollows as GetFollows,
AppBskyActorSearchTypeahead as SearchTypeahead, AppBskyActorSearchTypeahead as SearchTypeahead,
} from '@atproto/api' } from '@atproto/api'
import AwaitLock from 'await-lock'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
export class UserAutocompleteViewModel { export class UserAutocompleteViewModel {
@@ -10,7 +11,7 @@ export class UserAutocompleteViewModel {
isLoading = false isLoading = false
isActive = false isActive = false
prefix = '' prefix = ''
_searchPromise: Promise<any> | undefined lock = new AwaitLock()
// data // data
follows: GetFollows.Follow[] = [] follows: GetFollows.Follow[] = []
@@ -58,16 +59,20 @@ export class UserAutocompleteViewModel {
} }
async setPrefix(prefix: string) { async setPrefix(prefix: string) {
const origPrefix = prefix const origPrefix = prefix.trim()
this.prefix = prefix.trim() this.prefix = origPrefix
if (this.prefix) { await this.lock.acquireAsync()
await this._searchPromise try {
if (this.prefix !== origPrefix) { if (this.prefix) {
return // another prefix was set before we got our chance if (this.prefix !== origPrefix) {
return // another prefix was set before we got our chance
}
await this._search()
} else {
this.searchRes = []
} }
this._searchPromise = this._search() } finally {
} else { this.lock.release()
this.searchRes = []
} }
} }
+1 -1
View File
@@ -36,7 +36,6 @@ export const SuggestedFollows = observer(
const store = useStores() const store = useStores()
const [follows, setFollows] = useState<Record<string, string>>({}) const [follows, setFollows] = useState<Record<string, string>>({})
// Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment
const view = React.useMemo<SuggestedActorsViewModel>( const view = React.useMemo<SuggestedActorsViewModel>(
() => new SuggestedActorsViewModel(store), () => new SuggestedActorsViewModel(store),
[store], [store],
@@ -235,6 +234,7 @@ const styles = StyleSheet.create({
actor: { actor: {
borderTopWidth: 1, borderTopWidth: 1,
paddingHorizontal: 6,
}, },
actorMeta: { actorMeta: {
flexDirection: 'row', flexDirection: 'row',
+65
View File
@@ -0,0 +1,65 @@
import React from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useStores} from '../../../state'
import {SuggestedPostsView} from '../../../state/models/suggested-posts-view'
import {s} from '../../lib/styles'
import {FeedItem as Post} from '../posts/FeedItem'
import {Text} from '../util/text/Text'
import {usePalette} from '../../lib/hooks/usePalette'
export const SuggestedPosts = observer(() => {
const pal = usePalette('default')
const store = useStores()
const suggestedPostsView = React.useMemo<SuggestedPostsView>(
() => new SuggestedPostsView(store),
[store],
)
React.useEffect(() => {
if (!suggestedPostsView.hasLoaded) {
suggestedPostsView.setup()
}
}, [store, suggestedPostsView])
return (
<>
{(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && (
<Text type="lg-heavy" style={[styles.heading, pal.text]}>
Recently, on Bluesky...
</Text>
)}
{suggestedPostsView.hasContent && (
<>
<View style={[pal.border, styles.bottomBorder]}>
{suggestedPostsView.posts.map(item => (
<Post item={item} key={item._reactKey} />
))}
</View>
</>
)}
{suggestedPostsView.isLoading && (
<View style={s.mt10}>
<ActivityIndicator />
</View>
)}
</>
)
})
const styles = StyleSheet.create({
heading: {
paddingHorizontal: 12,
paddingTop: 16,
paddingBottom: 8,
},
bottomBorder: {
borderBottomWidth: 1,
},
loadMore: {
paddingLeft: 12,
paddingVertical: 10,
},
})
+167
View File
@@ -0,0 +1,167 @@
import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {observer} from 'mobx-react-lite'
import LinearGradient from 'react-native-linear-gradient'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import _omit from 'lodash.omit'
import {useStores} from '../../../state'
import {
SuggestedActorsViewModel,
SuggestedActor,
} from '../../../state/models/suggested-actors-view'
import * as apilib from '../../../state/lib/api'
import {s, gradients} from '../../lib/styles'
import {ProfileCard} from '../profile/ProfileCard'
import * as Toast from '../util/Toast'
import {Text} from '../util/text/Text'
import {usePalette} from '../../lib/hooks/usePalette'
export const WhoToFollow = observer(() => {
const pal = usePalette('default')
const store = useStores()
const [follows, setFollows] = React.useState<Record<string, string>>({})
const suggestedActorsView = React.useMemo<SuggestedActorsViewModel>(
() => new SuggestedActorsViewModel(store, {pageSize: 5}),
[store],
)
React.useEffect(() => {
suggestedActorsView.loadMore(true)
}, [store, suggestedActorsView])
const onPressLoadMoreSuggestedActors = () => {
suggestedActorsView.loadMore()
}
const onToggleFollow = async (item: SuggestedActor) => {
if (follows[item.did]) {
try {
await apilib.unfollow(store, follows[item.did])
setFollows(_omit(follows, [item.did]))
} catch (e: any) {
store.log.error('Failed fo delete follow', e)
Toast.show('An issue occurred, please try again.')
}
} else {
try {
const res = await apilib.follow(store, item.did, item.declaration.cid)
setFollows({[item.did]: res.uri, ...follows})
} catch (e: any) {
store.log.error('Failed fo create follow', e)
Toast.show('An issue occurred, please try again.')
}
}
}
return (
<>
{(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && (
<Text type="lg-heavy" style={[styles.heading, pal.text]}>
Who to follow
</Text>
)}
{suggestedActorsView.hasContent && (
<>
<View style={[pal.border, styles.bottomBorder]}>
{suggestedActorsView.suggestions.map(item => (
<ProfileCard
key={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
description={item.description}
renderButton={() => (
<FollowBtn
isFollowing={!!follows[item.did]}
onPress={() => onToggleFollow(item)}
/>
)}
/>
))}
</View>
{!suggestedActorsView.isLoading && suggestedActorsView.hasMore && (
<TouchableOpacity
onPress={onPressLoadMoreSuggestedActors}
style={styles.loadMore}>
<Text type="md-medium" style={pal.link}>
Show more
</Text>
</TouchableOpacity>
)}
</>
)}
{suggestedActorsView.isLoading && (
<View style={s.mt10}>
<ActivityIndicator />
</View>
)}
</>
)
})
function FollowBtn({
isFollowing,
onPress,
}: {
isFollowing: boolean
onPress: () => void
}) {
const pal = usePalette('default')
if (isFollowing) {
return (
<TouchableOpacity onPress={onPress}>
<View style={[styles.btn, pal.btn]}>
<Text type="button" style={pal.text}>
Unfollow
</Text>
</View>
</TouchableOpacity>
)
}
return (
<TouchableOpacity onPress={onPress}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn, styles.gradientBtn]}>
<FontAwesomeIcon icon="plus" style={[s.white, s.mr5]} size={15} />
<Text style={[s.white, s.fw600, s.f15]}>Follow</Text>
</LinearGradient>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
heading: {
paddingHorizontal: 12,
paddingTop: 16,
paddingBottom: 8,
},
bottomBorder: {
borderBottomWidth: 1,
},
loadMore: {
paddingLeft: 12,
paddingVertical: 10,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
borderRadius: 50,
marginLeft: 6,
paddingHorizontal: 14,
},
gradientBtn: {
paddingHorizontal: 24,
paddingVertical: 6,
},
})
+18 -20
View File
@@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
@@ -10,14 +10,14 @@ export function ProfileCard({
handle, handle,
displayName, displayName,
avatar, avatar,
description,
renderButton, renderButton,
onPressButton,
}: { }: {
handle: string handle: string
displayName?: string displayName?: string
avatar?: string avatar?: string
description?: string
renderButton?: () => JSX.Element renderButton?: () => JSX.Element
onPressButton?: () => void
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
@@ -44,15 +44,16 @@ export function ProfileCard({
</Text> </Text>
</View> </View>
{renderButton ? ( {renderButton ? (
<View style={styles.layoutButton}> <View style={styles.layoutButton}>{renderButton()}</View>
<TouchableOpacity
onPress={onPressButton}
style={[styles.btn, pal.btn]}>
{renderButton()}
</TouchableOpacity>
</View>
) : undefined} ) : undefined}
</View> </View>
{description ? (
<View style={styles.details}>
<Text style={pal.text} numberOfLines={4}>
{description}
</Text>
</View>
) : undefined}
</Link> </Link>
) )
} }
@@ -60,6 +61,7 @@ export function ProfileCard({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
borderTopWidth: 1, borderTopWidth: 1,
paddingHorizontal: 6,
}, },
layout: { layout: {
flexDirection: 'row', flexDirection: 'row',
@@ -68,7 +70,7 @@ const styles = StyleSheet.create({
layoutAvi: { layoutAvi: {
width: 60, width: 60,
paddingLeft: 10, paddingLeft: 10,
paddingTop: 10, paddingTop: 8,
paddingBottom: 10, paddingBottom: 10,
}, },
avi: { avi: {
@@ -80,19 +82,15 @@ const styles = StyleSheet.create({
layoutContent: { layoutContent: {
flex: 1, flex: 1,
paddingRight: 10, paddingRight: 10,
paddingTop: 12, paddingTop: 10,
paddingBottom: 10, paddingBottom: 10,
}, },
layoutButton: { layoutButton: {
paddingRight: 10, paddingRight: 10,
}, },
btn: { details: {
flexDirection: 'row', paddingLeft: 60,
alignItems: 'center', paddingRight: 10,
justifyContent: 'center', paddingBottom: 10,
paddingVertical: 7,
paddingHorizontal: 14,
borderRadius: 50,
marginLeft: 6,
}, },
}) })
-42
View File
@@ -4,22 +4,17 @@ import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {UserAvatar} from './UserAvatar' import {UserAvatar} from './UserAvatar'
import {Text} from './text/Text' import {Text} from './text/Text'
import {MagnifyingGlassIcon} from '../../lib/icons'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {usePalette} from '../../lib/hooks/usePalette' import {usePalette} from '../../lib/hooks/usePalette'
import {colors} from '../../lib/styles'
import {useAnalytics} from '@segment/analytics-react-native' import {useAnalytics} from '@segment/analytics-react-native'
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10} const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
export const ViewHeader = observer(function ViewHeader({ export const ViewHeader = observer(function ViewHeader({
title, title,
subtitle,
canGoBack, canGoBack,
}: { }: {
title: string title: string
subtitle?: string
canGoBack?: boolean canGoBack?: boolean
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
@@ -32,9 +27,6 @@ export const ViewHeader = observer(function ViewHeader({
track('ViewHeader:MenuButtonClicked') track('ViewHeader:MenuButtonClicked')
store.shell.setMainMenuOpen(true) store.shell.setMainMenuOpen(true)
} }
const onPressSearch = () => {
store.nav.navigate('/search')
}
if (typeof canGoBack === 'undefined') { if (typeof canGoBack === 'undefined') {
canGoBack = store.nav.tab.canGoBack canGoBack = store.nav.tab.canGoBack
} }
@@ -64,21 +56,7 @@ export const ViewHeader = observer(function ViewHeader({
<Text type="title" style={[pal.text, styles.title]}> <Text type="title" style={[pal.text, styles.title]}>
{title} {title}
</Text> </Text>
{subtitle ? (
<Text
type="title-sm"
style={[styles.subtitle, pal.textLight]}
numberOfLines={1}>
{subtitle}
</Text>
) : undefined}
</View> </View>
<TouchableOpacity
onPress={onPressSearch}
hitSlop={HITSLOP}
style={styles.btn}>
<MagnifyingGlassIcon size={21} strokeWidth={3} style={pal.text} />
</TouchableOpacity>
</View> </View>
) )
}) })
@@ -100,11 +78,6 @@ const styles = StyleSheet.create({
title: { title: {
fontWeight: 'bold', fontWeight: 'bold',
}, },
subtitle: {
marginLeft: 4,
maxWidth: 200,
fontWeight: 'normal',
},
backBtn: { backBtn: {
width: 30, width: 30,
@@ -118,19 +91,4 @@ const styles = StyleSheet.create({
backIcon: { backIcon: {
marginTop: 6, marginTop: 6,
}, },
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
borderRadius: 20,
marginLeft: 4,
},
littleXIcon: {
color: colors.red3,
position: 'absolute',
right: 7,
bottom: 7,
},
}) })
+2 -2
View File
@@ -14,7 +14,7 @@ export const defaultTheme: Theme = {
link: colors.blue3, link: colors.blue3,
border: '#f0e9e9', border: '#f0e9e9',
borderDark: '#e0d9d9', borderDark: '#e0d9d9',
icon: colors.gray3, icon: colors.gray4,
// non-standard // non-standard
textVeryLight: colors.gray4, textVeryLight: colors.gray4,
@@ -273,7 +273,7 @@ export const darkTheme: Theme = {
link: colors.blue3, link: colors.blue3,
border: colors.gray6, border: colors.gray6,
borderDark: colors.gray5, borderDark: colors.gray5,
icon: colors.gray5, icon: colors.gray4,
// non-standard // non-standard
textVeryLight: colors.gray4, textVeryLight: colors.gray4,
+1 -1
View File
@@ -85,7 +85,7 @@ export const Home = observer(function Home({
return ( return (
<View style={s.h100pct}> <View style={s.h100pct}>
<ViewHeader title="Bluesky" subtitle="Private Beta" canGoBack={false} /> <ViewHeader title="Bluesky" canGoBack={false} />
<Feed <Feed
testID="homeFeed" testID="homeFeed"
key="default" key="default"
+2 -4
View File
@@ -1,4 +1,4 @@
import React, {useEffect, useMemo, useState} from 'react' import React, {useEffect, useMemo} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {makeRecordUri} from '../../lib/strings' import {makeRecordUri} from '../../lib/strings'
import {ViewHeader} from '../com/util/ViewHeader' import {ViewHeader} from '../com/util/ViewHeader'
@@ -11,7 +11,6 @@ import {s} from '../lib/styles'
export const PostThread = ({navIdx, visible, params}: ScreenParams) => { export const PostThread = ({navIdx, visible, params}: ScreenParams) => {
const store = useStores() const store = useStores()
const {name, rkey} = params const {name, rkey} = params
const [viewSubtitle, setViewSubtitle] = useState<string>(`by ${name}`)
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
const view = useMemo<PostThreadViewModel>( const view = useMemo<PostThreadViewModel>(
() => new PostThreadViewModel(store, {uri}), () => new PostThreadViewModel(store, {uri}),
@@ -24,7 +23,6 @@ export const PostThread = ({navIdx, visible, params}: ScreenParams) => {
const setTitle = () => { const setTitle = () => {
const author = view.thread?.post.author const author = view.thread?.post.author
const niceName = author?.handle || name const niceName = author?.handle || name
setViewSubtitle(`by ${niceName}`)
store.nav.setTitle(navIdx, `Post by ${niceName}`) store.nav.setTitle(navIdx, `Post by ${niceName}`)
} }
if (!visible) { if (!visible) {
@@ -52,7 +50,7 @@ export const PostThread = ({navIdx, visible, params}: ScreenParams) => {
return ( return (
<View style={s.h100pct}> <View style={s.h100pct}>
<ViewHeader title="Post" subtitle={viewSubtitle} /> <ViewHeader title="Post" />
<View style={s.h100pct}> <View style={s.h100pct}>
<PostThreadComponent uri={uri} view={view} /> <PostThreadComponent uri={uri} view={view} />
</View> </View>
+1 -1
View File
@@ -18,7 +18,7 @@ export const ProfileFollowers = ({navIdx, visible, params}: ScreenParams) => {
return ( return (
<View> <View>
<ViewHeader title="Followers" subtitle={`of ${name}`} /> <ViewHeader title="Followers" />
<ProfileFollowersComponent name={name} /> <ProfileFollowersComponent name={name} />
</View> </View>
) )
+1 -1
View File
@@ -18,7 +18,7 @@ export const ProfileFollows = ({navIdx, visible, params}: ScreenParams) => {
return ( return (
<View> <View>
<ViewHeader title="Followed" subtitle={`by ${name}`} /> <ViewHeader title="Followed" />
<ProfileFollowsComponent name={name} /> <ProfileFollowsComponent name={name} />
</View> </View>
) )
+138 -66
View File
@@ -1,14 +1,14 @@
import React, {useEffect, useState, useMemo, useRef} from 'react' import React from 'react'
import { import {
Keyboard, Keyboard,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
TouchableWithoutFeedback,
View, View,
} from 'react-native' } from 'react-native'
import {ViewHeader} from '../com/util/ViewHeader' import {observer} from 'mobx-react-lite'
import {SuggestedFollows} from '../com/discover/SuggestedFollows'
import {UserAvatar} from '../com/util/UserAvatar' import {UserAvatar} from '../com/util/UserAvatar'
import {Text} from '../com/util/text/Text' import {Text} from '../com/util/text/Text'
import {ScreenParams} from '../routes' import {ScreenParams} from '../routes'
@@ -16,26 +16,45 @@ import {useStores} from '../../state'
import {UserAutocompleteViewModel} from '../../state/models/user-autocomplete-view' import {UserAutocompleteViewModel} from '../../state/models/user-autocomplete-view'
import {s} from '../lib/styles' import {s} from '../lib/styles'
import {MagnifyingGlassIcon} from '../lib/icons' import {MagnifyingGlassIcon} from '../lib/icons'
import {WhoToFollow} from '../com/discover/WhoToFollow'
import {SuggestedPosts} from '../com/discover/SuggestedPosts'
import {ProfileCard} from '../com/profile/ProfileCard'
import {usePalette} from '../lib/hooks/usePalette' import {usePalette} from '../lib/hooks/usePalette'
import {useAnalytics} from '@segment/analytics-react-native'
export const Search = ({navIdx, visible, params}: ScreenParams) => { const MENU_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
const FIVE_MIN = 5 * 60 * 1e3
export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const textInput = useRef<TextInput>(null) const {track} = useAnalytics()
const [query, setQuery] = useState<string>('') const textInput = React.useRef<TextInput>(null)
const autocompleteView = useMemo<UserAutocompleteViewModel>( const [lastRenderTime, setRenderTime] = React.useState<number>(0) // used to trigger reloads
const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
const [query, setQuery] = React.useState<string>('')
const autocompleteView = React.useMemo<UserAutocompleteViewModel>(
() => new UserAutocompleteViewModel(store), () => new UserAutocompleteViewModel(store),
[store], [store],
) )
const {name} = params const {name} = params
useEffect(() => { React.useEffect(() => {
if (visible) { if (visible) {
const now = Date.now()
if (lastRenderTime - now > FIVE_MIN) {
setRenderTime(Date.now()) // trigger reload of suggestions
}
store.shell.setMinimalShellMode(false) store.shell.setMinimalShellMode(false)
autocompleteView.setup() autocompleteView.setup()
store.nav.setTitle(navIdx, 'Search') store.nav.setTitle(navIdx, 'Search')
} }
}, [store, visible, name, navIdx, autocompleteView]) }, [store, visible, name, navIdx, autocompleteView, lastRenderTime])
const onPressMenu = () => {
track('ViewHeader:MenuButtonClicked')
store.shell.setMainMenuOpen(true)
}
const onChangeQuery = (text: string) => { const onChangeQuery = (text: string) => {
setQuery(text) setQuery(text)
@@ -46,87 +65,140 @@ export const Search = ({navIdx, visible, params}: ScreenParams) => {
autocompleteView.setActive(false) autocompleteView.setActive(false)
} }
} }
const onSelect = (handle: string) => { const onPressCancelSearch = () => {
textInput.current?.blur() setQuery('')
store.nav.navigate(`/profile/${handle}`) autocompleteView.setActive(false)
} }
return ( return (
<View style={[pal.view, styles.container]}> <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ViewHeader title="Search" /> <View style={[pal.view, styles.container]}>
<View style={[pal.view, pal.border, styles.inputContainer]}> <View style={[pal.view, pal.border, styles.header]}>
<MagnifyingGlassIcon style={[pal.text, styles.inputIcon]} /> <TouchableOpacity
<TextInput testID="viewHeaderBackOrMenuBtn"
testID="searchTextInput" onPress={onPressMenu}
ref={textInput} hitSlop={MENU_HITSLOP}
placeholder="Type your query here..." style={styles.headerMenuBtn}>
placeholderTextColor={pal.colors.textLight} <UserAvatar
selectTextOnFocus size={30}
returnKeyType="search" handle={store.me.handle}
style={[pal.text, styles.input]} displayName={store.me.displayName}
onChangeText={onChangeQuery} avatar={store.me.avatar}
/> />
</View> </TouchableOpacity>
<View style={styles.outputContainer}> <View
{query ? ( style={[
<ScrollView testID="searchScrollView" onScroll={Keyboard.dismiss}> {backgroundColor: pal.colors.backgroundLight},
{autocompleteView.searchRes.map((item, i) => ( styles.headerSearchContainer,
<TouchableOpacity ]}>
key={i} <MagnifyingGlassIcon
style={[pal.view, pal.border, styles.searchResult]} style={[pal.icon, styles.headerSearchIcon]}
onPress={() => onSelect(item.handle)}> size={21}
<UserAvatar />
<TextInput
testID="searchTextInput"
ref={textInput}
placeholder="Search"
placeholderTextColor={pal.colors.textLight}
selectTextOnFocus
returnKeyType="search"
value={query}
style={[pal.text, styles.headerSearchInput]}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onChangeText={onChangeQuery}
/>
</View>
{query ? (
<View style={styles.headerCancelBtn}>
<TouchableOpacity onPress={onPressCancelSearch}>
<Text>Cancel</Text>
</TouchableOpacity>
</View>
) : undefined}
</View>
<View style={styles.outputContainer}>
{query && autocompleteView.searchRes.length ? (
<ScrollView testID="searchScrollView" onScroll={Keyboard.dismiss}>
{autocompleteView.searchRes.map(item => (
<ProfileCard
key={item.did}
handle={item.handle} handle={item.handle}
displayName={item.displayName} displayName={item.displayName}
avatar={item.avatar} avatar={item.avatar}
size={36}
/> />
<View style={[s.ml10]}> ))}
<Text type="title-sm" style={pal.text}> <View style={s.footerSpacer} />
{item.displayName || item.handle} </ScrollView>
</Text> ) : query && !autocompleteView.searchRes.length ? (
<Text style={pal.textLight}>@{item.handle}</Text> <View>
</View> <Text style={[pal.textLight, styles.searchPrompt]}>
</TouchableOpacity> No results found for {autocompleteView.prefix}
))} </Text>
<View style={s.footerSpacer} /> </View>
</ScrollView> ) : isInputFocused ? (
) : ( <View>
<SuggestedFollows asLinks /> <Text style={[pal.textLight, styles.searchPrompt]}>
)} Search for users on the network
</Text>
</View>
) : (
<ScrollView onScroll={Keyboard.dismiss}>
<WhoToFollow key={`wtf-${lastRenderTime}`} />
<SuggestedPosts key={`sp-${lastRenderTime}`} />
<View style={s.footerSpacer} />
</ScrollView>
)}
</View>
</View> </View>
</View> </TouchableWithoutFeedback>
) )
} })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
inputContainer: { header: {
flexDirection: 'row', flexDirection: 'row',
paddingVertical: 16, alignItems: 'center',
paddingHorizontal: 16, paddingHorizontal: 12,
borderTopWidth: 1, paddingTop: 4,
paddingBottom: 5,
}, },
inputIcon: { headerMenuBtn: {
marginRight: 10, width: 40,
height: 30,
marginLeft: 6,
},
headerSearchContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
borderRadius: 30,
paddingHorizontal: 12,
paddingVertical: 6,
},
headerSearchIcon: {
marginRight: 6,
alignSelf: 'center', alignSelf: 'center',
}, },
input: { headerSearchInput: {
flex: 1, flex: 1,
fontSize: 16, fontSize: 16,
}, },
headerCancelBtn: {
width: 60,
paddingLeft: 10,
},
searchPrompt: {
textAlign: 'center',
paddingTop: 10,
},
outputContainer: { outputContainer: {
flex: 1, flex: 1,
}, },
searchResult: {
flexDirection: 'row',
borderTopWidth: 1,
paddingVertical: 12,
paddingHorizontal: 16,
},
}) })
+6 -3
View File
@@ -18,6 +18,7 @@ import {
CogIcon, CogIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
} from '../../lib/icons' } from '../../lib/icons'
import {TabPurpose, TabPurposeMainPath} from '../../../state/models/navigation'
import {UserAvatar} from '../../com/util/UserAvatar' import {UserAvatar} from '../../com/util/UserAvatar'
import {Text} from '../../com/util/text/Text' import {Text} from '../../com/util/text/Text'
import {ToggleButton} from '../../com/util/forms/ToggleButton' import {ToggleButton} from '../../com/util/forms/ToggleButton'
@@ -36,10 +37,12 @@ export const Menu = observer(({onClose}: {onClose: () => void}) => {
track('Menu:ItemClicked', {url}) track('Menu:ItemClicked', {url})
onClose() onClose()
if (url === '/notifications') { if (url === TabPurposeMainPath[TabPurpose.Notifs]) {
store.nav.switchTo(1, true) store.nav.switchTo(TabPurpose.Notifs, true)
} else if (url === TabPurposeMainPath[TabPurpose.Search]) {
store.nav.switchTo(TabPurpose.Search, true)
} else { } else {
store.nav.switchTo(0, true) store.nav.switchTo(TabPurpose.Default, true)
if (url !== '/') { if (url !== '/') {
store.nav.navigate(url) store.nav.navigate(url)
} }
+83 -36
View File
@@ -12,7 +12,6 @@ import {
useColorScheme, useColorScheme,
useWindowDimensions, useWindowDimensions,
View, View,
ViewStyle,
} from 'react-native' } from 'react-native'
import {ScreenContainer, Screen} from 'react-native-screens' import {ScreenContainer, Screen} from 'react-native-screens'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
@@ -20,7 +19,11 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {IconProp} from '@fortawesome/fontawesome-svg-core' import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {TABS_ENABLED} from '../../../build-flags' import {TABS_ENABLED} from '../../../build-flags'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {NavigationModel} from '../../../state/models/navigation' import {
NavigationModel,
TabPurpose,
TabPurposeMainPath,
} from '../../../state/models/navigation'
import {match, MatchResult} from '../../routes' import {match, MatchResult} from '../../routes'
import {Login} from '../../screens/Login' import {Login} from '../../screens/Login'
import {Menu} from './Menu' import {Menu} from './Menu'
@@ -39,6 +42,7 @@ import {
GridIconSolid, GridIconSolid,
HomeIcon, HomeIcon,
HomeIconSolid, HomeIconSolid,
MagnifyingGlassIcon,
BellIcon, BellIcon,
BellIconSolid, BellIconSolid,
} from '../../lib/icons' } from '../../lib/icons'
@@ -60,6 +64,8 @@ const Btn = ({
| 'menu-solid' | 'menu-solid'
| 'home' | 'home'
| 'home-solid' | 'home-solid'
| 'search'
| 'search-solid'
| 'bell' | 'bell'
| 'bell-solid' | 'bell-solid'
notificationCount?: number notificationCount?: number
@@ -68,29 +74,52 @@ const Btn = ({
onLongPress?: (event: GestureResponderEvent) => void onLongPress?: (event: GestureResponderEvent) => void
}) => { }) => {
const pal = usePalette('default') const pal = usePalette('default')
let size = 24 let iconEl
let addedStyles
let IconEl
if (icon === 'menu') { if (icon === 'menu') {
IconEl = GridIcon iconEl = <GridIcon style={[styles.ctrlIcon, pal.text]} />
} else if (icon === 'menu-solid') { } else if (icon === 'menu-solid') {
IconEl = GridIconSolid iconEl = <GridIconSolid style={[styles.ctrlIcon, pal.text]} />
} else if (icon === 'home') { } else if (icon === 'home') {
IconEl = HomeIcon iconEl = <HomeIcon size={27} style={[styles.ctrlIcon, pal.text]} />
size = 27
} else if (icon === 'home-solid') { } else if (icon === 'home-solid') {
IconEl = HomeIconSolid iconEl = <HomeIconSolid size={27} style={[styles.ctrlIcon, pal.text]} />
size = 27 } else if (icon === 'search') {
iconEl = (
<MagnifyingGlassIcon
size={28}
style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]}
/>
)
} else if (icon === 'search-solid') {
iconEl = (
<MagnifyingGlassIcon
size={28}
strokeWidth={3}
style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]}
/>
)
} else if (icon === 'bell') { } else if (icon === 'bell') {
IconEl = BellIcon iconEl = (
size = 27 <BellIcon
addedStyles = {position: 'relative', top: -1} as ViewStyle size={27}
style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]}
/>
)
} else if (icon === 'bell-solid') { } else if (icon === 'bell-solid') {
IconEl = BellIconSolid iconEl = (
size = 27 <BellIconSolid
addedStyles = {position: 'relative', top: -1} as ViewStyle size={27}
style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]}
/>
)
} else { } else {
IconEl = FontAwesomeIcon iconEl = (
<FontAwesomeIcon
icon={icon}
size={24}
style={[styles.ctrlIcon, pal.text]}
/>
)
} }
return ( return (
@@ -109,11 +138,7 @@ const Btn = ({
<Text style={styles.tabCountLabel}>{tabCount}</Text> <Text style={styles.tabCountLabel}>{tabCount}</Text>
</View> </View>
) : undefined} ) : undefined}
<IconEl {iconEl}
size={size}
style={[styles.ctrlIcon, pal.text, addedStyles]}
icon={icon}
/>
</TouchableOpacity> </TouchableOpacity>
) )
} }
@@ -138,17 +163,29 @@ export const MobileShell: React.FC = observer(() => {
const onPressHome = () => { const onPressHome = () => {
track('MobileShell:HomeButtonPressed') track('MobileShell:HomeButtonPressed')
if (store.shell.isMainMenuOpen) { if (store.nav.tab.fixedTabPurpose === TabPurpose.Default) {
store.shell.setMainMenuOpen(false)
}
if (store.nav.tab.fixedTabPurpose === 0) {
if (store.nav.tab.current.url === '/') { if (store.nav.tab.current.url === '/') {
scrollElRef.current?.scrollToOffset({offset: 0}) scrollElRef.current?.scrollToOffset({offset: 0})
} else { } else {
store.nav.tab.fixedTabReset() store.nav.tab.fixedTabReset()
} }
} else { } else {
store.nav.switchTo(0, false) store.nav.switchTo(TabPurpose.Default, false)
if (store.nav.tab.index === 0) {
store.nav.tab.fixedTabReset()
}
}
}
const onPressSearch = () => {
track('MobileShell:SearchButtonPressed')
if (store.nav.tab.fixedTabPurpose === TabPurpose.Search) {
if (store.nav.tab.current.url === '/') {
scrollElRef.current?.scrollToOffset({offset: 0})
} else {
store.nav.tab.fixedTabReset()
}
} else {
store.nav.switchTo(TabPurpose.Search, false)
if (store.nav.tab.index === 0) { if (store.nav.tab.index === 0) {
store.nav.tab.fixedTabReset() store.nav.tab.fixedTabReset()
} }
@@ -156,13 +193,10 @@ export const MobileShell: React.FC = observer(() => {
} }
const onPressNotifications = () => { const onPressNotifications = () => {
track('MobileShell:NotificationsButtonPressed') track('MobileShell:NotificationsButtonPressed')
if (store.shell.isMainMenuOpen) { if (store.nav.tab.fixedTabPurpose === TabPurpose.Notifs) {
store.shell.setMainMenuOpen(false)
}
if (store.nav.tab.fixedTabPurpose === 1) {
store.nav.tab.fixedTabReset() store.nav.tab.fixedTabReset()
} else { } else {
store.nav.switchTo(1, false) store.nav.switchTo(TabPurpose.Notifs, false)
if (store.nav.tab.index === 0) { if (store.nav.tab.index === 0) {
store.nav.tab.fixedTabReset() store.nav.tab.fixedTabReset()
} }
@@ -344,8 +378,12 @@ export const MobileShell: React.FC = observer(() => {
) )
} }
const isAtHome = store.nav.tab.current.url === '/' const isAtHome =
const isAtNotifications = store.nav.tab.current.url === '/notifications' store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default]
const isAtSearch =
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Search]
const isAtNotifications =
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Notifs]
const screenBg = { const screenBg = {
backgroundColor: theme.colorScheme === 'dark' ? colors.gray7 : colors.gray1, backgroundColor: theme.colorScheme === 'dark' ? colors.gray7 : colors.gray1,
@@ -458,6 +496,11 @@ export const MobileShell: React.FC = observer(() => {
onPress={onPressHome} onPress={onPressHome}
onLongPress={TABS_ENABLED ? doNewTab('/') : undefined} onLongPress={TABS_ENABLED ? doNewTab('/') : undefined}
/> />
<Btn
icon={isAtSearch ? 'search-solid' : 'search'}
onPress={onPressSearch}
onLongPress={TABS_ENABLED ? doNewTab('/') : undefined}
/>
{TABS_ENABLED ? ( {TABS_ENABLED ? (
<Btn <Btn
icon={isTabsSelectorActive ? 'clone' : ['far', 'clone']} icon={isTabsSelectorActive ? 'clone' : ['far', 'clone']}
@@ -580,7 +623,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
borderTopWidth: 1, borderTopWidth: 1,
paddingLeft: 5, paddingLeft: 5,
paddingRight: 15, paddingRight: 25,
}, },
ctrl: { ctrl: {
flex: 1, flex: 1,
@@ -618,4 +661,8 @@ const styles = StyleSheet.create({
inactive: { inactive: {
color: colors.gray3, color: colors.gray3,
}, },
bumpUpOnePixel: {
position: 'relative',
top: -1,
},
}) })