diff --git a/.eslintrc.js b/.eslintrc.js
index 92834fe68d..a999fd24b0 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -23,20 +23,12 @@ module.exports = {
'bsky-internal/avoid-unwrapped-text': [
'error',
{
- impliedTextComponents: [
- 'Button', // TODO: Not always safe.
- 'H1',
- 'H2',
- 'H3',
- 'H4',
- 'H5',
- 'H6',
- 'P',
- 'Prompt.Cancel', // TODO: Not always safe.
- 'Prompt.Action', // TODO: Not always safe.
- 'ToggleButton.Button', // TODO: Not always safe.
- ],
+ impliedTextComponents: ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P'],
impliedTextProps: [],
+ suggestedTextWrappers: {
+ Button: 'ButtonText',
+ 'ToggleButton.Button': 'ToggleButton.ButtonText',
+ },
},
],
'simple-import-sort/imports': [
diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml
index e126907890..2e07335eef 100644
--- a/.github/workflows/bundle-deploy-eas-update.yml
+++ b/.github/workflows/bundle-deploy-eas-update.yml
@@ -22,6 +22,9 @@ jobs:
bundleDeploy:
name: Bundle and Deploy EAS Update
runs-on: ubuntu-latest
+ concurrency:
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-deploy
+ cancel-in-progress: true
outputs:
fingerprint-is-different: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different }}
@@ -44,7 +47,18 @@ jobs:
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
- fetch-depth: 100
+ fetch-depth: 0
+
+ - name: ⬇️ Get last successful deployment commit from the cache
+ id: get-base-commit
+ uses: actions/cache@v4
+ with:
+ path: last-successful-commit-hash.txt
+ key: last-successful-deployment-commit-${{ github.ref_name }}
+
+ - name: Add the last successful deployment commit to the output
+ id: last-successful-commit
+ run: echo base-commit=$(cat last-successful-commit-hash.txt) >> "$GITHUB_OUTPUT"
- name: ⬇️ Fetch commits from base branch
if: ${{ github.ref != 'refs/heads/main' }}
@@ -57,12 +71,12 @@ jobs:
if [ -z "${{ inputs.channel == 'production' }}" ]; then
echo base-commit=$(git show-ref -s ${{ inputs.runtimeVersion }}) >> "$GITHUB_OUTPUT"
else
- echo base-commit=$(git log -n 1 --skip 1 main --pretty=format:'%H') >> "$GITHUB_OUTPUT"
+ echo base-commit=${{ steps.last-successful-commit.base-commit }} >> "$GITHUB_OUTPUT"
fi
- name: ✓ Make sure we found a base commit
run: |
- if [ -z "${{ steps.base-commit.outputs.base-commit }}" ]; then
+ if [ -z "${{ steps.base-commit.outputs.base-commit }}" && ${{ inputs.channel == 'production' }} ]; then
echo "Could not find a base commit for this release. Exiting."
exit 1
fi
@@ -82,7 +96,6 @@ jobs:
uses: expo/expo-github-action/fingerprint@main
with:
previous-git-commit: ${{ steps.base-commit.outputs.base-commit }}
- args:
- name: 👀 Debug fingerprint
id: fingerprint-debug
@@ -90,13 +103,26 @@ jobs:
echo "previousGitCommit=${{ steps.fingerprint.outputs.previous-git-commit }} currentGitCommit=${{ steps.fingerprint.outputs.current-git-commit }}"
echo "isPreviousFingerprintEmpty=${{ steps.fingerprint.outputs.previous-fingerprint == '' }}"
- fingerprintDiff="${{ steps.fingerprint.outputs.fingerprint-diff }}"
+ fingerprintDiff='$(echo "${{ steps.fingerprint.outputs.fingerprint-diff }}")'
+
if [[ $fingerprintDiff =~ "bareRncliAutolinking" || $fingerprintDiff =~ "expoAutolinkingAndroid" || $fingerprintDiff =~ "expoAutolinkingIos" ]]; then
echo fingerprint-is-different="true" >> "$GITHUB_OUTPUT"
else
echo fingerprint-is-different="false" >> "$GITHUB_OUTPUT"
fi
+ - name: Lint check
+ run: yarn lint
+
+ - name: Prettier check
+ run: yarn prettier --check .
+
+ - name: Check & compile i18n
+ run: yarn intl:build
+
+ - name: Type check
+ run: yarn typecheck
+
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
@@ -113,10 +139,6 @@ jobs:
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
uses: dcarbone/install-jq-action@v2
- - name: 🔤 Compile Translations
- if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
- run: yarn intl:build
-
- name: ✏️ Write environment variables
if: ${{ steps.fingerprint-debug.outputs.fingerprint-is-different == 'false'}}
run: |
@@ -136,11 +158,16 @@ jobs:
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
CHANNEL_NAME: ${{ inputs.channel || 'testflight' }}
+ - name: Save successful deployment commit hash
+ run: echo ${{ steps.fingerprint.outputs.current-git-commit }} > last-successful-commit-hash.txt
# GitHub actions are horrible so let's just copy paste this in
buildIfNecessaryIOS:
name: Build and Submit iOS
runs-on: macos-14
+ concurrency:
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
+ cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
@@ -206,6 +233,9 @@ jobs:
buildIfNecessaryAndroid:
name: Build and Submit Android
runs-on: ubuntu-latest
+ concurrency:
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-android
+ cancel-in-progress: false
needs: [ bundleDeploy ]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
diff --git a/.github/workflows/pull-request-commit.yml b/.github/workflows/pull-request-commit.yml
index 10c9a2c5ff..6c796fd7c4 100644
--- a/.github/workflows/pull-request-commit.yml
+++ b/.github/workflows/pull-request-commit.yml
@@ -13,13 +13,15 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
+permissions:
+ pull-requests: write
+ actions: write
+ contents: read
+
jobs:
webpack-analyzer:
runs-on: ubuntu-22.04
- if: ${{ github.event_name == 'pull_request' }}
- permissions:
- pull-requests: write
- actions: write
+ if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v4
@@ -94,11 +96,8 @@ jobs:
test-suite-fingerprint:
runs-on: ubuntu-22.04
- if: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
+ if: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' }}
concurrency: fingerprint-${{ github.event_name != 'pull_request' && 'main' || github.run_id }}
- permissions:
- pull-requests: write
- actions: write
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v4
diff --git a/eslint/__tests__/avoid-unwrapped-text.test.js b/eslint/__tests__/avoid-unwrapped-text.test.js
index 7c667b4a8a..a6762b8fd7 100644
--- a/eslint/__tests__/avoid-unwrapped-text.test.js
+++ b/eslint/__tests__/avoid-unwrapped-text.test.js
@@ -199,7 +199,7 @@ describe('avoid-unwrapped-text', () => {
{
code: `
-foo
}>
@@ -281,6 +281,170 @@ function MyText({ foo }) {
}
`,
},
+
+ {
+ code: `
+
+ {'foo'}
+
+ `,
+ },
+
+ {
+ code: `
+
+ {foo + 'foo'}
+
+ `,
+ },
+
+ {
+ code: `
+
+ {'foo'}
+
+ `,
+ },
+
+ {
+ code: `
+
+ {foo['bar'] && }
+
+ `,
+ },
+
+ {
+ code: `
+
+ {(foo === 'bar') && }
+
+ `,
+ },
+
+ {
+ code: `
+
+ {(foo !== 'bar') && }
+
+ `,
+ },
+
+ {
+ code: `
+
+ {\`foo\`}
+
+ `,
+ },
+
+ {
+ code: `
+
+ {\`foo\`}
+
+ `,
+ },
+
+ {
+ code: `
+
+ {_(msg\`foo\`)}
+
+ `,
+ },
+
+ {
+ code: `
+
+ {_(msg\`foo\`)}
+
+ `,
+ },
+
+ {
+ code: `
+
+
+
+
+
+ `,
+ },
+
+ {
+ code: `
+
+ stuff('foo')}>
+
+
+
+ `,
+ },
+
+ {
+ code: `
+
+ {renderItem('foo')}
+
+ `,
+ },
+
+ {
+ code: `
+
+ {foo === 'foo' && }
+
+ `,
+ },
+
+ {
+ code: `
+
+ {foo['foo'] && }
+
+ `,
+ },
+
+ {
+ code: `
+
+ {check('foo') && }
+
+ `,
+ },
+
+ {
+ code: `
+
+ {foo.bar && }
+
+ `,
+ },
+
+ {
+ code: `
+
+ {renderItem('foo')}
+
+ `,
+ },
+
+ {
+ code: `
+
+ {null}
+
+ `,
+ },
+
+ {
+ code: `
+
+ {null}
+
+ `,
+ },
],
invalid: [
@@ -455,6 +619,179 @@ function MyText({ foo }) {
`,
errors: 1,
},
+
+ {
+ code: `
+
+ {'foo'}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ {foo && 'foo'}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ {'foo'}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ {foo && {'foo'}}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ {10}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ {10}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ {foo + 10}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ {\`foo\`}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ {\`foo\`}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ {foo + \`foo\`}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ {_(msg\`foo\`)}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ {foo + _(msg\`foo\`)}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ {_(msg\`foo\`)}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ {foo + _(msg\`foo\`)}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ foo
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ foo
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ {foo}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+
+ {'foo'}
+
+ `,
+ errors: 1,
+ },
+
+ {
+ code: `
+foo
+}>
+
+
+ `,
+ errors: 1,
+ },
],
}
diff --git a/eslint/avoid-unwrapped-text.js b/eslint/avoid-unwrapped-text.js
index 79d099f00a..eef31f7951 100644
--- a/eslint/avoid-unwrapped-text.js
+++ b/eslint/avoid-unwrapped-text.js
@@ -33,6 +33,7 @@ exports.create = function create(context) {
const options = context.options[0] || {}
const impliedTextProps = options.impliedTextProps ?? []
const impliedTextComponents = options.impliedTextComponents ?? []
+ const suggestedTextWrappers = options.suggestedTextWrappers ?? {}
const textProps = [...impliedTextProps]
const textComponents = ['Text', ...impliedTextComponents]
@@ -54,13 +55,13 @@ exports.create = function create(context) {
return
}
if (tagName === 'Trans') {
- // Skip over it and check above.
+ // Exit and rely on the traversal for JSXElement (code below).
// TODO: Maybe validate that it's present.
- parent = parent.parent
- continue
+ return
}
- let message = 'Wrap this string in .'
- if (tagName !== 'View') {
+ const suggestedWrapper = suggestedTextWrappers[tagName]
+ let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
+ if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
@@ -112,6 +113,189 @@ exports.create = function create(context) {
continue
}
},
+ Literal(node) {
+ if (typeof node.value !== 'string' && typeof node.value !== 'number') {
+ return
+ }
+ let parent = node.parent
+ while (parent) {
+ if (parent.type === 'JSXElement') {
+ const tagName = getTagName(parent)
+ if (isTextComponent(tagName)) {
+ // We're good.
+ return
+ }
+ if (tagName === 'Trans') {
+ // Exit and rely on the traversal for JSXElement (code below).
+ // TODO: Maybe validate that it's present.
+ return
+ }
+ const suggestedWrapper = suggestedTextWrappers[tagName]
+ let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
+ if (tagName !== 'View' && !suggestedWrapper) {
+ message +=
+ ' If <' +
+ tagName +
+ '> is guaranteed to render , ' +
+ 'rename it to <' +
+ tagName +
+ 'Text> or add it to impliedTextComponents.'
+ }
+ context.report({
+ node,
+ message,
+ })
+ return
+ }
+
+ if (parent.type === 'BinaryExpression' && parent.operator === '+') {
+ parent = parent.parent
+ continue
+ }
+
+ if (
+ parent.type === 'JSXExpressionContainer' ||
+ parent.type === 'LogicalExpression'
+ ) {
+ parent = parent.parent
+ continue
+ }
+
+ // Be conservative for other types.
+ return
+ }
+ },
+ TemplateLiteral(node) {
+ let parent = node.parent
+ while (parent) {
+ if (parent.type === 'JSXElement') {
+ const tagName = getTagName(parent)
+ if (isTextComponent(tagName)) {
+ // We're good.
+ return
+ }
+ if (tagName === 'Trans') {
+ // Exit and rely on the traversal for JSXElement (code below).
+ // TODO: Maybe validate that it's present.
+ return
+ }
+ const suggestedWrapper = suggestedTextWrappers[tagName]
+ let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
+ if (tagName !== 'View' && !suggestedWrapper) {
+ message +=
+ ' If <' +
+ tagName +
+ '> is guaranteed to render , ' +
+ 'rename it to <' +
+ tagName +
+ 'Text> or add it to impliedTextComponents.'
+ }
+ context.report({
+ node,
+ message,
+ })
+ return
+ }
+
+ if (
+ parent.type === 'CallExpression' &&
+ parent.callee.type === 'Identifier' &&
+ parent.callee.name === '_'
+ ) {
+ // This is a user-facing string, keep going up.
+ parent = parent.parent
+ continue
+ }
+
+ if (parent.type === 'BinaryExpression' && parent.operator === '+') {
+ parent = parent.parent
+ continue
+ }
+
+ if (
+ parent.type === 'JSXExpressionContainer' ||
+ parent.type === 'LogicalExpression' ||
+ parent.type === 'TaggedTemplateExpression'
+ ) {
+ parent = parent.parent
+ continue
+ }
+
+ // Be conservative for other types.
+ return
+ }
+ },
+ JSXElement(node) {
+ if (getTagName(node) !== 'Trans') {
+ return
+ }
+ let parent = node.parent
+ while (parent) {
+ if (parent.type === 'JSXElement') {
+ const tagName = getTagName(parent)
+ if (isTextComponent(tagName)) {
+ // We're good.
+ return
+ }
+ if (tagName === 'Trans') {
+ // Exit and rely on the traversal for this JSXElement.
+ // TODO: Should nested even be allowed?
+ return
+ }
+ const suggestedWrapper = suggestedTextWrappers[tagName]
+ let message = `Wrap this in <${suggestedWrapper ?? 'Text'}>.`
+ if (tagName !== 'View' && !suggestedWrapper) {
+ message +=
+ ' If <' +
+ tagName +
+ '> is guaranteed to render , ' +
+ 'rename it to <' +
+ tagName +
+ 'Text> or add it to impliedTextComponents.'
+ }
+ context.report({
+ node,
+ message,
+ })
+ return
+ }
+
+ if (
+ parent.type === 'JSXAttribute' &&
+ parent.name.type === 'JSXIdentifier' &&
+ parent.parent.type === 'JSXOpeningElement' &&
+ parent.parent.parent.type === 'JSXElement'
+ ) {
+ const tagName = getTagName(parent.parent.parent)
+ const propName = parent.name.name
+ if (
+ textProps.includes(tagName + ' ' + propName) ||
+ propName === 'text' ||
+ propName.endsWith('Text')
+ ) {
+ // We're good.
+ return
+ }
+ const message =
+ 'Wrap this in .' +
+ ' If `' +
+ propName +
+ '` is guaranteed to be wrapped in , ' +
+ 'rename it to `' +
+ propName +
+ 'Text' +
+ '` or add it to impliedTextProps.'
+ context.report({
+ node,
+ message,
+ })
+ return
+ }
+
+ parent = parent.parent
+ continue
+ }
+ },
ReturnStatement(node) {
let fnScope = context.getScope()
while (fnScope && fnScope.type !== 'function') {
diff --git a/package.json b/package.json
index 433a1f80e0..55c5668544 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,7 @@
"e2e:run": "NODE_ENV=test detox test --configuration ios.sim.debug --take-screenshots all",
"perf:test": "NODE_ENV=test maestro test",
"perf:test:run": "NODE_ENV=test maestro test __e2e__/maestro/scroll.yaml",
- "perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
+ "perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand \"yarn perf:test\" --duration 150000 --resultsFilePath .perf/results.json",
"perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
"perf:measure": "NODE_ENV=test flashlight measure",
"intl:build": "yarn intl:extract && yarn intl:compile",
@@ -64,7 +64,7 @@
"@mattermost/react-native-paste-input": "^0.6.4",
"@miblanchard/react-native-slider": "^2.3.1",
"@radix-ui/react-dropdown-menu": "^2.0.6",
- "@react-native-async-storage/async-storage": "1.21.0",
+ "@react-native-async-storage/async-storage": "1.23.1",
"@react-native-masked-view/masked-view": "0.3.0",
"@react-native-menu/menu": "^0.8.0",
"@react-native-picker/picker": "2.6.1",
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 12b3fe4cbb..33d777971c 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -12,7 +12,6 @@ import {
ViewStyle,
} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
-import {Trans} from '@lingui/macro'
import {android, atoms as a, flatten, tokens, useTheme} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common'
@@ -59,6 +58,10 @@ export type ButtonState = {
export type ButtonContext = VariantProps & ButtonState
+type NonTextElements =
+ | React.ReactElement
+ | Iterable
+
export type ButtonProps = Pick<
PressableProps,
'disabled' | 'onPress' | 'testID'
@@ -68,11 +71,9 @@ export type ButtonProps = Pick<
testID?: string
label: string
style?: StyleProp
- children:
- | React.ReactNode
- | string
- | ((context: ButtonContext) => React.ReactNode | string)
+ children: NonTextElements | ((context: ButtonContext) => NonTextElements)
}
+
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
const Context = React.createContext({
@@ -404,15 +405,7 @@ export function Button({
)}
- {/* @ts-ignore */}
- {typeof children === 'string' || children?.type === Trans ? (
- /* @ts-ignore */
- {children}
- ) : typeof children === 'function' ? (
- children(context)
- ) : (
- children
- )}
+ {typeof children === 'function' ? children(context) : children}
)
diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts
index 859f8edd77..df8bbb0810 100644
--- a/src/components/Dialog/context.ts
+++ b/src/components/Dialog/context.ts
@@ -39,7 +39,8 @@ export function useDialogControl(): DialogOuterProps['control'] {
control.current.open()
},
close: cb => {
- control.current.close(cb)
+ control.current.close()
+ cb?.()
},
}),
[id, control],
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index 038f6295ae..8383979b3c 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -1,20 +1,24 @@
import React, {useImperativeHandle} from 'react'
-import {View, TouchableWithoutFeedback} from 'react-native'
-import {FocusScope} from '@tamagui/focus-scope'
-import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated'
+import {TouchableWithoutFeedback, View} from 'react-native'
+import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+import {FocusScope} from '@tamagui/focus-scope'
-import {useTheme, atoms as a, useBreakpoints, web, flatten} from '#/alf'
+import {logger} from '#/logger'
+import {useDialogStateControlContext} from '#/state/dialogs'
+import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {Context} from '#/components/Dialog/context'
+import {
+ DialogControlProps,
+ DialogInnerProps,
+ DialogOuterProps,
+} from '#/components/Dialog/types'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Portal} from '#/components/Portal'
-import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
-import {Context} from '#/components/Dialog/context'
-import {Button, ButtonIcon} from '#/components/Button'
-import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
-import {useDialogStateControlContext} from '#/state/dialogs'
-
-export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
+export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
export {Input} from '#/components/forms/TextField'
@@ -37,14 +41,31 @@ export function Outer({
setDialogIsOpen(control.id, true)
}, [setIsOpen, setDialogIsOpen, control.id])
- const close = React.useCallback(async () => {
+ const onCloseInner = React.useCallback(async () => {
setIsVisible(false)
await new Promise(resolve => setTimeout(resolve, 150))
setIsOpen(false)
setIsVisible(true)
setDialogIsOpen(control.id, false)
onClose?.()
- }, [onClose, setIsOpen, setDialogIsOpen, control.id])
+ }, [control.id, onClose, setDialogIsOpen])
+
+ const close = React.useCallback(
+ cb => {
+ try {
+ if (cb && typeof cb === 'function') {
+ cb()
+ }
+ } catch (e: any) {
+ logger.error(`Dialog closeCallback failed`, {
+ message: e.message,
+ })
+ } finally {
+ onCloseInner()
+ }
+ },
+ [onCloseInner],
+ )
useImperativeHandle(
control.ref,
@@ -52,7 +73,7 @@ export function Outer({
open,
close,
}),
- [open, close],
+ [close, open],
)
React.useEffect(() => {
@@ -65,7 +86,7 @@ export function Outer({
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
- }, [isOpen, close])
+ }, [close, isOpen])
const context = React.useMemo(
() => ({
@@ -82,7 +103,7 @@ export function Outer({
+ onPress={onCloseInner}>
- Retry
+
+ Retry
+
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 000d2a3cd5..c92fe26523 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -91,15 +91,13 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
}
export function Cancel({
- children,
cta,
-}: React.PropsWithChildren<{
+}: {
/**
- * Optional i18n string, used in lieu of `children` for simple buttons. If
- * undefined (and `children` is undefined), it will default to "Cancel".
+ * Optional i18n string. If undefined, it will default to "Cancel".
*/
cta?: string
-}>) {
+}) {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {close} = Dialog.useDialogContext()
@@ -114,33 +112,30 @@ export function Cancel({
size={gtMobile ? 'small' : 'medium'}
label={cta || _(msg`Cancel`)}
onPress={onPress}>
- {children ? children : {cta || _(msg`Cancel`)}}
+ {cta || _(msg`Cancel`)}
)
}
export function Action({
- children,
onPress,
color = 'primary',
cta,
testID,
-}: React.PropsWithChildren<{
+}: {
onPress: () => void
color?: ButtonColor
/**
- * Optional i18n string, used in lieu of `children` for simple buttons. If
- * undefined (and `children` is undefined), it will default to "Confirm".
+ * Optional i18n string. If undefined, it will default to "Confirm".
*/
cta?: string
testID?: string
-}>) {
+}) {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {close} = Dialog.useDialogContext()
const handleOnPress = React.useCallback(() => {
- close()
- onPress()
+ close(onPress)
}, [close, onPress])
return (
@@ -151,7 +146,7 @@ export function Action({
label={cta || _(msg`Confirm`)}
onPress={handleOnPress}
testID={testID}>
- {children ? children : {cta || _(msg`Confirm`)}}
+ {cta || _(msg`Confirm`)}
)
}
diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx
index 9cdaaaa9d1..7528426380 100644
--- a/src/components/forms/ToggleButton.tsx
+++ b/src/components/forms/ToggleButton.tsx
@@ -1,16 +1,15 @@
import React from 'react'
-import {View, AccessibilityProps, TextStyle, ViewStyle} from 'react-native'
+import {AccessibilityProps, TextStyle, View, ViewStyle} from 'react-native'
-import {atoms as a, useTheme, native} from '#/alf'
+import {atoms as a, native, useTheme} from '#/alf'
+import * as Toggle from '#/components/forms/Toggle'
import {Text} from '#/components/Typography'
-import * as Toggle from '#/components/forms/Toggle'
-
-export type ItemProps = Omit &
- AccessibilityProps &
- React.PropsWithChildren<{
+type ItemProps = Omit &
+ AccessibilityProps & {
+ children: React.ReactElement
testID?: string
- }>
+ }
export type GroupProps = Omit & {
multiple?: boolean
@@ -47,49 +46,42 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const state = Toggle.useItemContext()
- const {baseStyles, hoverStyles, activeStyles, textStyles} =
- React.useMemo(() => {
- const base: ViewStyle[] = []
- const hover: ViewStyle[] = []
- const active: ViewStyle[] = []
- const text: TextStyle[] = []
+ const {baseStyles, hoverStyles, activeStyles} = React.useMemo(() => {
+ const base: ViewStyle[] = []
+ const hover: ViewStyle[] = []
+ const active: ViewStyle[] = []
- hover.push(
- t.name === 'light' ? t.atoms.bg_contrast_100 : t.atoms.bg_contrast_25,
- )
+ hover.push(
+ t.name === 'light' ? t.atoms.bg_contrast_100 : t.atoms.bg_contrast_25,
+ )
- if (state.selected) {
- active.push({
- backgroundColor: t.palette.contrast_800,
- })
- text.push(t.atoms.text_inverted)
- hover.push({
- backgroundColor: t.palette.contrast_800,
- })
-
- if (state.disabled) {
- active.push({
- backgroundColor: t.palette.contrast_500,
- })
- }
- }
+ if (state.selected) {
+ active.push({
+ backgroundColor: t.palette.contrast_800,
+ })
+ hover.push({
+ backgroundColor: t.palette.contrast_800,
+ })
if (state.disabled) {
- base.push({
- backgroundColor: t.palette.contrast_100,
- })
- text.push({
- opacity: 0.5,
+ active.push({
+ backgroundColor: t.palette.contrast_500,
})
}
+ }
- return {
- baseStyles: base,
- hoverStyles: hover,
- activeStyles: active,
- textStyles: text,
- }
- }, [t, state])
+ if (state.disabled) {
+ base.push({
+ backgroundColor: t.palette.contrast_100,
+ })
+ }
+
+ return {
+ baseStyles: base,
+ hoverStyles: hover,
+ activeStyles: active,
+ }
+ }, [t, state])
return (
) {
activeStyles,
(state.hovered || state.pressed) && hoverStyles,
]}>
- {typeof children === 'string' ? (
-
- {children}
-
- ) : (
- children
- )}
+ {children}
)
}
+
+export function ButtonText({children}: {children: React.ReactNode}) {
+ const t = useTheme()
+ const state = Toggle.useItemContext()
+
+ const textStyles = React.useMemo(() => {
+ const text: TextStyle[] = []
+ if (state.selected) {
+ text.push(t.atoms.text_inverted)
+ }
+ if (state.disabled) {
+ text.push({
+ opacity: 0.5,
+ })
+ }
+ return text
+ }, [t, state])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/moderation/LabelPreference.tsx b/src/components/moderation/LabelPreference.tsx
index 990e736228..6191643038 100644
--- a/src/components/moderation/LabelPreference.tsx
+++ b/src/components/moderation/LabelPreference.tsx
@@ -84,17 +84,17 @@ export function Buttons({
onChange={onChange}>
{ignoreLabel && (
- {ignoreLabel}
+ {ignoreLabel}
)}
{warnLabel && (
- {warnLabel}
+ {warnLabel}
)}
{hideLabel && (
- {hideLabel}
+ {hideLabel}
)}
diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx
index 95e3d242b9..5cf86644c0 100644
--- a/src/components/moderation/LabelsOnMeDialog.tsx
+++ b/src/components/moderation/LabelsOnMeDialog.tsx
@@ -244,7 +244,7 @@ function AppealForm({
size="medium"
onPress={onPressBack}
label={_(msg`Back`)}>
- {_(msg`Back`)}
+ {_(msg`Back`)}
>
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 2de15b64ee..73e9876ac8 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -45,10 +45,12 @@ export type LogEvents = {
'onboarding:moderation:nextPressed': {}
'onboarding:finished:nextPressed': {}
'feed:endReached': {
+ feedUrl: string
feedType: string
itemCount: number
}
'feed:refresh': {
+ feedUrl: string
feedType: string
reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
}
@@ -65,6 +67,10 @@ export type LogEvents = {
logContext: 'Composer'
}
'post:like': {
+ doesLikerFollowPoster: boolean | undefined
+ doesPosterFollowLiker: boolean | undefined
+ likerClout: number | undefined
+ postClout: number | undefined
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:repost': {
@@ -77,6 +83,9 @@ export type LogEvents = {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'profile:follow': {
+ didBecomeMutual: boolean | undefined
+ followeeClout: number | undefined
+ followerClout: number | undefined
logContext:
| 'RecommendedFollowsItem'
| 'PostThreadItem'
diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx
index 68c63de616..c164616217 100644
--- a/src/lib/statsig/statsig.tsx
+++ b/src/lib/statsig/statsig.tsx
@@ -43,6 +43,14 @@ export function attachRouteToLogEvents(
getCurrentRouteName = getRouteName
}
+export function toClout(n: number | null | undefined): number | undefined {
+ if (n == null) {
+ return undefined
+ } else {
+ return Math.max(0, Math.round(Math.log(n)))
+ }
+}
+
export function logEvent(
eventName: E & string,
rawMetadata: LogEvents[E] & FlatJSONRecord,
@@ -78,6 +86,10 @@ function toStatsigUser(did: string | undefined) {
return {
userID,
platform: Platform.OS,
+ custom: {
+ // Need to specify here too for gating.
+ platform: Platform.OS,
+ },
}
}
diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx
index 01eca18760..134411903d 100644
--- a/src/screens/Login/ChooseAccountForm.tsx
+++ b/src/screens/Login/ChooseAccountForm.tsx
@@ -10,7 +10,7 @@ import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a} from '#/alf'
import {AccountList} from '#/components/AccountList'
-import {Button} from '#/components/Button'
+import {Button, ButtonText} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
import {FormContainer} from './FormContainer'
@@ -75,7 +75,7 @@ export const ChooseAccountForm = ({
color="secondary"
size="medium"
onPress={onPressBack}>
- {_(msg`Back`)}
+ {_(msg`Back`)}
diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx
index 5960ff4fdb..0c541c5a90 100644
--- a/src/screens/Login/LoginForm.tsx
+++ b/src/screens/Login/LoginForm.tsx
@@ -21,9 +21,7 @@ export const LoginForm = ({
error,
serviceUrl,
serviceDescription,
- setError,
setServiceUrl,
- onPressRetryConnect,
onPressBack,
}: {
error: string
diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx
index cfaf20ffe1..d48234cca8 100644
--- a/src/screens/Onboarding/Layout.tsx
+++ b/src/screens/Onboarding/Layout.tsx
@@ -17,7 +17,7 @@ import {
useTheme,
web,
} from '#/alf'
-import {Button, ButtonIcon} from '#/components/Button'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {createPortalGroup} from '#/components/Portal'
import {leading, P, Text} from '#/components/Typography'
@@ -73,7 +73,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
onPress={() => onboardDispatch({type: 'skip'})}
// DEV ONLY
label="Clear onboarding state">
- Clear
+ Clear
)}
diff --git a/src/screens/Onboarding/StepModeration/ModerationOption.tsx b/src/screens/Onboarding/StepModeration/ModerationOption.tsx
index ac02a874cb..d6334e6bda 100644
--- a/src/screens/Onboarding/StepModeration/ModerationOption.tsx
+++ b/src/screens/Onboarding/StepModeration/ModerationOption.tsx
@@ -1,17 +1,17 @@
import React from 'react'
import {View} from 'react-native'
-import {LabelPreference, InterpretedLabelValueDefinition} from '@atproto/api'
-import {useLingui} from '@lingui/react'
+import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
import {
usePreferencesQuery,
usePreferencesSetContentLabelMutation,
} from '#/state/queries/preferences'
import {atoms as a, useTheme} from '#/alf'
-import {Text} from '#/components/Typography'
import * as ToggleButton from '#/components/forms/ToggleButton'
-import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
+import {Text} from '#/components/Typography'
export function ModerationOption({
labelValueDefinition,
@@ -83,13 +83,13 @@ export function ModerationOption({
values={[visibility ?? 'hide']}
onChange={onChange}>
- {labels.show}
+ {labels.show}
- {labels.warn}
+ {labels.warn}
- {labels.hide}
+ {labels.hide}
)}
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index 420b54f491..d6c6ff7bd1 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -10,7 +10,9 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
import {useModalControls} from '#/state/modals'
import {
@@ -78,6 +80,9 @@ let ProfileHeaderStandard = ({
})
}, [track, openModal, profile])
+ const autoExpandSuggestionsOnProfileFollow = useGate(
+ 'autoexpand_suggestions_on_profile_follow',
+ )
const onPressFollow = () => {
requireAuth(async () => {
try {
@@ -91,6 +96,9 @@ let ProfileHeaderStandard = ({
)}`,
),
)
+ if (isWeb && autoExpandSuggestionsOnProfileFollow) {
+ setShowSuggestedFollows(true)
+ }
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to follow', {message: String(e)})
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index 746dedad27..77497f6bab 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -1,13 +1,14 @@
import {useCallback} from 'react'
-import {AppBskyFeedDefs, AtUri} from '@atproto/api'
+import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {track} from '#/lib/analytics/analytics'
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
-import {logEvent, LogEvents} from '#/lib/statsig/statsig'
+import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig'
import {updatePostShadow} from '#/state/cache/post-shadow'
import {Shadow} from '#/state/cache/types'
-import {getAgent} from '#/state/session'
+import {getAgent, useSession} from '#/state/session'
+import {findProfileQueryData} from './profile'
const RQKEY_ROOT = 'post'
export const RQKEY = (postUri: string) => [RQKEY_ROOT, postUri]
@@ -68,7 +69,7 @@ export function usePostLikeMutationQueue(
const postUri = post.uri
const postCid = post.cid
const initialLikeUri = post.viewer?.like
- const likeMutation = usePostLikeMutation(logContext)
+ const likeMutation = usePostLikeMutation(logContext, post)
const unlikeMutation = usePostUnlikeMutation(logContext)
const queueToggle = useToggleMutationQueue({
@@ -117,15 +118,40 @@ export function usePostLikeMutationQueue(
return [queueLike, queueUnlike]
}
-function usePostLikeMutation(logContext: LogEvents['post:like']['logContext']) {
+function usePostLikeMutation(
+ logContext: LogEvents['post:like']['logContext'],
+ post: Shadow,
+) {
+ const {currentAccount} = useSession()
+ const queryClient = useQueryClient()
+ const postAuthor = post.author
return useMutation<
{uri: string}, // responds with the uri of the like
Error,
{uri: string; cid: string} // the post's uri and cid
>({
- mutationFn: post => {
- logEvent('post:like', {logContext})
- return getAgent().like(post.uri, post.cid)
+ mutationFn: ({uri, cid}) => {
+ let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined
+ if (currentAccount) {
+ ownProfile = findProfileQueryData(queryClient, currentAccount.did)
+ }
+ logEvent('post:like', {
+ logContext,
+ doesPosterFollowLiker: postAuthor.viewer
+ ? Boolean(postAuthor.viewer.followedBy)
+ : undefined,
+ doesLikerFollowPoster: postAuthor.viewer
+ ? Boolean(postAuthor.viewer.following)
+ : undefined,
+ likerClout: toClout(ownProfile?.followersCount),
+ postClout:
+ post.likeCount != null &&
+ post.repostCount != null &&
+ post.replyCount != null
+ ? toClout(post.likeCount + post.repostCount + post.replyCount)
+ : undefined,
+ })
+ return getAgent().like(uri, cid)
},
onSuccess() {
track('Post:Like')
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index 2094e0c3a2..a962fecff7 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -20,7 +20,7 @@ import {track} from '#/lib/analytics/analytics'
import {uploadBlob} from '#/lib/api'
import {until} from '#/lib/async/until'
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
-import {logEvent, LogEvents} from '#/lib/statsig/statsig'
+import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig'
import {Shadow} from '#/state/cache/types'
import {STALE} from '#/state/queries'
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
@@ -202,7 +202,7 @@ export function useProfileFollowMutationQueue(
const queryClient = useQueryClient()
const did = profile.did
const initialFollowingUri = profile.viewer?.following
- const followMutation = useProfileFollowMutation(logContext)
+ const followMutation = useProfileFollowMutation(logContext, profile)
const unfollowMutation = useProfileUnfollowMutation(logContext)
const queueToggle = useToggleMutationQueue({
@@ -252,10 +252,24 @@ export function useProfileFollowMutationQueue(
function useProfileFollowMutation(
logContext: LogEvents['profile:follow']['logContext'],
+ profile: Shadow,
) {
+ const {currentAccount} = useSession()
+ const queryClient = useQueryClient()
return useMutation<{uri: string; cid: string}, Error, {did: string}>({
mutationFn: async ({did}) => {
- logEvent('profile:follow', {logContext})
+ let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined
+ if (currentAccount) {
+ ownProfile = findProfileQueryData(queryClient, currentAccount.did)
+ }
+ logEvent('profile:follow', {
+ logContext,
+ didBecomeMutual: profile.viewer
+ ? Boolean(profile.viewer.followedBy)
+ : undefined,
+ followeeClout: toClout(profile.followersCount),
+ followerClout: toClout(ownProfile?.followersCount),
+ })
return await getAgent().follow(did)
},
onSuccess(data, variables) {
@@ -530,3 +544,12 @@ export function* findAllProfilesInQueryData(
}
}
}
+
+export function findProfileQueryData(
+ queryClient: QueryClient,
+ did: string,
+): AppBskyActorDefs.ProfileViewDetailed | undefined {
+ return queryClient.getQueryData(
+ RQKEY(did),
+ )
+}
diff --git a/src/state/shell/selected-feed.tsx b/src/state/shell/selected-feed.tsx
index a05d8661b4..5c0ac0b02a 100644
--- a/src/state/shell/selected-feed.tsx
+++ b/src/state/shell/selected-feed.tsx
@@ -1,6 +1,8 @@
import React from 'react'
-import * as persisted from '#/state/persisted'
+
+import {useGate} from '#/lib/statsig/statsig'
import {isWeb} from '#/platform/detection'
+import * as persisted from '#/state/persisted'
type StateContext = string
type SetContext = (v: string) => void
@@ -8,7 +10,7 @@ type SetContext = (v: string) => void
const stateContext = React.createContext('home')
const setContext = React.createContext((_: string) => {})
-function getInitialFeed() {
+function getInitialFeed(startSessionWithFollowing: boolean) {
if (isWeb) {
if (window.location.pathname === '/') {
const params = new URLSearchParams(window.location.search)
@@ -24,16 +26,21 @@ function getInitialFeed() {
return feedFromSession
}
}
- const feedFromPersisted = persisted.get('lastSelectedHomeFeed')
- if (feedFromPersisted) {
- // Fall back to the last chosen one across all tabs.
- return feedFromPersisted
+ if (!startSessionWithFollowing) {
+ const feedFromPersisted = persisted.get('lastSelectedHomeFeed')
+ if (feedFromPersisted) {
+ // Fall back to the last chosen one across all tabs.
+ return feedFromPersisted
+ }
}
return 'home'
}
export function Provider({children}: React.PropsWithChildren<{}>) {
- const [state, setState] = React.useState(getInitialFeed)
+ const startSessionWithFollowing = useGate('start_session_with_following')
+ const [state, setState] = React.useState(() =>
+ getInitialFeed(startSessionWithFollowing),
+ )
const saveState = React.useCallback((feed: string) => {
setState(feed)
diff --git a/src/view/com/auth/server-input/index.tsx b/src/view/com/auth/server-input/index.tsx
index 8aa23c263c..0d64650ddb 100644
--- a/src/view/com/auth/server-input/index.tsx
+++ b/src/view/com/auth/server-input/index.tsx
@@ -87,13 +87,17 @@ export function ServerInputDialog({
values={fixedOption}
onChange={setFixedOption}>
- {_(msg`Bluesky`)}
+
+ {_(msg`Bluesky`)}
+
- {_(msg`Custom`)}
+
+ {_(msg`Custom`)}
+
@@ -163,7 +167,7 @@ export function ServerInputDialog({
size="small"
onPress={() => control.close()}
label={_(msg`Done`)}>
- {_(msg`Done`)}
+ {_(msg`Done`)}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 05367f0704..a3ee97a2ed 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -508,11 +508,7 @@ export const ComposePost = observer(function ComposePost({
title={_(msg`Discard draft?`)}
description={_(msg`Are you sure you'd like to discard this draft?`)}
onConfirm={() => {
- if (isWeb) {
- onClose()
- } else {
- discardPromptControl.close(onClose)
- }
+ discardPromptControl.close(onClose)
}}
confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative"
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index 2d0736b096..25c7e1006d 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -1,28 +1,29 @@
import React from 'react'
-import {useNavigation} from '@react-navigation/native'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {useQueryClient} from '@tanstack/react-query'
-import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
-import {MainScrollProvider} from '../util/MainScrollProvider'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {useSetMinimalShellMode} from '#/state/shell'
-import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
-import {ComposeIcon2} from 'lib/icons'
-import {s} from 'lib/styles'
-import {View, useWindowDimensions} from 'react-native'
-import {ListMethods} from '../util/List'
-import {Feed} from '../posts/Feed'
-import {FAB} from '../util/fab/FAB'
-import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
+import {useWindowDimensions, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useSession} from '#/state/session'
-import {useComposerControls} from '#/state/shell/composer'
-import {listenSoftReset} from '#/state/events'
-import {truncateAndInvalidate} from '#/state/queries/util'
-import {TabState, getTabState, getRootNavigation} from '#/lib/routes/helpers'
+import {useNavigation} from '@react-navigation/native'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
+import {logEvent, useGate} from '#/lib/statsig/statsig'
import {isNative} from '#/platform/detection'
-import {logEvent} from '#/lib/statsig/statsig'
+import {listenSoftReset} from '#/state/events'
+import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
+import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
+import {truncateAndInvalidate} from '#/state/queries/util'
+import {useSession} from '#/state/session'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {useComposerControls} from '#/state/shell/composer'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {ComposeIcon2} from 'lib/icons'
+import {s} from 'lib/styles'
+import {Feed} from '../posts/Feed'
+import {FAB} from '../util/fab/FAB'
+import {ListMethods} from '../util/List'
+import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
+import {MainScrollProvider} from '../util/MainScrollProvider'
const POLL_FREQ = 60e3 // 60sec
@@ -71,6 +72,7 @@ export function FeedPage({
setHasNew(false)
logEvent('feed:refresh', {
feedType: feed.split('|')[0],
+ feedUrl: feed,
reason: 'soft-reset',
})
}
@@ -96,10 +98,22 @@ export function FeedPage({
setHasNew(false)
logEvent('feed:refresh', {
feedType: feed.split('|')[0],
+ feedUrl: feed,
reason: 'load-latest',
})
}, [scrollToTop, feed, queryClient, setHasNew])
+ let feedPollInterval
+ if (
+ useGate('disable_poll_on_discover') &&
+ feed === // Discover
+ 'feedgen|at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'
+ ) {
+ feedPollInterval = undefined
+ } else {
+ feedPollInterval = POLL_FREQ
+ }
+
return (
@@ -108,7 +122,7 @@ export function FeedPage({
enabled={isPageFocused}
feed={feed}
feedParams={feedParams}
- pollInterval={POLL_FREQ}
+ pollInterval={feedPollInterval}
disablePoll={hasNew}
scrollElRef={scrollElRef}
onScrolledDownChange={setIsScrolledDown}
diff --git a/src/view/com/post-thread/PostThreadFollowBtn.tsx b/src/view/com/post-thread/PostThreadFollowBtn.tsx
index 45c3771f50..8b297121eb 100644
--- a/src/view/com/post-thread/PostThreadFollowBtn.tsx
+++ b/src/view/com/post-thread/PostThreadFollowBtn.tsx
@@ -1,24 +1,25 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {useNavigation} from '@react-navigation/native'
import {AppBskyActorDefs} from '@atproto/api'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useNavigation} from '@react-navigation/native'
+import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
-import {Text} from 'view/com/util/text/Text'
-import * as Toast from 'view/com/util/Toast'
-import {s} from 'lib/styles'
+import {track} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {s} from 'lib/styles'
import {Shadow, useProfileShadow} from 'state/cache/profile-shadow'
-import {track} from 'lib/analytics/analytics'
import {
useProfileFollowMutationQueue,
useProfileQuery,
} from 'state/queries/profile'
import {useRequireAuth} from 'state/session'
+import {Text} from 'view/com/util/text/Text'
+import * as Toast from 'view/com/util/Toast'
export function PostThreadFollowBtn({did}: {did: string}) {
const {data: profile, isLoading} = useProfileQuery({did})
@@ -47,8 +48,10 @@ function PostThreadFollowBtnLoaded({
'PostThreadItem',
)
const requireAuth = useRequireAuth()
+ const showFollowBackLabel = useGate('show_follow_back_label')
const isFollowing = !!profile.viewer?.following
+ const isFollowedBy = !!profile.viewer?.followedBy
const [wasFollowing, setWasFollowing] = React.useState(isFollowing)
// This prevents the button from disappearing as soon as we follow.
@@ -136,7 +139,15 @@ function PostThreadFollowBtnLoaded({
type="button"
style={[!isFollowing ? palInverted.text : pal.text, s.bold]}
numberOfLines={1}>
- {!isFollowing ? Follow : Following}
+ {!isFollowing ? (
+ showFollowBackLabel && isFollowedBy ? (
+ Follow Back
+ ) : (
+ Follow
+ )
+ ) : (
+ Following
+ )}
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 8afcce94f2..fb67d35c5c 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -8,32 +8,33 @@ import {
View,
ViewStyle,
} from 'react-native'
-import {useQueryClient} from '@tanstack/react-query'
-import {List, ListRef} from '../util/List'
-import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
-import {FeedErrorMessage} from './FeedErrorMessage'
-import {FeedSlice} from './FeedSlice'
-import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {useTheme} from 'lib/ThemeContext'
-import {logger} from '#/logger'
-import {
- RQKEY,
- FeedDescriptor,
- FeedParams,
- usePostFeedQuery,
- pollLatest,
-} from '#/state/queries/post-feed'
-import {isWeb} from '#/platform/detection'
-import {listenPostCreated} from '#/state/events'
-import {useSession} from '#/state/session'
-import {STALE} from '#/state/queries'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
+import {useQueryClient} from '@tanstack/react-query'
+
import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home'
-import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {logEvent} from '#/lib/statsig/statsig'
+import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
+import {listenPostCreated} from '#/state/events'
+import {STALE} from '#/state/queries'
+import {
+ FeedDescriptor,
+ FeedParams,
+ pollLatest,
+ RQKEY,
+ usePostFeedQuery,
+} from '#/state/queries/post-feed'
+import {useSession} from '#/state/session'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
+import {useTheme} from 'lib/ThemeContext'
+import {List, ListRef} from '../util/List'
+import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
+import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
+import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
+import {FeedErrorMessage} from './FeedErrorMessage'
+import {FeedSlice} from './FeedSlice'
const LOADING_ITEM = {_reactKey: '__loading__'}
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
@@ -217,6 +218,7 @@ let Feed = ({
track('Feed:onRefresh')
logEvent('feed:refresh', {
feedType: feedType,
+ feedUrl: feed,
reason: 'pull-to-refresh',
})
setIsPTRing(true)
@@ -227,13 +229,14 @@ let Feed = ({
logger.error('Failed to refresh posts feed', {message: err})
}
setIsPTRing(false)
- }, [refetch, track, setIsPTRing, onHasNew, feedType])
+ }, [refetch, track, setIsPTRing, onHasNew, feed, feedType])
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
logEvent('feed:endReached', {
feedType: feedType,
+ feedUrl: feed,
itemCount: feedItems.length,
})
track('Feed:onEndReached')
@@ -248,6 +251,7 @@ let Feed = ({
isError,
fetchNextPage,
track,
+ feed,
feedType,
feedItems.length,
])
diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx
index 1387c6202c..f88d500f97 100644
--- a/src/view/screens/DebugMod.tsx
+++ b/src/view/screens/DebugMod.tsx
@@ -274,13 +274,13 @@ export const DebugModScreen = ({}: NativeStackScreenProps<
values={scenario}
onChange={setScenario}>
- Label
+ Label
- Block
+ Block
- Mute
+ Mute
@@ -474,16 +474,16 @@ export const DebugModScreen = ({}: NativeStackScreenProps<
- Post
+ Post
- Notifications
+ Notifications
- Account
+ Account
- Data
+ Data
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 99ac8c44af..e6ba0395cf 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,23 +1,25 @@
import React from 'react'
-import {View, ActivityIndicator, StyleSheet} from 'react-native'
+import {ActivityIndicator, AppState, StyleSheet, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
-import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
+
+import {useSetTitle} from '#/lib/hooks/useSetTitle'
+import {useGate} from '#/lib/statsig/statsig'
+import {emitSoftReset} from '#/state/events'
+import {FeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
+import {useSession} from '#/state/session'
+import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
+import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
+import {HomeTabNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
+import {FeedPage} from 'view/com/feeds/FeedPage'
+import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
+import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
-import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
-import {HomeHeader} from '../com/home/HomeHeader'
-import {Pager, RenderTabBarFnProps, PagerRef} from 'view/com/pager/Pager'
-import {FeedPage} from 'view/com/feeds/FeedPage'
import {HomeLoggedOutCTA} from '../com/auth/HomeLoggedOutCTA'
-import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
-import {usePreferencesQuery} from '#/state/queries/preferences'
-import {usePinnedFeedsInfos, FeedSourceInfo} from '#/state/queries/feed'
-import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
-import {emitSoftReset} from '#/state/events'
-import {useSession} from '#/state/session'
-import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
-import {useSetTitle} from '#/lib/hooks/useSetTitle'
+import {HomeHeader} from '../com/home/HomeHeader'
type Props = NativeStackScreenProps
export function HomeScreen(props: Props) {
@@ -94,6 +96,22 @@ function HomeScreenReady({
}, [setDrawerSwipeDisabled, selectedIndex, setMinimalShellMode]),
)
+ const disableMinShellOnForegrounding = useGate(
+ 'disable_min_shell_on_foregrounding',
+ )
+ React.useEffect(() => {
+ if (disableMinShellOnForegrounding) {
+ const listener = AppState.addEventListener('change', nextAppState => {
+ if (nextAppState === 'active') {
+ setMinimalShellMode(false)
+ }
+ })
+ return () => {
+ listener.remove()
+ }
+ }
+ }, [setMinimalShellMode, disableMinShellOnForegrounding])
+
const onPageSelected = React.useCallback(
(index: number) => {
setMinimalShellMode(false)
diff --git a/src/view/screens/Settings/ExportCarDialog.tsx b/src/view/screens/Settings/ExportCarDialog.tsx
index 3ec37e85e5..e901fb0905 100644
--- a/src/view/screens/Settings/ExportCarDialog.tsx
+++ b/src/view/screens/Settings/ExportCarDialog.tsx
@@ -92,7 +92,9 @@ export function ExportCarDialog({
size={gtMobile ? 'small' : 'large'}
onPress={() => control.close()}
label={_(msg`Done`)}>
- {_(msg`Done`)}
+
+ Done
+
diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx
index ad2fff3f4a..cae8ec3144 100644
--- a/src/view/screens/Storybook/Buttons.tsx
+++ b/src/view/screens/Storybook/Buttons.tsx
@@ -4,15 +4,15 @@ import {View} from 'react-native'
import {atoms as a} from '#/alf'
import {
Button,
- ButtonVariant,
ButtonColor,
ButtonIcon,
ButtonText,
+ ButtonVariant,
} from '#/components/Button'
-import {H1} from '#/components/Typography'
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
+import {H1} from '#/components/Typography'
export function Buttons() {
return (
@@ -29,7 +29,7 @@ export function Buttons() {
color={color as ButtonColor}
size="large"
label="Click here">
- Button
+ Button
))}
@@ -54,7 +54,7 @@ export function Buttons() {
color={name as ButtonColor}
size="large"
label="Click here">
- Button
+ Button
),
@@ -77,7 +77,7 @@ export function Buttons() {
color={name as ButtonColor}
size="large"
label="Click here">
- Button
+ Button
),
diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx
index 41863bd9c4..4722784cae 100644
--- a/src/view/screens/Storybook/Dialogs.tsx
+++ b/src/view/screens/Storybook/Dialogs.tsx
@@ -3,7 +3,7 @@ import {View} from 'react-native'
import {useDialogStateControlContext} from '#/state/dialogs'
import {atoms as a} from '#/alf'
-import {Button} from '#/components/Button'
+import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
import {H3, P} from '#/components/Typography'
@@ -26,7 +26,7 @@ export function Dialogs() {
basic.open()
}}
label="Open basic dialog">
- Open all dialogs
+ Open all dialogs
@@ -67,8 +67,8 @@ export function Dialogs() {
description, as well as two actions.
- Cancel
- {}}>Confirm
+
+ {}} />
@@ -102,7 +102,7 @@ export function Dialogs() {
size="small"
onPress={closeAllDialogs}
label="Close all dialogs">
- Close all dialogs
+ Close all dialogs
@@ -116,7 +116,7 @@ export function Dialogs() {
})
}
label="Open basic dialog">
- Close dialog
+ Close dialog
diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx
index 182eacfde8..1e4efdcc7d 100644
--- a/src/view/screens/Storybook/Forms.tsx
+++ b/src/view/screens/Storybook/Forms.tsx
@@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import {atoms as a} from '#/alf'
-import {Button} from '#/components/Button'
+import {Button, ButtonText} from '#/components/Button'
import {DateField, LabelText} from '#/components/forms/DateField'
import * as TextField from '#/components/forms/TextField'
import * as Toggle from '#/components/forms/Toggle'
@@ -191,7 +191,7 @@ export function Forms() {
setToggleGroupBValues(['a', 'b'])
setToggleGroupCValues(['a'])
}}>
- Reset all toggles
+ Reset all toggles
@@ -202,13 +202,13 @@ export function Forms() {
values={toggleGroupDValues}
onChange={setToggleGroupDValues}>
- Hide
+ Hide
- Warn
+ Warn
- Show
+ Show
@@ -218,13 +218,13 @@ export function Forms() {
values={toggleGroupDValues}
onChange={setToggleGroupDValues}>
- Hide
+ Hide
- Warn
+ Warn
- Show
+ Show
diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx
index 3a2e2f3696..35a6666016 100644
--- a/src/view/screens/Storybook/index.tsx
+++ b/src/view/screens/Storybook/index.tsx
@@ -1,22 +1,21 @@
import React from 'react'
import {View} from 'react-native'
-import {CenteredView, ScrollView} from '#/view/com/util/Views'
-import {atoms as a, useTheme, ThemeProvider} from '#/alf'
import {useSetThemePrefs} from '#/state/shell'
-import {Button} from '#/components/Button'
-
+import {CenteredView, ScrollView} from '#/view/com/util/Views'
+import {atoms as a, ThemeProvider, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Breakpoints} from './Breakpoints'
+import {Buttons} from './Buttons'
+import {Dialogs} from './Dialogs'
+import {Forms} from './Forms'
+import {Icons} from './Icons'
+import {Links} from './Links'
+import {Menus} from './Menus'
+import {Shadows} from './Shadows'
+import {Spacing} from './Spacing'
import {Theming} from './Theming'
import {Typography} from './Typography'
-import {Spacing} from './Spacing'
-import {Buttons} from './Buttons'
-import {Links} from './Links'
-import {Forms} from './Forms'
-import {Dialogs} from './Dialogs'
-import {Breakpoints} from './Breakpoints'
-import {Shadows} from './Shadows'
-import {Icons} from './Icons'
-import {Menus} from './Menus'
export function Storybook() {
const t = useTheme()
@@ -33,7 +32,7 @@ export function Storybook() {
size="small"
label='Set theme to "system"'
onPress={() => setColorMode('system')}>
- System
+ System
diff --git a/yarn.lock b/yarn.lock
index 323ac3c106..4b6decea91 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4836,10 +4836,10 @@
dependencies:
"@babel/runtime" "^7.13.10"
-"@react-native-async-storage/async-storage@1.21.0":
- version "1.21.0"
- resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-1.21.0.tgz#d7e370028e228ab84637016ceeb495878b7a44c8"
- integrity sha512-JL0w36KuFHFCvnbOXRekqVAUplmOyT/OuCQkogo6X98MtpSaJOKEAeZnYO8JB0U/RIEixZaGI5px73YbRm/oag==
+"@react-native-async-storage/async-storage@1.23.1":
+ version "1.23.1"
+ resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-1.23.1.tgz#cad3cd4fab7dacfe9838dce6ecb352f79150c883"
+ integrity sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==
dependencies:
merge-options "^3.0.4"