about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.eslintrc.js1
-rw-r--r--__tests__/lib/string.test.ts74
-rw-r--r--assets/icons/newskie.svg1
-rw-r--r--eslint/index.js1
-rw-r--r--eslint/use-exact-imports.js22
-rw-r--r--package.json1
-rw-r--r--src/alf/atoms.ts3
-rw-r--r--src/alf/tokens.ts1
-rw-r--r--src/components/NewskieDialog.tsx81
-rw-r--r--src/components/forms/DateField/index.tsx7
-rw-r--r--src/components/icons/Newskie.tsx5
-rw-r--r--src/lib/hooks/__tests__/useTimeAgo.test.ts102
-rw-r--r--src/lib/hooks/useTimeAgo.ts95
-rw-r--r--src/lib/strings/time.ts42
-rw-r--r--src/locale/i18n.ts2
-rw-r--r--src/screens/Profile/Header/Handle.tsx7
-rw-r--r--src/view/com/util/TimeElapsed.tsx12
-rw-r--r--src/view/com/util/layouts/LoggedOutLayout.tsx8
-rw-r--r--src/view/icons/index.tsx6
-rw-r--r--src/view/screens/Log.tsx24
-rw-r--r--src/view/screens/Search/Explore.tsx31
-rw-r--r--yarn.lock7
22 files changed, 374 insertions, 159 deletions
diff --git a/.eslintrc.js b/.eslintrc.js
index 541b3d615..9d2b7bbb1 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 78478a26d..30072ccb1 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/assets/icons/newskie.svg b/assets/icons/newskie.svg
new file mode 100644
index 000000000..e3a9d83c8
--- /dev/null
+++ b/assets/icons/newskie.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#FFC404" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
diff --git a/eslint/index.js b/eslint/index.js
index bb31a942d..cf5d41225 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 000000000..06723043f
--- /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 e08aa9d29..29e198c9c 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/src/alf/atoms.ts b/src/alf/atoms.ts
index 1ccb0460c..1dc2dfa7b 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 1bddd95d4..675844e29 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/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx
new file mode 100644
index 000000000..fcdae0daa
--- /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 (
+    <View style={[a.pr_2xs]}>
+      <Button
+        label={_(
+          msg`This user is new here. Press for more info about when they joined.`,
+        )}
+        hitSlop={HITSLOP_10}
+        onPress={control.open}>
+        {({hovered, pressed}) => (
+          <Newskie
+            size="lg"
+            fill="#FFC404"
+            style={{
+              opacity: hovered || pressed ? 0.5 : 1,
+            }}
+          />
+        )}
+      </Button>
+
+      <Dialog.Outer control={control}>
+        <Dialog.Handle />
+        <Dialog.ScrollableInner
+          label={_(msg`New user info dialog`)}
+          style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
+          <View style={[a.gap_sm]}>
+            <Text style={[a.font_bold, a.text_xl]}>
+              <Trans>Say hello!</Trans>
+            </Text>
+            <Text style={[a.text_md]}>
+              <Trans>
+                {profileName} joined Bluesky{' '}
+                {timeAgo(createdAt, {format: 'long'})} ago
+              </Trans>
+            </Text>
+          </View>
+        </Dialog.ScrollableInner>
+      </Dialog.Outer>
+    </View>
+  )
+}
diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx
index e231ac5ba..c916f4efc 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({
       <DateFieldButton
         label={label}
         value={value}
-        onPress={control.open}
+        onPress={() => {
+          Keyboard.dismiss()
+          control.open()
+        }}
         isInvalid={isInvalid}
         accessibilityHint={accessibilityHint}
       />
diff --git a/src/components/icons/Newskie.tsx b/src/components/icons/Newskie.tsx
new file mode 100644
index 000000000..ddbb33201
--- /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/lib/hooks/__tests__/useTimeAgo.test.ts b/src/lib/hooks/__tests__/useTimeAgo.test.ts
new file mode 100644
index 000000000..e74f9c62d
--- /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 000000000..5f0782f96
--- /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<TimeAgoOptions, 'lingui'>,
+    ) => {
+      return dateDiff(date, Date.now(), {lingui: _, format: options?.format})
+    },
+    [_],
+  )
+}
+
+export function useTimeAgo(
+  date: number | string | Date,
+  options?: Omit<TimeAgoOptions, 'lingui'>,
+): 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 8de4b52ae..1194e0240 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/locale/i18n.ts b/src/locale/i18n.ts
index 9f75f83f3..baec4b8a2 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/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx
index 9ab24fbbe..4f438a286 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 (
-    <View style={[a.flex_row, a.gap_xs, a.align_center]} pointerEvents="none">
+    <View
+      style={[a.flex_row, a.gap_xs, a.align_center]}
+      pointerEvents={isAndroid ? 'box-only' : 'auto'}>
+      <NewskieDialog profile={profile} />
       {profile.viewer?.followedBy && !blockHide ? (
         <View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}>
           <Text style={[t.atoms.text, a.text_sm]}>
diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx
index a5d3a5372..d939b3163 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/com/util/layouts/LoggedOutLayout.tsx b/src/view/com/util/layouts/LoggedOutLayout.tsx
index 0272a44c6..c2c080c17 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 (
         <ScrollView
           style={styles.scrollview}
           keyboardShouldPersistTaps="handled"
-          keyboardDismissMode="on-drag">
+          keyboardDismissMode="none"
+          contentContainerStyle={[
+            {paddingBottom: isKeyboardVisible ? 300 : 0},
+          ]}>
           <View style={a.pt_md}>{children}</View>
         </ScrollView>
       )
diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx
index 025b903b2..b4feed990 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 e727a1fb8..e10aa83ab 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<string[]>([])
+  const timeAgo = useGetTimeAgo()
 
   useFocusEffect(
     React.useCallback(() => {
@@ -70,7 +72,7 @@ export function LogScreen({}: NativeStackScreenProps<
                     />
                   ) : undefined}
                   <Text type="sm" style={[styles.ts, pal.textLight]}>
-                    {ago(entry.timestamp)}
+                    {timeAgo(entry.timestamp)}
                   </Text>
                 </TouchableOpacity>
                 {expanded.includes(entry.id) ? (
diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx
index c7f5f939f..dd93bf813 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}}
           />
-          <Text style={[a.text_2xl, a.font_bold, t.atoms.text]}>{title}</Text>
+          <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>{title}</Text>
         </View>
         <Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
           {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,
                 },
               ]}>
               <View
                 style={[
                   a.align_center,
                   a.justify_center,
-                  a.border,
                   t.atoms.bg_contrast_25,
                   a.absolute,
                   {
                     width: 30,
                     height: 30,
                     left: 0,
+                    borderWidth: 1,
                     backgroundColor: t.palette.primary_500,
                     borderColor: t.atoms.bg.backgroundColor,
                     borderRadius: type === 'profile' ? 999 : 4,
@@ -169,13 +172,13 @@ function LoadMore({
                   <View
                     key={_item.key}
                     style={[
-                      a.border,
                       t.atoms.bg_contrast_25,
                       a.absolute,
                       {
                         width: 30,
                         height: 30,
                         left: (i + 1) * 15,
+                        borderWidth: 1,
                         borderColor: t.atoms.bg.backgroundColor,
                         borderRadius: _item.type === 'profile' ? 999 : 4,
                         zIndex: 3 - i,
@@ -350,13 +353,15 @@ export function Explore() {
         }
       }
 
-      i.push({
-        type: 'loadMore',
-        key: 'loadMoreProfiles',
-        isLoadingMore: isLoadingMoreProfiles,
-        onLoadMore: onLoadMoreProfiles,
-        items: i.filter(item => 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(
diff --git a/yarn.lock b/yarn.lock
index c56a56b28..51da5ea4f 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==