Lists updates: curate lists and blocklists (#1689)

* Add lists screen

* Update Lists screen and List create/edit modal to support curate lists

* Rework the ProfileList screen and add curatelist support

* More ProfileList progress

* Update list modals

* Rename mutelists to modlists

* Layout updates/fixes

* More layout fixes

* Modal fixes

* List list screen updates

* Update feed page to give more info

* Layout fixes to ListAddUser modal

* Layout fixes to FlatList and Feed on desktop

* Layout fix to LoadLatestBtn on Web

* Handle did resolution before showing the ProfileList screen

* Rename the CustomFeed routes to ProfileFeed for consistency

* Fix layout issues with the pager and feeds

* Factor out some common code

* Fix UIs for mobile

* Fix user list rendering

* Fix: dont bubble custom feed errors in the merge feed

* Refactor feed models to reduce usage of the SavedFeeds model

* Replace CustomFeedModel with FeedSourceModel which abstracts feed-generators and lists

* Add the ability to pin lists

* Add pinned lists to mobile

* Remove dead code

* Rework the ProfileScreenHeader to create more real-estate for action buttons

* Improve layout behavior on web mobile breakpoints

* Refactor feed & list pages to use new Tabs layout component

* Refactor to ProfileSubpageHeader

* Implement modlist block and mute

* Switch to new api and just modify state on modlist actions

* Fix some UI overflows

* Fix: dont show edit buttons on lists you dont own

* Fix alignment issue on long titles

* Improve loading and error states for feeds & lists

* Update list dropdown icons for ios

* Fetch feed display names in the mergefeed

* Improve rendering off offline feeds in the feed-listing page

* Update Feeds listing UI to react to changes in saved/pinned state

* Refresh list and feed on posts tab press

* Fix pinned feed ordering UI

* Fixes to list pinning

* Remove view=simple qp

* Add list to feed tuners

* Render richtext

* Add list href

* Add 'view avatar'

* Remove unused import

* Fix missing import

* Correctly reflect block by list state

* Replace the <Tabs> component with the more effective <PagerWithHeader> component

* Improve the responsiveness of the PagerWithHeader

* Fix visual jank in the feed loading state

* Improve performance of the PagerWithHeader

* Fix a case that would cause the header to animate too aggressively

* Add the ability to scroll to top by tapping the selected tab

* Fix unit test runner

* Update modlists test

* Add curatelist tests

* Fix: remove link behavior in ListAddUser modal

* Fix some layout jank in the PagerWithHeader on iOS

* Simplify ListItems header rendering

* Wait for the appview to recognize the list before proceeding with list creation

* Fix glitch in the onPageSelecting index of the Pager

* Fix until()

* Copy fix

Co-authored-by: Eric Bailey <git@esb.lol>

---------

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Paul Frazee 2023-11-01 16:15:40 -07:00 committed by GitHub
parent f9944b55e2
commit f57a8cf8ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
87 changed files with 4090 additions and 1988 deletions

View file

@ -1,10 +1,11 @@
import React, {useMemo} from 'react'
import React from 'react'
import {StyleSheet} from 'react-native'
import Animated from 'react-native-reanimated'
import {observer} from 'mobx-react-lite'
import {TabBar} from 'view/com/pager/TabBar'
import {RenderTabBarFnProps} from 'view/com/pager/Pager'
import {useStores} from 'state/index'
import {useHomeTabs} from 'lib/hooks/useHomeTabs'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
@ -27,10 +28,7 @@ const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
const store = useStores()
const items = useMemo(
() => ['Following', ...store.me.savedFeeds.pinnedFeedNames],
[store.me.savedFeeds.pinnedFeedNames],
)
const items = useHomeTabs(store.preferences.pinnedFeeds)
const pal = usePalette('default')
const {headerMinimalShellTransform} = useMinimalShellMode()

View file

@ -1,9 +1,10 @@
import React, {useMemo} from 'react'
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {TabBar} from 'view/com/pager/TabBar'
import {RenderTabBarFnProps} from 'view/com/pager/Pager'
import {useStores} from 'state/index'
import {useHomeTabs} from 'lib/hooks/useHomeTabs'
import {usePalette} from 'lib/hooks/usePalette'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {Link} from '../util/Link'
@ -18,9 +19,9 @@ import Animated from 'react-native-reanimated'
export const FeedsTabBar = observer(function FeedsTabBarImpl(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
const store = useStores()
const pal = usePalette('default')
const store = useStores()
const items = useHomeTabs(store.preferences.pinnedFeeds)
const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3)
const {headerMinimalShellTransform} = useMinimalShellMode()
@ -28,15 +29,6 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
store.shell.openDrawer()
}, [store])
const items = useMemo(
() => ['Following', ...store.me.savedFeeds.pinnedFeedNames],
[store.me.savedFeeds.pinnedFeedNames],
)
const tabBarKey = useMemo(() => {
return items.join(',')
}, [items])
return (
<Animated.View
style={[
@ -81,7 +73,7 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
</View>
</View>
<TabBar
key={tabBarKey}
key={items.join(',')}
onPressSelected={props.onPressSelected}
selectedPage={props.selectedPage}
onSelect={props.onSelect}

View file

@ -1,6 +1,10 @@
import React, {forwardRef} from 'react'
import {Animated, View} from 'react-native'
import PagerView, {PagerViewOnPageSelectedEvent} from 'react-native-pager-view'
import PagerView, {
PagerViewOnPageSelectedEvent,
PagerViewOnPageScrollEvent,
PageScrollStateChangedNativeEvent,
} from 'react-native-pager-view'
import {s} from 'lib/styles'
export type PageSelectedEvent = PagerViewOnPageSelectedEvent
@ -21,6 +25,7 @@ interface Props {
initialPage?: number
renderTabBar: RenderTabBarFn
onPageSelected?: (index: number) => void
onPageSelecting?: (index: number) => void
testID?: string
}
export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
@ -31,11 +36,15 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
initialPage = 0,
renderTabBar,
onPageSelected,
onPageSelecting,
testID,
}: React.PropsWithChildren<Props>,
ref,
) {
const [selectedPage, setSelectedPage] = React.useState(0)
const lastOffset = React.useRef(0)
const lastDirection = React.useRef(0)
const scrollState = React.useRef('')
const pagerView = React.useRef<PagerView>(null)
React.useImperativeHandle(ref, () => ({
@ -50,15 +59,61 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
[setSelectedPage, onPageSelected],
)
const onPageScroll = React.useCallback(
(e: PagerViewOnPageScrollEvent) => {
const {position, offset} = e.nativeEvent
if (offset === 0) {
// offset hits 0 in some awkward spots so we ignore it
return
}
// NOTE
// we want to call `onPageSelecting` as soon as the scroll-gesture
// enters the "settling" phase, which means the user has released it
// we can't infer directionality from the scroll information, so we
// track the offset changes. if the offset delta is consistent with
// the existing direction during the settling phase, we can say for
// certain where it's going and can fire
// -prf
if (scrollState.current === 'settling') {
if (lastDirection.current === -1 && offset < lastOffset.current) {
onPageSelecting?.(position)
lastDirection.current = 0
} else if (
lastDirection.current === 1 &&
offset > lastOffset.current
) {
onPageSelecting?.(position + 1)
lastDirection.current = 0
}
} else {
if (offset < lastOffset.current) {
lastDirection.current = -1
} else if (offset > lastOffset.current) {
lastDirection.current = 1
}
}
lastOffset.current = offset
},
[lastOffset, lastDirection, onPageSelecting],
)
const onPageScrollStateChanged = React.useCallback(
(e: PageScrollStateChangedNativeEvent) => {
scrollState.current = e.nativeEvent.pageScrollState
},
[scrollState],
)
const onTabBarSelect = React.useCallback(
(index: number) => {
pagerView.current?.setPage(index)
onPageSelecting?.(index)
},
[pagerView],
[pagerView, onPageSelecting],
)
return (
<View testID={testID}>
<View testID={testID} style={s.flex1}>
{tabBarPosition === 'top' &&
renderTabBar({
selectedPage,
@ -66,9 +121,11 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
})}
<AnimatedPagerView
ref={pagerView}
style={s.h100pct}
style={s.flex1}
initialPage={initialPage}
onPageSelected={onPageSelectedInner}>
onPageScrollStateChanged={onPageScrollStateChanged}
onPageSelected={onPageSelectedInner}
onPageScroll={onPageScroll}>
{children}
</AnimatedPagerView>
{tabBarPosition === 'bottom' &&

View file

@ -13,6 +13,7 @@ interface Props {
initialPage?: number
renderTabBar: RenderTabBarFn
onPageSelected?: (index: number) => void
onPageSelecting?: (index: number) => void
}
export const Pager = React.forwardRef(function PagerImpl(
{
@ -21,6 +22,7 @@ export const Pager = React.forwardRef(function PagerImpl(
initialPage = 0,
renderTabBar,
onPageSelected,
onPageSelecting,
}: React.PropsWithChildren<Props>,
ref,
) {
@ -34,21 +36,20 @@ export const Pager = React.forwardRef(function PagerImpl(
(index: number) => {
setSelectedPage(index)
onPageSelected?.(index)
onPageSelecting?.(index)
},
[setSelectedPage, onPageSelected],
[setSelectedPage, onPageSelected, onPageSelecting],
)
return (
<View>
<View style={s.hContentRegion}>
{tabBarPosition === 'top' &&
renderTabBar({
selectedPage,
onSelect: onTabBarSelect,
})}
{React.Children.map(children, (child, i) => (
<View
style={selectedPage === i ? undefined : s.hidden}
key={`page-${i}`}>
<View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}>
{child}
</View>
))}

View file

@ -0,0 +1,212 @@
import * as React from 'react'
import {LayoutChangeEvent, StyleSheet} from 'react-native'
import Animated, {
Easing,
useAnimatedReaction,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
withTiming,
runOnJS,
} from 'react-native-reanimated'
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
import {TabBar} from './TabBar'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
const SCROLLED_DOWN_LIMIT = 200
interface PagerWithHeaderChildParams {
headerHeight: number
onScroll: OnScrollCb
isScrolledDown: boolean
}
export interface PagerWithHeaderProps {
testID?: string
children:
| (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[]
| ((props: PagerWithHeaderChildParams) => JSX.Element)
items: string[]
renderHeader?: () => JSX.Element
initialPage?: number
onPageSelected?: (index: number) => void
onCurrentPageSelected?: (index: number) => void
}
export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
function PageWithHeaderImpl(
{
children,
testID,
items,
renderHeader,
initialPage,
onPageSelected,
onCurrentPageSelected,
}: PagerWithHeaderProps,
ref,
) {
const {isMobile} = useWebMediaQueries()
const [currentPage, setCurrentPage] = React.useState(0)
const scrollYs = React.useRef<Record<number, number>>({})
const scrollY = useSharedValue(scrollYs.current[currentPage] || 0)
const [tabBarHeight, setTabBarHeight] = React.useState(0)
const [headerHeight, setHeaderHeight] = React.useState(0)
const [isScrolledDown, setIsScrolledDown] = React.useState(
scrollYs.current[currentPage] > SCROLLED_DOWN_LIMIT,
)
// react to scroll updates
function onScrollUpdate(v: number) {
// track each page's current scroll position
scrollYs.current[currentPage] = Math.min(v, headerHeight - tabBarHeight)
// update the 'is scrolled down' value
setIsScrolledDown(v > SCROLLED_DOWN_LIMIT)
}
useAnimatedReaction(
() => scrollY.value,
v => runOnJS(onScrollUpdate)(v),
)
// capture the header bar sizing
const onTabBarLayout = React.useCallback(
(evt: LayoutChangeEvent) => {
setTabBarHeight(evt.nativeEvent.layout.height)
},
[setTabBarHeight],
)
const onHeaderLayout = React.useCallback(
(evt: LayoutChangeEvent) => {
setHeaderHeight(evt.nativeEvent.layout.height)
},
[setHeaderHeight],
)
// render the the header and tab bar
const headerTransform = useAnimatedStyle(
() => ({
transform: [
{
translateY: Math.min(
Math.min(scrollY.value, headerHeight - tabBarHeight) * -1,
0,
),
},
],
}),
[scrollY, headerHeight, tabBarHeight],
)
const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => {
return (
<Animated.View
onLayout={onHeaderLayout}
style={[
isMobile ? styles.tabBarMobile : styles.tabBarDesktop,
headerTransform,
]}>
{renderHeader?.()}
<TabBar
items={items}
selectedPage={currentPage}
onSelect={props.onSelect}
onPressSelected={onCurrentPageSelected}
onLayout={onTabBarLayout}
/>
</Animated.View>
)
},
[
items,
renderHeader,
headerTransform,
currentPage,
onCurrentPageSelected,
isMobile,
onTabBarLayout,
onHeaderLayout,
],
)
// props to pass into children render functions
const onScroll = useAnimatedScrollHandler({
onScroll(e) {
scrollY.value = e.contentOffset.y
},
})
const childProps = React.useMemo<PagerWithHeaderChildParams>(() => {
return {
headerHeight,
onScroll,
isScrolledDown,
}
}, [headerHeight, onScroll, isScrolledDown])
const onPageSelectedInner = React.useCallback(
(index: number) => {
setCurrentPage(index)
onPageSelected?.(index)
},
[onPageSelected, setCurrentPage],
)
const onPageSelecting = React.useCallback(
(index: number) => {
setCurrentPage(index)
if (scrollY.value > headerHeight) {
scrollY.value = headerHeight
}
scrollY.value = withTiming(scrollYs.current[index] || 0, {
duration: 170,
easing: Easing.inOut(Easing.quad),
})
},
[scrollY, setCurrentPage, scrollYs, headerHeight],
)
return (
<Pager
ref={ref}
testID={testID}
initialPage={initialPage}
onPageSelected={onPageSelectedInner}
onPageSelecting={onPageSelecting}
renderTabBar={renderTabBar}
tabBarPosition="top">
{toArray(children)
.filter(Boolean)
.map(child => {
if (child) {
return child(childProps)
}
return null
})}
</Pager>
)
},
)
const styles = StyleSheet.create({
tabBarMobile: {
position: 'absolute',
zIndex: 1,
top: 0,
left: 0,
width: '100%',
},
tabBarDesktop: {
position: 'absolute',
zIndex: 1,
top: 0,
// @ts-ignore Web only -prf
left: 'calc(50% - 299px)',
width: 598,
},
})
function toArray<T>(v: T | T[]): T[] {
if (Array.isArray(v)) {
return v
}
return [v]
}

View file

@ -13,7 +13,8 @@ export interface TabBarProps {
items: string[]
indicatorColor?: string
onSelect?: (index: number) => void
onPressSelected?: () => void
onPressSelected?: (index: number) => void
onLayout?: (evt: LayoutChangeEvent) => void
}
export function TabBar({
@ -23,6 +24,7 @@ export function TabBar({
indicatorColor,
onSelect,
onPressSelected,
onLayout,
}: TabBarProps) {
const pal = usePalette('default')
const scrollElRef = useRef<ScrollView>(null)
@ -44,7 +46,7 @@ export function TabBar({
(index: number) => {
onSelect?.(index)
if (index === selectedPage) {
onPressSelected?.()
onPressSelected?.(index)
}
},
[onSelect, selectedPage, onPressSelected],
@ -66,7 +68,7 @@ export function TabBar({
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
return (
<View testID={testID} style={[pal.view, styles.outer]}>
<View testID={testID} style={[pal.view, styles.outer]} onLayout={onLayout}>
<DraggableScrollView
horizontal={true}
showsHorizontalScrollIndicator={false}
@ -118,10 +120,7 @@ const desktopStyles = StyleSheet.create({
const mobileStyles = StyleSheet.create({
outer: {
flex: 1,
flexDirection: 'row',
backgroundColor: 'transparent',
maxWidth: '100%',
},
contentContainer: {
columnGap: isWeb ? 0 : 20,