Localize dates, counts (#5027)

* refactor: consistent localized formatting

* refactor: localized date time

* refactor: localize relative time with strings

* chore: fix typo from copy-paste

* Clean up useTimeAgo

* Remove old ago

* Const

* Reuse

* Prettier

---------

Co-authored-by: Mary <git@mary.my.id>
This commit is contained in:
Eric Bailey 2024-08-29 19:22:53 -05:00 committed by GitHub
parent d5a7618374
commit 8651f31ebb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 375 additions and 186 deletions

View file

@ -84,7 +84,7 @@ let FeedItem = ({
}): React.ReactNode => {
const queryClient = useQueryClient()
const pal = usePalette('default')
const {_} = useLingui()
const {_, i18n} = useLingui()
const t = useTheme()
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
const itemHref = useMemo(() => {
@ -225,11 +225,11 @@ let FeedItem = ({
}
const formattedCount =
authors.length > 1 ? formatCount(authors.length - 1) : ''
authors.length > 1 ? formatCount(i18n, authors.length - 1) : ''
const firstAuthorName = sanitizeDisplayName(
authors[0].profile.displayName || authors[0].profile.handle,
)
const niceTimestamp = niceDate(item.notification.indexedAt)
const niceTimestamp = niceDate(i18n, item.notification.indexedAt)
const a11yLabelUsers =
authors.length > 1
? _(msg` and `) +

View file

@ -181,7 +181,7 @@ let PostThreadItemLoaded = ({
threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => {
const pal = usePalette('default')
const {_} = useLingui()
const {_, i18n} = useLingui()
const langPrefs = useLanguagePrefs()
const {openComposer} = useComposerControls()
const [limitLines, setLimitLines] = React.useState(
@ -388,7 +388,7 @@ let PostThreadItemLoaded = ({
type="lg"
style={pal.textLight}>
<Text type="xl-bold" style={pal.text}>
{formatCount(post.repostCount)}
{formatCount(i18n, post.repostCount)}
</Text>{' '}
<Plural
value={post.repostCount}
@ -410,7 +410,7 @@ let PostThreadItemLoaded = ({
type="lg"
style={pal.textLight}>
<Text type="xl-bold" style={pal.text}>
{formatCount(post.quoteCount)}
{formatCount(i18n, post.quoteCount)}
</Text>{' '}
<Plural
value={post.quoteCount}
@ -430,7 +430,7 @@ let PostThreadItemLoaded = ({
type="lg"
style={pal.textLight}>
<Text type="xl-bold" style={pal.text}>
{formatCount(post.likeCount)}
{formatCount(i18n, post.likeCount)}
</Text>{' '}
<Plural value={post.likeCount} one="like" other="likes" />
</Text>
@ -705,7 +705,7 @@ function ExpandedPostDetails({
translatorUrl: string
}) {
const pal = usePalette('default')
const {_} = useLingui()
const {_, i18n} = useLingui()
const openLink = useOpenLink()
const isRootPost = !('reply' in post.record)
@ -723,7 +723,9 @@ function ExpandedPostDetails({
s.mt2,
s.mb10,
]}>
<Text style={[a.text_sm, pal.textLight]}>{niceDate(post.indexedAt)}</Text>
<Text style={[a.text_sm, pal.textLight]}>
{niceDate(i18n, post.indexedAt)}
</Text>
{isRootPost && (
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
)}

View file

@ -1,6 +1,7 @@
import React, {memo, useCallback} from 'react'
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {precacheProfile} from '#/state/queries/profile'
@ -35,6 +36,8 @@ interface PostMetaOpts {
}
let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
const {i18n} = useLingui()
const pal = usePalette('default')
const displayName = opts.author.displayName || opts.author.handle
const handle = opts.author.handle
@ -101,8 +104,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
type="md"
style={pal.textLight}
text={timeElapsed}
accessibilityLabel={niceDate(opts.timestamp)}
title={niceDate(opts.timestamp)}
accessibilityLabel={niceDate(i18n, opts.timestamp)}
title={niceDate(i18n, opts.timestamp)}
accessibilityHint=""
href={opts.postHref}
onBeforePress={onBeforePressPost}

View file

@ -1,4 +1,6 @@
import React from 'react'
import {I18n} from '@lingui/core'
import {useLingui} from '@lingui/react'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useTickEveryMinute} from '#/state/shell'
@ -10,19 +12,21 @@ export function TimeElapsed({
}: {
timestamp: string
children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element
timeToString?: (timeElapsed: string) => string
timeToString?: (i18n: I18n, timeElapsed: string) => string
}) {
const {i18n} = useLingui()
const ago = useGetTimeAgo()
const format = timeToString ?? ago
const tick = useTickEveryMinute()
const [timeElapsed, setTimeAgo] = React.useState(() =>
format(timestamp, tick),
timeToString ? timeToString(i18n, timestamp) : ago(timestamp, tick),
)
const [prevTick, setPrevTick] = React.useState(tick)
if (prevTick !== tick) {
setPrevTick(tick)
setTimeAgo(format(timestamp, tick))
setTimeAgo(
timeToString ? timeToString(i18n, timestamp) : ago(timestamp, tick),
)
}
return children({timeElapsed})

View file

@ -1,19 +1,18 @@
import React, {useState, useCallback} from 'react'
import React, {useCallback, useState} from 'react'
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
import DatePicker from 'react-native-date-picker'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {isIOS, isAndroid} from 'platform/detection'
import {Button, ButtonType} from './Button'
import {Text} from '../text/Text'
import {useLingui} from '@lingui/react'
import {usePalette} from 'lib/hooks/usePalette'
import {TypographyVariant} from 'lib/ThemeContext'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
import {getLocales} from 'expo-localization'
import DatePicker from 'react-native-date-picker'
const LOCALE = getLocales()[0]
import {isAndroid, isIOS} from 'platform/detection'
import {Text} from '../text/Text'
import {Button, ButtonType} from './Button'
interface Props {
testID?: string
@ -30,16 +29,11 @@ interface Props {
}
export function DateInput(props: Props) {
const {i18n} = useLingui()
const [show, setShow] = useState(false)
const theme = useTheme()
const pal = usePalette('default')
const formatter = React.useMemo(() => {
return new Intl.DateTimeFormat(LOCALE.languageTag, {
timeZone: props.handleAsUTC ? 'UTC' : undefined,
})
}, [props.handleAsUTC])
const onChangeInternal = useCallback(
(date: Date) => {
setShow(false)
@ -74,7 +68,9 @@ export function DateInput(props: Props) {
<Text
type={props.buttonLabelType}
style={[pal.text, props.buttonLabelStyle]}>
{formatter.format(props.value)}
{i18n.date(props.value, {
timeZone: props.handleAsUTC ? 'UTC' : undefined,
})}
</Text>
</View>
</Button>

View file

@ -1,19 +1,12 @@
export const formatCount = (num: number) =>
Intl.NumberFormat('en-US', {
import type {I18n} from '@lingui/core'
export const formatCount = (i18n: I18n, num: number) => {
return i18n.number(num, {
notation: 'compact',
maximumFractionDigits: 1,
// `1,953` shouldn't be rounded up to 2k, it should be truncated.
// @ts-expect-error: `roundingMode` doesn't seem to be in the typings yet
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingmode
roundingMode: 'trunc',
}).format(num)
export function formatCountShortOnly(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return String(num)
})
}

View file

@ -75,7 +75,7 @@ let PostCtrls = ({
threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {_, i18n} = useLingui()
const {openComposer} = useComposerControls()
const {currentAccount} = useSession()
const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext)
@ -247,7 +247,7 @@ let PostCtrls = ({
big ? a.text_md : {fontSize: 15},
a.user_select_none,
]}>
{formatCount(post.replyCount)}
{formatCount(i18n, post.replyCount)}
</Text>
) : undefined}
</Pressable>
@ -300,7 +300,7 @@ let PostCtrls = ({
: defaultCtrlColor,
],
]}>
{formatCount(post.likeCount)}
{formatCount(i18n, post.likeCount)}
</Text>
) : undefined}
</Pressable>

View file

@ -32,7 +32,7 @@ let RepostButton = ({
embeddingDisabled,
}: Props): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {_, i18n} = useLingui()
const requireAuth = useRequireAuth()
const dialogControl = Dialog.useDialogControl()
const playHaptic = useHaptics()
@ -79,7 +79,7 @@ let RepostButton = ({
big ? a.text_md : {fontSize: 15},
isReposted && a.font_bold,
]}>
{formatCount(repostCount)}
{formatCount(i18n, repostCount)}
</Text>
) : undefined}
</Button>

View file

@ -128,6 +128,7 @@ const RepostInner = ({
repostCount?: number
big?: boolean
}) => {
const {i18n} = useLingui()
return (
<View style={[a.flex_row, a.align_center, a.gap_xs, {padding: 5}]}>
<Repost style={color} width={big ? 22 : 18} />
@ -140,7 +141,7 @@ const RepostInner = ({
isReposted && [a.font_bold],
a.user_select_none,
]}>
{formatCount(repostCount)}
{formatCount(i18n, repostCount)}
</Text>
) : undefined}
</View>

View file

@ -18,7 +18,6 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {useModalControls} from '#/state/modals'
import {useLanguagePrefs} from '#/state/preferences'
import {
useAppPasswordDeleteMutation,
useAppPasswordsQuery,
@ -218,9 +217,8 @@ function AppPassword({
privileged?: boolean
}) {
const pal = usePalette('default')
const {_} = useLingui()
const {_, i18n} = useLingui()
const control = useDialogControl()
const {contentLanguages} = useLanguagePrefs()
const deleteMutation = useAppPasswordDeleteMutation()
const onDelete = React.useCallback(async () => {
@ -232,9 +230,6 @@ function AppPassword({
control.open()
}, [control])
const primaryLocale =
contentLanguages.length > 0 ? contentLanguages[0] : 'en-US'
return (
<TouchableOpacity
testID={testID}
@ -250,14 +245,14 @@ function AppPassword({
<Text type="md" style={[pal.text, styles.pr10]} numberOfLines={1}>
<Trans>
Created{' '}
{Intl.DateTimeFormat(primaryLocale, {
{i18n.date(createdAt, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(createdAt))}
})}
</Trans>
</Text>
{privileged && (

View file

@ -30,7 +30,7 @@ import {colors, s} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {isWeb} from 'platform/detection'
import {NavSignupCard} from '#/view/shell/NavSignupCard'
import {formatCountShortOnly} from 'view/com/util/numeric/format'
import {formatCount} from 'view/com/util/numeric/format'
import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {atoms as a} from '#/alf'
@ -68,7 +68,7 @@ let DrawerProfileCard = ({
account: SessionAccount
onPressProfile: () => void
}): React.ReactNode => {
const {_} = useLingui()
const {_, i18n} = useLingui()
const pal = usePalette('default')
const {data: profile} = useProfileQuery({did: account.did})
@ -108,7 +108,7 @@ let DrawerProfileCard = ({
<Text type="xl" style={pal.textLight}>
<Trans>
<Text type="xl-medium" style={pal.text}>
{formatCountShortOnly(profile?.followersCount ?? 0)}
{formatCount(i18n, profile?.followersCount ?? 0)}
</Text>{' '}
<Plural
value={profile?.followersCount || 0}
@ -123,7 +123,7 @@ let DrawerProfileCard = ({
<Text type="xl" style={pal.textLight}>
<Trans>
<Text type="xl-medium" style={pal.text}>
{formatCountShortOnly(profile?.followsCount ?? 0)}
{formatCount(i18n, profile?.followsCount ?? 0)}
</Text>{' '}
<Plural
value={profile?.followsCount || 0}