Move to expo and react-navigation (#288)

* WIP - adding expo

* WIP - adding expo 2

* Fix tsc

* Finish adding expo

* Disable the 'require cycle' warning

* Tweak plist

* Modify some dependency versions to make expo happy

* Fix icon fill

* Get Web compiling for expo

* 1.7

* Switch to react-navigation in expo2 (#287)

* WIP Switch to react-navigation

* WIP Switch to react-navigation 2

* WIP Switch to react-navigation 3

* Convert all screens to react navigation

* Update BottomBar for react navigation

* Update mobile menu to be react-native drawer

* Fixes to drawer and bottombar

* Factor out some helpers

* Replace the navigation model with react-navigation

* Restructure the shell folder and fix the header positioning

* Restore the error boundary

* Fix tsc

* Implement not-found page

* Remove react-native-gesture-handler (no longer used)

* Handle notifee card presses

* Handle all navigations from the state layer

* Fix drawer behaviors

* Fix two linking issues

* Switch to our react-native-progress fork to fix an svg rendering issue

* Get Web working with react-navigation

* Refactor routes and navigation for a bit more clarity

* Remove dead code

* Rework Web shell to left/right nav to make this easier

* Fix ViewHeader for desktop web

* Hide profileheader back btn on desktop web

* Move the compose button to the left nav

* Implement reply prompt in threads for desktop web

* Composer refactors

* Factor out all platform-specific text input behaviors from the composer

* Small fix

* Update the web build to use tiptap for the composer

* Tune up the mention autocomplete dropdown

* Simplify the default avatar and banner

* Fixes to link cards in web composer

* Fix dropdowns on web

* Tweak load latest on desktop

* Add web beta message and feedback link

* Fix up links in desktop web
This commit is contained in:
Paul Frazee 2023-03-13 16:01:43 -05:00 committed by GitHub
parent 503e03d91e
commit 56cf890deb
222 changed files with 8705 additions and 6338 deletions

View file

@ -1,434 +0,0 @@
import {RootStoreModel} from './root-store'
import {makeAutoObservable} from 'mobx'
import {TABS_ENABLED} from 'lib/build-flags'
import * as analytics from 'lib/analytics'
import {isNative} from 'platform/detection'
let __id = 0
function genId() {
return String(++__id)
}
// NOTE
// this model was originally built for a freeform "tabs" concept like a browser
// we've since decided to pause that idea and do something more traditional
// until we're fully sure what that is, the tabs are being repurposed into a fixed topology
// - Tab 0: The "Default" tab
// - Tab 1: The "Search" tab
// - Tab 2: The "Notifications" tab
// These tabs always retain the first item in their history.
// -prf
export enum TabPurpose {
Default = 0,
Search = 1,
Notifs = 2,
}
export const TabPurposeMainPath: Record<TabPurpose, string> = {
[TabPurpose.Default]: '/',
[TabPurpose.Search]: '/search',
[TabPurpose.Notifs]: '/notifications',
}
interface HistoryItem {
url: string
ts: number
title?: string
id: string
}
export type HistoryPtr = string // `{tabId}-{historyId}`
export class NavigationTabModel {
id = genId()
history: HistoryItem[]
index = 0
isNewTab = false
constructor(public fixedTabPurpose: TabPurpose) {
this.history = [
{url: TabPurposeMainPath[fixedTabPurpose], ts: Date.now(), id: genId()},
]
makeAutoObservable(this, {
serialize: false,
hydrate: false,
})
}
// accessors
// =
get current() {
return this.history[this.index]
}
get canGoBack() {
return this.index > 0
}
get canGoForward() {
return this.index < this.history.length - 1
}
getBackList(n: number) {
const start = Math.max(this.index - n, 0)
const end = this.index
return this.history.slice(start, end).map((item, i) => ({
url: item.url,
title: item.title,
index: start + i,
id: item.id,
}))
}
get backTen() {
return this.getBackList(10)
}
getForwardList(n: number) {
const start = Math.min(this.index + 1, this.history.length)
const end = Math.min(this.index + n + 1, this.history.length)
return this.history.slice(start, end).map((item, i) => ({
url: item.url,
title: item.title,
index: start + i,
id: item.id,
}))
}
get forwardTen() {
return this.getForwardList(10)
}
// navigation
// =
navigate(url: string, title?: string) {
try {
const path = url.split('/')[1]
analytics.track('Navigation', {
path,
})
} catch (error) {}
if (this.current?.url === url) {
this.refresh()
} else {
if (this.index < this.history.length - 1) {
this.history.length = this.index + 1
}
// TEMP ensure the tab has its purpose's main view -prf
if (this.history.length < 1) {
const fixedUrl = TabPurposeMainPath[this.fixedTabPurpose]
this.history.push({url: fixedUrl, ts: Date.now(), id: genId()})
}
this.history.push({url, title, ts: Date.now(), id: genId()})
this.index = this.history.length - 1
if (!isNative) {
window.history.pushState({hindex: this.index, hurl: url}, '', url)
}
}
}
refresh() {
this.history = [
...this.history.slice(0, this.index),
{
url: this.current.url,
title: this.current.title,
ts: Date.now(),
id: this.current.id,
},
...this.history.slice(this.index + 1),
]
}
goBack() {
if (this.canGoBack) {
this.index--
if (!isNative) {
window.history.back()
}
}
}
// TEMP
// a helper to bring the tab back to its base state
// -prf
fixedTabReset() {
this.index = 0
}
goForward() {
if (this.canGoForward) {
this.index++
if (!isNative) {
window.history.forward()
}
}
}
goToIndex(index: number) {
if (index >= 0 && index <= this.history.length - 1) {
const delta = index - this.index
this.index = index
if (!isNative) {
window.history.go(delta)
}
}
}
setTitle(id: string, title: string) {
this.history = this.history.map(h => {
if (h.id === id) {
return {...h, title}
}
return h
})
}
setIsNewTab(v: boolean) {
this.isNewTab = v
}
// browser only
// =
resetTo(url: string) {
this.index = 0
this.history.push({url, title: '', ts: Date.now(), id: genId()})
this.index = this.history.length - 1
}
// persistence
// =
serialize(): unknown {
return {
history: this.history,
index: this.index,
}
}
hydrate(_v: unknown) {
// TODO fixme
// if (isObj(v)) {
// if (hasProp(v, 'history') && Array.isArray(v.history)) {
// for (const item of v.history) {
// if (
// isObj(item) &&
// hasProp(item, 'url') &&
// typeof item.url === 'string'
// ) {
// let copy: HistoryItem = {
// url: item.url,
// ts:
// hasProp(item, 'ts') && typeof item.ts === 'number'
// ? item.ts
// : Date.now(),
// }
// if (hasProp(item, 'title') && typeof item.title === 'string') {
// copy.title = item.title
// }
// this.history.push(copy)
// }
// }
// }
// if (hasProp(v, 'index') && typeof v.index === 'number') {
// this.index = v.index
// }
// if (this.index >= this.history.length - 1) {
// this.index = this.history.length - 1
// }
// }
}
}
export class NavigationModel {
tabs: NavigationTabModel[] = isNative
? [
new NavigationTabModel(TabPurpose.Default),
new NavigationTabModel(TabPurpose.Search),
new NavigationTabModel(TabPurpose.Notifs),
]
: [new NavigationTabModel(TabPurpose.Default)]
tabIndex = 0
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
rootStore: false,
serialize: false,
hydrate: false,
})
}
/**
* Used only in the web build to sync with browser history state
*/
bindWebNavigation() {
if (!isNative) {
window.addEventListener('popstate', e => {
const {hindex, hurl} = e.state
if (hindex >= 0 && hindex <= this.tab.history.length - 1) {
this.tab.index = hindex
}
if (this.tab.current.url !== hurl) {
// desynced because they went back to an old tab session-
// do a reset to match that
this.tab.resetTo(hurl)
}
// sanity check
if (this.tab.current.url !== window.location.pathname) {
// state has completely desynced, reload
window.location.reload()
}
})
}
}
clear() {
this.tabs = isNative
? [
new NavigationTabModel(TabPurpose.Default),
new NavigationTabModel(TabPurpose.Search),
new NavigationTabModel(TabPurpose.Notifs),
]
: [new NavigationTabModel(TabPurpose.Default)]
this.tabIndex = 0
}
// accessors
// =
get tab() {
return this.tabs[this.tabIndex]
}
get tabCount() {
return this.tabs.length
}
isCurrentScreen(tabId: string, index: number) {
return this.tab.id === tabId && this.tab.index === index
}
// navigation
// =
navigate(url: string, title?: string) {
this.rootStore.emitNavigation()
this.tab.navigate(url, title)
}
refresh() {
this.tab.refresh()
}
setTitle(ptr: HistoryPtr, title: string) {
const [tid, hid] = ptr.split('-')
this.tabs.find(t => t.id === tid)?.setTitle(hid, title)
}
handleLink(url: string) {
let path
if (url.startsWith('/')) {
path = url
} else if (url.startsWith('http')) {
try {
path = new URL(url).pathname
} catch (e) {
console.error('Invalid url', url, e)
return
}
} else {
console.error('Invalid url', url)
return
}
this.navigate(path)
}
// tab management
// =
// TEMP
// fixed tab helper function
// -prf
switchTo(purpose: TabPurpose, reset: boolean) {
this.rootStore.emitNavigation()
switch (purpose) {
case TabPurpose.Notifs:
this.tabIndex = 2
break
case TabPurpose.Search:
this.tabIndex = 1
break
default:
this.tabIndex = 0
}
if (reset) {
this.tab.fixedTabReset()
}
}
newTab(url: string, title?: string) {
if (!TABS_ENABLED) {
return this.navigate(url)
}
const tab = new NavigationTabModel(TabPurpose.Default)
tab.navigate(url, title)
tab.isNewTab = true
this.tabs.push(tab)
this.tabIndex = this.tabs.length - 1
}
setActiveTab(tabIndex: number) {
if (!TABS_ENABLED) {
return
}
this.tabIndex = Math.max(Math.min(tabIndex, this.tabs.length - 1), 0)
}
closeTab(tabIndex: number) {
if (!TABS_ENABLED) {
return
}
this.tabs = [
...this.tabs.slice(0, tabIndex),
...this.tabs.slice(tabIndex + 1),
]
if (this.tabs.length === 0) {
this.newTab('/')
} else if (this.tabIndex >= this.tabs.length) {
this.tabIndex = this.tabs.length - 1
}
}
// persistence
// =
serialize(): unknown {
return {
tabs: this.tabs.map(t => t.serialize()),
tabIndex: this.tabIndex,
}
}
hydrate(_v: unknown) {
// TODO fixme
this.clear()
/*if (isObj(v)) {
if (hasProp(v, 'tabs') && Array.isArray(v.tabs)) {
for (const tab of v.tabs) {
const copy = new NavigationTabModel()
copy.hydrate(tab)
if (copy.history.length) {
this.tabs.push(copy)
}
}
}
if (hasProp(v, 'tabIndex') && typeof v.tabIndex === 'number') {
this.tabIndex = v.tabIndex
}
}*/
}
}

View file

@ -11,12 +11,12 @@ import {z} from 'zod'
import {isObj, hasProp} from 'lib/type-guards'
import {LogModel} from './log'
import {SessionModel} from './session'
import {NavigationModel} from './navigation'
import {ShellUiModel} from './shell-ui'
import {ProfilesViewModel} from './profiles-view'
import {LinkMetasViewModel} from './link-metas-view'
import {NotificationsViewItemModel} from './notifications-view'
import {MeModel} from './me'
import {resetToTab} from '../../Navigation'
export const appInfo = z.object({
build: z.string(),
@ -31,7 +31,6 @@ export class RootStoreModel {
appInfo?: AppInfo
log = new LogModel()
session = new SessionModel(this)
nav = new NavigationModel(this)
shell = new ShellUiModel(this)
me = new MeModel(this)
profiles = new ProfilesViewModel(this)
@ -82,7 +81,6 @@ export class RootStoreModel {
log: this.log.serialize(),
session: this.session.serialize(),
me: this.me.serialize(),
nav: this.nav.serialize(),
shell: this.shell.serialize(),
}
}
@ -101,9 +99,6 @@ export class RootStoreModel {
if (hasProp(v, 'me')) {
this.me.hydrate(v.me)
}
if (hasProp(v, 'nav')) {
this.nav.hydrate(v.nav)
}
if (hasProp(v, 'session')) {
this.session.hydrate(v.session)
}
@ -144,7 +139,7 @@ export class RootStoreModel {
*/
async handleSessionDrop() {
this.log.debug('RootStoreModel:handleSessionDrop')
this.nav.clear()
resetToTab('HomeTab')
this.me.clear()
this.emitSessionDropped()
}
@ -155,7 +150,7 @@ export class RootStoreModel {
clearAllSessionState() {
this.log.debug('RootStoreModel:clearAllSessionState')
this.session.clear()
this.nav.clear()
resetToTab('HomeTab')
this.me.clear()
}
@ -203,6 +198,7 @@ export class RootStoreModel {
}
// the current screen has changed
// TODO is this still needed?
onNavigation(handler: () => void): EmitterSubscription {
return DeviceEventEmitter.addListener('navigation', handler)
}

View file

@ -108,7 +108,6 @@ export interface ComposerOptsQuote {
}
}
export interface ComposerOpts {
imagesOpen?: boolean
replyTo?: ComposerOptsPostRef
onPost?: () => void
quote?: ComposerOptsQuote
@ -117,7 +116,7 @@ export interface ComposerOpts {
export class ShellUiModel {
darkMode = false
minimalShellMode = false
isMainMenuOpen = false
isDrawerOpen = false
isModalActive = false
activeModals: Modal[] = []
isLightboxActive = false
@ -156,8 +155,12 @@ export class ShellUiModel {
this.minimalShellMode = v
}
setMainMenuOpen(v: boolean) {
this.isMainMenuOpen = v
openDrawer() {
this.isDrawerOpen = true
}
closeDrawer() {
this.isDrawerOpen = false
}
openModal(modal: Modal) {

View file

@ -1,24 +0,0 @@
import {PhotoIdentifier} from './../../../node_modules/@react-native-camera-roll/camera-roll/src/CameraRoll'
import {makeAutoObservable, runInAction} from 'mobx'
import {CameraRoll} from '@react-native-camera-roll/camera-roll'
import {RootStoreModel} from './root-store'
export type {PhotoIdentifier} from './../../../node_modules/@react-native-camera-roll/camera-roll/src/CameraRoll'
export class UserLocalPhotosModel {
// state
photos: PhotoIdentifier[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
rootStore: false,
})
}
async setup() {
const r = await CameraRoll.getPhotos({first: 20})
runInAction(() => {
this.photos = r.edges
})
}
}