Scroll to column

This commit is contained in:
Bruno Lemos
2018-11-08 04:43:40 -02:00
parent 0356825b32
commit 8ebe26dd33
20 changed files with 271 additions and 64 deletions

View File

@@ -13,6 +13,7 @@
"bugsnag-js": "^4.7.3",
"bugsnag-react": "^1.1.1",
"bugsnag-react-native": "^2.12.2",
"fbemitter": "^2.1.1",
"gravatar": "^1.6.0",
"hoist-non-react-statics": "^3.1.0",
"immer": "^1.7.4",
@@ -38,6 +39,7 @@
"reselect": "^4.0.0"
},
"devDependencies": {
"@types/fbemitter": "^2.0.32",
"@types/gravatar": "^1.4.28",
"@types/hoist-non-react-statics": "^3.0.1",
"@types/jest": "^23.3.9",

View File

@@ -2,7 +2,6 @@ import React, { PureComponent, StrictMode } from 'react'
import { Provider as ReduxProvider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
import { bugsnagClient, initBugsnag } from '../libs/bugsnag'
import { AppNavigator } from '../navigation/AppNavigator'
import { configureStore } from '../redux/store'
import { AppGlobalStyles } from './AppGlobalStyles'
@@ -11,9 +10,6 @@ import { ThemeProvider } from './context/ThemeContext'
const { persistor, store } = configureStore()
initBugsnag('231f337f6090422c611017d3dab3d32e')
bugsnagClient.config.notifyReleaseStages = ['production']
// TODO: Enable StrictMode after react-redux fixes it
// @see https://github.com/reduxjs/react-redux/issues/897
// @see https://github.com/reduxjs/react-redux/issues/890

View File

@@ -1,7 +1,10 @@
import React, { PureComponent, ReactNode } from 'react'
import { StyleProp, StyleSheet, View, ViewProps, ViewStyle } from 'react-native'
import { EventSubscription } from 'fbemitter'
import { Platform } from '../../libs/platform'
import { emitter } from '../../setup'
import * as colors from '../../styles/colors'
import { contentPadding } from '../../styles/variables'
import { DimensionsConsumer } from '../context/DimensionsContext'
import { ThemeConsumer } from '../context/ThemeContext'
@@ -11,12 +14,17 @@ export const columnMargin = contentPadding / 2
export interface ColumnProps extends ViewProps {
children?: ReactNode
columnId: string
maxWidth?: number
minWidth?: number
pagingEnabled?: boolean
style?: StyleProp<ViewStyle>
}
export interface ColumnState {
showFocusBorder?: boolean
}
const styles = StyleSheet.create({
container: {
height: '100%',
@@ -38,9 +46,45 @@ export class Column extends PureComponent<ColumnProps> {
minWidth: 320,
}
state = {
showFocusBorder: false,
}
focusOnColumnListener?: EventSubscription
componentDidMount() {
this.focusOnColumnListener = emitter.addListener(
'FOCUS_ON_COLUMN',
this.handleColumnFocusRequest,
)
}
componentWillUnmount() {
if (this.focusOnColumnListener) this.focusOnColumnListener.remove()
}
handleColumnFocusRequest = ({
columnId,
highlight,
}: {
columnId: string
highlight?: boolean
}) => {
if (!(columnId && columnId === this.props.columnId)) return
if (!highlight) return
this.setState({ showFocusBorder: true }, () => {
setTimeout(() => {
this.setState({ showFocusBorder: false })
}, 1000)
})
}
render() {
const { showFocusBorder } = this.state
const {
children,
columnId,
maxWidth,
minWidth,
pagingEnabled,
@@ -49,7 +93,7 @@ export class Column extends PureComponent<ColumnProps> {
} = this.props
return (
<ThemeConsumer>
<ThemeConsumer key={`column-inner-${columnId}`}>
{({ theme }) => (
<DimensionsConsumer>
{({ width }) => (
@@ -73,6 +117,18 @@ export class Column extends PureComponent<ColumnProps> {
]}
>
{children}
{!!showFocusBorder && (
<View
style={{
...StyleSheet.absoluteFillObject,
borderWidth: 0,
borderRightWidth: 4,
borderLeftWidth: 4,
borderColor: theme.foregroundColorTransparent50,
}}
/>
)}
</View>
)}
</DimensionsConsumer>

View File

@@ -26,11 +26,7 @@ import { ThemeConsumer } from '../context/ThemeContext'
export const columnHeaderItemContentSize = 20
export interface ColumnHeaderItemProps {
avatarDetails?: {
owner: string
repo?: string
}
avatarShape?: AvatarProps['shape']
avatarProps?: Partial<AvatarProps>
avatarStyle?: StyleProp<ImageStyle>
iconName?: GitHubIcon
iconStyle?: StyleProp<TextStyle>
@@ -90,9 +86,7 @@ class ColumnHeaderItemComponent extends PureComponent<
render() {
const {
avatarDetails,
avatarShape,
avatarStyle,
avatarProps: _avatarProps,
iconName,
iconStyle,
subtitle,
@@ -102,12 +96,13 @@ class ColumnHeaderItemComponent extends PureComponent<
username: _username,
} = this.props
const avatarProps = _avatarProps || {}
const username =
_username &&
avatarDetails &&
avatarDetails.owner &&
!(_username.toLowerCase() === avatarDetails.owner.toLowerCase())
? avatarDetails.owner
avatarProps.username &&
!(_username.toLowerCase() === avatarProps.username.toLowerCase())
? avatarProps.username
: undefined
const smallAvatarSpacing = 5
@@ -153,8 +148,7 @@ class ColumnHeaderItemComponent extends PureComponent<
}}
isBot={false}
linkURL=""
repo={avatarDetails && avatarDetails.repo}
shape={avatarShape}
{...avatarProps}
style={[
{
position: 'absolute',
@@ -163,7 +157,7 @@ class ColumnHeaderItemComponent extends PureComponent<
width: 10,
height: 10,
},
avatarStyle,
avatarProps.style,
]}
username={username}
/>
@@ -174,14 +168,13 @@ class ColumnHeaderItemComponent extends PureComponent<
<Avatar
isBot={false}
linkURL=""
repo={avatarDetails && avatarDetails.repo}
shape={avatarShape}
{...avatarProps}
style={[
{
width: columnHeaderItemContentSize,
height: columnHeaderItemContentSize,
},
avatarStyle,
avatarProps.style,
]}
username={username}
/>

View File

@@ -1,3 +1,4 @@
import { EventSubscription } from 'fbemitter'
import React, { PureComponent } from 'react'
import {
FlatList,
@@ -7,6 +8,7 @@ import {
ViewStyle,
} from 'react-native'
import { emitter } from '../../setup'
import { Column, Omit } from '../../types'
import { DimensionsConsumer } from '../context/DimensionsContext'
import { EventColumn } from './EventColumn'
@@ -26,9 +28,42 @@ const styles = StyleSheet.create({
})
export class Columns extends PureComponent<ColumnsProps> {
flatListRef = React.createRef<FlatList<Column>>()
focusOnColumnListener?: EventSubscription
pagingEnabled: boolean = true
swipeable: boolean = false
componentDidMount() {
this.focusOnColumnListener = emitter.addListener(
'FOCUS_ON_COLUMN',
this.handleColumnFocusRequest,
)
}
componentWillUnmount() {
if (this.focusOnColumnListener) this.focusOnColumnListener.remove()
}
handleColumnFocusRequest = ({
animated,
columnIndex,
}: {
columnId: string
columnIndex: number
animated?: boolean
highlight?: boolean
}) => {
if (!this.flatListRef.current) return
if (!(this.props.data && this.props.data!.length)) return
if (columnIndex >= 0 && columnIndex < this.props.data.length) {
this.flatListRef.current.scrollToIndex({
animated,
index: columnIndex,
})
}
}
keyExtractor(column: Column) {
return `column-container-${column.id}`
}
@@ -79,6 +114,7 @@ export class Columns extends PureComponent<ColumnsProps> {
return (
<FlatList
ref={this.flatListRef}
key="columns-flat-list"
bounces={!this.swipeable}
className="snap-container"

View File

@@ -53,11 +53,12 @@ export class EventColumnComponent extends PureComponent<
return (
<Column
key={`event-column-${this.props.column.id}-inner`}
columnId={this.props.column.id}
pagingEnabled={pagingEnabled}
>
<ColumnHeader>
<ColumnHeaderItem
avatarDetails={requestTypeIconAndData.avatarDetails}
avatarProps={requestTypeIconAndData.avatarProps}
iconName={requestTypeIconAndData.icon}
subtitle={requestTypeIconAndData.subtitle}
title={requestTypeIconAndData.title}

View File

@@ -12,6 +12,7 @@ import { ColumnHeader } from './ColumnHeader'
import { ColumnHeaderItem, ColumnHeaderItemProps } from './ColumnHeaderItem'
export interface ModalColumnProps extends ColumnHeaderItemProps {
columnId: string
minWidth?: number
maxWidth?: number
}
@@ -50,6 +51,7 @@ class ModalColumnComponent extends PureComponent<
return (
<Column
columnId={this.props.columnId}
style={[
{
zIndex: 100,

View File

@@ -51,6 +51,7 @@ class NotificationColumnComponent extends PureComponent<
return (
<Column
key={`notification-column-${this.props.column.id}-inner`}
columnId={this.props.column.id}
pagingEnabled={pagingEnabled}
>
<ColumnHeader>

View File

@@ -10,11 +10,13 @@ import {
import { fixURL } from '../../utils/helpers/github/url'
import { getRepositoryURL, getUserURL } from '../cards/partials/rows/helpers'
import { ThemeConsumer } from '../context/ThemeContext'
import { ConditionalWrap } from './ConditionalWrap'
import { ImageWithLoading, ImageWithLoadingProps } from './ImageWithLoading'
import { Link } from './Link'
export interface AvatarProps extends Partial<ImageWithLoadingProps> {
avatarURL?: string
disableLink?: boolean
email?: string
hitSlop?: TouchableOpacityProps['hitSlop']
isBot?: boolean
@@ -31,6 +33,7 @@ export const size = avatarSize
export const Avatar: SFC<AvatarProps> = ({
avatarURL: _avatarURL,
disableLink,
email,
hitSlop,
isBot: _isBot,
@@ -57,20 +60,26 @@ export const Avatar: SFC<AvatarProps> = ({
if (!uri) return null
const linkUri = disableLink
? undefined
: linkURL
? fixURL(linkURL)
: username
? repo
? getRepositoryURL(username, repo)
: getUserURL(username, { isBot })
: undefined
return (
<ThemeConsumer>
{({ theme }) => (
<Link
hitSlop={hitSlop}
href={
linkURL
? fixURL(linkURL)
: username
? repo
? getRepositoryURL(username, repo)
: getUserURL(username, { isBot })
: undefined
}
<ConditionalWrap
condition={!!linkUri}
wrap={children => (
<Link hitSlop={hitSlop} href={linkUri}>
{children}
</Link>
)}
>
<ImageWithLoading
{...props}
@@ -92,7 +101,7 @@ export const Avatar: SFC<AvatarProps> = ({
style,
]}
/>
</Link>
</ConditionalWrap>
)}
</ThemeConsumer>
)

View File

@@ -10,6 +10,7 @@ import { connect } from 'react-redux'
import * as actions from '../../redux/actions'
import * as selectors from '../../redux/selectors'
import { emitter } from '../../setup'
import { ExtractPropsFromConnector } from '../../types'
import { getColumnHeaderDetails } from '../../utils/helpers/github/events'
import { columnHeaderHeight } from '../columns/ColumnHeader'
@@ -40,6 +41,7 @@ const connectToStore = connect(
return {
columns: selectors.columnsSelector(state),
currentOpenedModal: selectors.currentOpenedModal(state),
username: (user && user.login) || '',
}
},
@@ -81,8 +83,8 @@ class SidebarComponent extends PureComponent<
<View
style={[
styles.centerContainer,
squareStyle,
{
...squareStyle,
backgroundColor: theme.backgroundColorLess08,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: theme.backgroundColorDarker08,
@@ -100,7 +102,14 @@ class SidebarComponent extends PureComponent<
{!small && (
<TouchableOpacity
onPress={() => replaceModal({ name: 'ADD_COLUMN' })}
style={[styles.centerContainer, squareStyle]}
style={[
styles.centerContainer,
squareStyle,
{
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: theme.backgroundColorDarker08,
},
]}
>
<ColumnHeaderItem iconName="plus" />
</TouchableOpacity>
@@ -125,15 +134,26 @@ class SidebarComponent extends PureComponent<
)
return (
<View
key={`sidebar-column-${index}`}
<TouchableOpacity
key={`sidebar-column-${column.id}`}
style={[styles.centerContainer, squareStyle]}
onPress={() => {
emitter.emit('FOCUS_ON_COLUMN', {
animated: !small || !this.props.currentOpenedModal,
columnId: column.id,
columnIndex: index,
highlight: !small,
})
}}
>
<ColumnHeaderItem
avatarDetails={requestTypeIconAndData.avatarDetails}
avatarProps={{
...requestTypeIconAndData.avatarProps,
disableLink: true,
}}
iconName={requestTypeIconAndData.icon}
/>
</View>
</TouchableOpacity>
)
})}

View File

@@ -205,7 +205,11 @@ class AddColumnDetailsModalComponent extends PureComponent<
const { icon, name, paramList } = this.props
return (
<ModalColumn iconName="plus" title="Add Column">
<ModalColumn
columnId="add-column-details-modal"
iconName="plus"
title="Add Column"
>
<Spacer height={contentPadding} />
<View

View File

@@ -133,7 +133,11 @@ class AddColumnModalComponent extends PureComponent<
render() {
return (
<ModalColumn iconName="plus" title="Add Column">
<ModalColumn
columnId="add-column-modal"
iconName="plus"
title="Add Column"
>
<View
style={{
flex: 1,

View File

@@ -24,7 +24,11 @@ class SettingsModalComponent extends PureComponent<
> {
render() {
return (
<ModalColumn iconName="gear" title="Preferences">
<ModalColumn
columnId="preferences-modal"
iconName="gear"
title="Preferences"
>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: contentPadding }}

View File

@@ -4,9 +4,9 @@ import React from 'react'
export * from 'bugsnag-react-native'
export let bugsnagClient: InstanceType<typeof Bugsnag>
export let ErrorBoundary: React.ComponentType<any>
export let ErrorBoundary: React.ComponentType<any> = React.Fragment
let bugsnagClient: InstanceType<typeof Bugsnag>
export function initBugsnag(apiKey: string) {
bugsnagClient = new Bugsnag(apiKey)

View File

@@ -4,9 +4,9 @@ import React from 'react'
export * from 'bugsnag-js'
export let bugsnagClient: ReturnType<typeof bugsnag>
export let ErrorBoundary: React.ComponentType<any>
export let ErrorBoundary: React.ComponentType<any> = React.Fragment
let bugsnagClient: ReturnType<typeof bugsnag>
export function initBugsnag(apiKey: string) {
bugsnagClient = bugsnag(apiKey)
;(bugsnagClient as any).apiKey = apiKey

View File

@@ -1,5 +1,7 @@
import { all, put, select, takeLatest } from 'redux-saga/effects'
import { delay } from 'redux-saga'
import { emitter } from '../../setup'
import { Column, ExtractActionFromActionCreator } from '../../types'
import { guid } from '../../utils/helpers/shared'
import * as actions from '../actions'
@@ -41,6 +43,38 @@ function* onLoginSuccess(
if (!columns) yield put(actions.replaceColumns(getDefaultColumns(username)))
}
export function* columnsSagas() {
yield all([yield takeLatest('LOGIN_SUCCESS', onLoginSuccess)])
function* onAddColumn(
action: ExtractActionFromActionCreator<typeof actions.addColumn>,
) {
const columnId = action.payload.id
const columns: Column[] | undefined = yield select(selectors.columnsSelector)
const columnIndex = columns && columns.findIndex(c => c.id === columnId)
yield delay(300)
emitter.emit('FOCUS_ON_COLUMN', {
animated: true,
columnId,
columnIndex,
highlight: true,
})
}
function onMoveColumn(
action: ExtractActionFromActionCreator<typeof actions.moveColumn>,
) {
emitter.emit('FOCUS_ON_COLUMN', {
animated: true,
columnId: action.payload.id,
columnIndex: action.payload.index,
highlight: true,
})
}
export function* columnsSagas() {
yield all([
yield takeLatest('LOGIN_SUCCESS', onLoginSuccess),
yield takeLatest('ADD_COLUMN', onAddColumn),
yield takeLatest('MOVE_COLUMN', onMoveColumn),
])
}

View File

@@ -1,6 +1,7 @@
import { EventSubscription } from 'fbemitter'
import hoistNonReactStatics from 'hoist-non-react-statics'
import React, { PureComponent } from 'react'
import { StyleSheet, View } from 'react-native'
import { Dimensions, StyleSheet, View } from 'react-native'
import {
NavigationScreenProps,
NavigationStackScreenOptions,
@@ -16,6 +17,7 @@ import { ModalRenderer } from '../components/modals/ModalRenderer'
import { ColumnsContainer } from '../containers/ColumnsContainer'
import * as actions from '../redux/actions'
import * as selectors from '../redux/selectors'
import { emitter } from '../setup'
import { contentPadding } from '../styles/variables'
import { ExtractPropsFromConnector } from '../types'
@@ -34,6 +36,7 @@ const connectToStore = connect(
currentOpenedModal: selectors.currentOpenedModal(state),
}),
{
closeAllModals: actions.closeAllModals,
replaceModal: actions.replaceModal,
},
)
@@ -45,6 +48,28 @@ class MainScreenComponent extends PureComponent<
header: null,
}
focusOnColumnListener?: EventSubscription
componentDidMount() {
this.focusOnColumnListener = emitter.addListener(
'FOCUS_ON_COLUMN',
this.handleColumnFocusRequest,
)
}
componentWillUnmount() {
if (this.focusOnColumnListener) this.focusOnColumnListener.remove()
}
handleColumnFocusRequest = () => {
if (
this.props.currentOpenedModal &&
Dimensions.get('window').width <= 420
) {
this.props.closeAllModals()
}
}
render() {
const { currentOpenedModal, replaceModal } = this.props

View File

@@ -0,0 +1,8 @@
import { EventEmitter } from 'fbemitter'
import { initBugsnag } from './libs/bugsnag'
export const emitter = new EventEmitter()
export const bugsnagClient = initBugsnag('231f337f6090422c611017d3dab3d32e')
bugsnagClient.config.notifyReleaseStages = ['production']

View File

@@ -22,9 +22,9 @@ import {
export function getColumnHeaderDetails(
column: Column,
): {
avatarDetails?: {
owner: string
avatarProps?: {
repo?: string
username: string
}
icon: GitHubIcon
repoIsKnown: boolean
@@ -36,7 +36,7 @@ export function getColumnHeaderDetails(
switch (column.subtype) {
case 'ORG_PUBLIC_EVENTS': {
return {
avatarDetails: { owner: column.params.org },
avatarProps: { username: column.params.org },
icon: 'organization',
repoIsKnown: false,
subtitle: 'Activity',
@@ -53,9 +53,9 @@ export function getColumnHeaderDetails(
}
case 'REPO_EVENTS': {
return {
avatarDetails: {
owner: column.params.owner,
avatarProps: {
repo: column.params.repo,
username: column.params.owner,
},
icon: 'repo',
repoIsKnown: true,
@@ -65,9 +65,9 @@ export function getColumnHeaderDetails(
}
case 'REPO_NETWORK_EVENTS': {
return {
avatarDetails: {
owner: column.params.owner,
avatarProps: {
repo: column.params.repo,
username: column.params.owner,
},
icon: 'repo',
repoIsKnown: true,
@@ -77,7 +77,7 @@ export function getColumnHeaderDetails(
}
case 'USER_EVENTS': {
return {
avatarDetails: { owner: column.params.username },
avatarProps: { username: column.params.username },
icon: 'person',
repoIsKnown: false,
subtitle: 'Activity',
@@ -86,7 +86,7 @@ export function getColumnHeaderDetails(
}
case 'USER_ORG_EVENTS': {
return {
avatarDetails: { owner: column.params.org },
avatarProps: { username: column.params.org },
icon: 'organization',
repoIsKnown: false,
subtitle: 'Activity',
@@ -95,7 +95,7 @@ export function getColumnHeaderDetails(
}
case 'USER_PUBLIC_EVENTS': {
return {
avatarDetails: { owner: column.params.username },
avatarProps: { username: column.params.username },
icon: 'person',
repoIsKnown: false,
subtitle: 'Activity',
@@ -105,7 +105,7 @@ export function getColumnHeaderDetails(
case 'USER_RECEIVED_EVENTS':
case 'USER_RECEIVED_PUBLIC_EVENTS': {
return {
avatarDetails: { owner: column.params.username },
avatarProps: { username: column.params.username },
icon: 'home',
repoIsKnown: false,
subtitle: 'Dashboard',

View File

@@ -969,6 +969,11 @@
"@svgr/core" "^2.4.1"
loader-utils "^1.1.0"
"@types/fbemitter@^2.0.32":
version "2.0.32"
resolved "http://registry.npmjs.org/@types/fbemitter/-/fbemitter-2.0.32.tgz#8ed204da0f54e9c8eaec31b1eec91e25132d082c"
integrity sha1-jtIE2g9U6cjq7DGx7skeJRMtCCw=
"@types/gravatar@^1.4.28":
version "1.4.28"
resolved "http://registry.npmjs.org/@types/gravatar/-/gravatar-1.4.28.tgz#a83527c354b3bce265ef489facfbed34971f9b16"
@@ -4764,6 +4769,13 @@ fb-watchman@^2.0.0:
dependencies:
bser "^2.0.0"
fbemitter@^2.1.1:
version "2.1.1"
resolved "https://registry.npmjs.org/fbemitter/-/fbemitter-2.1.1.tgz#523e14fdaf5248805bb02f62efc33be703f51865"
integrity sha1-Uj4U/a9SSIBbsC9i78M75wP1GGU=
dependencies:
fbjs "^0.8.4"
fbjs-css-vars@^1.0.0:
version "1.0.1"
resolved "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.1.tgz#836d876e887d702f45610f5ebd2fbeef649527fc"
@@ -4785,7 +4797,7 @@ fbjs-scripts@^0.8.1:
semver "^5.1.0"
through2 "^2.0.0"
fbjs@^0.8.0, fbjs@^0.8.16, fbjs@^0.8.9:
fbjs@^0.8.0, fbjs@^0.8.16, fbjs@^0.8.4, fbjs@^0.8.9:
version "0.8.17"
resolved "https://registry.npmjs.org/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd"
integrity sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90=