diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index 92800098..3890790d 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -377,7 +377,7 @@ function Inner({ hide: () => void }) { const t = useTheme() - const {_} = useLingui() + const {_, i18n} = useLingui() const {currentAccount} = useSession() const moderation = React.useMemo( () => moderateProfile(profile, moderationOpts), @@ -393,8 +393,8 @@ function Inner({ profile.viewer?.blocking || profile.viewer?.blockedBy || profile.viewer?.blockingByList - const following = formatCount(profile.followsCount || 0) - const followers = formatCount(profile.followersCount || 0) + const following = formatCount(i18n, profile.followsCount || 0) + const followers = formatCount(i18n, profile.followersCount || 0) const pluralizedFollowers = plural(profile.followersCount || 0, { one: 'follower', other: 'followers', diff --git a/src/components/dialogs/Embed.tsx b/src/components/dialogs/Embed.tsx index 7d858cae..f43c3c6f 100644 --- a/src/components/dialogs/Embed.tsx +++ b/src/components/dialogs/Embed.tsx @@ -43,7 +43,7 @@ function EmbedDialogInner({ timestamp, }: Omit) { const t = useTheme() - const {_} = useLingui() + const {_, i18n} = useLingui() const ref = useRef(null) const [copied, setCopied] = useState(false) @@ -86,9 +86,9 @@ function EmbedDialogInner({ )} (@${escapeHtml( postAuthor.handle, )}) ${escapeHtml( - niceDate(timestamp), + niceDate(i18n, timestamp), )}` - }, [postUri, postCid, record, timestamp, postAuthor]) + }, [i18n, postUri, postCid, record, timestamp, postAuthor]) return ( diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 573c24f7..c5c472cf 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -11,6 +11,7 @@ import { ChatBskyConvoDefs, RichText as RichTextAPI, } from '@atproto/api' +import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -153,14 +154,14 @@ let MessageItemMetadata = ({ ) const relativeTimestamp = useCallback( - (timestamp: string) => { + (i18n: I18n, timestamp: string) => { const date = new Date(timestamp) const now = new Date() - const time = new Intl.DateTimeFormat(undefined, { + const time = i18n.date(date, { hour: 'numeric', minute: 'numeric', - }).format(date) + }) const diff = now.getTime() - date.getTime() @@ -182,13 +183,13 @@ let MessageItemMetadata = ({ return _(msg`Yesterday, ${time}`) } - return new Intl.DateTimeFormat(undefined, { + return i18n.date(date, { hour: 'numeric', minute: 'numeric', day: 'numeric', month: 'numeric', year: 'numeric', - }).format(date) + }) }, [_], ) diff --git a/src/components/forms/DateField/index.shared.tsx b/src/components/forms/DateField/index.shared.tsx index 1f54bdc8..814bbed7 100644 --- a/src/components/forms/DateField/index.shared.tsx +++ b/src/components/forms/DateField/index.shared.tsx @@ -1,12 +1,12 @@ import React from 'react' import {Pressable, View} from 'react-native' +import {useLingui} from '@lingui/react' import {android, atoms as a, useTheme, web} from '#/alf' import * as TextField from '#/components/forms/TextField' import {useInteractionState} from '#/components/hooks/useInteractionState' import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' 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 // iOS: open a dialog with an inline date picker @@ -25,6 +25,7 @@ export function DateFieldButton({ isInvalid?: boolean accessibilityHint?: string }) { + const {i18n} = useLingui() const t = useTheme() const { @@ -91,7 +92,7 @@ export function DateFieldButton({ t.atoms.text, {lineHeight: a.text_md.fontSize * 1.1875}, ]}> - {localizeDate(value)} + {i18n.date(value, {timeZone: 'UTC'})} diff --git a/src/components/forms/DateField/utils.ts b/src/components/forms/DateField/utils.ts index c787272f..04bb482c 100644 --- a/src/components/forms/DateField/utils.ts +++ b/src/components/forms/DateField/utils.ts @@ -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 export function toSimpleDateString(date: Date | string): string { const _date = typeof date === 'string' ? new Date(date) : date 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) -} diff --git a/src/lib/hooks/__tests__/useTimeAgo.test.ts b/src/lib/hooks/__tests__/useTimeAgo.test.ts index e74f9c62..68eb6e43 100644 --- a/src/lib/hooks/__tests__/useTimeAgo.test.ts +++ b/src/lib/hooks/__tests__/useTimeAgo.test.ts @@ -1,102 +1,213 @@ import {describe, expect, it} from '@jest/globals' -import {MessageDescriptor} from '@lingui/core' import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns' import {dateDiff} from '../useTimeAgo' -const lingui: any = (obj: MessageDescriptor) => obj.message - const base = new Date('2024-06-17T00:00:00Z') describe('dateDiff', () => { 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`, () => { - 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`, () => { - 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`, () => { - 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`, () => { - 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`, () => { 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`, () => { 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`, () => { 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`, () => { 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`, () => { 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`, () => { 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`, () => { 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`, () => { 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`, () => { 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`, () => { 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`, () => { 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`, () => { 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`, () => { 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`, () => { 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`, () => { 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`, () => { 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`, () => { 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, + }) }) }) diff --git a/src/lib/hooks/useTimeAgo.ts b/src/lib/hooks/useTimeAgo.ts index efcb4754..3a8bf49b 100644 --- a/src/lib/hooks/useTimeAgo.ts +++ b/src/lib/hooks/useTimeAgo.ts @@ -1,25 +1,16 @@ import {useCallback} from 'react' -import {msg, plural} from '@lingui/macro' -import {I18nContext, useLingui} from '@lingui/react' +import {I18n} from '@lingui/core' +import {defineMessage, msg, plural} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {differenceInSeconds} from 'date-fns' -export type TimeAgoOptions = { - lingui: I18nContext['_'] - format?: 'long' | 'short' -} +export type DateDiffFormat = 'long' | 'short' -export function useGetTimeAgo() { - const {_} = useLingui() - return useCallback( - ( - earlier: number | string | Date, - later: number | string | Date, - options?: Omit, - ) => { - return dateDiff(earlier, later, {lingui: _, format: options?.format}) - }, - [_], - ) +type DateDiff = { + value: number + unit: 'now' | 'second' | 'minute' | 'hour' | 'day' | 'month' + earlier: Date + later: Date } const NOW = 5 @@ -28,59 +19,160 @@ const HOUR = MINUTE * 60 const DAY = HOUR * 24 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 - * natural language string. + * Returns the difference between `earlier` and `later` dates, based on + * 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. * - Dates assume `earlier` <= `later`, and will otherwise return 'now'. * - Differences >= 360 days are returned as the "M/D/YYYY" string * - All values round down */ -export function dateDiff( - earlier: number | string | Date, - later: number | string | Date, - options: TimeAgoOptions, -): string { - const _ = options.lingui - const format = options?.format || 'short' +export function formatDateDiff({ + diff, + format = 'short', + i18n, +}: { + diff: DateDiff + format?: DateDiffFormat + i18n: I18n +}): string { const long = format === 'long' - const diffSeconds = differenceInSeconds(new Date(later), new Date(earlier)) - if (diffSeconds < NOW) { - return _(msg`now`) - } else if (diffSeconds < MINUTE) { - return `${diffSeconds}${ - long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's' - }` - } else if (diffSeconds < HOUR) { - const diff = Math.floor(diffSeconds / MINUTE) - return `${diff}${ - long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm' - }` - } else if (diffSeconds < DAY) { - const diff = Math.floor(diffSeconds / HOUR) - return `${diff}${ - long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h' - }` - } else if (diffSeconds < MONTH_30) { - const diff = Math.floor(diffSeconds / DAY) - return `${diff}${ - long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd' - }` - } else { - const diff = Math.floor(diffSeconds / MONTH_30) - if (diff < 12) { - return `${diff}${ - long ? ` ${plural(diff, {one: 'month', other: 'months'})}` : 'mo' - }` - } else { - const str = new Date(earlier).toLocaleDateString() - - if (long) { - return _(msg`on ${str}`) + switch (diff.unit) { + case 'now': { + return i18n._(msg`now`) + } + case 'second': { + return long + ? i18n._(plural(diff.value, {one: '# second', other: '# seconds'})) + : i18n._( + defineMessage({ + message: `${diff.value}s`, + comment: `How many seconds have passed, displayed in a narrow form`, + }), + ) + } + case 'minute': { + return long + ? i18n._(plural(diff.value, {one: '# minute', other: '# minutes'})) + : i18n._( + defineMessage({ + message: `${diff.value}m`, + comment: `How many minutes have passed, displayed in a narrow form`, + }), + ) + } + case 'hour': { + return long + ? i18n._(plural(diff.value, {one: '# hour', other: '# hours'})) + : i18n._( + defineMessage({ + message: `${diff.value}h`, + comment: `How many hours have passed, displayed in a narrow form`, + }), + ) + } + 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)) } } } diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts index bfefea9b..e505b789 100644 --- a/src/lib/strings/time.ts +++ b/src/lib/strings/time.ts @@ -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) - return `${d.toLocaleDateString('en-us', { - year: 'numeric', - month: 'short', - day: 'numeric', - })} at ${d.toLocaleTimeString(undefined, { - hour: 'numeric', - minute: '2-digit', - })}` + + return i18n.date(d, { + dateStyle: 'long', + timeStyle: 'short', + }) } export function getAge(birthDate: Date): number { diff --git a/src/screens/Profile/Header/Metrics.tsx b/src/screens/Profile/Header/Metrics.tsx index e3537f44..756eb1f8 100644 --- a/src/screens/Profile/Header/Metrics.tsx +++ b/src/screens/Profile/Header/Metrics.tsx @@ -17,9 +17,9 @@ export function ProfileHeaderMetrics({ profile: Shadow }) { const t = useTheme() - const {_} = useLingui() - const following = formatCount(profile.followsCount || 0) - const followers = formatCount(profile.followersCount || 0) + const {_, i18n} = useLingui() + const following = formatCount(i18n, profile.followsCount || 0) + const followers = formatCount(i18n, profile.followersCount || 0) const pluralizedFollowers = plural(profile.followersCount || 0, { one: 'follower', other: 'followers', @@ -54,7 +54,7 @@ export function ProfileHeaderMetrics({ - {formatCount(profile.postsCount || 0)}{' '} + {formatCount(i18n, profile.postsCount || 0)}{' '} {plural(profile.postsCount || 0, {one: 'post', other: 'posts'})} diff --git a/src/screens/StarterPack/StarterPackLandingScreen.tsx b/src/screens/StarterPack/StarterPackLandingScreen.tsx index 7dda45f9..5f1d5e06 100644 --- a/src/screens/StarterPack/StarterPackLandingScreen.tsx +++ b/src/screens/StarterPack/StarterPackLandingScreen.tsx @@ -113,7 +113,7 @@ function LandingScreenLoaded({ moderationOpts: ModerationOpts }) { const {creator, listItemsSample, feeds} = starterPack - const {_} = useLingui() + const {_, i18n} = useLingui() const t = useTheme() const activeStarterPack = useActiveStarterPack() const setActiveStarterPack = useSetActiveStarterPack() @@ -225,7 +225,9 @@ function LandingScreenLoaded({ t.atoms.text_contrast_medium, ]} numberOfLines={1}> - {formatCount(JOINED_THIS_WEEK)} joined this week + + {formatCount(i18n, JOINED_THIS_WEEK)} joined this week + diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index b591b4b7..3e8f8d86 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -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(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 `) + diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index a3cfebba..3b5ddb1d 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -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}> - {formatCount(post.repostCount)} + {formatCount(i18n, post.repostCount)} {' '} - {formatCount(post.quoteCount)} + {formatCount(i18n, post.quoteCount)} {' '} - {formatCount(post.likeCount)} + {formatCount(i18n, post.likeCount)} {' '} @@ -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, ]}> - {niceDate(post.indexedAt)} + + {niceDate(i18n, post.indexedAt)} + {isRootPost && ( )} diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index b1567c2c..3bd350bf 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -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} diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx index a4958518..70fed222 100644 --- a/src/view/com/util/TimeElapsed.tsx +++ b/src/view/com/util/TimeElapsed.tsx @@ -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}) diff --git a/src/view/com/util/forms/DateInput.tsx b/src/view/com/util/forms/DateInput.tsx index 0104562a..bfbb2ff5 100644 --- a/src/view/com/util/forms/DateInput.tsx +++ b/src/view/com/util/forms/DateInput.tsx @@ -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) { - {formatter.format(props.value)} + {i18n.date(props.value, { + timeZone: props.handleAsUTC ? 'UTC' : undefined, + })} diff --git a/src/view/com/util/numeric/format.ts b/src/view/com/util/numeric/format.ts index 71d8d73e..cca9fc7e 100644 --- a/src/view/com/util/numeric/format.ts +++ b/src/view/com/util/numeric/format.ts @@ -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) + }) } diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index a0cef869..f577e168 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -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)} ) : undefined} @@ -300,7 +300,7 @@ let PostCtrls = ({ : defaultCtrlColor, ], ]}> - {formatCount(post.likeCount)} + {formatCount(i18n, post.likeCount)} ) : undefined} diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index 5994b7ef..d924adbe 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -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)} ) : undefined} diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx index 9a8776b9..111b41dd 100644 --- a/src/view/com/util/post-ctrls/RepostButton.web.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx @@ -128,6 +128,7 @@ const RepostInner = ({ repostCount?: number big?: boolean }) => { + const {i18n} = useLingui() return ( @@ -140,7 +141,7 @@ const RepostInner = ({ isReposted && [a.font_bold], a.user_select_none, ]}> - {formatCount(repostCount)} + {formatCount(i18n, repostCount)} ) : undefined} diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index 5bf9e8a1..6f1cd1bb 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -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 ( 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))} + })} {privileged && ( diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 0e852edd..facead2c 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -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 = ({ - {formatCountShortOnly(profile?.followersCount ?? 0)} + {formatCount(i18n, profile?.followersCount ?? 0)} {' '} - {formatCountShortOnly(profile?.followsCount ?? 0)} + {formatCount(i18n, profile?.followsCount ?? 0)} {' '}