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"