Add the ability to expand/collapse users in notifications

zio/stable
Paul Frazee 2023-01-19 11:34:07 -06:00
parent 1ed82b6c59
commit 74ab6530d4
4 changed files with 202 additions and 58 deletions

View File

@ -1,6 +1,12 @@
import React, {useMemo} from 'react' import React from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {StyleSheet, View} from 'react-native' import {
Animated,
TouchableOpacity,
TouchableWithoutFeedback,
StyleSheet,
View,
} from 'react-native'
import {AppBskyEmbedImages} from '@atproto/api' import {AppBskyEmbedImages} from '@atproto/api'
import {AtUri} from '../../../third-party/uri' import {AtUri} from '../../../third-party/uri'
import {FontAwesomeIcon, Props} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon, Props} from '@fortawesome/react-native-fontawesome'
@ -16,16 +22,28 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
import {Post} from '../post/Post' import {Post} from '../post/Post'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {usePalette} from '../../lib/hooks/usePalette' import {usePalette} from '../../lib/hooks/usePalette'
import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
const MAX_AUTHORS = 8 const MAX_AUTHORS = 8
const EXPANDED_AUTHOR_EL_HEIGHT = 35
const EXPANDED_AUTHORS_CLOSE_EL_HEIGHT = 26
interface Author {
href: string
handle: string
displayName?: string
avatar?: string
}
export const FeedItem = observer(function FeedItem({ export const FeedItem = observer(function FeedItem({
item, item,
}: { }: {
item: NotificationsViewItemModel item: NotificationsViewItemModel
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const itemHref = useMemo(() => { const [isAuthorsExpanded, setAuthorsExpanded] = React.useState<boolean>(false)
const itemHref = React.useMemo(() => {
if (item.isUpvote || item.isRepost) { if (item.isUpvote || item.isRepost) {
const urip = new AtUri(item.subjectUri) const urip = new AtUri(item.subjectUri)
return `/profile/${urip.host}/post/${urip.rkey}` return `/profile/${urip.host}/post/${urip.rkey}`
@ -37,7 +55,7 @@ export const FeedItem = observer(function FeedItem({
} }
return '' return ''
}, [item]) }, [item])
const itemTitle = useMemo(() => { const itemTitle = React.useMemo(() => {
if (item.isUpvote || item.isRepost) { if (item.isUpvote || item.isRepost) {
return 'Post' return 'Post'
} else if (item.isFollow || item.isAssertion) { } else if (item.isFollow || item.isAssertion) {
@ -47,6 +65,10 @@ export const FeedItem = observer(function FeedItem({
} }
}, [item]) }, [item])
const onToggleAuthorsExpanded = () => {
setAuthorsExpanded(!isAuthorsExpanded)
}
if (item.additionalPost?.notFound) { if (item.additionalPost?.notFound) {
// don't render anything if the target post was deleted or unfindable // don't render anything if the target post was deleted or unfindable
return <View /> return <View />
@ -93,12 +115,7 @@ export const FeedItem = observer(function FeedItem({
return <></> return <></>
} }
let authors: { let authors: Author[] = [
href: string
handle: string
displayName?: string
avatar?: string
}[] = [
{ {
href: `/profile/${item.author.handle}`, href: `/profile/${item.author.handle}`,
handle: item.author.handle, handle: item.author.handle,
@ -143,50 +160,45 @@ export const FeedItem = observer(function FeedItem({
)} )}
</View> </View>
<View style={styles.layoutContent}> <View style={styles.layoutContent}>
<View style={styles.avis}> <TouchableWithoutFeedback
{authors.slice(0, MAX_AUTHORS).map(author => ( onPress={authors.length > 1 ? onToggleAuthorsExpanded : () => {}}>
<Link <View>
style={{marginRight: 5}} <ExpandedAuthorsList
key={author.href} visible={isAuthorsExpanded}
href={author.href} authors={authors}
title={`@${author.handle}`}> onToggleAuthorsExpanded={onToggleAuthorsExpanded}
<UserAvatar />
size={35} {isAuthorsExpanded ? (
displayName={author.displayName} <></>
handle={author.handle} ) : (
avatar={author.avatar} <CondensedAuthorsList authors={authors} />
/> )}
</Link> <View style={styles.meta}>
))} <Link
{authors.length > MAX_AUTHORS ? ( key={authors[0].href}
<Text style={[styles.aviExtraCount, pal.textLight]}> style={styles.metaItem}
+{authors.length - MAX_AUTHORS} href={authors[0].href}
</Text> title={`@${authors[0].handle}`}>
) : undefined} <Text style={[pal.text, s.bold]}>
</View> {authors[0].displayName || authors[0].handle}
<View style={styles.meta}> </Text>
<Link </Link>
key={authors[0].href} {authors.length > 1 ? (
style={styles.metaItem} <>
href={authors[0].href} <Text style={[styles.metaItem, pal.text]}>and</Text>
title={`@${authors[0].handle}`}> <Text style={[styles.metaItem, pal.text, s.bold]}>
<Text style={[pal.text, s.bold]}> {authors.length - 1}{' '}
{authors[0].displayName || authors[0].handle} {pluralize(authors.length - 1, 'other')}
</Text> </Text>
</Link> </>
{authors.length > 1 ? ( ) : undefined}
<> <Text style={[styles.metaItem, pal.text]}>{action}</Text>
<Text style={[styles.metaItem, pal.text]}>and</Text> <Text style={[styles.metaItem, pal.textLight]}>
<Text style={[styles.metaItem, pal.text, s.bold]}> {ago(item.indexedAt)}
{authors.length - 1} {pluralize(authors.length - 1, 'other')}
</Text> </Text>
</> </View>
) : undefined} </View>
<Text style={[styles.metaItem, pal.text]}>{action}</Text> </TouchableWithoutFeedback>
<Text style={[styles.metaItem, pal.textLight]}>
{ago(item.indexedAt)}
</Text>
</View>
{item.isUpvote || item.isRepost ? ( {item.isUpvote || item.isRepost ? (
<AdditionalPostText additionalPost={item.additionalPost} /> <AdditionalPostText additionalPost={item.additionalPost} />
) : ( ) : (
@ -198,6 +210,116 @@ export const FeedItem = observer(function FeedItem({
) )
}) })
function CondensedAuthorsList({authors}: {authors: Author[]}) {
const pal = usePalette('default')
if (authors.length === 1) {
return (
<View style={styles.avis}>
<Link
style={s.mr5}
href={authors[0].href}
title={`@${authors[0].handle}`}>
<UserAvatar
size={35}
displayName={authors[0].displayName}
handle={authors[0].handle}
avatar={authors[0].avatar}
/>
</Link>
</View>
)
}
return (
<View style={styles.avis}>
{authors.slice(0, MAX_AUTHORS).map(author => (
<View key={author.href} style={s.mr5}>
<UserAvatar
size={35}
displayName={author.displayName}
handle={author.handle}
avatar={author.avatar}
/>
</View>
))}
{authors.length > MAX_AUTHORS ? (
<Text style={[styles.aviExtraCount, pal.textLight]}>
+{authors.length - MAX_AUTHORS}
</Text>
) : undefined}
<FontAwesomeIcon
icon="angle-down"
size={18}
style={[styles.expandedAuthorsCloseBtnIcon, pal.icon]}
/>
</View>
)
}
function ExpandedAuthorsList({
visible,
authors,
onToggleAuthorsExpanded,
}: {
visible: boolean
authors: Author[]
onToggleAuthorsExpanded: () => void
}) {
const pal = usePalette('default')
const heightInterp = useAnimatedValue(visible ? 1 : 0)
const targetHeight =
authors.length * (EXPANDED_AUTHOR_EL_HEIGHT + 10) /*10=margin*/ +
EXPANDED_AUTHORS_CLOSE_EL_HEIGHT
const heightStyle = {
height: Animated.multiply(heightInterp, targetHeight),
overflow: 'hidden',
}
React.useEffect(() => {
Animated.timing(heightInterp, {
toValue: visible ? 1 : 0,
duration: 200,
useNativeDriver: false,
}).start()
}, [heightInterp, visible])
return (
<Animated.View style={(s.mb5, heightStyle)}>
<TouchableOpacity
style={styles.expandedAuthorsCloseBtn}
onPress={onToggleAuthorsExpanded}>
<FontAwesomeIcon
icon="angle-up"
size={18}
style={[styles.expandedAuthorsCloseBtnIcon, pal.text]}
/>
<Text type="sm-medium" style={pal.text}>
Hide
</Text>
</TouchableOpacity>
{authors.map(author => (
<Link
href={author.href}
title={author.displayName || author.handle}
style={styles.expandedAuthor}>
<View style={styles.expandedAuthorAvi}>
<UserAvatar
size={35}
displayName={author.displayName}
handle={author.handle}
avatar={author.avatar}
/>
</View>
<View style={s.flex1}>
<Text type="lg-bold" numberOfLines={1}>
{author.displayName || author.handle}
&nbsp;
<Text style={[pal.textLight]}>{author.handle}</Text>
</Text>
</View>
</Link>
))}
</Animated.View>
)
}
function AdditionalPostText({ function AdditionalPostText({
additionalPost, additionalPost,
}: { }: {
@ -282,4 +404,25 @@ const styles = StyleSheet.create({
paddingTop: 4, paddingTop: 4,
paddingLeft: 36, paddingLeft: 36,
}, },
expandedAuthorsCloseBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: 8,
height: EXPANDED_AUTHORS_CLOSE_EL_HEIGHT,
overflow: 'hidden',
},
expandedAuthorsCloseBtnIcon: {
marginLeft: 4,
marginRight: 4,
},
expandedAuthor: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 10,
height: EXPANDED_AUTHOR_EL_HEIGHT,
},
expandedAuthorAvi: {
marginRight: 5,
},
}) })

View File

@ -103,10 +103,7 @@ export const PostThreadItem = observer(function PostThreadItem({
if (deleted) { if (deleted) {
return ( return (
<View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}> <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
<FontAwesomeIcon <FontAwesomeIcon icon={['far', 'trash-can']} style={pal.icon} />
icon={['far', 'trash-can']}
style={{color: pal.colors.icon}}
/>
<Text style={[pal.textLight, s.ml10]}>This post has been deleted.</Text> <Text style={[pal.textLight, s.ml10]}>This post has been deleted.</Text>
</View> </View>
) )

View File

@ -10,6 +10,7 @@ export interface UsePaletteValue {
textLight: TextStyle textLight: TextStyle
textInverted: TextStyle textInverted: TextStyle
link: TextStyle link: TextStyle
icon: TextStyle
} }
export function usePalette(color: PaletteColorName): UsePaletteValue { export function usePalette(color: PaletteColorName): UsePaletteValue {
const palette = useTheme().palette[color] const palette = useTheme().palette[color]
@ -36,5 +37,8 @@ export function usePalette(color: PaletteColorName): UsePaletteValue {
link: { link: {
color: palette.link, color: palette.link,
}, },
icon: {
color: palette.icon,
},
} }
} }

View File

@ -13,7 +13,7 @@ export const defaultTheme: Theme = {
textInverted: colors.white, textInverted: colors.white,
link: colors.blue3, link: colors.blue3,
border: '#f0e9e9', border: '#f0e9e9',
icon: colors.gray2, icon: colors.gray3,
// non-standard // non-standard
textVeryLight: colors.gray4, textVeryLight: colors.gray4,