diff --git a/.eslintrc.js b/.eslintrc.js index 541b3d6153..9d2b7bbb1a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -31,6 +31,7 @@ module.exports = { }, }, ], + 'bsky-internal/use-exact-imports': 'error', 'bsky-internal/use-typed-gates': 'error', 'simple-import-sort/imports': [ 'warn', diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 7e11ce6219..2aef90fea1 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -11,7 +11,6 @@ import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles' import {enforceLen} from '../../src/lib/strings/helpers' import {detectLinkables} from '../../src/lib/strings/rich-text-detection' import {shortenLinks} from '../../src/lib/strings/rich-text-manip' -import {ago} from '../../src/lib/strings/time' import { makeRecordUri, toNiceDomain, @@ -147,79 +146,6 @@ describe('makeRecordUri', () => { }) }) -// FIXME: Reenable after fixing non-deterministic test. -describe.skip('ago', () => { - const oneYearDate = new Date( - new Date().setMonth(new Date().getMonth() - 11), - ).setDate(new Date().getDate() - 28) - - const inputs = [ - 1671461038, - '04 Dec 1995 00:12:00 GMT', - new Date(), - new Date().setSeconds(new Date().getSeconds() - 10), - new Date().setMinutes(new Date().getMinutes() - 10), - new Date().setHours(new Date().getHours() - 1), - new Date().setDate(new Date().getDate() - 1), - new Date().setDate(new Date().getDate() - 20), - new Date().setDate(new Date().getDate() - 25), - new Date().setDate(new Date().getDate() - 28), - new Date().setDate(new Date().getDate() - 29), - new Date().setDate(new Date().getDate() - 30), - new Date().setMonth(new Date().getMonth() - 1), - new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate( - new Date().getDate() - 20, - ), - new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate( - new Date().getDate() - 25, - ), - new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate( - new Date().getDate() - 28, - ), - new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate( - new Date().getDate() - 29, - ), - new Date().setMonth(new Date().getMonth() - 11), - new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate( - new Date().getDate() - 20, - ), - new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate( - new Date().getDate() - 25, - ), - oneYearDate, - ] - const outputs = [ - new Date(1671461038).toLocaleDateString(), - new Date('04 Dec 1995 00:12:00 GMT').toLocaleDateString(), - 'now', - '10s', - '10m', - '1h', - '1d', - '20d', - '25d', - '28d', - '29d', - '1mo', - '1mo', - '1mo', - '1mo', - '2mo', - '2mo', - '11mo', - '11mo', - '11mo', - new Date(oneYearDate).toLocaleDateString(), - ] - - it('correctly calculates how much time passed, in a string', () => { - for (let i = 0; i < inputs.length; i++) { - const result = ago(inputs[i]) - expect(result).toEqual(outputs[i]) - } - }) -}) - describe('makeValidHandle', () => { const inputs = [ 'test-handle-123', diff --git a/eslint/index.js b/eslint/index.js index bb31a942d1..cf5d41225d 100644 --- a/eslint/index.js +++ b/eslint/index.js @@ -3,6 +3,7 @@ module.exports = { rules: { 'avoid-unwrapped-text': require('./avoid-unwrapped-text'), + 'use-exact-imports': require('./use-exact-imports'), 'use-typed-gates': require('./use-typed-gates'), }, } diff --git a/eslint/use-exact-imports.js b/eslint/use-exact-imports.js new file mode 100644 index 0000000000..06723043fe --- /dev/null +++ b/eslint/use-exact-imports.js @@ -0,0 +1,22 @@ +/* eslint-disable bsky-internal/use-exact-imports */ +const BANNED_IMPORTS = [ + '@fortawesome/free-regular-svg-icons', + '@fortawesome/free-solid-svg-icons', +] + +exports.create = function create(context) { + return { + Literal(node) { + if (typeof node.value !== 'string') { + return + } + if (BANNED_IMPORTS.includes(node.value)) { + context.report({ + node, + message: + 'Import the specific thing you want instead of the entire package', + }) + } + }, + } +} diff --git a/package.json b/package.json index ae9f908038..8f857b6920 100644 --- a/package.json +++ b/package.json @@ -272,6 +272,7 @@ "resolutions": { "@types/react": "^18", "**/zeed-dom": "0.10.9", + "**/zod": "3.23.8", "**/expo-constants": "16.0.1", "**/expo-device": "6.0.2", "@react-native/babel-preset": "0.74.1" diff --git a/src/lib/hooks/__tests__/useTimeAgo.test.ts b/src/lib/hooks/__tests__/useTimeAgo.test.ts new file mode 100644 index 0000000000..e74f9c62db --- /dev/null +++ b/src/lib/hooks/__tests__/useTimeAgo.test.ts @@ -0,0 +1,102 @@ +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') + }) + it(`works with strings`, () => { + expect(dateDiff(subDays(base, 3), base.toString(), {lingui})).toEqual('3d') + }) + it(`works with dates`, () => { + expect(dateDiff(subDays(base, 3), base, {lingui})).toEqual('3d') + }) + + it(`equal values return now`, () => { + expect(dateDiff(base, base, {lingui})).toEqual('now') + }) + it(`future dates return now`, () => { + expect(dateDiff(addDays(base, 3), base, {lingui})).toEqual('now') + }) + + it(`values < 5 seconds ago return now`, () => { + const then = subSeconds(base, 4) + expect(dateDiff(then, base, {lingui})).toEqual('now') + }) + it(`values >= 5 seconds ago return seconds`, () => { + const then = subSeconds(base, 5) + expect(dateDiff(then, base, {lingui})).toEqual('5s') + }) + + it(`values < 1 min return seconds`, () => { + const then = subSeconds(base, 59) + expect(dateDiff(then, base, {lingui})).toEqual('59s') + }) + it(`values >= 1 min return minutes`, () => { + const then = subSeconds(base, 60) + expect(dateDiff(then, base, {lingui})).toEqual('1m') + }) + it(`minutes round down`, () => { + const then = subSeconds(base, 119) + expect(dateDiff(then, base, {lingui})).toEqual('1m') + }) + + it(`values < 1 hour return minutes`, () => { + const then = subMinutes(base, 59) + expect(dateDiff(then, base, {lingui})).toEqual('59m') + }) + it(`values >= 1 hour return hours`, () => { + const then = subMinutes(base, 60) + expect(dateDiff(then, base, {lingui})).toEqual('1h') + }) + it(`hours round down`, () => { + const then = subMinutes(base, 119) + expect(dateDiff(then, base, {lingui})).toEqual('1h') + }) + + it(`values < 1 day return hours`, () => { + const then = subHours(base, 23) + expect(dateDiff(then, base, {lingui})).toEqual('23h') + }) + it(`values >= 1 day return days`, () => { + const then = subHours(base, 24) + expect(dateDiff(then, base, {lingui})).toEqual('1d') + }) + it(`days round down`, () => { + const then = subHours(base, 47) + expect(dateDiff(then, base, {lingui})).toEqual('1d') + }) + + it(`values < 30 days return days`, () => { + const then = subDays(base, 29) + expect(dateDiff(then, base, {lingui})).toEqual('29d') + }) + it(`values >= 30 days return months`, () => { + const then = subDays(base, 30) + expect(dateDiff(then, base, {lingui})).toEqual('1mo') + }) + it(`months round down`, () => { + const then = subDays(base, 59) + expect(dateDiff(then, base, {lingui})).toEqual('1mo') + }) + it(`values are rounded by increments of 30`, () => { + const then = subDays(base, 61) + expect(dateDiff(then, base, {lingui})).toEqual('2mo') + }) + + it(`values < 360 days return months`, () => { + const then = subDays(base, 359) + expect(dateDiff(then, base, {lingui})).toEqual('11mo') + }) + it(`values >= 360 days return the earlier value`, () => { + const then = subDays(base, 360) + expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString()) + }) +}) diff --git a/src/lib/hooks/useTimeAgo.ts b/src/lib/hooks/useTimeAgo.ts new file mode 100644 index 0000000000..5f0782f96f --- /dev/null +++ b/src/lib/hooks/useTimeAgo.ts @@ -0,0 +1,95 @@ +import {useCallback, useMemo} from 'react' +import {msg, plural} from '@lingui/macro' +import {I18nContext, useLingui} from '@lingui/react' +import {differenceInSeconds} from 'date-fns' + +export type TimeAgoOptions = { + lingui: I18nContext['_'] + format?: 'long' | 'short' +} + +export function useGetTimeAgo() { + const {_} = useLingui() + return useCallback( + ( + date: number | string | Date, + options?: Omit, + ) => { + return dateDiff(date, Date.now(), {lingui: _, format: options?.format}) + }, + [_], + ) +} + +export function useTimeAgo( + date: number | string | Date, + options?: Omit, +): string { + const timeAgo = useGetTimeAgo() + return useMemo(() => { + return timeAgo(date, {...options}) + }, [date, options, timeAgo]) +} + +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. + * + * - 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)) + + 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}`) + } + return str + } + } +} diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts index 918621df38..1194e0240c 100644 --- a/src/lib/strings/time.ts +++ b/src/lib/strings/time.ts @@ -1,70 +1,3 @@ -import {plural} from '@lingui/macro' - -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, long = false): string { - let ts: number - if (typeof date === 'string') { - ts = Number(new Date(date)) - } else if (date instanceof Date) { - ts = Number(date) - } else { - ts = date - } - const diffSeconds = Math.floor((Date.now() - ts) / 1e3) - if (diffSeconds < NOW) { - return `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.round(diffSeconds / DAY) - return `${diff}${ - long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd' - }` - } else { - let months = diffSeconds / MONTH - if (months % 1 >= 0.9) { - months = Math.ceil(months) - } else { - months = Math.floor(months) - } - - if (months < 12) { - return `${months} ${ - long - ? plural(months, { - one: 'month', - other: 'months', - }) - : 'mo' - }` - } else { - const str = new Date(ts).toLocaleDateString() - - if (long) { - return `on ${str}` - } - return str - } - } -} - export function niceDate(date: number | string | Date) { const d = new Date(date) return `${d.toLocaleDateString('en-us', { diff --git a/src/locale/i18n.ts b/src/locale/i18n.ts index 9f75f83f3d..baec4b8a2d 100644 --- a/src/locale/i18n.ts +++ b/src/locale/i18n.ts @@ -1,5 +1,5 @@ import '@formatjs/intl-locale/polyfill' -import '@formatjs/intl-pluralrules/polyfill' +import '@formatjs/intl-pluralrules/polyfill-force' // Don't remove -force because detection is very slow import '@formatjs/intl-pluralrules/locale-data/en' import {useEffect} from 'react' diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx index a5d3a53722..d939b3163d 100644 --- a/src/view/com/util/TimeElapsed.tsx +++ b/src/view/com/util/TimeElapsed.tsx @@ -1,26 +1,26 @@ import React from 'react' +import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useTickEveryMinute} from '#/state/shell' -import {ago} from 'lib/strings/time' export function TimeElapsed({ timestamp, children, - timeToString = ago, + timeToString, }: { timestamp: string children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element timeToString?: (timeElapsed: string) => string }) { + const ago = useGetTimeAgo() + const format = timeToString ?? ago const tick = useTickEveryMinute() - const [timeElapsed, setTimeAgo] = React.useState(() => - timeToString(timestamp), - ) + const [timeElapsed, setTimeAgo] = React.useState(() => format(timestamp)) const [prevTick, setPrevTick] = React.useState(tick) if (prevTick !== tick) { setPrevTick(tick) - setTimeAgo(timeToString(timestamp)) + setTimeAgo(format(timestamp)) } return children({timeElapsed}) diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx index 025b903b22..b4feed990c 100644 --- a/src/view/icons/index.tsx +++ b/src/view/icons/index.tsx @@ -1,5 +1,5 @@ import {library} from '@fortawesome/fontawesome-svg-core' -import {faAddressCard} from '@fortawesome/free-regular-svg-icons' +import {faAddressCard} from '@fortawesome/free-regular-svg-icons/faAddressCard' import {faBell as farBell} from '@fortawesome/free-regular-svg-icons/faBell' import {faBookmark as farBookmark} from '@fortawesome/free-regular-svg-icons/faBookmark' import {faCalendar as farCalendar} from '@fortawesome/free-regular-svg-icons/faCalendar' @@ -25,8 +25,6 @@ import {faSquareCheck} from '@fortawesome/free-regular-svg-icons/faSquareCheck' import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus' import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan' import {faUser} from '@fortawesome/free-regular-svg-icons/faUser' -import {faFlask} from '@fortawesome/free-solid-svg-icons' -import {faUniversalAccess} from '@fortawesome/free-solid-svg-icons' import {faAngleDown} from '@fortawesome/free-solid-svg-icons/faAngleDown' import {faAngleLeft} from '@fortawesome/free-solid-svg-icons/faAngleLeft' import {faAngleRight} from '@fortawesome/free-solid-svg-icons/faAngleRight' @@ -62,6 +60,7 @@ import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation' import {faEye} from '@fortawesome/free-solid-svg-icons/faEye' import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter' import {faFire} from '@fortawesome/free-solid-svg-icons/faFire' +import {faFlask} from '@fortawesome/free-solid-svg-icons/faFlask' import {faGear} from '@fortawesome/free-solid-svg-icons/faGear' import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' import {faHand} from '@fortawesome/free-solid-svg-icons/faHand' @@ -97,6 +96,7 @@ import {faSignal} from '@fortawesome/free-solid-svg-icons/faSignal' import {faSliders} from '@fortawesome/free-solid-svg-icons/faSliders' import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack' import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket' +import {faUniversalAccess} from '@fortawesome/free-solid-svg-icons/faUniversalAccess' import {faUserCheck} from '@fortawesome/free-solid-svg-icons/faUserCheck' import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus' import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers' diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx index e727a1fb81..e10aa83ab7 100644 --- a/src/view/screens/Log.tsx +++ b/src/view/screens/Log.tsx @@ -1,18 +1,19 @@ 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 {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' +import {getEntries} from '#/logger/logDump' import {useSetMinimalShellMode} from '#/state/shell' +import {usePalette} from 'lib/hooks/usePalette' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +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, @@ -22,6 +23,7 @@ export function LogScreen({}: NativeStackScreenProps< const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() const [expanded, setExpanded] = React.useState([]) + const timeAgo = useGetTimeAgo() useFocusEffect( React.useCallback(() => { @@ -70,7 +72,7 @@ export function LogScreen({}: NativeStackScreenProps< /> ) : undefined} - {ago(entry.timestamp)} + {timeAgo(entry.timestamp)} {expanded.includes(entry.id) ? ( diff --git a/yarn.lock b/yarn.lock index d1299545be..04fdb577f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22502,12 +22502,7 @@ zod-validation-error@^3.0.3: resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.3.0.tgz#2cfe81b62d044e0453d1aa3ae7c32a2f36dde9af" integrity sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw== -zod@^3.14.2, zod@^3.20.2: - version "3.22.2" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b" - integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg== - -zod@^3.21.4, zod@^3.22.4: +zod@3.23.8, zod@^3.14.2, zod@^3.20.2, zod@^3.21.4, zod@^3.22.4: version "3.23.8" resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==