about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/components/ProfileHoverCard/index.web.tsx6
-rw-r--r--src/components/dialogs/Embed.tsx6
-rw-r--r--src/components/dms/MessageItem.tsx11
-rw-r--r--src/components/forms/DateField/index.shared.tsx5
-rw-r--r--src/components/forms/DateField/utils.ts11
-rw-r--r--src/lib/hooks/__tests__/useTimeAgo.test.ts161
-rw-r--r--src/lib/hooks/useTimeAgo.ts192
-rw-r--r--src/lib/strings/time.ts17
-rw-r--r--src/screens/Profile/Header/Metrics.tsx8
-rw-r--r--src/screens/StarterPack/StarterPackLandingScreen.tsx6
-rw-r--r--src/view/com/notifications/FeedItem.tsx6
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx14
-rw-r--r--src/view/com/util/PostMeta.tsx7
-rw-r--r--src/view/com/util/TimeElapsed.tsx12
-rw-r--r--src/view/com/util/forms/DateInput.tsx28
-rw-r--r--src/view/com/util/numeric/format.ts17
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx6
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.tsx4
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.web.tsx3
-rw-r--r--src/view/screens/AppPasswords.tsx11
-rw-r--r--src/view/shell/Drawer.tsx8
21 files changed, 364 insertions, 175 deletions
diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx
index 928000988..3890790db 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 7d858cae4..f43c3c6fe 100644
--- a/src/components/dialogs/Embed.tsx
+++ b/src/components/dialogs/Embed.tsx
@@ -43,7 +43,7 @@ function EmbedDialogInner({
   timestamp,
 }: Omit<EmbedDialogProps, 'control'>) {
   const t = useTheme()
-  const {_} = useLingui()
+  const {_, i18n} = useLingui()
   const ref = useRef<TextInput>(null)
   const [copied, setCopied] = useState(false)
 
@@ -86,9 +86,9 @@ function EmbedDialogInner({
     )} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
       postAuthor.handle,
     )}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
-      niceDate(timestamp),
+      niceDate(i18n, timestamp),
     )}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
-  }, [postUri, postCid, record, timestamp, postAuthor])
+  }, [i18n, postUri, postCid, record, timestamp, postAuthor])
 
   return (
     <Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}>
diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx
index 573c24f77..c5c472cf0 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 1f54bdc8b..814bbed7c 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'})}
         </Text>
       </Pressable>
     </View>
diff --git a/src/components/forms/DateField/utils.ts b/src/components/forms/DateField/utils.ts
index c787272fe..04bb482ce 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 e74f9c62d..68eb6e43a 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 efcb4754b..3a8bf49bc 100644
--- a/src/lib/hooks/useTimeAgo.ts
+++ b/src/lib/hooks/useTimeAgo.ts
@@ -1,86 +1,178 @@
 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'
+
+type DateDiff = {
+  value: number
+  unit: 'now' | 'second' | 'minute' | 'hour' | 'day' | 'month'
+  earlier: Date
+  later: Date
 }
 
+const NOW = 5
+const MINUTE = 60
+const HOUR = MINUTE * 60
+const DAY = HOUR * 24
+const MONTH_30 = DAY * 30
+
 export function useGetTimeAgo() {
-  const {_} = useLingui()
+  const {i18n} = useLingui()
   return useCallback(
     (
       earlier: number | string | Date,
       later: number | string | Date,
-      options?: Omit<TimeAgoOptions, 'lingui'>,
+      options?: {format: DateDiffFormat},
     ) => {
-      return dateDiff(earlier, later, {lingui: _, format: options?.format})
+      const diff = dateDiff(earlier, later)
+      return formatDateDiff({diff, i18n, format: options?.format})
     },
-    [_],
+    [i18n],
   )
 }
 
-const NOW = 5
-const MINUTE = 60
-const HOUR = MINUTE * 60
-const DAY = HOUR * 24
-const MONTH_30 = DAY * 30
-
 /**
- * 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'.
- * - 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'
-  const long = format === 'long'
-  const diffSeconds = differenceInSeconds(new Date(later), new Date(earlier))
+): 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) {
-    return _(msg`now`)
+    diff = {
+      value: 0,
+      unit: 'now' as DateDiff['unit'],
+    }
   } else if (diffSeconds < MINUTE) {
-    return `${diffSeconds}${
-      long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's'
-    }`
+    diff = {
+      value: diffSeconds,
+      unit: 'second' as DateDiff['unit'],
+    }
   } else if (diffSeconds < HOUR) {
-    const diff = Math.floor(diffSeconds / MINUTE)
-    return `${diff}${
-      long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm'
-    }`
+    const value = Math.floor(diffSeconds / MINUTE)
+    diff = {
+      value,
+      unit: 'minute' as DateDiff['unit'],
+    }
   } else if (diffSeconds < DAY) {
-    const diff = Math.floor(diffSeconds / HOUR)
-    return `${diff}${
-      long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h'
-    }`
+    const value = Math.floor(diffSeconds / HOUR)
+    diff = {
+      value,
+      unit: 'hour' as DateDiff['unit'],
+    }
   } else if (diffSeconds < MONTH_30) {
-    const diff = Math.floor(diffSeconds / DAY)
-    return `${diff}${
-      long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd'
-    }`
+    const value = Math.floor(diffSeconds / DAY)
+    diff = {
+      value,
+      unit: 'day' as DateDiff['unit'],
+    }
   } 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()
+    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 formatDateDiff({
+  diff,
+  format = 'short',
+  i18n,
+}: {
+  diff: DateDiff
+  format?: DateDiffFormat
+  i18n: I18n
+}): string {
+  const long = format === 'long'
 
-      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 bfefea9bc..e505b7892 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 e3537f44b..756eb1f89 100644
--- a/src/screens/Profile/Header/Metrics.tsx
+++ b/src/screens/Profile/Header/Metrics.tsx
@@ -17,9 +17,9 @@ export function ProfileHeaderMetrics({
   profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
 }) {
   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({
         </Text>
       </InlineLinkText>
       <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]}>
           {plural(profile.postsCount || 0, {one: 'post', other: 'posts'})}
         </Text>
diff --git a/src/screens/StarterPack/StarterPackLandingScreen.tsx b/src/screens/StarterPack/StarterPackLandingScreen.tsx
index 7dda45f96..5f1d5e062 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}>
-                <Trans>{formatCount(JOINED_THIS_WEEK)} joined this week</Trans>
+                <Trans>
+                  {formatCount(i18n, JOINED_THIS_WEEK)} joined this week
+                </Trans>
               </Text>
             </View>
           </View>
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index b591b4b7f..3e8f8d86d 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<boolean>(false)
   const itemHref = useMemo(() => {
@@ -225,11 +225,11 @@ let FeedItem = ({
   }
 
   const formattedCount =
-    authors.length > 1 ? formatCount(authors.length - 1) : ''
+    authors.length > 1 ? formatCount(i18n, authors.length - 1) : ''
   const firstAuthorName = sanitizeDisplayName(
     authors[0].profile.displayName || authors[0].profile.handle,
   )
-  const niceTimestamp = niceDate(item.notification.indexedAt)
+  const niceTimestamp = niceDate(i18n, item.notification.indexedAt)
   const a11yLabelUsers =
     authors.length > 1
       ? _(msg` and `) +
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index a3cfebbab..3b5ddb1dc 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}>
                       <Text type="xl-bold" style={pal.text}>
-                        {formatCount(post.repostCount)}
+                        {formatCount(i18n, post.repostCount)}
                       </Text>{' '}
                       <Plural
                         value={post.repostCount}
@@ -410,7 +410,7 @@ let PostThreadItemLoaded = ({
                       type="lg"
                       style={pal.textLight}>
                       <Text type="xl-bold" style={pal.text}>
-                        {formatCount(post.quoteCount)}
+                        {formatCount(i18n, post.quoteCount)}
                       </Text>{' '}
                       <Plural
                         value={post.quoteCount}
@@ -430,7 +430,7 @@ let PostThreadItemLoaded = ({
                       type="lg"
                       style={pal.textLight}>
                       <Text type="xl-bold" style={pal.text}>
-                        {formatCount(post.likeCount)}
+                        {formatCount(i18n, post.likeCount)}
                       </Text>{' '}
                       <Plural value={post.likeCount} one="like" other="likes" />
                     </Text>
@@ -705,7 +705,7 @@ function ExpandedPostDetails({
   translatorUrl: string
 }) {
   const pal = usePalette('default')
-  const {_} = useLingui()
+  const {_, i18n} = useLingui()
   const openLink = useOpenLink()
   const isRootPost = !('reply' in post.record)
 
@@ -723,7 +723,9 @@ function ExpandedPostDetails({
         s.mt2,
         s.mb10,
       ]}>
-      <Text style={[a.text_sm, pal.textLight]}>{niceDate(post.indexedAt)}</Text>
+      <Text style={[a.text_sm, pal.textLight]}>
+        {niceDate(i18n, post.indexedAt)}
+      </Text>
       {isRootPost && (
         <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
       )}
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index b1567c2c6..3bd350bf3 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 a49585182..70fed222f 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 0104562aa..bfbb2ff55 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) {
             <Text
               type={props.buttonLabelType}
               style={[pal.text, props.buttonLabelStyle]}>
-              {formatter.format(props.value)}
+              {i18n.date(props.value, {
+                timeZone: props.handleAsUTC ? 'UTC' : undefined,
+              })}
             </Text>
           </View>
         </Button>
diff --git a/src/view/com/util/numeric/format.ts b/src/view/com/util/numeric/format.ts
index 71d8d73e0..cca9fc7e7 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 a0cef8692..f577e1683 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)}
             </Text>
           ) : undefined}
         </Pressable>
@@ -300,7 +300,7 @@ let PostCtrls = ({
                     : defaultCtrlColor,
                 ],
               ]}>
-              {formatCount(post.likeCount)}
+              {formatCount(i18n, post.likeCount)}
             </Text>
           ) : undefined}
         </Pressable>
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index 5994b7ef6..d924adbe4 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)}
           </Text>
         ) : undefined}
       </Button>
diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx
index 9a8776b9c..111b41dd7 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 (
     <View style={[a.flex_row, a.align_center, a.gap_xs, {padding: 5}]}>
       <Repost style={color} width={big ? 22 : 18} />
@@ -140,7 +141,7 @@ const RepostInner = ({
             isReposted && [a.font_bold],
             a.user_select_none,
           ]}>
-          {formatCount(repostCount)}
+          {formatCount(i18n, repostCount)}
         </Text>
       ) : undefined}
     </View>
diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx
index 5bf9e8a16..6f1cd1bb8 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 (
     <TouchableOpacity
       testID={testID}
@@ -250,14 +245,14 @@ function AppPassword({
         <Text type="md" style={[pal.text, styles.pr10]} numberOfLines={1}>
           <Trans>
             Created{' '}
-            {Intl.DateTimeFormat(primaryLocale, {
+            {i18n.date(createdAt, {
               year: 'numeric',
               month: 'numeric',
               day: 'numeric',
               hour: '2-digit',
               minute: '2-digit',
               second: '2-digit',
-            }).format(new Date(createdAt))}
+            })}
           </Trans>
         </Text>
         {privileged && (
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 0e852edd1..facead2c1 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 = ({
         <Text type="xl" style={pal.textLight}>
           <Trans>
             <Text type="xl-medium" style={pal.text}>
-              {formatCountShortOnly(profile?.followersCount ?? 0)}
+              {formatCount(i18n, profile?.followersCount ?? 0)}
             </Text>{' '}
             <Plural
               value={profile?.followersCount || 0}
@@ -123,7 +123,7 @@ let DrawerProfileCard = ({
         <Text type="xl" style={pal.textLight}>
           <Trans>
             <Text type="xl-medium" style={pal.text}>
-              {formatCountShortOnly(profile?.followsCount ?? 0)}
+              {formatCount(i18n, profile?.followsCount ?? 0)}
             </Text>{' '}
             <Plural
               value={profile?.followsCount || 0}