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:
@@ -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)
|
||||||
})
|
// })
|
||||||
})
|
// })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
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 = []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user