Bluesky app fork with some witchin' additions 💫

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>

authored by

Eric Bailey
Mary
and committed by
GitHub
8651f31e d5a76183

+364 -175
+3 -3
src/components/ProfileHoverCard/index.web.tsx
··· 377 377 hide: () => void 378 378 }) { 379 379 const t = useTheme() 380 - const {_} = useLingui() 380 + const {_, i18n} = useLingui() 381 381 const {currentAccount} = useSession() 382 382 const moderation = React.useMemo( 383 383 () => moderateProfile(profile, moderationOpts), ··· 393 393 profile.viewer?.blocking || 394 394 profile.viewer?.blockedBy || 395 395 profile.viewer?.blockingByList 396 - const following = formatCount(profile.followsCount || 0) 397 - const followers = formatCount(profile.followersCount || 0) 396 + const following = formatCount(i18n, profile.followsCount || 0) 397 + const followers = formatCount(i18n, profile.followersCount || 0) 398 398 const pluralizedFollowers = plural(profile.followersCount || 0, { 399 399 one: 'follower', 400 400 other: 'followers',
+3 -3
src/components/dialogs/Embed.tsx
··· 43 43 timestamp, 44 44 }: Omit<EmbedDialogProps, 'control'>) { 45 45 const t = useTheme() 46 - const {_} = useLingui() 46 + const {_, i18n} = useLingui() 47 47 const ref = useRef<TextInput>(null) 48 48 const [copied, setCopied] = useState(false) 49 49 ··· 86 86 )} (<a href="${escapeHtml(profileHref)}">@${escapeHtml( 87 87 postAuthor.handle, 88 88 )}</a>) <a href="${escapeHtml(href)}">${escapeHtml( 89 - niceDate(timestamp), 89 + niceDate(i18n, timestamp), 90 90 )}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>` 91 - }, [postUri, postCid, record, timestamp, postAuthor]) 91 + }, [i18n, postUri, postCid, record, timestamp, postAuthor]) 92 92 93 93 return ( 94 94 <Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}>
+6 -5
src/components/dms/MessageItem.tsx
··· 11 11 ChatBskyConvoDefs, 12 12 RichText as RichTextAPI, 13 13 } from '@atproto/api' 14 + import {I18n} from '@lingui/core' 14 15 import {msg} from '@lingui/macro' 15 16 import {useLingui} from '@lingui/react' 16 17 ··· 153 154 ) 154 155 155 156 const relativeTimestamp = useCallback( 156 - (timestamp: string) => { 157 + (i18n: I18n, timestamp: string) => { 157 158 const date = new Date(timestamp) 158 159 const now = new Date() 159 160 160 - const time = new Intl.DateTimeFormat(undefined, { 161 + const time = i18n.date(date, { 161 162 hour: 'numeric', 162 163 minute: 'numeric', 163 - }).format(date) 164 + }) 164 165 165 166 const diff = now.getTime() - date.getTime() 166 167 ··· 182 183 return _(msg`Yesterday, ${time}`) 183 184 } 184 185 185 - return new Intl.DateTimeFormat(undefined, { 186 + return i18n.date(date, { 186 187 hour: 'numeric', 187 188 minute: 'numeric', 188 189 day: 'numeric', 189 190 month: 'numeric', 190 191 year: 'numeric', 191 - }).format(date) 192 + }) 192 193 }, 193 194 [_], 194 195 )
+3 -2
src/components/forms/DateField/index.shared.tsx
··· 1 1 import React from 'react' 2 2 import {Pressable, View} from 'react-native' 3 + import {useLingui} from '@lingui/react' 3 4 4 5 import {android, atoms as a, useTheme, web} from '#/alf' 5 6 import * as TextField from '#/components/forms/TextField' 6 7 import {useInteractionState} from '#/components/hooks/useInteractionState' 7 8 import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' 8 9 import {Text} from '#/components/Typography' 9 - import {localizeDate} from './utils' 10 10 11 11 // looks like a TextField.Input, but is just a button. It'll do something different on each platform on press 12 12 // iOS: open a dialog with an inline date picker ··· 25 25 isInvalid?: boolean 26 26 accessibilityHint?: string 27 27 }) { 28 + const {i18n} = useLingui() 28 29 const t = useTheme() 29 30 30 31 const { ··· 91 92 t.atoms.text, 92 93 {lineHeight: a.text_md.fontSize * 1.1875}, 93 94 ]}> 94 - {localizeDate(value)} 95 + {i18n.date(value, {timeZone: 'UTC'})} 95 96 </Text> 96 97 </Pressable> 97 98 </View>
-11
src/components/forms/DateField/utils.ts
··· 1 - import {getLocales} from 'expo-localization' 2 - 3 - const LOCALE = getLocales()[0] 4 - 5 1 // we need the date in the form yyyy-MM-dd to pass to the input 6 2 export function toSimpleDateString(date: Date | string): string { 7 3 const _date = typeof date === 'string' ? new Date(date) : date 8 4 return _date.toISOString().split('T')[0] 9 5 } 10 - 11 - export function localizeDate(date: Date | string): string { 12 - const _date = typeof date === 'string' ? new Date(date) : date 13 - return new Intl.DateTimeFormat(LOCALE.languageTag, { 14 - timeZone: 'UTC', 15 - }).format(_date) 16 - }
+136 -25
src/lib/hooks/__tests__/useTimeAgo.test.ts
··· 1 1 import {describe, expect, it} from '@jest/globals' 2 - import {MessageDescriptor} from '@lingui/core' 3 2 import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns' 4 3 5 4 import {dateDiff} from '../useTimeAgo' 6 - 7 - const lingui: any = (obj: MessageDescriptor) => obj.message 8 5 9 6 const base = new Date('2024-06-17T00:00:00Z') 10 7 11 8 describe('dateDiff', () => { 12 9 it(`works with numbers`, () => { 13 - expect(dateDiff(subDays(base, 3), Number(base), {lingui})).toEqual('3d') 10 + const earlier = subDays(base, 3) 11 + expect(dateDiff(earlier, Number(base))).toEqual({ 12 + value: 3, 13 + unit: 'day', 14 + earlier, 15 + later: base, 16 + }) 14 17 }) 15 18 it(`works with strings`, () => { 16 - expect(dateDiff(subDays(base, 3), base.toString(), {lingui})).toEqual('3d') 19 + const earlier = subDays(base, 3) 20 + expect(dateDiff(earlier, base.toString())).toEqual({ 21 + value: 3, 22 + unit: 'day', 23 + earlier, 24 + later: base, 25 + }) 17 26 }) 18 27 it(`works with dates`, () => { 19 - expect(dateDiff(subDays(base, 3), base, {lingui})).toEqual('3d') 28 + const earlier = subDays(base, 3) 29 + expect(dateDiff(earlier, base)).toEqual({ 30 + value: 3, 31 + unit: 'day', 32 + earlier, 33 + later: base, 34 + }) 20 35 }) 21 36 22 37 it(`equal values return now`, () => { 23 - expect(dateDiff(base, base, {lingui})).toEqual('now') 38 + expect(dateDiff(base, base)).toEqual({ 39 + value: 0, 40 + unit: 'now', 41 + earlier: base, 42 + later: base, 43 + }) 24 44 }) 25 45 it(`future dates return now`, () => { 26 - expect(dateDiff(addDays(base, 3), base, {lingui})).toEqual('now') 46 + const earlier = addDays(base, 3) 47 + expect(dateDiff(earlier, base)).toEqual({ 48 + value: 0, 49 + unit: 'now', 50 + earlier, 51 + later: base, 52 + }) 27 53 }) 28 54 29 55 it(`values < 5 seconds ago return now`, () => { 30 56 const then = subSeconds(base, 4) 31 - expect(dateDiff(then, base, {lingui})).toEqual('now') 57 + expect(dateDiff(then, base)).toEqual({ 58 + value: 0, 59 + unit: 'now', 60 + earlier: then, 61 + later: base, 62 + }) 32 63 }) 33 64 it(`values >= 5 seconds ago return seconds`, () => { 34 65 const then = subSeconds(base, 5) 35 - expect(dateDiff(then, base, {lingui})).toEqual('5s') 66 + expect(dateDiff(then, base)).toEqual({ 67 + value: 5, 68 + unit: 'second', 69 + earlier: then, 70 + later: base, 71 + }) 36 72 }) 37 73 38 74 it(`values < 1 min return seconds`, () => { 39 75 const then = subSeconds(base, 59) 40 - expect(dateDiff(then, base, {lingui})).toEqual('59s') 76 + expect(dateDiff(then, base)).toEqual({ 77 + value: 59, 78 + unit: 'second', 79 + earlier: then, 80 + later: base, 81 + }) 41 82 }) 42 83 it(`values >= 1 min return minutes`, () => { 43 84 const then = subSeconds(base, 60) 44 - expect(dateDiff(then, base, {lingui})).toEqual('1m') 85 + expect(dateDiff(then, base)).toEqual({ 86 + value: 1, 87 + unit: 'minute', 88 + earlier: then, 89 + later: base, 90 + }) 45 91 }) 46 92 it(`minutes round down`, () => { 47 93 const then = subSeconds(base, 119) 48 - expect(dateDiff(then, base, {lingui})).toEqual('1m') 94 + expect(dateDiff(then, base)).toEqual({ 95 + value: 1, 96 + unit: 'minute', 97 + earlier: then, 98 + later: base, 99 + }) 49 100 }) 50 101 51 102 it(`values < 1 hour return minutes`, () => { 52 103 const then = subMinutes(base, 59) 53 - expect(dateDiff(then, base, {lingui})).toEqual('59m') 104 + expect(dateDiff(then, base)).toEqual({ 105 + value: 59, 106 + unit: 'minute', 107 + earlier: then, 108 + later: base, 109 + }) 54 110 }) 55 111 it(`values >= 1 hour return hours`, () => { 56 112 const then = subMinutes(base, 60) 57 - expect(dateDiff(then, base, {lingui})).toEqual('1h') 113 + expect(dateDiff(then, base)).toEqual({ 114 + value: 1, 115 + unit: 'hour', 116 + earlier: then, 117 + later: base, 118 + }) 58 119 }) 59 120 it(`hours round down`, () => { 60 121 const then = subMinutes(base, 119) 61 - expect(dateDiff(then, base, {lingui})).toEqual('1h') 122 + expect(dateDiff(then, base)).toEqual({ 123 + value: 1, 124 + unit: 'hour', 125 + earlier: then, 126 + later: base, 127 + }) 62 128 }) 63 129 64 130 it(`values < 1 day return hours`, () => { 65 131 const then = subHours(base, 23) 66 - expect(dateDiff(then, base, {lingui})).toEqual('23h') 132 + expect(dateDiff(then, base)).toEqual({ 133 + value: 23, 134 + unit: 'hour', 135 + earlier: then, 136 + later: base, 137 + }) 67 138 }) 68 139 it(`values >= 1 day return days`, () => { 69 140 const then = subHours(base, 24) 70 - expect(dateDiff(then, base, {lingui})).toEqual('1d') 141 + expect(dateDiff(then, base)).toEqual({ 142 + value: 1, 143 + unit: 'day', 144 + earlier: then, 145 + later: base, 146 + }) 71 147 }) 72 148 it(`days round down`, () => { 73 149 const then = subHours(base, 47) 74 - expect(dateDiff(then, base, {lingui})).toEqual('1d') 150 + expect(dateDiff(then, base)).toEqual({ 151 + value: 1, 152 + unit: 'day', 153 + earlier: then, 154 + later: base, 155 + }) 75 156 }) 76 157 77 158 it(`values < 30 days return days`, () => { 78 159 const then = subDays(base, 29) 79 - expect(dateDiff(then, base, {lingui})).toEqual('29d') 160 + expect(dateDiff(then, base)).toEqual({ 161 + value: 29, 162 + unit: 'day', 163 + earlier: then, 164 + later: base, 165 + }) 80 166 }) 81 167 it(`values >= 30 days return months`, () => { 82 168 const then = subDays(base, 30) 83 - expect(dateDiff(then, base, {lingui})).toEqual('1mo') 169 + expect(dateDiff(then, base)).toEqual({ 170 + value: 1, 171 + unit: 'month', 172 + earlier: then, 173 + later: base, 174 + }) 84 175 }) 85 176 it(`months round down`, () => { 86 177 const then = subDays(base, 59) 87 - expect(dateDiff(then, base, {lingui})).toEqual('1mo') 178 + expect(dateDiff(then, base)).toEqual({ 179 + value: 1, 180 + unit: 'month', 181 + earlier: then, 182 + later: base, 183 + }) 88 184 }) 89 185 it(`values are rounded by increments of 30`, () => { 90 186 const then = subDays(base, 61) 91 - expect(dateDiff(then, base, {lingui})).toEqual('2mo') 187 + expect(dateDiff(then, base)).toEqual({ 188 + value: 2, 189 + unit: 'month', 190 + earlier: then, 191 + later: base, 192 + }) 92 193 }) 93 194 94 195 it(`values < 360 days return months`, () => { 95 196 const then = subDays(base, 359) 96 - expect(dateDiff(then, base, {lingui})).toEqual('11mo') 197 + expect(dateDiff(then, base)).toEqual({ 198 + value: 11, 199 + unit: 'month', 200 + earlier: then, 201 + later: base, 202 + }) 97 203 }) 98 204 it(`values >= 360 days return the earlier value`, () => { 99 205 const then = subDays(base, 360) 100 - expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString()) 206 + expect(dateDiff(then, base)).toEqual({ 207 + value: 12, 208 + unit: 'month', 209 + earlier: then, 210 + later: base, 211 + }) 101 212 }) 102 213 })
+142 -50
src/lib/hooks/useTimeAgo.ts
··· 1 1 import {useCallback} from 'react' 2 - import {msg, plural} from '@lingui/macro' 3 - import {I18nContext, useLingui} from '@lingui/react' 2 + import {I18n} from '@lingui/core' 3 + import {defineMessage, msg, plural} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 4 5 import {differenceInSeconds} from 'date-fns' 5 6 6 - export type TimeAgoOptions = { 7 - lingui: I18nContext['_'] 8 - format?: 'long' | 'short' 7 + export type DateDiffFormat = 'long' | 'short' 8 + 9 + type DateDiff = { 10 + value: number 11 + unit: 'now' | 'second' | 'minute' | 'hour' | 'day' | 'month' 12 + earlier: Date 13 + later: Date 9 14 } 10 15 16 + const NOW = 5 17 + const MINUTE = 60 18 + const HOUR = MINUTE * 60 19 + const DAY = HOUR * 24 20 + const MONTH_30 = DAY * 30 21 + 11 22 export function useGetTimeAgo() { 12 - const {_} = useLingui() 23 + const {i18n} = useLingui() 13 24 return useCallback( 14 25 ( 15 26 earlier: number | string | Date, 16 27 later: number | string | Date, 17 - options?: Omit<TimeAgoOptions, 'lingui'>, 28 + options?: {format: DateDiffFormat}, 18 29 ) => { 19 - return dateDiff(earlier, later, {lingui: _, format: options?.format}) 30 + const diff = dateDiff(earlier, later) 31 + return formatDateDiff({diff, i18n, format: options?.format}) 20 32 }, 21 - [_], 33 + [i18n], 22 34 ) 23 35 } 24 - 25 - const NOW = 5 26 - const MINUTE = 60 27 - const HOUR = MINUTE * 60 28 - const DAY = HOUR * 24 29 - const MONTH_30 = DAY * 30 30 36 31 37 /** 32 - * Returns the difference between `earlier` and `later` dates, formatted as a 33 - * natural language string. 38 + * Returns the difference between `earlier` and `later` dates, based on 39 + * opinionated rules. 34 40 * 35 41 * - All month are considered exactly 30 days. 36 42 * - Dates assume `earlier` <= `later`, and will otherwise return 'now'. 37 - * - Differences >= 360 days are returned as the "M/D/YYYY" string 38 43 * - All values round down 39 44 */ 40 45 export function dateDiff( 41 46 earlier: number | string | Date, 42 47 later: number | string | Date, 43 - options: TimeAgoOptions, 44 - ): string { 45 - const _ = options.lingui 46 - const format = options?.format || 'short' 47 - const long = format === 'long' 48 - const diffSeconds = differenceInSeconds(new Date(later), new Date(earlier)) 48 + ): DateDiff { 49 + let diff = { 50 + value: 0, 51 + unit: 'now' as DateDiff['unit'], 52 + } 53 + const e = new Date(earlier) 54 + const l = new Date(later) 55 + const diffSeconds = differenceInSeconds(l, e) 49 56 50 57 if (diffSeconds < NOW) { 51 - return _(msg`now`) 58 + diff = { 59 + value: 0, 60 + unit: 'now' as DateDiff['unit'], 61 + } 52 62 } else if (diffSeconds < MINUTE) { 53 - return `${diffSeconds}${ 54 - long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's' 55 - }` 63 + diff = { 64 + value: diffSeconds, 65 + unit: 'second' as DateDiff['unit'], 66 + } 56 67 } else if (diffSeconds < HOUR) { 57 - const diff = Math.floor(diffSeconds / MINUTE) 58 - return `${diff}${ 59 - long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm' 60 - }` 68 + const value = Math.floor(diffSeconds / MINUTE) 69 + diff = { 70 + value, 71 + unit: 'minute' as DateDiff['unit'], 72 + } 61 73 } else if (diffSeconds < DAY) { 62 - const diff = Math.floor(diffSeconds / HOUR) 63 - return `${diff}${ 64 - long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h' 65 - }` 74 + const value = Math.floor(diffSeconds / HOUR) 75 + diff = { 76 + value, 77 + unit: 'hour' as DateDiff['unit'], 78 + } 66 79 } else if (diffSeconds < MONTH_30) { 67 - const diff = Math.floor(diffSeconds / DAY) 68 - return `${diff}${ 69 - long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd' 70 - }` 80 + const value = Math.floor(diffSeconds / DAY) 81 + diff = { 82 + value, 83 + unit: 'day' as DateDiff['unit'], 84 + } 71 85 } else { 72 - const diff = Math.floor(diffSeconds / MONTH_30) 73 - if (diff < 12) { 74 - return `${diff}${ 75 - long ? ` ${plural(diff, {one: 'month', other: 'months'})}` : 'mo' 76 - }` 77 - } else { 78 - const str = new Date(earlier).toLocaleDateString() 86 + const value = Math.floor(diffSeconds / MONTH_30) 87 + diff = { 88 + value, 89 + unit: 'month' as DateDiff['unit'], 90 + } 91 + } 79 92 80 - if (long) { 81 - return _(msg`on ${str}`) 93 + return { 94 + ...diff, 95 + earlier: e, 96 + later: l, 97 + } 98 + } 99 + 100 + /** 101 + * Accepts a `DateDiff` and teturns the difference between `earlier` and 102 + * `later` dates, formatted as a natural language string. 103 + * 104 + * - All month are considered exactly 30 days. 105 + * - Dates assume `earlier` <= `later`, and will otherwise return 'now'. 106 + * - Differences >= 360 days are returned as the "M/D/YYYY" string 107 + * - All values round down 108 + */ 109 + export function formatDateDiff({ 110 + diff, 111 + format = 'short', 112 + i18n, 113 + }: { 114 + diff: DateDiff 115 + format?: DateDiffFormat 116 + i18n: I18n 117 + }): string { 118 + const long = format === 'long' 119 + 120 + switch (diff.unit) { 121 + case 'now': { 122 + return i18n._(msg`now`) 123 + } 124 + case 'second': { 125 + return long 126 + ? i18n._(plural(diff.value, {one: '# second', other: '# seconds'})) 127 + : i18n._( 128 + defineMessage({ 129 + message: `${diff.value}s`, 130 + comment: `How many seconds have passed, displayed in a narrow form`, 131 + }), 132 + ) 133 + } 134 + case 'minute': { 135 + return long 136 + ? i18n._(plural(diff.value, {one: '# minute', other: '# minutes'})) 137 + : i18n._( 138 + defineMessage({ 139 + message: `${diff.value}m`, 140 + comment: `How many minutes have passed, displayed in a narrow form`, 141 + }), 142 + ) 143 + } 144 + case 'hour': { 145 + return long 146 + ? i18n._(plural(diff.value, {one: '# hour', other: '# hours'})) 147 + : i18n._( 148 + defineMessage({ 149 + message: `${diff.value}h`, 150 + comment: `How many hours have passed, displayed in a narrow form`, 151 + }), 152 + ) 153 + } 154 + case 'day': { 155 + return long 156 + ? i18n._(plural(diff.value, {one: '# day', other: '# days'})) 157 + : i18n._( 158 + defineMessage({ 159 + message: `${diff.value}d`, 160 + comment: `How many days have passed, displayed in a narrow form`, 161 + }), 162 + ) 163 + } 164 + case 'month': { 165 + if (diff.value < 12) { 166 + return long 167 + ? i18n._(plural(diff.value, {one: '# month', other: '# months'})) 168 + : i18n._( 169 + defineMessage({ 170 + message: `${diff.value}mo`, 171 + comment: `How many months have passed, displayed in a narrow form`, 172 + }), 173 + ) 82 174 } 83 - return str 175 + return i18n.date(new Date(diff.earlier)) 84 176 } 85 177 } 86 178 }
+8 -9
src/lib/strings/time.ts
··· 1 - export function niceDate(date: number | string | Date) { 1 + import {I18n} from '@lingui/core' 2 + 3 + export function niceDate(i18n: I18n, date: number | string | Date) { 2 4 const d = new Date(date) 3 - return `${d.toLocaleDateString('en-us', { 4 - year: 'numeric', 5 - month: 'short', 6 - day: 'numeric', 7 - })} at ${d.toLocaleTimeString(undefined, { 8 - hour: 'numeric', 9 - minute: '2-digit', 10 - })}` 5 + 6 + return i18n.date(d, { 7 + dateStyle: 'long', 8 + timeStyle: 'short', 9 + }) 11 10 } 12 11 13 12 export function getAge(birthDate: Date): number {
+4 -4
src/screens/Profile/Header/Metrics.tsx
··· 17 17 profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 18 18 }) { 19 19 const t = useTheme() 20 - const {_} = useLingui() 21 - const following = formatCount(profile.followsCount || 0) 22 - const followers = formatCount(profile.followersCount || 0) 20 + const {_, i18n} = useLingui() 21 + const following = formatCount(i18n, profile.followsCount || 0) 22 + const followers = formatCount(i18n, profile.followersCount || 0) 23 23 const pluralizedFollowers = plural(profile.followersCount || 0, { 24 24 one: 'follower', 25 25 other: 'followers', ··· 54 54 </Text> 55 55 </InlineLinkText> 56 56 <Text style={[a.font_bold, t.atoms.text, a.text_md]}> 57 - {formatCount(profile.postsCount || 0)}{' '} 57 + {formatCount(i18n, profile.postsCount || 0)}{' '} 58 58 <Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}> 59 59 {plural(profile.postsCount || 0, {one: 'post', other: 'posts'})} 60 60 </Text>
+4 -2
src/screens/StarterPack/StarterPackLandingScreen.tsx
··· 113 113 moderationOpts: ModerationOpts 114 114 }) { 115 115 const {creator, listItemsSample, feeds} = starterPack 116 - const {_} = useLingui() 116 + const {_, i18n} = useLingui() 117 117 const t = useTheme() 118 118 const activeStarterPack = useActiveStarterPack() 119 119 const setActiveStarterPack = useSetActiveStarterPack() ··· 225 225 t.atoms.text_contrast_medium, 226 226 ]} 227 227 numberOfLines={1}> 228 - <Trans>{formatCount(JOINED_THIS_WEEK)} joined this week</Trans> 228 + <Trans> 229 + {formatCount(i18n, JOINED_THIS_WEEK)} joined this week 230 + </Trans> 229 231 </Text> 230 232 </View> 231 233 </View>
+3 -3
src/view/com/notifications/FeedItem.tsx
··· 84 84 }): React.ReactNode => { 85 85 const queryClient = useQueryClient() 86 86 const pal = usePalette('default') 87 - const {_} = useLingui() 87 + const {_, i18n} = useLingui() 88 88 const t = useTheme() 89 89 const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false) 90 90 const itemHref = useMemo(() => { ··· 225 225 } 226 226 227 227 const formattedCount = 228 - authors.length > 1 ? formatCount(authors.length - 1) : '' 228 + authors.length > 1 ? formatCount(i18n, authors.length - 1) : '' 229 229 const firstAuthorName = sanitizeDisplayName( 230 230 authors[0].profile.displayName || authors[0].profile.handle, 231 231 ) 232 - const niceTimestamp = niceDate(item.notification.indexedAt) 232 + const niceTimestamp = niceDate(i18n, item.notification.indexedAt) 233 233 const a11yLabelUsers = 234 234 authors.length > 1 235 235 ? _(msg` and `) +
+8 -6
src/view/com/post-thread/PostThreadItem.tsx
··· 181 181 threadgateRecord?: AppBskyFeedThreadgate.Record 182 182 }): React.ReactNode => { 183 183 const pal = usePalette('default') 184 - const {_} = useLingui() 184 + const {_, i18n} = useLingui() 185 185 const langPrefs = useLanguagePrefs() 186 186 const {openComposer} = useComposerControls() 187 187 const [limitLines, setLimitLines] = React.useState( ··· 388 388 type="lg" 389 389 style={pal.textLight}> 390 390 <Text type="xl-bold" style={pal.text}> 391 - {formatCount(post.repostCount)} 391 + {formatCount(i18n, post.repostCount)} 392 392 </Text>{' '} 393 393 <Plural 394 394 value={post.repostCount} ··· 410 410 type="lg" 411 411 style={pal.textLight}> 412 412 <Text type="xl-bold" style={pal.text}> 413 - {formatCount(post.quoteCount)} 413 + {formatCount(i18n, post.quoteCount)} 414 414 </Text>{' '} 415 415 <Plural 416 416 value={post.quoteCount} ··· 430 430 type="lg" 431 431 style={pal.textLight}> 432 432 <Text type="xl-bold" style={pal.text}> 433 - {formatCount(post.likeCount)} 433 + {formatCount(i18n, post.likeCount)} 434 434 </Text>{' '} 435 435 <Plural value={post.likeCount} one="like" other="likes" /> 436 436 </Text> ··· 705 705 translatorUrl: string 706 706 }) { 707 707 const pal = usePalette('default') 708 - const {_} = useLingui() 708 + const {_, i18n} = useLingui() 709 709 const openLink = useOpenLink() 710 710 const isRootPost = !('reply' in post.record) 711 711 ··· 723 723 s.mt2, 724 724 s.mb10, 725 725 ]}> 726 - <Text style={[a.text_sm, pal.textLight]}>{niceDate(post.indexedAt)}</Text> 726 + <Text style={[a.text_sm, pal.textLight]}> 727 + {niceDate(i18n, post.indexedAt)} 728 + </Text> 727 729 {isRootPost && ( 728 730 <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> 729 731 )}
+5 -2
src/view/com/util/PostMeta.tsx
··· 1 1 import React, {memo, useCallback} from 'react' 2 2 import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' 3 3 import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api' 4 + import {useLingui} from '@lingui/react' 4 5 import {useQueryClient} from '@tanstack/react-query' 5 6 6 7 import {precacheProfile} from '#/state/queries/profile' ··· 35 36 } 36 37 37 38 let PostMeta = (opts: PostMetaOpts): React.ReactNode => { 39 + const {i18n} = useLingui() 40 + 38 41 const pal = usePalette('default') 39 42 const displayName = opts.author.displayName || opts.author.handle 40 43 const handle = opts.author.handle ··· 101 104 type="md" 102 105 style={pal.textLight} 103 106 text={timeElapsed} 104 - accessibilityLabel={niceDate(opts.timestamp)} 105 - title={niceDate(opts.timestamp)} 107 + accessibilityLabel={niceDate(i18n, opts.timestamp)} 108 + title={niceDate(i18n, opts.timestamp)} 106 109 accessibilityHint="" 107 110 href={opts.postHref} 108 111 onBeforePress={onBeforePressPost}
+8 -4
src/view/com/util/TimeElapsed.tsx
··· 1 1 import React from 'react' 2 + import {I18n} from '@lingui/core' 3 + import {useLingui} from '@lingui/react' 2 4 3 5 import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' 4 6 import {useTickEveryMinute} from '#/state/shell' ··· 10 12 }: { 11 13 timestamp: string 12 14 children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element 13 - timeToString?: (timeElapsed: string) => string 15 + timeToString?: (i18n: I18n, timeElapsed: string) => string 14 16 }) { 17 + const {i18n} = useLingui() 15 18 const ago = useGetTimeAgo() 16 - const format = timeToString ?? ago 17 19 const tick = useTickEveryMinute() 18 20 const [timeElapsed, setTimeAgo] = React.useState(() => 19 - format(timestamp, tick), 21 + timeToString ? timeToString(i18n, timestamp) : ago(timestamp, tick), 20 22 ) 21 23 22 24 const [prevTick, setPrevTick] = React.useState(tick) 23 25 if (prevTick !== tick) { 24 26 setPrevTick(tick) 25 - setTimeAgo(format(timestamp, tick)) 27 + setTimeAgo( 28 + timeToString ? timeToString(i18n, timestamp) : ago(timestamp, tick), 29 + ) 26 30 } 27 31 28 32 return children({timeElapsed})
+12 -16
src/view/com/util/forms/DateInput.tsx
··· 1 - import React, {useState, useCallback} from 'react' 1 + import React, {useCallback, useState} from 'react' 2 2 import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' 3 + import DatePicker from 'react-native-date-picker' 3 4 import { 4 5 FontAwesomeIcon, 5 6 FontAwesomeIconStyle, 6 7 } from '@fortawesome/react-native-fontawesome' 7 - import {isIOS, isAndroid} from 'platform/detection' 8 - import {Button, ButtonType} from './Button' 9 - import {Text} from '../text/Text' 8 + import {useLingui} from '@lingui/react' 9 + 10 + import {usePalette} from 'lib/hooks/usePalette' 10 11 import {TypographyVariant} from 'lib/ThemeContext' 11 12 import {useTheme} from 'lib/ThemeContext' 12 - import {usePalette} from 'lib/hooks/usePalette' 13 - import {getLocales} from 'expo-localization' 14 - import DatePicker from 'react-native-date-picker' 15 - 16 - const LOCALE = getLocales()[0] 13 + import {isAndroid, isIOS} from 'platform/detection' 14 + import {Text} from '../text/Text' 15 + import {Button, ButtonType} from './Button' 17 16 18 17 interface Props { 19 18 testID?: string ··· 30 29 } 31 30 32 31 export function DateInput(props: Props) { 32 + const {i18n} = useLingui() 33 33 const [show, setShow] = useState(false) 34 34 const theme = useTheme() 35 35 const pal = usePalette('default') 36 - 37 - const formatter = React.useMemo(() => { 38 - return new Intl.DateTimeFormat(LOCALE.languageTag, { 39 - timeZone: props.handleAsUTC ? 'UTC' : undefined, 40 - }) 41 - }, [props.handleAsUTC]) 42 36 43 37 const onChangeInternal = useCallback( 44 38 (date: Date) => { ··· 74 68 <Text 75 69 type={props.buttonLabelType} 76 70 style={[pal.text, props.buttonLabelStyle]}> 77 - {formatter.format(props.value)} 71 + {i18n.date(props.value, { 72 + timeZone: props.handleAsUTC ? 'UTC' : undefined, 73 + })} 78 74 </Text> 79 75 </View> 80 76 </Button>
+5 -12
src/view/com/util/numeric/format.ts
··· 1 - export const formatCount = (num: number) => 2 - Intl.NumberFormat('en-US', { 1 + import type {I18n} from '@lingui/core' 2 + 3 + export const formatCount = (i18n: I18n, num: number) => { 4 + return i18n.number(num, { 3 5 notation: 'compact', 4 6 maximumFractionDigits: 1, 5 7 // `1,953` shouldn't be rounded up to 2k, it should be truncated. 6 8 // @ts-expect-error: `roundingMode` doesn't seem to be in the typings yet 7 9 // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingmode 8 10 roundingMode: 'trunc', 9 - }).format(num) 10 - 11 - export function formatCountShortOnly(num: number): string { 12 - if (num >= 1000000) { 13 - return (num / 1000000).toFixed(1) + 'M' 14 - } 15 - if (num >= 1000) { 16 - return (num / 1000).toFixed(1) + 'K' 17 - } 18 - return String(num) 11 + }) 19 12 }
+3 -3
src/view/com/util/post-ctrls/PostCtrls.tsx
··· 75 75 threadgateRecord?: AppBskyFeedThreadgate.Record 76 76 }): React.ReactNode => { 77 77 const t = useTheme() 78 - const {_} = useLingui() 78 + const {_, i18n} = useLingui() 79 79 const {openComposer} = useComposerControls() 80 80 const {currentAccount} = useSession() 81 81 const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext) ··· 247 247 big ? a.text_md : {fontSize: 15}, 248 248 a.user_select_none, 249 249 ]}> 250 - {formatCount(post.replyCount)} 250 + {formatCount(i18n, post.replyCount)} 251 251 </Text> 252 252 ) : undefined} 253 253 </Pressable> ··· 300 300 : defaultCtrlColor, 301 301 ], 302 302 ]}> 303 - {formatCount(post.likeCount)} 303 + {formatCount(i18n, post.likeCount)} 304 304 </Text> 305 305 ) : undefined} 306 306 </Pressable>
+2 -2
src/view/com/util/post-ctrls/RepostButton.tsx
··· 32 32 embeddingDisabled, 33 33 }: Props): React.ReactNode => { 34 34 const t = useTheme() 35 - const {_} = useLingui() 35 + const {_, i18n} = useLingui() 36 36 const requireAuth = useRequireAuth() 37 37 const dialogControl = Dialog.useDialogControl() 38 38 const playHaptic = useHaptics() ··· 79 79 big ? a.text_md : {fontSize: 15}, 80 80 isReposted && a.font_bold, 81 81 ]}> 82 - {formatCount(repostCount)} 82 + {formatCount(i18n, repostCount)} 83 83 </Text> 84 84 ) : undefined} 85 85 </Button>
+2 -1
src/view/com/util/post-ctrls/RepostButton.web.tsx
··· 128 128 repostCount?: number 129 129 big?: boolean 130 130 }) => { 131 + const {i18n} = useLingui() 131 132 return ( 132 133 <View style={[a.flex_row, a.align_center, a.gap_xs, {padding: 5}]}> 133 134 <Repost style={color} width={big ? 22 : 18} /> ··· 140 141 isReposted && [a.font_bold], 141 142 a.user_select_none, 142 143 ]}> 143 - {formatCount(repostCount)} 144 + {formatCount(i18n, repostCount)} 144 145 </Text> 145 146 ) : undefined} 146 147 </View>
+3 -8
src/view/screens/AppPasswords.tsx
··· 18 18 import {CommonNavigatorParams} from '#/lib/routes/types' 19 19 import {cleanError} from '#/lib/strings/errors' 20 20 import {useModalControls} from '#/state/modals' 21 - import {useLanguagePrefs} from '#/state/preferences' 22 21 import { 23 22 useAppPasswordDeleteMutation, 24 23 useAppPasswordsQuery, ··· 218 217 privileged?: boolean 219 218 }) { 220 219 const pal = usePalette('default') 221 - const {_} = useLingui() 220 + const {_, i18n} = useLingui() 222 221 const control = useDialogControl() 223 - const {contentLanguages} = useLanguagePrefs() 224 222 const deleteMutation = useAppPasswordDeleteMutation() 225 223 226 224 const onDelete = React.useCallback(async () => { ··· 232 230 control.open() 233 231 }, [control]) 234 232 235 - const primaryLocale = 236 - contentLanguages.length > 0 ? contentLanguages[0] : 'en-US' 237 - 238 233 return ( 239 234 <TouchableOpacity 240 235 testID={testID} ··· 250 245 <Text type="md" style={[pal.text, styles.pr10]} numberOfLines={1}> 251 246 <Trans> 252 247 Created{' '} 253 - {Intl.DateTimeFormat(primaryLocale, { 248 + {i18n.date(createdAt, { 254 249 year: 'numeric', 255 250 month: 'numeric', 256 251 day: 'numeric', 257 252 hour: '2-digit', 258 253 minute: '2-digit', 259 254 second: '2-digit', 260 - }).format(new Date(createdAt))} 255 + })} 261 256 </Trans> 262 257 </Text> 263 258 {privileged && (
+4 -4
src/view/shell/Drawer.tsx
··· 30 30 import {useTheme} from 'lib/ThemeContext' 31 31 import {isWeb} from 'platform/detection' 32 32 import {NavSignupCard} from '#/view/shell/NavSignupCard' 33 - import {formatCountShortOnly} from 'view/com/util/numeric/format' 33 + import {formatCount} from 'view/com/util/numeric/format' 34 34 import {Text} from 'view/com/util/text/Text' 35 35 import {UserAvatar} from 'view/com/util/UserAvatar' 36 36 import {atoms as a} from '#/alf' ··· 68 68 account: SessionAccount 69 69 onPressProfile: () => void 70 70 }): React.ReactNode => { 71 - const {_} = useLingui() 71 + const {_, i18n} = useLingui() 72 72 const pal = usePalette('default') 73 73 const {data: profile} = useProfileQuery({did: account.did}) 74 74 ··· 108 108 <Text type="xl" style={pal.textLight}> 109 109 <Trans> 110 110 <Text type="xl-medium" style={pal.text}> 111 - {formatCountShortOnly(profile?.followersCount ?? 0)} 111 + {formatCount(i18n, profile?.followersCount ?? 0)} 112 112 </Text>{' '} 113 113 <Plural 114 114 value={profile?.followersCount || 0} ··· 123 123 <Text type="xl" style={pal.textLight}> 124 124 <Trans> 125 125 <Text type="xl-medium" style={pal.text}> 126 - {formatCountShortOnly(profile?.followsCount ?? 0)} 126 + {formatCount(i18n, profile?.followsCount ?? 0)} 127 127 </Text>{' '} 128 128 <Plural 129 129 value={profile?.followsCount || 0}