📓 Bookmarks (#8976)
* Add button to controls, respace * Hook up shadow and mutation * Add Bookmarks screen * Build out Bookmarks screen * Handle removals via shadow * Use truncateAndInvalidate strategy * Add empty state * Add toasts * Add undo buttons to toasts * Stage NUX, needs image * Finesse post controls * New reply icon * Use curvier variant of repost icon * Prevent layout shift with align_start * Update api pkg * Swap in new image * Limit spacing on desktop * Rm decimals over 10k * Better optimistic adding/removing * Add metrics * Comment * Remove unused code block * Remove debug limit * Fork shadow for web/native * Tweak alt * add preventExpansion: true * Refine hitslop * Add count to anchor * Reduce space in compact mode --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import {memo} from 'react'
|
||||
import {type Insets} from 'react-native'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import type React from 'react'
|
||||
|
||||
import {useCleanError} from '#/lib/hooks/useCleanError'
|
||||
import {logger} from '#/logger'
|
||||
import {type Shadow} from '#/state/cache/post-shadow'
|
||||
import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
|
||||
import {useTheme} from '#/alf'
|
||||
import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import * as toast from '#/components/Toast'
|
||||
import {PostControlButton, PostControlButtonIcon} from './PostControlButton'
|
||||
|
||||
export const BookmarkButton = memo(function BookmarkButton({
|
||||
post,
|
||||
big,
|
||||
logContext,
|
||||
hitSlop,
|
||||
}: {
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
big?: boolean
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||
hitSlop?: Insets
|
||||
}): React.ReactNode {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {mutateAsync: bookmark} = useBookmarkMutation()
|
||||
const cleanError = useCleanError()
|
||||
|
||||
const {viewer} = post
|
||||
const isBookmarked = !!viewer?.bookmarked
|
||||
|
||||
const undoLabel = _(
|
||||
msg({
|
||||
message: `Undo`,
|
||||
context: `Button label to undo saving/removing a post from saved posts.`,
|
||||
}),
|
||||
)
|
||||
|
||||
const save = async ({disableUndo}: {disableUndo?: boolean} = {}) => {
|
||||
try {
|
||||
await bookmark({
|
||||
action: 'create',
|
||||
post,
|
||||
})
|
||||
|
||||
logger.metric('post:bookmark', {logContext})
|
||||
|
||||
toast.show(
|
||||
<toast.Outer>
|
||||
<toast.Icon />
|
||||
<toast.Text>
|
||||
<Trans>Post saved</Trans>
|
||||
</toast.Text>
|
||||
{!disableUndo && (
|
||||
<toast.Action
|
||||
label={undoLabel}
|
||||
onPress={() => remove({disableUndo: true})}>
|
||||
{undoLabel}
|
||||
</toast.Action>
|
||||
)}
|
||||
</toast.Outer>,
|
||||
{
|
||||
type: 'success',
|
||||
},
|
||||
)
|
||||
} catch (e: any) {
|
||||
const {raw, clean} = cleanError(e)
|
||||
toast.show(clean || raw || e, {
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async ({disableUndo}: {disableUndo?: boolean} = {}) => {
|
||||
try {
|
||||
await bookmark({
|
||||
action: 'delete',
|
||||
uri: post.uri,
|
||||
})
|
||||
|
||||
logger.metric('post:unbookmark', {logContext})
|
||||
|
||||
toast.show(
|
||||
<toast.Outer>
|
||||
<toast.Icon icon={TrashIcon} />
|
||||
<toast.Text>
|
||||
<Trans>Removed from saved posts</Trans>
|
||||
</toast.Text>
|
||||
{!disableUndo && (
|
||||
<toast.Action
|
||||
label={undoLabel}
|
||||
onPress={() => save({disableUndo: true})}>
|
||||
{undoLabel}
|
||||
</toast.Action>
|
||||
)}
|
||||
</toast.Outer>,
|
||||
)
|
||||
} catch (e: any) {
|
||||
const {raw, clean} = cleanError(e)
|
||||
toast.show(clean || raw || e, {
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onHandlePress = async () => {
|
||||
if (isBookmarked) {
|
||||
await remove()
|
||||
} else {
|
||||
await save()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PostControlButton
|
||||
testID="postBookmarkBtn"
|
||||
big={big}
|
||||
label={
|
||||
isBookmarked
|
||||
? _(msg`Remove from saved posts`)
|
||||
: _(msg`Add to saved posts`)
|
||||
}
|
||||
onPress={onHandlePress}
|
||||
hitSlop={hitSlop}>
|
||||
<PostControlButtonIcon
|
||||
fill={isBookmarked ? t.palette.primary_500 : undefined}
|
||||
icon={isBookmarked ? BookmarkFilled : Bookmark}
|
||||
/>
|
||||
</PostControlButton>
|
||||
)
|
||||
})
|
||||
@@ -1,13 +1,14 @@
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
import {type GestureResponderEvent, type View} from 'react-native'
|
||||
import {type GestureResponderEvent, type Insets, type View} from 'react-native'
|
||||
|
||||
import {POST_CTRL_HITSLOP} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, type ButtonProps} from '#/components/Button'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Text, type TextProps} from '#/components/Typography'
|
||||
|
||||
export const DEFAULT_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10}
|
||||
|
||||
const PostControlContext = createContext<{
|
||||
big?: boolean
|
||||
active?: boolean
|
||||
@@ -25,12 +26,13 @@ export function PostControlButton({
|
||||
active,
|
||||
activeColor,
|
||||
...props
|
||||
}: ButtonProps & {
|
||||
}: Omit<ButtonProps, 'hitSlop'> & {
|
||||
ref?: React.Ref<View>
|
||||
active?: boolean
|
||||
big?: boolean
|
||||
color?: string
|
||||
activeColor?: string
|
||||
hitSlop?: Insets
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const playHaptic = useHaptics()
|
||||
@@ -83,8 +85,11 @@ export function PostControlButton({
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
hitSlop={POST_CTRL_HITSLOP}
|
||||
{...props}>
|
||||
{...props}
|
||||
hitSlop={{
|
||||
...DEFAULT_HITSLOP,
|
||||
...(props.hitSlop || {}),
|
||||
}}>
|
||||
{typeof children === 'function' ? (
|
||||
args => (
|
||||
<PostControlContext.Provider value={ctx}>
|
||||
@@ -102,12 +107,20 @@ export function PostControlButton({
|
||||
|
||||
export function PostControlButtonIcon({
|
||||
icon: Comp,
|
||||
}: {
|
||||
style,
|
||||
...rest
|
||||
}: SVGIconProps & {
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
}) {
|
||||
const {big, color} = useContext(PostControlContext)
|
||||
|
||||
return <Comp style={[color, a.pointer_events_none]} width={big ? 22 : 18} />
|
||||
return (
|
||||
<Comp
|
||||
style={[color, a.pointer_events_none, style]}
|
||||
{...rest}
|
||||
width={big ? 22 : 18}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PostControlButtonText({style, ...props}: TextProps) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {memo, useMemo, useState} from 'react'
|
||||
import {type Insets} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedPost,
|
||||
@@ -28,6 +29,7 @@ let PostMenuButton = ({
|
||||
timestamp,
|
||||
threadgateRecord,
|
||||
onShowLess,
|
||||
hitSlop,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -39,6 +41,7 @@ let PostMenuButton = ({
|
||||
timestamp: string
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
hitSlop?: Insets
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -66,7 +69,8 @@ let PostMenuButton = ({
|
||||
testID="postDropdownBtn"
|
||||
big={big}
|
||||
label={props.accessibilityLabel}
|
||||
{...props}>
|
||||
{...props}
|
||||
hitSlop={hitSlop}>
|
||||
<PostControlButtonIcon icon={DotsHorizontal} />
|
||||
</PostControlButton>
|
||||
)
|
||||
|
||||
@@ -5,12 +5,12 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useRequireAuth} from '#/state/session'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote'
|
||||
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
|
||||
import {Repost_Stroke2_Corner3_Rounded as Repost} from '#/components/icons/Repost'
|
||||
import {formatPostStatCount} from '#/components/PostControls/util'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
PostControlButton,
|
||||
@@ -25,6 +25,7 @@ interface Props {
|
||||
onQuote: () => void
|
||||
big?: boolean
|
||||
embeddingDisabled: boolean
|
||||
compactCount?: boolean
|
||||
}
|
||||
|
||||
let RepostButton = ({
|
||||
@@ -34,6 +35,7 @@ let RepostButton = ({
|
||||
onQuote,
|
||||
big,
|
||||
embeddingDisabled,
|
||||
compactCount,
|
||||
}: Props): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_, i18n} = useLingui()
|
||||
@@ -86,7 +88,7 @@ let RepostButton = ({
|
||||
<PostControlButtonIcon icon={Repost} />
|
||||
{typeof repostCount !== 'undefined' && repostCount > 0 && (
|
||||
<PostControlButtonText testID="repostCount">
|
||||
{formatCount(i18n, repostCount)}
|
||||
{formatPostStatCount(i18n, repostCount, {compact: compactCount})}
|
||||
</PostControlButtonText>
|
||||
)}
|
||||
</PostControlButton>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {memo, useMemo, useState} from 'react'
|
||||
import {type Insets} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedPost,
|
||||
@@ -34,6 +35,7 @@ let ShareMenuButton = ({
|
||||
timestamp,
|
||||
threadgateRecord,
|
||||
onShare,
|
||||
hitSlop,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -43,6 +45,7 @@ let ShareMenuButton = ({
|
||||
timestamp: string
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShare: () => void
|
||||
hitSlop?: Insets
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const gate = useGate()
|
||||
@@ -92,7 +95,8 @@ let ShareMenuButton = ({
|
||||
big={big}
|
||||
label={props.accessibilityLabel}
|
||||
{...props}
|
||||
onLongPress={native(onNativeLongPress)}>
|
||||
onLongPress={native(onNativeLongPress)}
|
||||
hitSlop={hitSlop}>
|
||||
<PostControlButtonIcon icon={ShareIcon} />
|
||||
</PostControlButton>
|
||||
)
|
||||
|
||||
@@ -24,10 +24,11 @@ import {
|
||||
ProgressGuideAction,
|
||||
useProgressGuideControls,
|
||||
} from '#/state/shell/progress-guide'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
|
||||
import {atoms as a, flatten, useBreakpoints} from '#/alf'
|
||||
import {Reply as Bubble} from '#/components/icons/Reply'
|
||||
import {formatPostStatCount} from '#/components/PostControls/util'
|
||||
import {BookmarkButton} from './BookmarkButton'
|
||||
import {
|
||||
PostControlButton,
|
||||
PostControlButtonIcon,
|
||||
@@ -51,6 +52,7 @@ let PostControls = ({
|
||||
threadgateRecord,
|
||||
onShowLess,
|
||||
viaRepost,
|
||||
variant,
|
||||
}: {
|
||||
big?: boolean
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -65,9 +67,9 @@ let PostControls = ({
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
viaRepost?: {uri: string; cid: string}
|
||||
variant?: 'compact' | 'normal' | 'large'
|
||||
}): React.ReactNode => {
|
||||
const {_, i18n} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {openComposer} = useOpenComposer()
|
||||
const {feedDescriptor} = useFeedFeedbackContext()
|
||||
const [queueLike, queueUnlike] = usePostLikeMutationQueue(
|
||||
@@ -92,6 +94,7 @@ let PostControls = ({
|
||||
post.author.viewer?.blockingByList,
|
||||
)
|
||||
const replyDisabled = post.viewer?.replyDisabled
|
||||
const {gtPhone} = useBreakpoints()
|
||||
|
||||
const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false)
|
||||
|
||||
@@ -184,6 +187,12 @@ let PostControls = ({
|
||||
})
|
||||
}
|
||||
|
||||
const secondaryControlSpacingStyles = flatten([
|
||||
{gap: 0}, // default, we want `gap` to be defined on the resulting object
|
||||
variant !== 'compact' && a.gap_xs,
|
||||
(big || gtPhone) && a.gap_sm,
|
||||
])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -191,104 +200,124 @@ let PostControls = ({
|
||||
a.justify_between,
|
||||
a.align_center,
|
||||
!big && a.pt_2xs,
|
||||
a.gap_md,
|
||||
style,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}],
|
||||
replyDisabled ? {opacity: 0.5} : undefined,
|
||||
]}>
|
||||
<PostControlButton
|
||||
testID="replyBtn"
|
||||
onPress={
|
||||
!replyDisabled ? () => requireAuth(() => onPressReply()) : undefined
|
||||
}
|
||||
label={_(
|
||||
msg({
|
||||
message: `Reply (${plural(post.replyCount || 0, {
|
||||
one: '# reply',
|
||||
other: '# replies',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the reply button, verb form followed by number of replies and noun form',
|
||||
}),
|
||||
)}
|
||||
big={big}>
|
||||
<PostControlButtonIcon icon={Bubble} />
|
||||
{typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
|
||||
<PostControlButtonText>
|
||||
{formatCount(i18n, post.replyCount)}
|
||||
</PostControlButtonText>
|
||||
)}
|
||||
</PostControlButton>
|
||||
</View>
|
||||
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
|
||||
<RepostButton
|
||||
isReposted={!!post.viewer?.repost}
|
||||
repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
|
||||
onRepost={onRepost}
|
||||
onQuote={onQuote}
|
||||
big={big}
|
||||
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
|
||||
/>
|
||||
</View>
|
||||
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
|
||||
<PostControlButton
|
||||
testID="likeBtn"
|
||||
big={big}
|
||||
onPress={() => requireAuth(() => onPressToggleLike())}
|
||||
label={
|
||||
post.viewer?.like
|
||||
? _(
|
||||
msg({
|
||||
message: `Unlike (${plural(post.likeCount || 0, {
|
||||
one: '# like',
|
||||
other: '# likes',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
|
||||
}),
|
||||
)
|
||||
: _(
|
||||
msg({
|
||||
message: `Like (${plural(post.likeCount || 0, {
|
||||
one: '# like',
|
||||
other: '# likes',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
|
||||
}),
|
||||
)
|
||||
}>
|
||||
<AnimatedLikeIcon
|
||||
isLiked={Boolean(post.viewer?.like)}
|
||||
<View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_start,
|
||||
{marginLeft: big ? -2 : -6},
|
||||
replyDisabled ? {opacity: 0.5} : undefined,
|
||||
]}>
|
||||
<PostControlButton
|
||||
testID="replyBtn"
|
||||
onPress={
|
||||
!replyDisabled
|
||||
? () => requireAuth(() => onPressReply())
|
||||
: undefined
|
||||
}
|
||||
label={_(
|
||||
msg({
|
||||
message: `Reply (${plural(post.replyCount || 0, {
|
||||
one: '# reply',
|
||||
other: '# replies',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the reply button, verb form followed by number of replies and noun form',
|
||||
}),
|
||||
)}
|
||||
big={big}>
|
||||
<PostControlButtonIcon icon={Bubble} />
|
||||
{typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
|
||||
<PostControlButtonText>
|
||||
{formatPostStatCount(i18n, post.replyCount, {
|
||||
compact: variant === 'compact',
|
||||
})}
|
||||
</PostControlButtonText>
|
||||
)}
|
||||
</PostControlButton>
|
||||
</View>
|
||||
<View style={[a.flex_1, a.align_start]}>
|
||||
<RepostButton
|
||||
isReposted={!!post.viewer?.repost}
|
||||
repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
|
||||
onRepost={onRepost}
|
||||
onQuote={onQuote}
|
||||
big={big}
|
||||
hasBeenToggled={hasLikeIconBeenToggled}
|
||||
/>
|
||||
<CountWheel
|
||||
likeCount={post.likeCount ?? 0}
|
||||
big={big}
|
||||
isLiked={Boolean(post.viewer?.like)}
|
||||
hasBeenToggled={hasLikeIconBeenToggled}
|
||||
/>
|
||||
</PostControlButton>
|
||||
</View>
|
||||
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
|
||||
<View style={[!big && a.ml_sm]}>
|
||||
<ShareMenuButton
|
||||
testID="postShareBtn"
|
||||
post={post}
|
||||
big={big}
|
||||
record={record}
|
||||
richText={richText}
|
||||
timestamp={post.indexedAt}
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShare={onShare}
|
||||
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
|
||||
compactCount={variant === 'compact'}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_1, a.align_start]}>
|
||||
<PostControlButton
|
||||
testID="likeBtn"
|
||||
big={big}
|
||||
onPress={() => requireAuth(() => onPressToggleLike())}
|
||||
label={
|
||||
post.viewer?.like
|
||||
? _(
|
||||
msg({
|
||||
message: `Unlike (${plural(post.likeCount || 0, {
|
||||
one: '# like',
|
||||
other: '# likes',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
|
||||
}),
|
||||
)
|
||||
: _(
|
||||
msg({
|
||||
message: `Like (${plural(post.likeCount || 0, {
|
||||
one: '# like',
|
||||
other: '# likes',
|
||||
})})`,
|
||||
comment:
|
||||
'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
|
||||
}),
|
||||
)
|
||||
}>
|
||||
<AnimatedLikeIcon
|
||||
isLiked={Boolean(post.viewer?.like)}
|
||||
big={big}
|
||||
hasBeenToggled={hasLikeIconBeenToggled}
|
||||
/>
|
||||
<CountWheel
|
||||
likeCount={post.likeCount ?? 0}
|
||||
big={big}
|
||||
isLiked={Boolean(post.viewer?.like)}
|
||||
hasBeenToggled={hasLikeIconBeenToggled}
|
||||
compactCount={variant === 'compact'}
|
||||
/>
|
||||
</PostControlButton>
|
||||
</View>
|
||||
{/* Spacer! */}
|
||||
<View />
|
||||
</View>
|
||||
<View
|
||||
style={big ? a.align_center : [gtMobile && a.flex_1, a.align_start]}>
|
||||
<View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}>
|
||||
<BookmarkButton
|
||||
post={post}
|
||||
big={big}
|
||||
logContext={logContext}
|
||||
hitSlop={{
|
||||
right: secondaryControlSpacingStyles.gap / 2,
|
||||
}}
|
||||
/>
|
||||
<ShareMenuButton
|
||||
testID="postShareBtn"
|
||||
post={post}
|
||||
big={big}
|
||||
record={record}
|
||||
richText={richText}
|
||||
timestamp={post.indexedAt}
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShare={onShare}
|
||||
hitSlop={{
|
||||
left: secondaryControlSpacingStyles.gap / 2,
|
||||
right: secondaryControlSpacingStyles.gap / 2,
|
||||
}}
|
||||
/>
|
||||
<PostMenuButton
|
||||
testID="postDropdownBtn"
|
||||
post={post}
|
||||
@@ -300,6 +329,9 @@ let PostControls = ({
|
||||
timestamp={post.indexedAt}
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShowLess={onShowLess}
|
||||
hitSlop={{
|
||||
left: secondaryControlSpacingStyles.gap / 2,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {type I18n} from '@lingui/core'
|
||||
|
||||
/**
|
||||
* This matches `formatCount` from `view/com/util/numeric/format.ts`, but has
|
||||
* additional truncation logic for large numbers. `roundingMode` should always
|
||||
* match the original impl, regardless of if we add more formatting here.
|
||||
*/
|
||||
export function formatPostStatCount(
|
||||
i18n: I18n,
|
||||
count: number,
|
||||
{
|
||||
compact = false,
|
||||
}: {
|
||||
compact?: boolean
|
||||
} = {},
|
||||
): string {
|
||||
const isOver10k = count >= 10_000
|
||||
return i18n.number(count, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: isOver10k || compact ? 0 : 1,
|
||||
// @ts-expect-error - roundingMode not in the types
|
||||
roundingMode: 'trunc',
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user