From bf924a9a438cf9e153034de23513d1ce5fc4ea19 Mon Sep 17 00:00:00 2001 From: Mary Date: Thu, 30 May 2024 11:19:47 +0700 Subject: [PATCH] refactor: localized date time --- __tests__/lib/string.test.ts | 5 +- src/components/dialogs/Embed.tsx | 6 +-- src/components/dms/MessageItem.tsx | 7 +-- src/lib/strings/time.ts | 58 +++++++++++++++------ src/locale/constants.ts | 4 ++ src/view/com/notifications/FeedItem.tsx | 2 +- src/view/com/post-thread/PostThreadItem.tsx | 4 +- src/view/com/util/PostMeta.tsx | 7 ++- src/view/com/util/TimeElapsed.tsx | 10 ++-- src/view/screens/Log.tsx | 25 ++++----- 10 files changed, 84 insertions(+), 44 deletions(-) create mode 100644 src/locale/constants.ts diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 75cbaeea56..389293bdf3 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -1,4 +1,5 @@ import {RichText} from '@atproto/api' +import {I18n} from '@lingui/core' import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player' import {cleanError} from '../../src/lib/strings/errors' @@ -207,8 +208,10 @@ describe('ago', () => { ] it('correctly calculates how much time passed, in a string', () => { + const i18n = new I18n({locale: 'en'}) + for (let i = 0; i < inputs.length; i++) { - const result = ago(inputs[i]) + const result = ago(i18n, inputs[i]) expect(result).toEqual(outputs[i]) } }) diff --git a/src/components/dialogs/Embed.tsx b/src/components/dialogs/Embed.tsx index 7d858cae40..f43c3c6fe6 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 1c425580e7..a2fbca0f64 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -7,6 +7,7 @@ import { View, } from 'react-native' import {ChatBskyConvoDefs, RichText as RichTextAPI} from '@atproto/api' +import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -130,7 +131,7 @@ let MessageItemMetadata = ({ style: StyleProp }): React.ReactNode => { const t = useTheme() - const {_, i18n} = useLingui() + const {_} = useLingui() const {message} = item const handleRetry = useCallback( @@ -145,7 +146,7 @@ let MessageItemMetadata = ({ ) const relativeTimestamp = useCallback( - (timestamp: string) => { + (i18n: I18n, timestamp: string) => { const date = new Date(timestamp) const now = new Date() @@ -182,7 +183,7 @@ let MessageItemMetadata = ({ year: 'numeric', }) }, - [_, i18n], + [_], ) return ( diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts index 8de4b52aed..b0972e640a 100644 --- a/src/lib/strings/time.ts +++ b/src/lib/strings/time.ts @@ -1,10 +1,15 @@ +import {I18n} from '@lingui/core' +import {msg} from '@lingui/macro' + +import {MONTH_FALLBACK_LOCALES} from '#/locale/constants' + const NOW = 5 const MINUTE = 60 const HOUR = MINUTE * 60 const DAY = HOUR * 24 const MONTH_30 = DAY * 30 const MONTH = DAY * 30.41675 // This results in 365.001 days in a year, which is close enough for nearly all cases -export function ago(date: number | string | Date): string { +export function ago(i18n: I18n, date: number | string | Date): string { let ts: number if (typeof date === 'string') { ts = Number(new Date(date)) @@ -15,15 +20,31 @@ export function ago(date: number | string | Date): string { } const diffSeconds = Math.floor((Date.now() - ts) / 1e3) if (diffSeconds < NOW) { - return `now` + return i18n._(msg`now`) } else if (diffSeconds < MINUTE) { - return `${diffSeconds}s` + return i18n.number(diffSeconds, { + style: 'unit', + unitDisplay: 'narrow', + unit: 'second', + }) } else if (diffSeconds < HOUR) { - return `${Math.floor(diffSeconds / MINUTE)}m` + return i18n.number(Math.floor(diffSeconds / MINUTE), { + style: 'unit', + unitDisplay: 'narrow', + unit: 'minute', + }) } else if (diffSeconds < DAY) { - return `${Math.floor(diffSeconds / HOUR)}h` + return i18n.number(Math.floor(diffSeconds / HOUR), { + style: 'unit', + unitDisplay: 'narrow', + unit: 'hour', + }) } else if (diffSeconds < MONTH_30) { - return `${Math.round(diffSeconds / DAY)}d` + return i18n.number(Math.round(diffSeconds / DAY), { + style: 'unit', + unitDisplay: 'narrow', + unit: 'day', + }) } else { let months = diffSeconds / MONTH if (months % 1 >= 0.9) { @@ -33,23 +54,26 @@ export function ago(date: number | string | Date): string { } if (months < 12) { - return `${months}mo` + if (MONTH_FALLBACK_LOCALES.includes(i18n.locale)) return `${months}mo` + + return i18n.number(months, { + style: 'unit', + unitDisplay: 'narrow', + unit: 'month', + }) } else { - return new Date(ts).toLocaleDateString() + return i18n.date(new Date(ts)) } } } -export function niceDate(date: number | string | Date) { +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/locale/constants.ts b/src/locale/constants.ts new file mode 100644 index 0000000000..dfbc23d08b --- /dev/null +++ b/src/locale/constants.ts @@ -0,0 +1,4 @@ +// The narrow unit display for `month` in English is `m`, which conflicts with +// the one for `minute` (also `m`), this also goes for any locale that currently +// falls back to English for narrow-unit display (missing localization?) +export const MONTH_FALLBACK_LOCALES = ['en', 'ja', 'es', 'fr', 'tr'] diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 7fe85cfe53..7c01dd9c2b 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -249,7 +249,7 @@ let FeedItem = ({ {({timeElapsed}) => ( + title={niceDate(i18n, item.notification.indexedAt)}> {' ' + timeElapsed} )} diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 1a6ee2c615..65d2d97890 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -636,7 +636,7 @@ function ExpandedPostDetails({ translatorUrl: string }) { const pal = usePalette('default') - const {_} = useLingui() + const {_, i18n} = useLingui() const openLink = useOpenLink() const text = record?.text || '' @@ -654,7 +654,7 @@ function ExpandedPostDetails({ return ( - {niceDate(post.indexedAt)} + {niceDate(i18n, post.indexedAt)} {needsTranslation && ( <> · diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index b6fe6d374d..c2adc9126c 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, usePrefetchProfileQuery} from '#/state/queries/profile' @@ -33,6 +34,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 @@ -114,8 +117,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { style={pal.textLight} lineHeight={1.2} 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 a5d3a53722..5545b1c878 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 {useTickEveryMinute} from '#/state/shell' import {ago} from 'lib/strings/time' @@ -10,17 +12,19 @@ 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 tick = useTickEveryMinute() const [timeElapsed, setTimeAgo] = React.useState(() => - timeToString(timestamp), + timeToString(i18n, timestamp), ) const [prevTick, setPrevTick] = React.useState(tick) if (prevTick !== tick) { setPrevTick(tick) - setTimeAgo(timeToString(timestamp)) + setTimeAgo(timeToString(i18n, timestamp)) } return children({timeElapsed}) diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx index e727a1fb81..4e3110dc75 100644 --- a/src/view/screens/Log.tsx +++ b/src/view/screens/Log.tsx @@ -1,25 +1,26 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {ScrollView} from '../com/util/Views' -import {s} from 'lib/styles' -import {ViewHeader} from '../com/util/ViewHeader' -import {Text} from '../com/util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {getEntries} from '#/logger/logDump' -import {ago} from 'lib/strings/time' -import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {getEntries} from '#/logger/logDump' import {useSetMinimalShellMode} from '#/state/shell' +import {usePalette} from 'lib/hooks/usePalette' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {ago} from 'lib/strings/time' +import {s} from 'lib/styles' +import {Text} from '../com/util/text/Text' +import {ViewHeader} from '../com/util/ViewHeader' +import {ScrollView} from '../com/util/Views' export function LogScreen({}: NativeStackScreenProps< CommonNavigatorParams, 'Log' >) { const pal = usePalette('default') - const {_} = useLingui() + const {_, i18n} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() const [expanded, setExpanded] = React.useState([]) @@ -70,7 +71,7 @@ export function LogScreen({}: NativeStackScreenProps< /> ) : undefined} - {ago(entry.timestamp)} + {ago(i18n, entry.timestamp)} {expanded.includes(entry.id) ? (