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>
zio/stable
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

@ -377,7 +377,7 @@ function Inner({
hide: () => void hide: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_, i18n} = useLingui()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const moderation = React.useMemo( const moderation = React.useMemo(
() => moderateProfile(profile, moderationOpts), () => moderateProfile(profile, moderationOpts),
@ -393,8 +393,8 @@ function Inner({
profile.viewer?.blocking || profile.viewer?.blocking ||
profile.viewer?.blockedBy || profile.viewer?.blockedBy ||
profile.viewer?.blockingByList profile.viewer?.blockingByList
const following = formatCount(profile.followsCount || 0) const following = formatCount(i18n, profile.followsCount || 0)
const followers = formatCount(profile.followersCount || 0) const followers = formatCount(i18n, profile.followersCount || 0)
const pluralizedFollowers = plural(profile.followersCount || 0, { const pluralizedFollowers = plural(profile.followersCount || 0, {
one: 'follower', one: 'follower',
other: 'followers', other: 'followers',

View File

@ -43,7 +43,7 @@ function EmbedDialogInner({
timestamp, timestamp,
}: Omit<EmbedDialogProps, 'control'>) { }: Omit<EmbedDialogProps, 'control'>) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_, i18n} = useLingui()
const ref = useRef<TextInput>(null) const ref = useRef<TextInput>(null)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
@ -86,9 +86,9 @@ function EmbedDialogInner({
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml( )} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
postAuthor.handle, postAuthor.handle,
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml( )}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
niceDate(timestamp), niceDate(i18n, timestamp),
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>` )}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
}, [postUri, postCid, record, timestamp, postAuthor]) }, [i18n, postUri, postCid, record, timestamp, postAuthor])
return ( return (
<Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}> <Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}>

View File

@ -11,6 +11,7 @@ import {
ChatBskyConvoDefs, ChatBskyConvoDefs,
RichText as RichTextAPI, RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@ -153,14 +154,14 @@ let MessageItemMetadata = ({
) )
const relativeTimestamp = useCallback( const relativeTimestamp = useCallback(
(timestamp: string) => { (i18n: I18n, timestamp: string) => {
const date = new Date(timestamp) const date = new Date(timestamp)
const now = new Date() const now = new Date()
const time = new Intl.DateTimeFormat(undefined, { const time = i18n.date(date, {
hour: 'numeric', hour: 'numeric',
minute: 'numeric', minute: 'numeric',
}).format(date) })
const diff = now.getTime() - date.getTime() const diff = now.getTime() - date.getTime()
@ -182,13 +183,13 @@ let MessageItemMetadata = ({
return _(msg`Yesterday, ${time}`) return _(msg`Yesterday, ${time}`)
} }
return new Intl.DateTimeFormat(undefined, { return i18n.date(date, {
hour: 'numeric', hour: 'numeric',
minute: 'numeric', minute: 'numeric',
day: 'numeric', day: 'numeric',
month: 'numeric', month: 'numeric',
year: 'numeric', year: 'numeric',
}).format(date) })
}, },
[_], [_],
) )

View File

@ -1,12 +1,12 @@
import React from 'react' import React from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import {useLingui} from '@lingui/react'
import {android, atoms as a, useTheme, web} from '#/alf' import {android, atoms as a, useTheme, web} from '#/alf'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {localizeDate} from './utils'
// looks like a TextField.Input, but is just a button. It'll do something different on each platform on press // looks like a TextField.Input, but is just a button. It'll do something different on each platform on press
// iOS: open a dialog with an inline date picker // iOS: open a dialog with an inline date picker
@ -25,6 +25,7 @@ export function DateFieldButton({
isInvalid?: boolean isInvalid?: boolean
accessibilityHint?: string accessibilityHint?: string
}) { }) {
const {i18n} = useLingui()
const t = useTheme() const t = useTheme()
const { const {
@ -91,7 +92,7 @@ export function DateFieldButton({
t.atoms.text, t.atoms.text,
{lineHeight: a.text_md.fontSize * 1.1875}, {lineHeight: a.text_md.fontSize * 1.1875},
]}> ]}>
{localizeDate(value)} {i18n.date(value, {timeZone: 'UTC'})}
</Text> </Text>
</Pressable> </Pressable>
</View> </View>

View File

@ -1,16 +1,5 @@
import {getLocales} from 'expo-localization'
const LOCALE = getLocales()[0]
// we need the date in the form yyyy-MM-dd to pass to the input // we need the date in the form yyyy-MM-dd to pass to the input
export function toSimpleDateString(date: Date | string): string { export function toSimpleDateString(date: Date | string): string {
const _date = typeof date === 'string' ? new Date(date) : date const _date = typeof date === 'string' ? new Date(date) : date
return _date.toISOString().split('T')[0] return _date.toISOString().split('T')[0]
} }
export function localizeDate(date: Date | string): string {
const _date = typeof date === 'string' ? new Date(date) : date
return new Intl.DateTimeFormat(LOCALE.languageTag, {
timeZone: 'UTC',
}).format(_date)
}

View File

@ -1,102 +1,213 @@
import {describe, expect, it} from '@jest/globals' import {describe, expect, it} from '@jest/globals'
import {MessageDescriptor} from '@lingui/core'
import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns' import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns'
import {dateDiff} from '../useTimeAgo' import {dateDiff} from '../useTimeAgo'
const lingui: any = (obj: MessageDescriptor) => obj.message
const base = new Date('2024-06-17T00:00:00Z') const base = new Date('2024-06-17T00:00:00Z')
describe('dateDiff', () => { describe('dateDiff', () => {
it(`works with numbers`, () => { it(`works with numbers`, () => {
expect(dateDiff(subDays(base, 3), Number(base), {lingui})).toEqual('3d') const earlier = subDays(base, 3)
expect(dateDiff(earlier, Number(base))).toEqual({
value: 3,
unit: 'day',
earlier,
later: base,
})
}) })
it(`works with strings`, () => { it(`works with strings`, () => {
expect(dateDiff(subDays(base, 3), base.toString(), {lingui})).toEqual('3d') const earlier = subDays(base, 3)
expect(dateDiff(earlier, base.toString())).toEqual({
value: 3,
unit: 'day',
earlier,
later: base,
})
}) })
it(`works with dates`, () => { it(`works with dates`, () => {
expect(dateDiff(subDays(base, 3), base, {lingui})).toEqual('3d') const earlier = subDays(base, 3)
expect(dateDiff(earlier, base)).toEqual({
value: 3,
unit: 'day',
earlier,
later: base,
})
}) })
it(`equal values return now`, () => { it(`equal values return now`, () => {
expect(dateDiff(base, base, {lingui})).toEqual('now') expect(dateDiff(base, base)).toEqual({
value: 0,
unit: 'now',
earlier: base,
later: base,
})
}) })
it(`future dates return now`, () => { it(`future dates return now`, () => {
expect(dateDiff(addDays(base, 3), base, {lingui})).toEqual('now') const earlier = addDays(base, 3)
expect(dateDiff(earlier, base)).toEqual({
value: 0,
unit: 'now',
earlier,
later: base,
})
}) })
it(`values < 5 seconds ago return now`, () => { it(`values < 5 seconds ago return now`, () => {
const then = subSeconds(base, 4) const then = subSeconds(base, 4)
expect(dateDiff(then, base, {lingui})).toEqual('now') expect(dateDiff(then, base)).toEqual({
value: 0,
unit: 'now',
earlier: then,
later: base,
})
}) })
it(`values >= 5 seconds ago return seconds`, () => { it(`values >= 5 seconds ago return seconds`, () => {
const then = subSeconds(base, 5) const then = subSeconds(base, 5)
expect(dateDiff(then, base, {lingui})).toEqual('5s') expect(dateDiff(then, base)).toEqual({
value: 5,
unit: 'second',
earlier: then,
later: base,
})
}) })
it(`values < 1 min return seconds`, () => { it(`values < 1 min return seconds`, () => {
const then = subSeconds(base, 59) const then = subSeconds(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('59s') expect(dateDiff(then, base)).toEqual({
value: 59,
unit: 'second',
earlier: then,
later: base,
})
}) })
it(`values >= 1 min return minutes`, () => { it(`values >= 1 min return minutes`, () => {
const then = subSeconds(base, 60) const then = subSeconds(base, 60)
expect(dateDiff(then, base, {lingui})).toEqual('1m') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'minute',
earlier: then,
later: base,
})
}) })
it(`minutes round down`, () => { it(`minutes round down`, () => {
const then = subSeconds(base, 119) const then = subSeconds(base, 119)
expect(dateDiff(then, base, {lingui})).toEqual('1m') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'minute',
earlier: then,
later: base,
})
}) })
it(`values < 1 hour return minutes`, () => { it(`values < 1 hour return minutes`, () => {
const then = subMinutes(base, 59) const then = subMinutes(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('59m') expect(dateDiff(then, base)).toEqual({
value: 59,
unit: 'minute',
earlier: then,
later: base,
})
}) })
it(`values >= 1 hour return hours`, () => { it(`values >= 1 hour return hours`, () => {
const then = subMinutes(base, 60) const then = subMinutes(base, 60)
expect(dateDiff(then, base, {lingui})).toEqual('1h') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'hour',
earlier: then,
later: base,
})
}) })
it(`hours round down`, () => { it(`hours round down`, () => {
const then = subMinutes(base, 119) const then = subMinutes(base, 119)
expect(dateDiff(then, base, {lingui})).toEqual('1h') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'hour',
earlier: then,
later: base,
})
}) })
it(`values < 1 day return hours`, () => { it(`values < 1 day return hours`, () => {
const then = subHours(base, 23) const then = subHours(base, 23)
expect(dateDiff(then, base, {lingui})).toEqual('23h') expect(dateDiff(then, base)).toEqual({
value: 23,
unit: 'hour',
earlier: then,
later: base,
})
}) })
it(`values >= 1 day return days`, () => { it(`values >= 1 day return days`, () => {
const then = subHours(base, 24) const then = subHours(base, 24)
expect(dateDiff(then, base, {lingui})).toEqual('1d') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'day',
earlier: then,
later: base,
})
}) })
it(`days round down`, () => { it(`days round down`, () => {
const then = subHours(base, 47) const then = subHours(base, 47)
expect(dateDiff(then, base, {lingui})).toEqual('1d') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'day',
earlier: then,
later: base,
})
}) })
it(`values < 30 days return days`, () => { it(`values < 30 days return days`, () => {
const then = subDays(base, 29) const then = subDays(base, 29)
expect(dateDiff(then, base, {lingui})).toEqual('29d') expect(dateDiff(then, base)).toEqual({
value: 29,
unit: 'day',
earlier: then,
later: base,
})
}) })
it(`values >= 30 days return months`, () => { it(`values >= 30 days return months`, () => {
const then = subDays(base, 30) const then = subDays(base, 30)
expect(dateDiff(then, base, {lingui})).toEqual('1mo') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'month',
earlier: then,
later: base,
})
}) })
it(`months round down`, () => { it(`months round down`, () => {
const then = subDays(base, 59) const then = subDays(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('1mo') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'month',
earlier: then,
later: base,
})
}) })
it(`values are rounded by increments of 30`, () => { it(`values are rounded by increments of 30`, () => {
const then = subDays(base, 61) const then = subDays(base, 61)
expect(dateDiff(then, base, {lingui})).toEqual('2mo') expect(dateDiff(then, base)).toEqual({
value: 2,
unit: 'month',
earlier: then,
later: base,
})
}) })
it(`values < 360 days return months`, () => { it(`values < 360 days return months`, () => {
const then = subDays(base, 359) const then = subDays(base, 359)
expect(dateDiff(then, base, {lingui})).toEqual('11mo') expect(dateDiff(then, base)).toEqual({
value: 11,
unit: 'month',
earlier: then,
later: base,
})
}) })
it(`values >= 360 days return the earlier value`, () => { it(`values >= 360 days return the earlier value`, () => {
const then = subDays(base, 360) const then = subDays(base, 360)
expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString()) expect(dateDiff(then, base)).toEqual({
value: 12,
unit: 'month',
earlier: then,
later: base,
})
}) })
}) })

View File

@ -1,25 +1,16 @@
import {useCallback} from 'react' import {useCallback} from 'react'
import {msg, plural} from '@lingui/macro' import {I18n} from '@lingui/core'
import {I18nContext, useLingui} from '@lingui/react' import {defineMessage, msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns' import {differenceInSeconds} from 'date-fns'
export type TimeAgoOptions = { export type DateDiffFormat = 'long' | 'short'
lingui: I18nContext['_']
format?: 'long' | 'short'
}
export function useGetTimeAgo() { type DateDiff = {
const {_} = useLingui() value: number
return useCallback( unit: 'now' | 'second' | 'minute' | 'hour' | 'day' | 'month'
( earlier: Date
earlier: number | string | Date, later: Date
later: number | string | Date,
options?: Omit<TimeAgoOptions, 'lingui'>,
) => {
return dateDiff(earlier, later, {lingui: _, format: options?.format})
},
[_],
)
} }
const NOW = 5 const NOW = 5
@ -28,59 +19,160 @@ const HOUR = MINUTE * 60
const DAY = HOUR * 24 const DAY = HOUR * 24
const MONTH_30 = DAY * 30 const MONTH_30 = DAY * 30
export function useGetTimeAgo() {
const {i18n} = useLingui()
return useCallback(
(
earlier: number | string | Date,
later: number | string | Date,
options?: {format: DateDiffFormat},
) => {
const diff = dateDiff(earlier, later)
return formatDateDiff({diff, i18n, format: options?.format})
},
[i18n],
)
}
/** /**
* Returns the difference between `earlier` and `later` dates, formatted as a * Returns the difference between `earlier` and `later` dates, based on
* natural language string. * opinionated rules.
*
* - All month are considered exactly 30 days.
* - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
* - All values round down
*/
export function dateDiff(
earlier: number | string | Date,
later: number | string | Date,
): DateDiff {
let diff = {
value: 0,
unit: 'now' as DateDiff['unit'],
}
const e = new Date(earlier)
const l = new Date(later)
const diffSeconds = differenceInSeconds(l, e)
if (diffSeconds < NOW) {
diff = {
value: 0,
unit: 'now' as DateDiff['unit'],
}
} else if (diffSeconds < MINUTE) {
diff = {
value: diffSeconds,
unit: 'second' as DateDiff['unit'],
}
} else if (diffSeconds < HOUR) {
const value = Math.floor(diffSeconds / MINUTE)
diff = {
value,
unit: 'minute' as DateDiff['unit'],
}
} else if (diffSeconds < DAY) {
const value = Math.floor(diffSeconds / HOUR)
diff = {
value,
unit: 'hour' as DateDiff['unit'],
}
} else if (diffSeconds < MONTH_30) {
const value = Math.floor(diffSeconds / DAY)
diff = {
value,
unit: 'day' as DateDiff['unit'],
}
} else {
const value = Math.floor(diffSeconds / MONTH_30)
diff = {
value,
unit: 'month' as DateDiff['unit'],
}
}
return {
...diff,
earlier: e,
later: l,
}
}
/**
* Accepts a `DateDiff` and teturns the difference between `earlier` and
* `later` dates, formatted as a natural language string.
* *
* - All month are considered exactly 30 days. * - All month are considered exactly 30 days.
* - Dates assume `earlier` <= `later`, and will otherwise return 'now'. * - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
* - Differences >= 360 days are returned as the "M/D/YYYY" string * - Differences >= 360 days are returned as the "M/D/YYYY" string
* - All values round down * - All values round down
*/ */
export function dateDiff( export function formatDateDiff({
earlier: number | string | Date, diff,
later: number | string | Date, format = 'short',
options: TimeAgoOptions, i18n,
): string { }: {
const _ = options.lingui diff: DateDiff
const format = options?.format || 'short' format?: DateDiffFormat
i18n: I18n
}): string {
const long = format === 'long' const long = format === 'long'
const diffSeconds = differenceInSeconds(new Date(later), new Date(earlier))
if (diffSeconds < NOW) { switch (diff.unit) {
return _(msg`now`) case 'now': {
} else if (diffSeconds < MINUTE) { return i18n._(msg`now`)
return `${diffSeconds}${ }
long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's' case 'second': {
}` return long
} else if (diffSeconds < HOUR) { ? i18n._(plural(diff.value, {one: '# second', other: '# seconds'}))
const diff = Math.floor(diffSeconds / MINUTE) : i18n._(
return `${diff}${ defineMessage({
long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm' message: `${diff.value}s`,
}` comment: `How many seconds have passed, displayed in a narrow form`,
} else if (diffSeconds < DAY) { }),
const diff = Math.floor(diffSeconds / HOUR) )
return `${diff}${ }
long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h' case 'minute': {
}` return long
} else if (diffSeconds < MONTH_30) { ? i18n._(plural(diff.value, {one: '# minute', other: '# minutes'}))
const diff = Math.floor(diffSeconds / DAY) : i18n._(
return `${diff}${ defineMessage({
long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd' message: `${diff.value}m`,
}` comment: `How many minutes have passed, displayed in a narrow form`,
} else { }),
const diff = Math.floor(diffSeconds / MONTH_30) )
if (diff < 12) { }
return `${diff}${ case 'hour': {
long ? ` ${plural(diff, {one: 'month', other: 'months'})}` : 'mo' return long
}` ? i18n._(plural(diff.value, {one: '# hour', other: '# hours'}))
} else { : i18n._(
const str = new Date(earlier).toLocaleDateString() defineMessage({
message: `${diff.value}h`,
if (long) { comment: `How many hours have passed, displayed in a narrow form`,
return _(msg`on ${str}`) }),
)
}
case 'day': {
return long
? i18n._(plural(diff.value, {one: '# day', other: '# days'}))
: i18n._(
defineMessage({
message: `${diff.value}d`,
comment: `How many days have passed, displayed in a narrow form`,
}),
)
}
case 'month': {
if (diff.value < 12) {
return long
? i18n._(plural(diff.value, {one: '# month', other: '# months'}))
: i18n._(
defineMessage({
message: `${diff.value}mo`,
comment: `How many months have passed, displayed in a narrow form`,
}),
)
} }
return str return i18n.date(new Date(diff.earlier))
} }
} }
} }

View File

@ -1,13 +1,12 @@
export function niceDate(date: number | string | Date) { import {I18n} from '@lingui/core'
export function niceDate(i18n: I18n, date: number | string | Date) {
const d = new Date(date) const d = new Date(date)
return `${d.toLocaleDateString('en-us', {
year: 'numeric', return i18n.date(d, {
month: 'short', dateStyle: 'long',
day: 'numeric', timeStyle: 'short',
})} at ${d.toLocaleTimeString(undefined, { })
hour: 'numeric',
minute: '2-digit',
})}`
} }
export function getAge(birthDate: Date): number { export function getAge(birthDate: Date): number {

View File

@ -17,9 +17,9 @@ export function ProfileHeaderMetrics({
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_, i18n} = useLingui()
const following = formatCount(profile.followsCount || 0) const following = formatCount(i18n, profile.followsCount || 0)
const followers = formatCount(profile.followersCount || 0) const followers = formatCount(i18n, profile.followersCount || 0)
const pluralizedFollowers = plural(profile.followersCount || 0, { const pluralizedFollowers = plural(profile.followersCount || 0, {
one: 'follower', one: 'follower',
other: 'followers', other: 'followers',
@ -54,7 +54,7 @@ export function ProfileHeaderMetrics({
</Text> </Text>
</InlineLinkText> </InlineLinkText>
<Text style={[a.font_bold, t.atoms.text, a.text_md]}> <Text style={[a.font_bold, t.atoms.text, a.text_md]}>
{formatCount(profile.postsCount || 0)}{' '} {formatCount(i18n, profile.postsCount || 0)}{' '}
<Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}> <Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}>
{plural(profile.postsCount || 0, {one: 'post', other: 'posts'})} {plural(profile.postsCount || 0, {one: 'post', other: 'posts'})}
</Text> </Text>

View File

@ -113,7 +113,7 @@ function LandingScreenLoaded({
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
}) { }) {
const {creator, listItemsSample, feeds} = starterPack const {creator, listItemsSample, feeds} = starterPack
const {_} = useLingui() const {_, i18n} = useLingui()
const t = useTheme() const t = useTheme()
const activeStarterPack = useActiveStarterPack() const activeStarterPack = useActiveStarterPack()
const setActiveStarterPack = useSetActiveStarterPack() const setActiveStarterPack = useSetActiveStarterPack()
@ -225,7 +225,9 @@ function LandingScreenLoaded({
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
]} ]}
numberOfLines={1}> numberOfLines={1}>
<Trans>{formatCount(JOINED_THIS_WEEK)} joined this week</Trans> <Trans>
{formatCount(i18n, JOINED_THIS_WEEK)} joined this week
</Trans>
</Text> </Text>
</View> </View>
</View> </View>

View File

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

View File

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

View File

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

View File

@ -1,4 +1,6 @@
import React from 'react' import React from 'react'
import {I18n} from '@lingui/core'
import {useLingui} from '@lingui/react'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useTickEveryMinute} from '#/state/shell' import {useTickEveryMinute} from '#/state/shell'
@ -10,19 +12,21 @@ export function TimeElapsed({
}: { }: {
timestamp: string timestamp: string
children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element
timeToString?: (timeElapsed: string) => string timeToString?: (i18n: I18n, timeElapsed: string) => string
}) { }) {
const {i18n} = useLingui()
const ago = useGetTimeAgo() const ago = useGetTimeAgo()
const format = timeToString ?? ago
const tick = useTickEveryMinute() const tick = useTickEveryMinute()
const [timeElapsed, setTimeAgo] = React.useState(() => const [timeElapsed, setTimeAgo] = React.useState(() =>
format(timestamp, tick), timeToString ? timeToString(i18n, timestamp) : ago(timestamp, tick),
) )
const [prevTick, setPrevTick] = React.useState(tick) const [prevTick, setPrevTick] = React.useState(tick)
if (prevTick !== tick) { if (prevTick !== tick) {
setPrevTick(tick) setPrevTick(tick)
setTimeAgo(format(timestamp, tick)) setTimeAgo(
timeToString ? timeToString(i18n, timestamp) : ago(timestamp, tick),
)
} }
return children({timeElapsed}) 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 {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
import DatePicker from 'react-native-date-picker'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {isIOS, isAndroid} from 'platform/detection' import {useLingui} from '@lingui/react'
import {Button, ButtonType} from './Button'
import {Text} from '../text/Text' import {usePalette} from 'lib/hooks/usePalette'
import {TypographyVariant} from 'lib/ThemeContext' import {TypographyVariant} from 'lib/ThemeContext'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette' import {isAndroid, isIOS} from 'platform/detection'
import {getLocales} from 'expo-localization' import {Text} from '../text/Text'
import DatePicker from 'react-native-date-picker' import {Button, ButtonType} from './Button'
const LOCALE = getLocales()[0]
interface Props { interface Props {
testID?: string testID?: string
@ -30,16 +29,11 @@ interface Props {
} }
export function DateInput(props: Props) { export function DateInput(props: Props) {
const {i18n} = useLingui()
const [show, setShow] = useState(false) const [show, setShow] = useState(false)
const theme = useTheme() const theme = useTheme()
const pal = usePalette('default') const pal = usePalette('default')
const formatter = React.useMemo(() => {
return new Intl.DateTimeFormat(LOCALE.languageTag, {
timeZone: props.handleAsUTC ? 'UTC' : undefined,
})
}, [props.handleAsUTC])
const onChangeInternal = useCallback( const onChangeInternal = useCallback(
(date: Date) => { (date: Date) => {
setShow(false) setShow(false)
@ -74,7 +68,9 @@ export function DateInput(props: Props) {
<Text <Text
type={props.buttonLabelType} type={props.buttonLabelType}
style={[pal.text, props.buttonLabelStyle]}> style={[pal.text, props.buttonLabelStyle]}>
{formatter.format(props.value)} {i18n.date(props.value, {
timeZone: props.handleAsUTC ? 'UTC' : undefined,
})}
</Text> </Text>
</View> </View>
</Button> </Button>

View File

@ -1,19 +1,12 @@
export const formatCount = (num: number) => import type {I18n} from '@lingui/core'
Intl.NumberFormat('en-US', {
export const formatCount = (i18n: I18n, num: number) => {
return i18n.number(num, {
notation: 'compact', notation: 'compact',
maximumFractionDigits: 1, maximumFractionDigits: 1,
// `1,953` shouldn't be rounded up to 2k, it should be truncated. // `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 // @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 // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingmode
roundingMode: 'trunc', 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 threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_, i18n} = useLingui()
const {openComposer} = useComposerControls() const {openComposer} = useComposerControls()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext) const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext)
@ -247,7 +247,7 @@ let PostCtrls = ({
big ? a.text_md : {fontSize: 15}, big ? a.text_md : {fontSize: 15},
a.user_select_none, a.user_select_none,
]}> ]}>
{formatCount(post.replyCount)} {formatCount(i18n, post.replyCount)}
</Text> </Text>
) : undefined} ) : undefined}
</Pressable> </Pressable>
@ -300,7 +300,7 @@ let PostCtrls = ({
: defaultCtrlColor, : defaultCtrlColor,
], ],
]}> ]}>
{formatCount(post.likeCount)} {formatCount(i18n, post.likeCount)}
</Text> </Text>
) : undefined} ) : undefined}
</Pressable> </Pressable>

View File

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

View File

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

View File

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

View File

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