From e6213d7aa56faa6994a27bf127c63ded69e67d6f Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 18 Jun 2024 08:23:41 +0300 Subject: [PATCH 1/8] Fix Android startup perf regression (#4544) --- .eslintrc.js | 1 + eslint/index.js | 1 + eslint/keep-i18n-patch-in-sync.js | 28 ++++++++++++++++++ .../@formatjs+intl-pluralrules+5.2.10.patch | 29 +++++++++++++++++++ 4 files changed, 59 insertions(+) create mode 100644 eslint/keep-i18n-patch-in-sync.js create mode 100644 patches/@formatjs+intl-pluralrules+5.2.10.patch diff --git a/.eslintrc.js b/.eslintrc.js index 541b3d61..caeddd83 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -32,6 +32,7 @@ module.exports = { }, ], 'bsky-internal/use-typed-gates': 'error', + 'bsky-internal/keep-i18n-patch-in-sync': 'error', 'simple-import-sort/imports': [ 'warn', { diff --git a/eslint/index.js b/eslint/index.js index bb31a942..955103d8 100644 --- a/eslint/index.js +++ b/eslint/index.js @@ -2,6 +2,7 @@ module.exports = { rules: { + 'keep-i18n-patch-in-sync': require('./keep-i18n-patch-in-sync'), 'avoid-unwrapped-text': require('./avoid-unwrapped-text'), 'use-typed-gates': require('./use-typed-gates'), }, diff --git a/eslint/keep-i18n-patch-in-sync.js b/eslint/keep-i18n-patch-in-sync.js new file mode 100644 index 00000000..ee183a5c --- /dev/null +++ b/eslint/keep-i18n-patch-in-sync.js @@ -0,0 +1,28 @@ +/* eslint-disable bsky-internal/keep-i18n-patch-in-sync */ +const LOCALE_DATA_FOLDER = '@formatjs/intl-pluralrules/locale-data/' +const GEN_MODULE_PATH = + '@formatjs/intl-pluralrules/supported-locales.generated.js' + +exports.create = function create(context) { + delete require.cache[require.resolve(GEN_MODULE_PATH)] + const {supportedLocales} = require(GEN_MODULE_PATH) + return { + Literal(node) { + if (typeof node.value !== 'string') { + return + } + if (!node.value.startsWith(LOCALE_DATA_FOLDER)) { + return + } + const code = node.value.slice(LOCALE_DATA_FOLDER.length) + if (!supportedLocales.includes(code)) { + context.report({ + node, + message: + 'Edit .patches/@formatjs+intl-pluralrules+XXX.patch to include ' + + code, + }) + } + }, + } +} diff --git a/patches/@formatjs+intl-pluralrules+5.2.10.patch b/patches/@formatjs+intl-pluralrules+5.2.10.patch new file mode 100644 index 00000000..329eba2e --- /dev/null +++ b/patches/@formatjs+intl-pluralrules+5.2.10.patch @@ -0,0 +1,29 @@ +diff --git a/node_modules/@formatjs/intl-pluralrules/supported-locales.generated.js b/node_modules/@formatjs/intl-pluralrules/supported-locales.generated.js +index 5e0692b..d11157a 100644 +--- a/node_modules/@formatjs/intl-pluralrules/supported-locales.generated.js ++++ b/node_modules/@formatjs/intl-pluralrules/supported-locales.generated.js +@@ -2,3 +2,24 @@ + Object.defineProperty(exports, "__esModule", { value: true }); + exports.supportedLocales = void 0; + exports.supportedLocales = ["af", "ak", "am", "an", "ar", "ars", "as", "asa", "ast", "az", "bal", "be", "bem", "bez", "bg", "bho", "bm", "bn", "bo", "br", "brx", "bs", "ca", "ce", "ceb", "cgg", "chr", "ckb", "cs", "cy", "da", "de", "doi", "dsb", "dv", "dz", "ee", "el", "en", "eo", "es", "et", "eu", "fa", "ff", "fi", "fil", "fo", "fr", "fur", "fy", "ga", "gd", "gl", "gsw", "gu", "guw", "gv", "ha", "haw", "he", "hi", "hnj", "hr", "hsb", "hu", "hy", "ia", "id", "ig", "ii", "io", "is", "it", "iu", "ja", "jbo", "jgo", "jmc", "jv", "jw", "ka", "kab", "kaj", "kcg", "kde", "kea", "kk", "kkj", "kl", "km", "kn", "ko", "ks", "ksb", "ksh", "ku", "kw", "ky", "lag", "lb", "lg", "lij", "lkt", "ln", "lo", "lt", "lv", "mas", "mg", "mgo", "mk", "ml", "mn", "mo", "mr", "ms", "mt", "my", "nah", "naq", "nb", "nd", "ne", "nl", "nn", "nnh", "no", "nqo", "nr", "nso", "ny", "nyn", "om", "or", "os", "osa", "pa", "pap", "pcm", "pl", "prg", "ps", "pt", "pt-PT", "rm", "ro", "rof", "ru", "rwk", "sah", "saq", "sat", "sc", "scn", "sd", "sdh", "se", "seh", "ses", "sg", "sh", "shi", "si", "sk", "sl", "sma", "smi", "smj", "smn", "sms", "sn", "so", "sq", "sr", "ss", "ssy", "st", "su", "sv", "sw", "syr", "ta", "te", "teo", "th", "ti", "tig", "tk", "tl", "tn", "to", "tpi", "tr", "ts", "tzm", "ug", "uk", "und", "ur", "uz", "ve", "vi", "vo", "vun", "wa", "wae", "wo", "xh", "xog", "yi", "yo", "yue", "zh", "zu"]; ++ ++// PATCHED FOR ANDROID PERF. KEEP IN SYNC WITH i18n.ts -dan ++exports.supportedLocales = [ ++ 'ca', ++ 'de', ++ 'en', ++ 'es', ++ 'fi', ++ 'fr', ++ 'ga', ++ 'hi', ++ 'id', ++ 'it', ++ 'ja', ++ 'ko', ++ 'pt', ++ 'tr', ++ 'uk', ++ 'zh' ++]; ++ From e30575c0dc3b2d81694a8a08543c6348e0c27322 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 18 Jun 2024 15:37:08 +0300 Subject: [PATCH 2/8] Use exact imports for icons (#4549) * Use exact imports for icons * Add a lint rule --- .eslintrc.js | 1 + eslint/index.js | 1 + eslint/use-exact-imports.js | 22 ++++++++++++++++++++++ src/view/icons/index.tsx | 6 +++--- 4 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 eslint/use-exact-imports.js diff --git a/.eslintrc.js b/.eslintrc.js index caeddd83..8915c501 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', 'bsky-internal/keep-i18n-patch-in-sync': 'error', 'simple-import-sort/imports': [ diff --git a/eslint/index.js b/eslint/index.js index 955103d8..cb6291d7 100644 --- a/eslint/index.js +++ b/eslint/index.js @@ -4,6 +4,7 @@ module.exports = { rules: { 'keep-i18n-patch-in-sync': require('./keep-i18n-patch-in-sync'), '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 00000000..06723043 --- /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/src/view/icons/index.tsx b/src/view/icons/index.tsx index 025b903b..b4feed99 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' From f142339e0638aaa8b2d6297fec536eff08dab539 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 18 Jun 2024 17:20:54 +0300 Subject: [PATCH 3/8] Dedupe Zod installation (#4551) --- package.json | 1 + yarn.lock | 7 +------ 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index e08aa9d2..29e198c9 100644 --- a/package.json +++ b/package.json @@ -271,6 +271,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/yarn.lock b/yarn.lock index c56a56b2..51da5ea4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22470,12 +22470,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== From 08cfb0958907408933982ece4a16d59625b423b0 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 18 Jun 2024 17:27:40 +0300 Subject: [PATCH 4/8] Unconditionally polyfill Intl.PluralRules for native (#4554) * Revert "Fix Android startup perf regression (#4544)" This reverts commit e6213d7aa56faa6994a27bf127c63ded69e67d6f. * Force polyfill --- .eslintrc.js | 1 - eslint/index.js | 1 - eslint/keep-i18n-patch-in-sync.js | 28 ------------------ .../@formatjs+intl-pluralrules+5.2.10.patch | 29 ------------------- src/locale/i18n.ts | 2 +- 5 files changed, 1 insertion(+), 60 deletions(-) delete mode 100644 eslint/keep-i18n-patch-in-sync.js delete mode 100644 patches/@formatjs+intl-pluralrules+5.2.10.patch diff --git a/.eslintrc.js b/.eslintrc.js index 8915c501..9d2b7bbb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -33,7 +33,6 @@ module.exports = { ], 'bsky-internal/use-exact-imports': 'error', 'bsky-internal/use-typed-gates': 'error', - 'bsky-internal/keep-i18n-patch-in-sync': 'error', 'simple-import-sort/imports': [ 'warn', { diff --git a/eslint/index.js b/eslint/index.js index cb6291d7..cf5d4122 100644 --- a/eslint/index.js +++ b/eslint/index.js @@ -2,7 +2,6 @@ module.exports = { rules: { - 'keep-i18n-patch-in-sync': require('./keep-i18n-patch-in-sync'), '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/keep-i18n-patch-in-sync.js b/eslint/keep-i18n-patch-in-sync.js deleted file mode 100644 index ee183a5c..00000000 --- a/eslint/keep-i18n-patch-in-sync.js +++ /dev/null @@ -1,28 +0,0 @@ -/* eslint-disable bsky-internal/keep-i18n-patch-in-sync */ -const LOCALE_DATA_FOLDER = '@formatjs/intl-pluralrules/locale-data/' -const GEN_MODULE_PATH = - '@formatjs/intl-pluralrules/supported-locales.generated.js' - -exports.create = function create(context) { - delete require.cache[require.resolve(GEN_MODULE_PATH)] - const {supportedLocales} = require(GEN_MODULE_PATH) - return { - Literal(node) { - if (typeof node.value !== 'string') { - return - } - if (!node.value.startsWith(LOCALE_DATA_FOLDER)) { - return - } - const code = node.value.slice(LOCALE_DATA_FOLDER.length) - if (!supportedLocales.includes(code)) { - context.report({ - node, - message: - 'Edit .patches/@formatjs+intl-pluralrules+XXX.patch to include ' + - code, - }) - } - }, - } -} diff --git a/patches/@formatjs+intl-pluralrules+5.2.10.patch b/patches/@formatjs+intl-pluralrules+5.2.10.patch deleted file mode 100644 index 329eba2e..00000000 --- a/patches/@formatjs+intl-pluralrules+5.2.10.patch +++ /dev/null @@ -1,29 +0,0 @@ -diff --git a/node_modules/@formatjs/intl-pluralrules/supported-locales.generated.js b/node_modules/@formatjs/intl-pluralrules/supported-locales.generated.js -index 5e0692b..d11157a 100644 ---- a/node_modules/@formatjs/intl-pluralrules/supported-locales.generated.js -+++ b/node_modules/@formatjs/intl-pluralrules/supported-locales.generated.js -@@ -2,3 +2,24 @@ - Object.defineProperty(exports, "__esModule", { value: true }); - exports.supportedLocales = void 0; - exports.supportedLocales = ["af", "ak", "am", "an", "ar", "ars", "as", "asa", "ast", "az", "bal", "be", "bem", "bez", "bg", "bho", "bm", "bn", "bo", "br", "brx", "bs", "ca", "ce", "ceb", "cgg", "chr", "ckb", "cs", "cy", "da", "de", "doi", "dsb", "dv", "dz", "ee", "el", "en", "eo", "es", "et", "eu", "fa", "ff", "fi", "fil", "fo", "fr", "fur", "fy", "ga", "gd", "gl", "gsw", "gu", "guw", "gv", "ha", "haw", "he", "hi", "hnj", "hr", "hsb", "hu", "hy", "ia", "id", "ig", "ii", "io", "is", "it", "iu", "ja", "jbo", "jgo", "jmc", "jv", "jw", "ka", "kab", "kaj", "kcg", "kde", "kea", "kk", "kkj", "kl", "km", "kn", "ko", "ks", "ksb", "ksh", "ku", "kw", "ky", "lag", "lb", "lg", "lij", "lkt", "ln", "lo", "lt", "lv", "mas", "mg", "mgo", "mk", "ml", "mn", "mo", "mr", "ms", "mt", "my", "nah", "naq", "nb", "nd", "ne", "nl", "nn", "nnh", "no", "nqo", "nr", "nso", "ny", "nyn", "om", "or", "os", "osa", "pa", "pap", "pcm", "pl", "prg", "ps", "pt", "pt-PT", "rm", "ro", "rof", "ru", "rwk", "sah", "saq", "sat", "sc", "scn", "sd", "sdh", "se", "seh", "ses", "sg", "sh", "shi", "si", "sk", "sl", "sma", "smi", "smj", "smn", "sms", "sn", "so", "sq", "sr", "ss", "ssy", "st", "su", "sv", "sw", "syr", "ta", "te", "teo", "th", "ti", "tig", "tk", "tl", "tn", "to", "tpi", "tr", "ts", "tzm", "ug", "uk", "und", "ur", "uz", "ve", "vi", "vo", "vun", "wa", "wae", "wo", "xh", "xog", "yi", "yo", "yue", "zh", "zu"]; -+ -+// PATCHED FOR ANDROID PERF. KEEP IN SYNC WITH i18n.ts -dan -+exports.supportedLocales = [ -+ 'ca', -+ 'de', -+ 'en', -+ 'es', -+ 'fi', -+ 'fr', -+ 'ga', -+ 'hi', -+ 'id', -+ 'it', -+ 'ja', -+ 'ko', -+ 'pt', -+ 'tr', -+ 'uk', -+ 'zh' -+]; -+ diff --git a/src/locale/i18n.ts b/src/locale/i18n.ts index 9f75f83f..baec4b8a 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' From 443beda74190b5af3083625116e9a9fdd4aa0fe0 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 18 Jun 2024 10:55:02 -0500 Subject: [PATCH 5/8] Add `useGetTimeAgo` and utils (#4556) * Create a testable version of ago() and re-enable the disabled test (#4364) * Enable the test of ago() * Use test cases This puts the input and the expected values next to each other. * Create dateDiff function This is a copy of ago(), but with the ability to specify the second date instead of using Date.now(). * Let ago() use dateDiff() * Move constants close to usage * Test dateDiff instead of ago This makes it possible to test the dates without being forced to rely on what the current date is. The commented out tests do not yet pass. This is fixed in later commits. * Update dateDiff and enable the remaining tests * Split up tests, use date-fns as helpers * Remove old test * Add long format * Add hook * Migrate to hooks * Delete old code * Or equal to * Update comment --------- Co-authored-by: Jan Aagaard --- __tests__/lib/string.test.ts | 74 --------------- src/lib/hooks/__tests__/useTimeAgo.test.ts | 102 +++++++++++++++++++++ src/lib/hooks/useTimeAgo.ts | 95 +++++++++++++++++++ src/lib/strings/time.ts | 42 --------- src/view/com/util/TimeElapsed.tsx | 12 +-- src/view/screens/Log.tsx | 24 ++--- 6 files changed, 216 insertions(+), 133 deletions(-) create mode 100644 src/lib/hooks/__tests__/useTimeAgo.test.ts create mode 100644 src/lib/hooks/useTimeAgo.ts diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 78478a26..30072ccb 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -6,7 +6,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, @@ -142,79 +141,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/src/lib/hooks/__tests__/useTimeAgo.test.ts b/src/lib/hooks/__tests__/useTimeAgo.test.ts new file mode 100644 index 00000000..e74f9c62 --- /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 00000000..5f0782f9 --- /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 8de4b52a..1194e024 100644 --- a/src/lib/strings/time.ts +++ b/src/lib/strings/time.ts @@ -1,45 +1,3 @@ -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 { - 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}s` - } else if (diffSeconds < HOUR) { - return `${Math.floor(diffSeconds / MINUTE)}m` - } else if (diffSeconds < DAY) { - return `${Math.floor(diffSeconds / HOUR)}h` - } else if (diffSeconds < MONTH_30) { - return `${Math.round(diffSeconds / DAY)}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}mo` - } else { - return new Date(ts).toLocaleDateString() - } - } -} - export function niceDate(date: number | string | Date) { const d = new Date(date) return `${d.toLocaleDateString('en-us', { diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx index a5d3a537..d939b316 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/screens/Log.tsx b/src/view/screens/Log.tsx index e727a1fb..e10aa83a 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) ? ( From 73c9de3ce27a379b9d57550a8dcc13e251a4e60e Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 18 Jun 2024 10:57:08 -0700 Subject: [PATCH 6/8] fix keyboard overlaying onboarding inputs (#4558) --- src/components/forms/DateField/index.tsx | 7 +++++-- src/view/com/util/layouts/LoggedOutLayout.tsx | 8 +++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx index e231ac5b..c916f4ef 100644 --- a/src/components/forms/DateField/index.tsx +++ b/src/components/forms/DateField/index.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {View} from 'react-native' +import {Keyboard, View} from 'react-native' import DatePicker from 'react-native-date-picker' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -49,7 +49,10 @@ export function DateField({ { + Keyboard.dismiss() + control.open() + }} isInvalid={isInvalid} accessibilityHint={accessibilityHint} /> diff --git a/src/view/com/util/layouts/LoggedOutLayout.tsx b/src/view/com/util/layouts/LoggedOutLayout.tsx index 0272a44c..c2c080c1 100644 --- a/src/view/com/util/layouts/LoggedOutLayout.tsx +++ b/src/view/com/util/layouts/LoggedOutLayout.tsx @@ -3,6 +3,7 @@ import {ScrollView, StyleSheet, View} from 'react-native' import {isWeb} from '#/platform/detection' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' +import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {atoms as a} from '#/alf' @@ -29,13 +30,18 @@ export const LoggedOutLayout = ({ borderLeftWidth: 1, }) + const [isKeyboardVisible] = useIsKeyboardVisible() + if (isMobile) { if (scrollable) { return ( + keyboardDismissMode="none" + contentContainerStyle={[ + {paddingBottom: isKeyboardVisible ? 300 : 0}, + ]}> {children} ) From 11065174813a322e5a53097c0ea309fd8e613cc7 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 18 Jun 2024 12:59:50 -0500 Subject: [PATCH 7/8] =?UTF-8?q?Is=20it=20"newskie"=20or=20"newsky"=20?= =?UTF-8?q?=F0=9F=A4=94=20=20(#4557)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add newskie icon (cherry picked from commit 152e074ee053e076bf644e368047e486a5ad127c) (cherry picked from commit 8d2326f115c9c9d32aa1c41259bb81936b3868aa) * add size prop (cherry picked from commit af09ae2d8f4fedf8a50993e94b76efc44a2ef4ea) (cherry picked from commit 38dd38451bcce8afcf302ad1180802640857722a) * add a dialog for newskies to profiles (cherry picked from commit fe16f55e9c5e8faef540b563662b0c0c9a1d2d77) (cherry picked from commit c5b9f1b16ace276f422832069db076a5360616fe) * move newskie to handle (cherry picked from commit 150f2635b278a92ed67dcec748333b428aacb670) (cherry picked from commit 1efaaf835380f4e76d2e4b7fe8b727a92731a794) * use "say hello" in newskie dialog (cherry picked from commit d9a286cfc823a9e697061de84dd317625741a862) (cherry picked from commit 018dd1739fee68906dec63e05519f5ca9ae73910) * tweaks (cherry picked from commit 070363c947600c48368b01c776ea34fbf422f81e) (cherry picked from commit c30855d4ff311e31fb6ae357a9d6cd1662b291d5) * Tweaks * Re-export newskie icon * Design tweaks * Tweaks * Add source icon * Remove unused file * Remove unneeded edits * Simplify logic * Update source * Moderate displayName, fix createdAt type --------- Co-authored-by: Hailey --- assets/icons/newskie.svg | 1 + src/components/NewskieDialog.tsx | 81 +++++++++++++++++++++++++++ src/components/icons/Newskie.tsx | 5 ++ src/screens/Profile/Header/Handle.tsx | 7 ++- 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 assets/icons/newskie.svg create mode 100644 src/components/NewskieDialog.tsx create mode 100644 src/components/icons/Newskie.tsx diff --git a/assets/icons/newskie.svg b/assets/icons/newskie.svg new file mode 100644 index 00000000..e3a9d83c --- /dev/null +++ b/assets/icons/newskie.svg @@ -0,0 +1 @@ + diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx new file mode 100644 index 00000000..fcdae0da --- /dev/null +++ b/src/components/NewskieDialog.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {differenceInSeconds} from 'date-fns' + +import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {HITSLOP_10} from 'lib/constants' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {atoms as a} from '#/alf' +import {Button} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {useDialogControl} from '#/components/Dialog' +import {Newskie} from '#/components/icons/Newskie' +import {Text} from '#/components/Typography' + +export function NewskieDialog({ + profile, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed +}) { + const {_} = useLingui() + const moderationOpts = useModerationOpts() + const control = useDialogControl() + const profileName = React.useMemo(() => { + const name = profile.displayName || profile.handle + if (!moderationOpts) return name + const moderation = moderateProfile(profile, moderationOpts) + return sanitizeDisplayName(name, moderation.ui('displayName')) + }, [moderationOpts, profile]) + const timeAgo = useGetTimeAgo() + const createdAt = profile.createdAt as string | undefined + const daysOld = React.useMemo(() => { + if (!createdAt) return Infinity + return differenceInSeconds(new Date(), new Date(createdAt)) / 86400 + }, [createdAt]) + + if (!createdAt || daysOld > 7) return null + + return ( + + + + + + + + + Say hello! + + + + {profileName} joined Bluesky{' '} + {timeAgo(createdAt, {format: 'long'})} ago + + + + + + + ) +} diff --git a/src/components/icons/Newskie.tsx b/src/components/icons/Newskie.tsx new file mode 100644 index 00000000..ddbb3320 --- /dev/null +++ b/src/components/icons/Newskie.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Newskie = createSinglePathSVG({ + path: 'M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021 2.783 0 5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.872.872 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z', +}) diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx index 9ab24fbb..4f438a28 100644 --- a/src/screens/Profile/Header/Handle.tsx +++ b/src/screens/Profile/Header/Handle.tsx @@ -5,7 +5,9 @@ import {Trans} from '@lingui/macro' import {Shadow} from '#/state/cache/types' import {isInvalidHandle} from 'lib/strings/handles' +import {isAndroid} from 'platform/detection' import {atoms as a, useTheme, web} from '#/alf' +import {NewskieDialog} from '#/components/NewskieDialog' import {Text} from '#/components/Typography' export function ProfileHeaderHandle({ @@ -17,7 +19,10 @@ export function ProfileHeaderHandle({ const invalidHandle = isInvalidHandle(profile.handle) const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy return ( - + + {profile.viewer?.followedBy && !blockHide ? ( From 35e54e24a0b08ce0f2e3389aeb4fb0f29778170e Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 18 Jun 2024 13:37:14 -0500 Subject: [PATCH 8/8] Explore fixes (#4540) * Use safe check, check for next page, handle varied lengths * Fix border width * Move safe check * Add font_heavy and use it on the explore page headers --------- Co-authored-by: Paul Frazee --- src/alf/atoms.ts | 3 +++ src/alf/tokens.ts | 1 + src/view/screens/Search/Explore.tsx | 31 ++++++++++++++++++----------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 1ccb0460..1dc2dfa7 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -267,6 +267,9 @@ export const atoms = { font_bold: { fontWeight: tokens.fontWeight.bold, }, + font_heavy: { + fontWeight: tokens.fontWeight.heavy, + }, italic: { fontStyle: 'italic', }, diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts index 1bddd95d..675844e2 100644 --- a/src/alf/tokens.ts +++ b/src/alf/tokens.ts @@ -118,6 +118,7 @@ export const fontWeight = { normal: '400', semibold: '500', bold: '600', + heavy: '700', } as const export const gradients = { diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx index c7f5f939..dd93bf81 100644 --- a/src/view/screens/Search/Explore.tsx +++ b/src/view/screens/Search/Explore.tsx @@ -64,7 +64,7 @@ function SuggestedItemsHeader({ fill={t.palette.primary_500} style={{marginLeft: -2}} /> - {title} + {title} {description} @@ -119,6 +119,9 @@ function LoadMore({ }) .filter(Boolean) as LoadMoreItems[] }, [item.items, moderationOpts]) + + if (items.length === 0) return null + const type = items[0].type return ( @@ -142,20 +145,20 @@ function LoadMore({ a.relative, { height: 32, - width: 32 + 15 * 3, + width: 32 + 15 * items.length, }, ]}> item.type === 'profile').slice(-3), - }) + if (hasNextProfilesPage) { + i.push({ + type: 'loadMore', + key: 'loadMoreProfiles', + isLoadingMore: isLoadingMoreProfiles, + onLoadMore: onLoadMoreProfiles, + items: i.filter(item => item.type === 'profile').slice(-3), + }) + } } else { if (profilesError) { i.push({ @@ -412,7 +417,7 @@ export function Explore() { message: _(msg`Failed to load feeds preferences`), error: cleanError(preferencesError), }) - } else { + } else if (hasNextFeedsPage) { i.push({ type: 'loadMore', key: 'loadMoreFeeds', @@ -454,6 +459,8 @@ export function Explore() { profilesError, feedsError, preferencesError, + hasNextProfilesPage, + hasNextFeedsPage, ]) const renderItem = React.useCallback(