diff --git a/.eslintrc.js b/.eslintrc.js index 8dab053c24..37ed895aa4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -43,6 +43,7 @@ module.exports = { suggestedTextWrappers: { Button: 'ButtonText', 'ToggleButton.Button': 'ToggleButton.ButtonText', + 'SegmentedControl.Item': 'SegmentedControl.ItemText', }, }, ], diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml index 3c9c044e36..235aa9af2c 100644 --- a/.github/workflows/build-submit-android.yml +++ b/.github/workflows/build-submit-android.yml @@ -15,7 +15,7 @@ jobs: build: if: github.repository == 'bluesky-social/social-app' name: Build and Submit Android - runs-on: ubuntu-latest + runs-on: Linux-x64-32core steps: - name: Check for EXPO_TOKEN run: > diff --git a/__e2e__/flows/create-account.yml b/__e2e__/flows/create-account.yml index eb2f05ca1e..46a9275cc7 100644 --- a/__e2e__/flows/create-account.yml +++ b/__e2e__/flows/create-account.yml @@ -31,9 +31,15 @@ appId: xyz.blueskyweb.app text: "Your account" - tapOn: id: "nextBtn" +- waitForAnimationToEnd - tapOn: - text: "Not Now" - optional: true + # save password thing no longer seems to be in hierarchy + # tap on something else instead + point: 20%,20% + repeat: 2 + delay: 1000 +- tapOn: + id: "handleInput" - inputText: "e2e-test" - extendedWaitUntil: visible: diff --git a/__e2e__/flows/home-screen.yml b/__e2e__/flows/home-screen.yml index 420f68b92b..df2f884693 100644 --- a/__e2e__/flows/home-screen.yml +++ b/__e2e__/flows/home-screen.yml @@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app - runScript: file: ../setupServer.js env: - SERVER_PATH: ?users&follows&posts&feeds + SERVER_PATH: ?users&follows&posts&feeds - runFlow: file: ../setupApp.yml - tapOn: @@ -11,7 +11,7 @@ appId: xyz.blueskyweb.app - extendedWaitUntil: visible: - text: "Feeds ✨" + text: "Feeds ✨" - tapOn: label: "Can go to feeds page using feeds button in tab bar" text: "Feeds ✨" @@ -50,6 +50,6 @@ appId: xyz.blueskyweb.app label: "Can delete posts" id: "postDropdownBtn" childOf: - id: "feedItem-by-alice.test" + id: "feedItem-by-alice.test" - tapOn: "Delete post" - tapOn: "Delete" diff --git a/__e2e__/flows/post-report-flow.yml b/__e2e__/flows/post-report-flow.yml index a509fcc024..7806dba7d6 100644 --- a/__e2e__/flows/post-report-flow.yml +++ b/__e2e__/flows/post-report-flow.yml @@ -17,7 +17,8 @@ appId: xyz.blueskyweb.app index: 0 - tapOn: id: "postDropdownReportBtn" -- tapOn: "Create report for Misleading Post" +- tapOn: "Create report for Misleading" +- tapOn: "Create report for Spam" - tapOn: "Send report to Dev-env Moderation" - tapOn: point: "50%,90%" diff --git a/__e2e__/flows/report-dialog/account.default.yml b/__e2e__/flows/report-dialog/account.default.yml index 0d42254737..372fc31c7e 100644 --- a/__e2e__/flows/report-dialog/account.default.yml +++ b/__e2e__/flows/report-dialog/account.default.yml @@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app - runScript: file: ../../setupServer.js env: - SERVER_PATH: ?users&follows&posts&feeds + SERVER_PATH: ?users&follows&posts&feeds - runFlow: file: ../../setupApp.yml - tapOn: @@ -15,9 +15,11 @@ appId: xyz.blueskyweb.app - tapOn: id: "profileHeaderDropdownReportBtn" - tapOn: - id: "report:option:com.atproto.moderation.defs#reasonSpam" + id: "report:category:Misleading" +- tapOn: + id: "report:option:Spam" - assertVisible: - id: "report:labeler:mod-authority.test" + text: "Send report to Dev-env Moderation" - tapOn: id: "report:submit" - assertNotVisible: diff --git a/__e2e__/flows/report-dialog/post.default.yml b/__e2e__/flows/report-dialog/post.default.yml index 955177b594..be3ac6b68a 100644 --- a/__e2e__/flows/report-dialog/post.default.yml +++ b/__e2e__/flows/report-dialog/post.default.yml @@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app - runScript: file: ../../setupServer.js env: - SERVER_PATH: ?users&follows&posts&feeds + SERVER_PATH: ?users&follows&posts&feeds - runFlow: file: ../../setupApp.yml - tapOn: @@ -15,9 +15,11 @@ appId: xyz.blueskyweb.app - tapOn: id: "postDropdownReportBtn" - tapOn: - id: "report:option:com.atproto.moderation.defs#reasonSpam" + id: "report:category:Misleading" +- tapOn: + id: "report:option:Spam" - assertVisible: - id: "report:labeler:mod-authority.test" + text: "Send report to Dev-env Moderation" - tapOn: id: "report:submit" - assertNotVisible: diff --git a/__e2e__/flows/report-dialog/post.edit-reason.yml b/__e2e__/flows/report-dialog/post.edit-reason.yml index 3c612d4bad..eec5794c4f 100644 --- a/__e2e__/flows/report-dialog/post.edit-reason.yml +++ b/__e2e__/flows/report-dialog/post.edit-reason.yml @@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app - runScript: file: ../../setupServer.js env: - SERVER_PATH: ?users&follows&posts&feeds + SERVER_PATH: ?users&follows&posts&feeds - runFlow: file: ../../setupApp.yml - tapOn: @@ -15,20 +15,28 @@ appId: xyz.blueskyweb.app - tapOn: id: "postDropdownReportBtn" - tapOn: - id: "report:option:com.atproto.moderation.defs#reasonOther" + id: "report:category:Misleading" +- tapOn: + id: "report:option:Other misleading content" - assertVisible: - id: "report:labeler:mod-authority.test" + text: Your report will be sent to Dev-env Moderation.* # reason "other" defaults with details open - assertVisible: id: "report:details" - tapOn: - id: "report:clearOption" + id: "report:clearReportOption" - assertNotVisible: id: "report:details" - tapOn: - id: "report:option:com.atproto.moderation.defs#reasonSpam" + id: "report:clearCategory" +- assertNotVisible: + id: "report:option:Other misleading content" +- tapOn: + id: "report:category:Misleading" +- tapOn: + id: "report:option:Spam" - assertVisible: - id: "report:labeler:mod-authority.test" + text: Your report will be sent to Dev-env Moderation.* - tapOn: id: "report:submit" - assertNotVisible: diff --git a/__e2e__/flows/report-dialog/post.reason-other.yml b/__e2e__/flows/report-dialog/post.reason-other.yml index 39c0e7084d..72da5a9fdf 100644 --- a/__e2e__/flows/report-dialog/post.reason-other.yml +++ b/__e2e__/flows/report-dialog/post.reason-other.yml @@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app - runScript: file: ../../setupServer.js env: - SERVER_PATH: ?users&follows&posts&feeds + SERVER_PATH: ?users&follows&posts&feeds - runFlow: file: ../../setupApp.yml - tapOn: @@ -15,9 +15,11 @@ appId: xyz.blueskyweb.app - tapOn: id: "postDropdownReportBtn" - tapOn: - id: "report:option:com.atproto.moderation.defs#reasonOther" + id: "report:category:Misleading" +- tapOn: + id: "report:option:Other misleading content" - assertVisible: - id: "report:labeler:mod-authority.test" + text: "Send report to Dev-env Moderation" # reason "other" defaults with details open - assertVisible: id: "report:details" diff --git a/__e2e__/perf-test.yml b/__e2e__/perf-test.yml index 7a7b7a18c9..c27bab26e4 100644 --- a/__e2e__/perf-test.yml +++ b/__e2e__/perf-test.yml @@ -1,4 +1,3 @@ - # flow.yaml appId: xyz.blueskyweb.app @@ -33,7 +32,7 @@ appId: xyz.blueskyweb.app direction: "LEFT" - swipe: direction: "LEFT" -- swipe: +- swipe: direction: "LEFT" - swipe: direction: "RIGHT" diff --git a/assets/icons/tinyChevronBottom_stroke2_corner0_rounded.svg b/assets/icons/tinyChevronBottom_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..c8d9d51d0e --- /dev/null +++ b/assets/icons/tinyChevronBottom_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt index 6db6e35fac..fa42e37d5a 100644 --- a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt @@ -243,13 +243,18 @@ class BottomSheetView( val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) bottomSheet?.let { val behavior = BottomSheetBehavior.from(it) + val currentState = behavior.state - behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight) + val oldRatio = behavior.halfExpandedRatio + var newRatio = getHalfExpandedRatio(contentHeight) + behavior.halfExpandedRatio = newRatio if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) { behavior.state = BottomSheetBehavior.STATE_EXPANDED } else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) { behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED + } else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) { + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED } } } diff --git a/modules/bottom-sheet/index.ts b/modules/bottom-sheet/index.ts index 4009f2ab28..a52b4201ac 100644 --- a/modules/bottom-sheet/index.ts +++ b/modules/bottom-sheet/index.ts @@ -1,8 +1,8 @@ import {BottomSheet} from './src/BottomSheet' import { BottomSheetSnapPoint, - BottomSheetState, - BottomSheetViewProps, + type BottomSheetState, + type BottomSheetViewProps, } from './src/BottomSheet.types' import {BottomSheetNativeComponent} from './src/BottomSheetNativeComponent' import { diff --git a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx index d367ac300c..aa69cfd599 100644 --- a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx +++ b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx @@ -112,9 +112,21 @@ export class BottomSheetNativeComponent extends React.Component< onStateChange={this.onStateChange} extraStyles={extraStyles} onLayout={e => { - const {height} = e.nativeEvent.layout - this.setState({viewHeight: height}) - this.updateLayout() + if (isIOS15) { + const {height} = e.nativeEvent.layout + this.setState({viewHeight: height}) + } + if (Platform.OS === 'android') { + // TEMP HACKFIX: I had to timebox this, but this is Bad. + // On Android, if you run updateLayout() immediately, + // it will take ages to actually run on the native side. + // However, adding literally any delay will fix this, including + // a console.log() - just sending the log to the CLI is enough. + // TODO: Get to the bottom of this and fix it properly! -sfn + setTimeout(() => this.updateLayout()) + } else { + this.updateLayout() + } }} /> diff --git a/package.json b/package.json index 4ca2b3833f..c11ae7685c 100644 --- a/package.json +++ b/package.json @@ -143,7 +143,7 @@ "expo-font": "~14.0.9", "expo-haptics": "~15.0.7", "expo-image": "~3.0.10", - "expo-image-crop-tool": "^0.1.8", + "expo-image-crop-tool": "^0.4.0", "expo-image-manipulator": "~14.0.7", "expo-image-picker": "~17.0.8", "expo-intent-launcher": "~13.0.7", diff --git a/src/alf/typography.tsx b/src/alf/typography.tsx index 3c3bb95489..2def71a290 100644 --- a/src/alf/typography.tsx +++ b/src/alf/typography.tsx @@ -25,7 +25,8 @@ export function normalizeTextStyles( fontFamily: Alf['fonts']['family'] } & Pick, ) { - const s = flatten(styles) + const s = flatten(styles) ?? {} + // should always be defined on these components s.fontSize = (s.fontSize || atoms.text_md.fontSize) * fontScale diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 2fdcd64914..efac8468d0 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -798,6 +798,7 @@ export function ButtonIcon({ * also so that we can calculate transforms. */ const iconSize = { + '2xs': 8, xs: 12, sm: 16, md: 18, @@ -842,7 +843,7 @@ export function ButtonIcon({ style={[ a.z_20, { - width: iconContainerSize, + width: size === '2xs' ? 10 : iconContainerSize, height: iconContainerSize, marginLeft: iconNegativeMargin, marginRight: iconNegativeMargin, diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index 3c895e4d8e..762223fc35 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -163,7 +163,10 @@ export function MenuButton() { shape="square" onPress={onPress} hitSlop={HITSLOP_30} - style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}]}> + style={[ + {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, + a.bg_transparent, + ]}> diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 52449698c2..add2a7afb4 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -7,7 +7,6 @@ import type * as HlsTypes from 'hls.js' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a} from '#/alf' -import {MediaInsetBorder} from '#/components/MediaInsetBorder' import * as BandwidthEstimate from './bandwidth-estimate' import {Controls} from './web-controls/VideoControls' @@ -102,7 +101,6 @@ export function VideoEmbedInnerWeb({ hasSubtitleTrack={hasSubtitleTrack} /> - ) } diff --git a/src/components/Post/Embed/VideoEmbed/index.tsx b/src/components/Post/Embed/VideoEmbed/index.tsx index 2212be83d3..9de292b4ad 100644 --- a/src/components/Post/Embed/VideoEmbed/index.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.tsx @@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a} from '#/alf' import {Button} from '#/components/Button' import {useThrottledValue} from '#/components/hooks/useThrottledValue' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' @@ -16,11 +16,9 @@ import * as VideoFallback from './VideoEmbedInner/VideoFallback' interface Props { embed: AppBskyEmbedVideo.View - crop?: 'none' | 'square' | 'constrained' } -export function VideoEmbed({embed, crop}: Props) { - const t = useTheme() +export function VideoEmbed({embed}: Props) { const [key, setKey] = useState(0) const renderError = useCallback( @@ -40,13 +38,10 @@ export function VideoEmbed({embed, crop}: Props) { } let constrained: number | undefined - let max: number | undefined if (aspectRatio !== undefined) { const ratio = 1 / 2 // max of 1:2 ratio in feeds constrained = Math.max(aspectRatio, ratio) - max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread } - const cropDisabled = crop === 'none' const contents = ( @@ -56,28 +51,13 @@ export function VideoEmbed({embed, crop}: Props) { return ( - {cropDisabled ? ( - - {contents} - - ) : ( - - {contents} - - )} + + {contents} + ) } diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx index 8965e8b90e..3de5a99687 100644 --- a/src/components/Post/Embed/VideoEmbed/index.web.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx @@ -17,6 +17,7 @@ import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' import {atoms as a, useTheme} from '#/alf' import {useIsWithinMessage} from '#/components/dms/MessageContext' import {useFullscreen} from '#/components/hooks/useFullscreen' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' import { HLSUnsupportedError, VideoEmbedInnerWeb, @@ -25,13 +26,7 @@ import { import {useActiveVideoWeb} from './ActiveVideoWebContext' import * as VideoFallback from './VideoEmbedInner/VideoFallback' -export function VideoEmbed({ - embed, - crop, -}: { - embed: AppBskyEmbedVideo.View - crop?: 'none' | 'square' | 'constrained' -}) { +export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { const t = useTheme() const ref = useRef(null) const {active, setActive, sendPosition, currentActiveView} = @@ -76,13 +71,10 @@ export function VideoEmbed({ } let constrained: number | undefined - let max: number | undefined if (aspectRatio !== undefined) { const ratio = 1 / 2 // max of 1:2 ratio in feeds constrained = Math.max(aspectRatio, ratio) - max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread } - const cropDisabled = crop === 'none' const contents = (
evt.stopPropagation()}> @@ -114,28 +109,15 @@ export function VideoEmbed({ - {cropDisabled ? ( - - {contents} - - ) : ( - - {contents} - - )} + + {contents} + + ) diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index 1462ef542f..6382412b56 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -112,7 +112,7 @@ function MediaEmbed({ - + ) } diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 41626ff399..55bef226b3 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -48,11 +48,15 @@ export function Default({ moderationOpts, logContext = 'ProfileCard', testID, + position, + contextProfileDid, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts logContext?: 'ProfileCard' | 'StarterPackProfilesList' testID?: string + position?: number + contextProfileDid?: string }) { return ( @@ -60,6 +64,8 @@ export function Default({ profile={profile} moderationOpts={moderationOpts} logContext={logContext} + position={position} + contextProfileDid={contextProfileDid} /> ) @@ -69,10 +75,14 @@ export function Card({ profile, moderationOpts, logContext = 'ProfileCard', + position, + contextProfileDid, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts logContext?: 'ProfileCard' | 'StarterPackProfilesList' + position?: number + contextProfileDid?: string }) { return ( @@ -83,6 +93,8 @@ export function Card({ profile={profile} moderationOpts={moderationOpts} logContext={logContext} + position={position} + contextProfileDid={contextProfileDid} /> @@ -437,6 +449,8 @@ export type FollowButtonProps = { colorInverted?: boolean onFollow?: () => void withIcon?: boolean + position?: number + contextProfileDid?: string } & Partial export function FollowButton(props: FollowButtonProps) { @@ -453,6 +467,8 @@ export function FollowButtonInner({ onFollow, colorInverted, withIcon = true, + position, + contextProfileDid, ...rest }: FollowButtonProps) { const {_} = useLingui() @@ -461,6 +477,8 @@ export function FollowButtonInner({ const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profile, logContext, + position, + contextProfileDid, ) const isRound = Boolean(rest.shape && rest.shape === 'round') diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 40683523c0..9908679c57 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {type TextStyle} from 'react-native' +import {type StyleProp, type TextStyle} from 'react-native' import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' import {toShortUrl} from '#/lib/strings/url-helpers' @@ -21,7 +21,7 @@ export type RichTextProps = TextStyleProp & enableTags?: boolean authorHandle?: string onLinkPress?: LinkProps['onPress'] - interactiveStyle?: TextStyle + interactiveStyle?: StyleProp emojiMultiplier?: number shouldProxyLinks?: boolean } @@ -55,7 +55,7 @@ export function RichText({ if (!facets?.length) { if (isOnlyEmoji(text)) { - const flattenedStyle = flatten(style) + const flattenedStyle = flatten(style) ?? {} const fontSize = (flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier return ( diff --git a/src/components/Select/index.web.tsx b/src/components/Select/index.web.tsx index f53749ef0d..995f9d412e 100644 --- a/src/components/Select/index.web.tsx +++ b/src/components/Select/index.web.tsx @@ -1,4 +1,4 @@ -import {createContext, forwardRef, useContext, useMemo} from 'react' +import {createContext, forwardRef, Fragment, useContext, useMemo} from 'react' import {View} from 'react-native' import {Select as RadixSelect} from 'radix-ui' @@ -96,8 +96,7 @@ export function Trigger({children, label}: TriggerProps) { style={flatten([ a.flex, a.relative, - t.atoms.bg_contrast_25, - a.rounded_sm, + t.atoms.bg_contrast_50, a.w_full, a.align_center, a.gap_sm, @@ -106,15 +105,14 @@ export function Trigger({children, label}: TriggerProps) { a.px_md, a.pointer, { + borderRadius: 10, maxWidth: 400, outline: 0, borderWidth: 2, borderStyle: 'solid', borderColor: focused ? t.palette.primary_500 - : hovered - ? t.palette.contrast_100 - : t.palette.contrast_25, + : t.palette.contrast_50, }, ])}> {children} @@ -140,7 +138,11 @@ export function Icon({style}: IconProps) { ) } -export function Content({items, renderItem}: ContentProps) { +export function Content({ + items, + renderItem, + valueExtractor = defaultItemValueExtractor, +}: ContentProps) { const t = useTheme() const selectedValue = useContext(SelectedValueContext) @@ -198,7 +200,11 @@ export function Content({items, renderItem}: ContentProps) { - {items.map((item, index) => renderItem(item, index, selectedValue))} + {items.map((item, index) => ( + + {renderItem(item, index, selectedValue)} + + ))} @@ -209,6 +215,10 @@ export function Content({items, renderItem}: ContentProps) { ) } +function defaultItemValueExtractor(item: any) { + return item.value +} + const ItemContext = createContext<{ hovered: boolean focused: boolean diff --git a/src/components/Tooltip/index.e2e.tsx b/src/components/Tooltip/index.e2e.tsx new file mode 100644 index 0000000000..905da174c1 --- /dev/null +++ b/src/components/Tooltip/index.e2e.tsx @@ -0,0 +1,19 @@ +export function SheetCompatProvider({children}: {children: React.ReactNode}) { + return children +} + +export function Outer({children}: {children: React.ReactNode}) { + return children +} + +export function Target({children}: {children: React.ReactNode}) { + return children +} + +export function Content() { + return null +} + +export function TextBubble() { + return null +} diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx index a7d1510205..e916ee0ed3 100644 --- a/src/components/Tooltip/index.tsx +++ b/src/components/Tooltip/index.tsx @@ -12,9 +12,11 @@ import {useWindowDimensions, View} from 'react-native' import Animated, {Easing, ZoomIn} from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' +import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' import {atoms as a, select, useTheme} from '#/alf' import {useOnGesture} from '#/components/hooks/useOnGesture' -import {Portal} from '#/components/Portal' +import {createPortalGroup, Portal as RootPortal} from '#/components/Portal' import { ARROW_HALF_SIZE, ARROW_SIZE, @@ -23,6 +25,33 @@ import { } from '#/components/Tooltip/const' import {Text} from '#/components/Typography' +const TooltipPortal = createPortalGroup() +const TooltipProviderContext = + createContext | null>(null) + +/** + * Provider for Tooltip component. Only needed when you need to position the tooltip relative to a container, + * such as in the composer sheet. + * + * Only really necessary on iOS but can work on Android. + */ +export function SheetCompatProvider({children}: {children: React.ReactNode}) { + const ref = useRef(null) + return ( + + + + + {children} + + + + + + ) +} +SheetCompatProvider.displayName = 'TooltipSheetCompatProvider' + /** * These are native specific values, not shared with web */ @@ -120,22 +149,46 @@ export function Outer({ export function Target({children}: {children: React.ReactNode}) { const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext) + const [hasLayedOut, setHasLayedOut] = useState(false) const targetRef = useRef(null) + const containerRef = useContext(TooltipProviderContext) + const keyboardIsOpen = useIsKeyboardVisible() useEffect(() => { - if (!shouldMeasure) return + if (!shouldMeasure || !hasLayedOut) return /* * Once opened, measure the dimensions and position of the target */ - targetRef.current?.measure((_x, _y, width, height, pageX, pageY) => { - if (pageX !== undefined && pageY !== undefined && width && height) { - setTargetMeasurements({x: pageX, y: pageY, width, height}) - } - }) - }, [shouldMeasure, setTargetMeasurements]) + + if (containerRef?.current) { + targetRef.current?.measureLayout( + containerRef.current, + (x, y, width, height) => { + if (x !== undefined && y !== undefined && width && height) { + setTargetMeasurements({x, y, width, height}) + } + }, + ) + } else { + targetRef.current?.measure((_x, _y, width, height, x, y) => { + if (x !== undefined && y !== undefined && width && height) { + setTargetMeasurements({x, y, width, height}) + } + }) + } + }, [ + shouldMeasure, + setTargetMeasurements, + hasLayedOut, + containerRef, + keyboardIsOpen, + ]) return ( - + setHasLayedOut(true)}> {children} ) @@ -150,12 +203,15 @@ export function Content({ }) { const {position, visible, onVisibleChange} = useContext(TooltipContext) const {targetMeasurements} = useContext(TargetContext) + const isWithinProvider = !!useContext(TooltipProviderContext) const requestClose = useCallback(() => { onVisibleChange(false) }, [onVisibleChange]) if (!visible || !targetMeasurements) return null + const Portal = isWithinProvider ? TooltipPortal.Portal : RootPortal + return ( {children} +} +Provider.displayName = 'TooltipProvider' + type TooltipContextType = { position: 'top' | 'bottom' onVisibleChange: (open: boolean) => void } -const TooltipContext = createContext({ +const TooltipContext = createContext>({ position: 'bottom', - onVisibleChange: () => {}, }) TooltipContext.displayName = 'TooltipContext' @@ -33,10 +38,7 @@ export function Outer({ visible: boolean onVisibleChange: (visible: boolean) => void }) { - const ctx = useMemo( - () => ({position, onVisibleChange}), - [position, onVisibleChange], - ) + const ctx = useMemo(() => ({position}), [position]) return ( {children} @@ -60,7 +62,7 @@ export function Content({ label: string }) { const t = useTheme() - const {position, onVisibleChange} = useContext(TooltipContext) + const {position} = useContext(TooltipContext) return ( onVisibleChange(false)} + onInteractOutside={evt => { + if (evt.type === 'dismissableLayer.focusOutside') { + evt.preventDefault() + } + }} style={flatten([ a.rounded_sm, select(t.name, { diff --git a/src/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx index a10508f2e4..005236417a 100644 --- a/src/components/WhoCanReply.tsx +++ b/src/components/WhoCanReply.tsx @@ -1,4 +1,4 @@ -import {Fragment, useMemo} from 'react' +import {Fragment, useMemo, useRef} from 'react' import { Keyboard, Platform, @@ -17,12 +17,13 @@ import {useLingui} from '@lingui/react' import {HITSLOP_10} from '#/lib/constants' import {makeListLink, makeProfileLink} from '#/lib/routes/links' +import {logger} from '#/logger' import {isNative} from '#/platform/detection' import { type ThreadgateAllowUISetting, threadgateViewToAllowUISetting, } from '#/state/queries/threadgate' -import {atoms as a, useTheme, web} from '#/alf' +import {atoms as a, native, useTheme, web} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog' @@ -30,13 +31,13 @@ import { PostInteractionSettingsDialog, usePrefetchPostInteractionSettings, } from '#/components/dialogs/PostInteractionSettingsDialog' -import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' -import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' -import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' +import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronDownIcon} from '#/components/icons/Chevron' +import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSignIcon} from '#/components/icons/CircleBanSign' +import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe' +import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' import * as bsky from '#/types/bsky' -import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil' interface WhoCanReplyProps { post: AppBskyFeedDefs.PostView @@ -69,6 +70,11 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { postUri: post.uri, rootPostUri: rootUri, }) + const prefetchPromise = useRef>(Promise.resolve()) + + const prefetch = () => { + prefetchPromise.current = prefetchPostInteractionSettings() + } const anyoneCanReply = settings.length === 1 && settings[0].type === 'everybody' @@ -84,8 +90,19 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { Keyboard.dismiss() } if (isThreadAuthor) { - editDialogControl.open() + logger.metric('thread:click:editOwnThreadgate', {}) + + // wait on prefetch if it manages to resolve in under 200ms + // otherwise, proceed immediately and show the spinner -sfn + Promise.race([ + prefetchPromise.current, + new Promise(res => setTimeout(res, 200)), + ]).finally(() => { + editDialogControl.open() + }) } else { + logger.metric('thread:click:viewSomeoneElsesThreadgate', {}) + infoDialogControl.open() } } @@ -100,18 +117,27 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { {...(isThreadAuthor ? Platform.select({ web: { - onHoverIn: prefetchPostInteractionSettings, + onHoverIn: prefetch, }, native: { - onPressIn: prefetchPostInteractionSettings, + onPressIn: prefetch, }, }) : {})} hitSlop={HITSLOP_10}> - {({hovered}) => ( - + {({hovered, focused, pressed}) => ( + @@ -119,14 +145,16 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { style={[ a.text_sm, a.leading_tight, - t.atoms.text_contrast_medium, - hovered && a.underline, + isThreadAuthor + ? {color: t.palette.primary_500} + : t.atoms.text_contrast_medium, + (hovered || focused || pressed) && web(a.underline), ]}> {description} {isThreadAuthor && ( - + )} )} @@ -164,7 +192,11 @@ function Icon({ settings.length === 0 || settings.every(setting => setting.type === 'everybody') const isNobody = !!settings.find(gate => gate.type === 'nobody') - const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group + const IconComponent = isEverybody + ? EarthIcon + : isNobody + ? CircleBanSignIcon + : GroupIcon return } diff --git a/src/components/activity-notifications/SubscribeProfileButton.tsx b/src/components/activity-notifications/SubscribeProfileButton.tsx index 71253dca9b..84d8c80518 100644 --- a/src/components/activity-notifications/SubscribeProfileButton.tsx +++ b/src/components/activity-notifications/SubscribeProfileButton.tsx @@ -1,4 +1,4 @@ -import {useCallback} from 'react' +import {useCallback, useEffect, useState} from 'react' import {type ModerationOpts} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -27,8 +27,21 @@ export function SubscribeProfileButton({ const subscribeDialogControl = useDialogControl() const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] = useActivitySubscriptionsNudged() + const [showTooltip, setShowTooltip] = useState(false) - const onDismissTooltip = () => { + useEffect(() => { + if (!activitySubscriptionsNudged) { + const timeout = setTimeout(() => { + setShowTooltip(true) + }, 500) + return () => clearTimeout(timeout) + } + }, [activitySubscriptionsNudged]) + + const onDismissTooltip = (visible: boolean) => { + if (visible) return + + setShowTooltip(false) setActivitySubscriptionsNudged(true) } @@ -56,7 +69,7 @@ export function SubscribeProfileButton({ return ( <> @@ -65,7 +78,6 @@ export function SubscribeProfileButton({ testID="dmBtn" size="small" color="secondary" - variant="solid" shape="round" label={_(msg`Get notified when ${name} posts`)} onPress={wrappedOnPress}> diff --git a/src/components/dialogs/Embed.tsx b/src/components/dialogs/Embed.tsx index a61004fd2e..048cf208ba 100644 --- a/src/components/dialogs/Embed.tsx +++ b/src/components/dialogs/Embed.tsx @@ -10,8 +10,8 @@ import {toShareUrl} from '#/lib/strings/url-helpers' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' +import * as SegmentedControl from '#/components/forms/SegmentedControl' import * as TextField from '#/components/forms/TextField' -import * as ToggleButton from '#/components/forms/ToggleButton' import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import { ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon, @@ -150,26 +150,27 @@ function EmbedDialogInner({ Color theme - setColorMode(value as ColorModeValues)}> - - + type="radio" + value={colorMode} + onChange={setColorMode}> + + System - - - - + + + + Light - - - - + + + + Dark - - - + + + )} diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx index 5b9fc262dc..9f7ff85268 100644 --- a/src/components/dialogs/PostInteractionSettingsDialog.tsx +++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx @@ -1,16 +1,18 @@ -import React from 'react' -import {type StyleProp, View, type ViewStyle} from 'react-native' +import {useCallback, useMemo, useState} from 'react' +import {LayoutAnimation, Text as NestedText, View} from 'react-native' import { type AppBskyFeedDefs, type AppBskyFeedPostgate, AtUri, } from '@atproto/api' -import {msg, Trans} from '@lingui/macro' +import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import isEqual from 'lodash.isequal' +import {useHaptics} from '#/lib/haptics' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {logger} from '#/logger' +import {isIOS} from '#/platform/detection' import {STALE} from '#/state/queries' import {useMyListsQuery} from '#/state/queries/my-lists' import {useGetPost} from '#/state/queries/post' @@ -37,13 +39,17 @@ import { } from '#/state/queries/usePostThread' import {useAgent, useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' -import {atoms as a, useTheme} from '#/alf' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {Divider} from '#/components/Divider' import * as Toggle from '#/components/forms/Toggle' -import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, + ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, +} from '#/components/icons/Chevron' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' @@ -52,6 +58,10 @@ export type PostInteractionSettingsFormProps = { onSave: () => void isSaving?: boolean + isDirty?: boolean + persist?: boolean + onChangePersist?: (v: boolean) => void + postgate: AppBskyFeedPostgate.Record onChangePostgate: (v: AppBskyFeedPostgate.Record) => void @@ -61,57 +71,52 @@ export type PostInteractionSettingsFormProps = { replySettingsDisabled?: boolean } +/** + * Threadgate settings dialog. Used in the composer. + */ export function PostInteractionSettingsControlledDialog({ control, ...rest }: PostInteractionSettingsFormProps & { control: Dialog.DialogControlProps }) { - const t = useTheme() - const {_} = useLingui() + const onClose = useNonReactiveCallback(() => { + logger.metric('composer:threadgate:save', { + hasChanged: !!rest.isDirty, + persist: !!rest.persist, + replyOptions: + rest.threadgateAllowUISettings?.map(gate => gate.type)?.join(',') ?? '', + quotesEnabled: !rest.postgate?.embeddingRules?.find( + v => v.$type === embeddingRules.disableRule.$type, + ), + }) + }) return ( - + - - -
- - - - You can set default interaction settings in{' '} - - Settings → Moderation → Interaction settings - - . - - - - - + ) } -export function Header() { +function DialogInner(props: Omit) { + const {_} = useLingui() + return ( - - - Post interaction settings - - - Customize who can interact with this post. - - - + +
+ + + ) } @@ -134,12 +139,17 @@ export type PostInteractionSettingsDialogProps = { initialThreadgateView?: AppBskyFeedDefs.ThreadgateView } +/** + * Threadgate settings dialog. Used in the thread. + */ export function PostInteractionSettingsDialog( props: PostInteractionSettingsDialogProps, ) { const postThreadContext = usePostThreadContext() return ( - + @@ -153,7 +163,7 @@ export function PostInteractionSettingsDialogControlledInner( ) { const {_} = useLingui() const {currentAccount} = useSession() - const [isSaving, setIsSaving] = React.useState(false) + const [isSaving, setIsSaving] = useState(false) const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} = useThreadgateViewQuery({postUri: props.rootPostUri}) @@ -165,28 +175,28 @@ export function PostInteractionSettingsDialogControlledInner( const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation() const [editedPostgate, setEditedPostgate] = - React.useState() + useState() const [editedAllowUISettings, setEditedAllowUISettings] = - React.useState() + useState() const isLoading = isLoadingThreadgate || isLoadingPostgate const threadgateView = threadgateViewLoaded || props.initialThreadgateView - const isThreadgateOwnedByViewer = React.useMemo(() => { + const isThreadgateOwnedByViewer = useMemo(() => { return currentAccount?.did === new AtUri(props.rootPostUri).host }, [props.rootPostUri, currentAccount?.did]) - const postgateValue = React.useMemo(() => { + const postgateValue = useMemo(() => { return ( editedPostgate || postgate || createPostgateRecord({post: props.postUri}) ) }, [postgate, editedPostgate, props.postUri]) - const allowUIValue = React.useMemo(() => { + const allowUIValue = useMemo(() => { return ( editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView) ) }, [threadgateView, editedAllowUISettings]) - const onSave = React.useCallback(async () => { + const onSave = useCallback(async () => { if (!editedPostgate && !editedAllowUISettings) { props.control.close() return @@ -248,15 +258,24 @@ export function PostInteractionSettingsDialogControlledInner( return ( - -
- - {isLoading ? ( - - - - ) : ( + style={[web({maxWidth: 400}), a.w_full]}> + {isLoading ? ( + + + + Loading post interaction settings... + + + ) : ( + <> +
- )} - + + )} + ) } @@ -281,11 +301,20 @@ export function PostInteractionSettingsForm({ threadgateAllowUISettings, onChangeThreadgateAllowUISettings, replySettingsDisabled, + isDirty, + persist, + onChangePersist, }: PostInteractionSettingsFormProps) { const t = useTheme() const {_} = useLingui() - const {data: lists} = useMyListsQuery('curate') - const [quotesEnabled, setQuotesEnabled] = React.useState( + const playHaptic = useHaptics() + const [showLists, setShowLists] = useState(false) + const { + data: lists, + isPending: isListsPending, + isError: isListsError, + } = useMyListsQuery('curate') + const [quotesEnabled, setQuotesEnabled] = useState( !( postgate.embeddingRules && postgate.embeddingRules.find( @@ -294,27 +323,7 @@ export function PostInteractionSettingsForm({ ), ) - const onPressAudience = (setting: ThreadgateAllowUISetting) => { - // remove boolean values - let newSelected: ThreadgateAllowUISetting[] = - threadgateAllowUISettings.filter( - v => v.type !== 'nobody' && v.type !== 'everybody', - ) - // toggle - const i = newSelected.findIndex(v => isEqual(v, setting)) - if (i === -1) { - newSelected.push(setting) - } else { - newSelected.splice(i, 1) - } - if (newSelected.length === 0) { - newSelected.push({type: 'everybody'}) - } - - onChangeThreadgateAllowUISettings(newSelected) - } - - const onChangeQuotesEnabled = React.useCallback( + const onChangeQuotesEnabled = useCallback( (enabled: boolean) => { setQuotesEnabled(enabled) onChangePostgate( @@ -330,229 +339,346 @@ export function PostInteractionSettingsForm({ const noOneCanReply = !!threadgateAllowUISettings.find( v => v.type === 'nobody', ) + const everyoneCanReply = !!threadgateAllowUISettings.find( + v => v.type === 'everybody', + ) + const numberOfListsSelected = threadgateAllowUISettings.filter( + v => v.type === 'list', + ).length + + const toggleGroupValues = useMemo(() => { + const values: string[] = [] + for (const setting of threadgateAllowUISettings) { + switch (setting.type) { + case 'everybody': + case 'nobody': + // no granularity, early return with nothing + return [] + case 'followers': + values.push('followers') + break + case 'following': + values.push('following') + break + case 'mention': + values.push('mention') + break + case 'list': + values.push(`list:${setting.list}`) + break + default: + break + } + } + return values + }, [threadgateAllowUISettings]) + + const toggleGroupOnChange = (values: string[]) => { + const settings: ThreadgateAllowUISetting[] = [] + + if (values.length === 0) { + settings.push({type: 'everybody'}) + } else { + for (const value of values) { + if (value.startsWith('list:')) { + const listId = value.slice('list:'.length) + settings.push({type: 'list', list: listId}) + } else { + settings.push({type: value as 'followers' | 'following' | 'mention'}) + } + } + } + + onChangeThreadgateAllowUISettings(settings) + } return ( - - - - - - Quote settings - - - - - Allow quote posts - - - - - - - - {replySettingsDisabled && ( - - - - - Reply settings are chosen by the author of the thread - - - - )} - + + + {replySettingsDisabled && ( - - Reply settings + + + + Reply settings are chosen by the author of the thread + - - - Allow replies from: - - - - v.type === 'everybody') - } - onPress={() => - onChangeThreadgateAllowUISettings([{type: 'everybody'}]) - } - style={{flex: 1}} - disabled={replySettingsDisabled} - /> - - onChangeThreadgateAllowUISettings([{type: 'nobody'}]) - } - style={{flex: 1}} - disabled={replySettingsDisabled} - /> - - - {!noOneCanReply && ( - <> - - Or combine these options: - - - - v.type === 'mention', - ) - } - onPress={() => onPressAudience({type: 'mention'})} - disabled={replySettingsDisabled} - /> - v.type === 'following', - ) - } - onPress={() => onPressAudience({type: 'following'})} - disabled={replySettingsDisabled} - /> - v.type === 'followers', - ) - } - onPress={() => onPressAudience({type: 'followers'})} - disabled={replySettingsDisabled} - /> - {lists && lists.length > 0 - ? lists.map(list => ( - v.type === 'list' && v.list === list.uri, - ) - } - onPress={() => - onPressAudience({type: 'list', list: list.uri}) - } - disabled={replySettingsDisabled} - /> - )) - : // No loading states to avoid jumps for the common case (no lists) - null} - - - )} + )} + + + + Who can reply + + + { + if (val.includes('everyone')) { + onChangeThreadgateAllowUISettings([{type: 'everybody'}]) + } else if (val.includes('nobody')) { + onChangeThreadgateAllowUISettings([{type: 'nobody'}]) + } else { + onChangeThreadgateAllowUISettings([{type: 'mention'}]) + } + }}> + + + {({selected}) => ( + + + + Anyone + + + )} + + + {({selected}) => ( + + + + Nobody + + + )} + + + + + + + + {({selected}) => ( + + + + Your followers + + + )} + + + {({selected}) => ( + + + + People you follow + + + )} + + + {({selected}) => ( + + + + People you mention + + + )} + + + + {showLists && + (isListsPending ? ( + + + Loading lists... + + + ) : isListsError ? ( + + + + An error occurred while loading your lists :/ + + + + ) : lists.length === 0 ? ( + + + You don't have any lists yet. + + + ) : ( + lists.map((list, i) => ( + + {({selected}) => ( + + + + {list.name} + + )} + + )) + ))} + + + + {({selected}) => ( + + + Allow quote posts + + + + )} + + + {typeof persist !== 'undefined' && ( + + {isDirty ? ( + onChangePersist?.(!persist)}> + + + Save these options for next time + + + ) : ( + + These are your default settings + + )} + + )} + ) } -function Selectable({ - label, - isSelected, - onPress, - style, - disabled, -}: { - label: string - isSelected: boolean - onPress: () => void - style?: StyleProp - disabled?: boolean -}) { - const t = useTheme() +function Header() { return ( - + + + Post interaction settings + + ) } @@ -567,7 +693,7 @@ export function usePrefetchPostInteractionSettings({ const agent = useAgent() const getPost = useGetPost() - return React.useCallback(async () => { + return useCallback(async () => { try { await Promise.all([ queryClient.prefetchQuery({ diff --git a/src/view/com/auth/server-input/index.tsx b/src/components/dialogs/ServerInput.tsx similarity index 73% rename from src/view/com/auth/server-input/index.tsx rename to src/components/dialogs/ServerInput.tsx index c79b8a5794..d7c02bb9f4 100644 --- a/src/view/com/auth/server-input/index.tsx +++ b/src/components/dialogs/ServerInput.tsx @@ -5,18 +5,20 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {BSKY_SERVICE} from '#/lib/constants' -import {logEvent} from '#/lib/statsig/statsig' +import {logger} from '#/logger' import * as persisted from '#/state/persisted' import {useSession} from '#/state/session' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' +import * as SegmentedControl from '#/components/forms/SegmentedControl' import * as TextField from '#/components/forms/TextField' -import * as ToggleButton from '#/components/forms/ToggleButton' import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' import {InlineLinkText} from '#/components/Link' -import {P, Text} from '#/components/Typography' +import {Text} from '#/components/Typography' + +type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom' export function ServerInputDialog({ control, @@ -29,7 +31,8 @@ export function ServerInputDialog({ const formRef = useRef(null) // persist these options between dialog open/close - const [fixedOption, setFixedOption] = useState(BSKY_SERVICE) + const [fixedOption, setFixedOption] = + useState(BSKY_SERVICE) const [previousCustomAddress, setPreviousCustomAddress] = useState('') const onClose = useCallback(() => { @@ -40,7 +43,7 @@ export function ServerInputDialog({ setPreviousCustomAddress(result) } } - logEvent('signin:hostingProviderPressed', { + logger.metric('signin:hostingProviderPressed', { hostingProviderDidChange: fixedOption !== BSKY_SERVICE, }) }, [onSelect, fixedOption]) @@ -49,7 +52,10 @@ export function ServerInputDialog({ + nativeOptions={platform({ + android: {minHeight: height / 2}, + ios: {preventExpansion: true}, + })}> - fixedOption: string - setFixedOption: (opt: string) => void + fixedOption: SegmentedControlOptions + setFixedOption: (opt: SegmentedControlOptions) => void initialCustomAddress: string }) { const control = Dialog.useDialogContext() @@ -124,45 +130,49 @@ function DialogInner({ return ( + accessibilityLabelledBy="dialog-title" + style={web({maxWidth: 500})}> - + Choose your account provider - setFixedOption(values[0])}> - - {_(msg`Bluesky`)} - - + + + {_(msg`Bluesky`)} + + + - {_(msg`Custom`)} - - + + {_(msg`Custom`)} + + + {fixedOption === BSKY_SERVICE && isFirstTimeUser && ( - - - Bluesky is an open network where you can choose your own provider. - If you're new here, we recommend sticking with the default Bluesky - Social option. - - + + + + Bluesky is an open network where you can choose your own + provider. If you're new here, we recommend sticking with the + default Bluesky Social option. + + + )} {fixedOption === 'custom' && ( - + Server address @@ -197,13 +207,8 @@ function DialogInner({ )} -

+ {isFirstTimeUser ? ( If you're a developer, you can host your own server. @@ -219,18 +224,23 @@ function DialogInner({ to="https://atproto.com/guides/self-hosting"> Learn more. -

+
diff --git a/src/components/forms/DateField/index.shared.tsx b/src/components/forms/DateField/index.shared.tsx index b0522175c6..24344b4417 100644 --- a/src/components/forms/DateField/index.shared.tsx +++ b/src/components/forms/DateField/index.shared.tsx @@ -73,8 +73,8 @@ export function DateFieldButton({ a.flex_row, a.flex_1, a.w_full, - a.rounded_sm, - t.atoms.bg_contrast_25, + {borderRadius: 10}, + t.atoms.bg_contrast_50, a.align_center, hovered ? chromeHover : {}, focused || pressed ? chromeFocus : {}, diff --git a/src/components/forms/HostingProvider.tsx b/src/components/forms/HostingProvider.tsx index 1100900176..b7d23ba3ab 100644 --- a/src/components/forms/HostingProvider.tsx +++ b/src/components/forms/HostingProvider.tsx @@ -4,10 +4,10 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {toNiceDomain} from '#/lib/strings/url-helpers' -import {ServerInputDialog} from '#/view/com/auth/server-input' import {atoms as a, tokens, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' +import {ServerInputDialog} from '#/components/dialogs/ServerInput' import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil' import {Text} from '#/components/Typography' diff --git a/src/components/forms/SegmentedControl.tsx b/src/components/forms/SegmentedControl.tsx new file mode 100644 index 0000000000..68f57c83a3 --- /dev/null +++ b/src/components/forms/SegmentedControl.tsx @@ -0,0 +1,287 @@ +import { + createContext, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useState, +} from 'react' +import {type StyleProp, View, type ViewStyle} from 'react-native' +import Animated, {Easing, LinearTransition} from 'react-native-reanimated' + +import {useHaptics} from '#/lib/haptics' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {atoms as a, native, platform, useTheme} from '#/alf' +import { + Button, + type ButtonProps, + ButtonText, + type ButtonTextProps, +} from '../Button' + +const InternalContext = createContext<{ + type: 'tabs' | 'radio' + size: 'small' | 'large' + selectedValue: string + selectedPosition: {width: number; x: number} | null + onSelectValue: ( + value: string, + position: {width: number; x: number} | null, + ) => void + updatePosition: (position: {width: number; x: number}) => void +} | null>(null) + +/** + * Segmented control component. + * + * @example + * ```tsx + * + * + * + * One + * + * + * + * + * Two + * + * + * + * ``` + */ +export function Root({ + label, + type = 'radio', + size = 'large', + value, + onChange, + children, + style, + accessibilityHint, +}: { + label: string + type: 'tabs' | 'radio' + size?: 'small' | 'large' + value: T + onChange: (value: T) => void + children: React.ReactNode + style?: StyleProp + accessibilityHint?: string +}) { + const t = useTheme() + const [selectedPosition, setSelectedPosition] = useState<{ + width: number + x: number + } | null>(null) + + const contextValue = useMemo(() => { + return { + type, + size, + selectedValue: value, + selectedPosition, + onSelectValue: ( + val: string, + position: {width: number; x: number} | null, + ) => { + onChange(val as T) + if (position) setSelectedPosition(position) + }, + updatePosition: (position: {width: number; x: number}) => { + setSelectedPosition(currPos => { + if ( + currPos && + currPos.width === position.width && + currPos.x === position.x + ) { + return currPos + } + return position + }) + }, + } + }, [value, selectedPosition, setSelectedPosition, onChange, type, size]) + + return ( + + {selectedPosition !== null && ( + + )} + + {children} + + + ) +} + +const InternalItemContext = createContext<{ + active: boolean + pressed: boolean + hovered: boolean + focused: boolean +} | null>(null) + +export function Item({ + value, + style, + children, + onPress: onPressProp, + ...props +}: {value: string; children: React.ReactNode} & Omit) { + const playHaptic = useHaptics() + const [position, setPosition] = useState<{x: number; width: number} | null>( + null, + ) + + const ctx = useContext(InternalContext) + if (!ctx) + throw new Error( + 'SegmentedControl.Item must be used within a SegmentedControl.Root', + ) + + const active = ctx.selectedValue === value + + // update position if change was external, and not due to onPress + const needsUpdate = + active && + position && + (ctx.selectedPosition?.x !== position.x || + ctx.selectedPosition?.width !== position.width) + + // can't wait for `useEffectEvent` + const update = useNonReactiveCallback(() => { + if (position) ctx.updatePosition(position) + }) + + useLayoutEffect(() => { + if (needsUpdate) { + update() + } + }, [needsUpdate, update]) + + const onPress = useCallback( + (evt: any) => { + playHaptic('Light') + ctx.onSelectValue(value, position) + onPressProp?.(evt) + }, + [ctx, value, position, onPressProp, playHaptic], + ) + + return ( + { + const measuredPosition = { + x: evt.nativeEvent.layout.x, + width: evt.nativeEvent.layout.width, + } + if (!ctx.selectedPosition && active) { + ctx.onSelectValue(value, measuredPosition) + } + setPosition(measuredPosition) + }}> + + + ) +} + +export function ItemText({style, ...props}: ButtonTextProps) { + const t = useTheme() + const ctx = useContext(InternalItemContext) + if (!ctx) + throw new Error( + 'SegmentedControl.ItemText must be used within a SegmentedControl.Item', + ) + return ( + + ) +} + +function Slider({x, width}: {x: number; width: number}) { + const t = useTheme() + + return ( + + ) +} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 48f71e73a0..62f30db47b 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -266,7 +266,7 @@ export function createInput(Component: typeof TextInput) { a.z_10, a.absolute, a.inset_0, - a.rounded_sm, + {borderRadius: 10}, t.atoms.bg_contrast_50, {borderColor: 'transparent', borderWidth: 2}, ctx.hovered ? chromeHover : {}, diff --git a/src/components/forms/Toggle/Panel.tsx b/src/components/forms/Toggle/Panel.tsx new file mode 100644 index 0000000000..d874750db6 --- /dev/null +++ b/src/components/forms/Toggle/Panel.tsx @@ -0,0 +1,120 @@ +import {createContext, useContext} from 'react' +import {View, type ViewStyle} from 'react-native' + +import {atoms as a, tokens, useTheme} from '#/alf' +import {type Props as SVGIconProps} from '#/components/icons/common' +import {Text} from '#/components/Typography' + +const PanelContext = createContext<{active: boolean}>({active: false}) + +/** + * A nice container for Toggles. See the Threadgate dialog for an example. + */ +export function Panel({ + children, + active = false, + adjacent, +}: { + children: React.ReactNode + active?: boolean + adjacent?: 'leading' | 'trailing' | 'both' +}) { + const t = useTheme() + + const leading = adjacent === 'leading' || adjacent === 'both' + const trailing = adjacent === 'trailing' || adjacent === 'both' + const rounding = { + borderTopLeftRadius: leading + ? tokens.borderRadius.xs + : tokens.borderRadius.md, + borderTopRightRadius: leading + ? tokens.borderRadius.xs + : tokens.borderRadius.md, + borderBottomLeftRadius: trailing + ? tokens.borderRadius.xs + : tokens.borderRadius.md, + borderBottomRightRadius: trailing + ? tokens.borderRadius.xs + : tokens.borderRadius.md, + } satisfies ViewStyle + + return ( + + {children} + + ) +} + +export function PanelText({ + children, + icon, +}: { + children: React.ReactNode + icon?: React.ComponentType +}) { + const t = useTheme() + const ctx = useContext(PanelContext) + + const text = ( + + {children} + + ) + + if (icon) { + // eslint-disable-next-line bsky-internal/avoid-unwrapped-text + return ( + + + {text} + + ) + } + + return text +} + +export function PanelIcon({ + icon: Icon, +}: { + icon: React.ComponentType +}) { + const t = useTheme() + const ctx = useContext(PanelContext) + return ( + + ) +} + +/** + * A group of panels. TODO: auto-leading/trailing + */ +export function PanelGroup({children}: {children: React.ReactNode}) { + return {children} +} diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle/index.tsx similarity index 68% rename from src/components/forms/Toggle.tsx rename to src/components/forms/Toggle/index.tsx index 849e014fac..60fa50478a 100644 --- a/src/components/forms/Toggle.tsx +++ b/src/components/forms/Toggle/index.tsx @@ -1,12 +1,20 @@ -import React from 'react' -import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' -import Animated, {LinearTransition} from 'react-native-reanimated' +import {createContext, useCallback, useContext, useMemo} from 'react' +import { + Pressable, + type PressableProps, + type StyleProp, + View, + type ViewStyle, +} from 'react-native' +import Animated, {Easing, LinearTransition} from 'react-native-reanimated' import {HITSLOP_10} from '#/lib/constants' +import {useHaptics} from '#/lib/haptics' import {isNative} from '#/platform/detection' import { atoms as a, native, + platform, type TextStyleProp, useTheme, type ViewStyleProp, @@ -15,6 +23,8 @@ import {useInteractionState} from '#/components/hooks/useInteractionState' import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check' import {Text} from '#/components/Typography' +export * from './Panel' + export type ItemState = { name: string selected: boolean @@ -25,7 +35,7 @@ export type ItemState = { focused: boolean } -const ItemContext = React.createContext({ +const ItemContext = createContext({ name: '', selected: false, disabled: false, @@ -36,7 +46,7 @@ const ItemContext = React.createContext({ }) ItemContext.displayName = 'ToggleItemContext' -const GroupContext = React.createContext<{ +const GroupContext = createContext<{ values: string[] disabled: boolean type: 'radio' | 'checkbox' @@ -70,10 +80,11 @@ export type ItemProps = ViewStyleProp & { onChange?: (selected: boolean) => void isInvalid?: boolean children: ((props: ItemState) => React.ReactNode) | React.ReactNode + hitSlop?: PressableProps['hitSlop'] } export function useItemContext() { - return React.useContext(ItemContext) + return useContext(ItemContext) } export function Group({ @@ -88,9 +99,8 @@ export function Group({ }: GroupProps) { const groupRole = type === 'radio' ? 'radiogroup' : undefined const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues - const [maxReached, setMaxReached] = React.useState(false) - const setFieldValue = React.useCallback< + const setFieldValue = useCallback< (props: {name: string; value: boolean}) => void >( ({name, value}) => { @@ -105,25 +115,13 @@ export function Group({ [type, onChange, values], ) - React.useEffect(() => { - if (type === 'checkbox') { - if ( - maxSelections && - values.length >= maxSelections && - maxReached === false - ) { - setMaxReached(true) - } else if ( - maxSelections && - values.length < maxSelections && - maxReached === true - ) { - setMaxReached(false) - } - } - }, [type, values.length, maxSelections, maxReached, setMaxReached]) + const maxReached = !!( + type === 'checkbox' && + maxSelections && + values.length >= maxSelections + ) - const context = React.useMemo( + const context = useMemo( () => ({ values, type, @@ -170,7 +168,7 @@ export function Item({ disabled: groupDisabled, setFieldValue, maxSelectionsReached, - } = React.useContext(GroupContext) + } = useContext(GroupContext) const { state: hovered, onIn: onHoverIn, @@ -182,19 +180,21 @@ export function Item({ onOut: onPressOut, } = useInteractionState() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const playHaptic = useHaptics() const role = groupType === 'radio' ? 'radio' : type const selected = selectedValues.includes(name) || !!value const disabled = groupDisabled || itemDisabled || (!selected && maxSelectionsReached) - const onPress = React.useCallback(() => { + const onPress = useCallback(() => { + playHaptic('Light') const next = !selected setFieldValue({name, value: next}) onChange?.(next) - }, [name, selected, onChange, setFieldValue]) + }, [playHaptic, name, selected, onChange, setFieldValue]) - const state = React.useMemo( + const state = useMemo( () => ({ name, selected, @@ -250,8 +250,8 @@ export function LabelText({ style={[ a.font_semi_bold, a.leading_tight, + a.user_select_none, { - userSelect: 'none', color: disabled ? t.atoms.text_contrast_low.color : t.atoms.text_contrast_high.color, @@ -287,21 +287,26 @@ export function createSharedToggleStyles({ if (selected) { base.push({ - backgroundColor: t.palette.primary_25, + backgroundColor: t.palette.primary_500, borderColor: t.palette.primary_500, }) if (hovered) { baseHover.push({ - backgroundColor: t.palette.primary_100, - borderColor: t.palette.primary_600, + backgroundColor: t.palette.primary_400, + borderColor: t.palette.primary_400, }) } } else { + base.push({ + backgroundColor: t.palette.contrast_25, + borderColor: t.palette.contrast_100, + }) + if (hovered) { baseHover.push({ backgroundColor: t.palette.contrast_50, - borderColor: t.palette.contrast_500, + borderColor: t.palette.contrast_200, }) } } @@ -318,6 +323,20 @@ export function createSharedToggleStyles({ borderColor: t.palette.negative_600, }) } + + if (selected) { + base.push({ + backgroundColor: t.palette.negative_500, + borderColor: t.palette.negative_500, + }) + + if (hovered) { + baseHover.push({ + backgroundColor: t.palette.negative_400, + borderColor: t.palette.negative_400, + }) + } + } } if (disabled) { @@ -325,6 +344,13 @@ export function createSharedToggleStyles({ backgroundColor: t.palette.contrast_100, borderColor: t.palette.contrast_400, }) + + if (selected) { + base.push({ + backgroundColor: t.palette.primary_100, + borderColor: t.palette.contrast_400, + }) + } } return { @@ -350,66 +376,125 @@ export function Checkbox() { style={[ a.justify_center, a.align_center, - a.rounded_xs, t.atoms.border_contrast_high, + a.transition_color, { borderWidth: 1, height: 24, width: 24, + borderRadius: 6, }, baseStyles, hovered ? baseHoverStyles : {}, ]}> - {selected ? : null} + {selected && }
) } export function Switch() { const t = useTheme() - const {selected, hovered, focused, disabled, isInvalid} = useItemContext() - const {baseStyles, baseHoverStyles, indicatorStyles} = - createSharedToggleStyles({ - theme: t, - hovered, - focused, - selected, - disabled, - isInvalid, - }) + const {selected, hovered, disabled, isInvalid} = useItemContext() + const {baseStyles, baseHoverStyles, indicatorStyles} = useMemo(() => { + const base: ViewStyle[] = [] + const baseHover: ViewStyle[] = [] + const indicator: ViewStyle[] = [] + + if (selected) { + base.push({ + backgroundColor: t.palette.primary_500, + }) + + if (hovered) { + baseHover.push({ + backgroundColor: t.palette.primary_400, + }) + } + } else { + base.push({ + backgroundColor: t.palette.contrast_200, + }) + + if (hovered) { + baseHover.push({ + backgroundColor: t.palette.contrast_100, + }) + } + } + + if (isInvalid) { + base.push({ + backgroundColor: t.palette.negative_200, + }) + + if (hovered) { + baseHover.push({ + backgroundColor: t.palette.negative_100, + }) + } + + if (selected) { + base.push({ + backgroundColor: t.palette.negative_500, + }) + + if (hovered) { + baseHover.push({ + backgroundColor: t.palette.negative_400, + }) + } + } + } + + if (disabled) { + base.push({ + backgroundColor: t.palette.contrast_50, + }) + + if (selected) { + base.push({ + backgroundColor: t.palette.primary_100, + }) + } + } + + return { + baseStyles: base, + baseHoverStyles: disabled ? [] : baseHover, + indicatorStyles: indicator, + } + }, [t, hovered, disabled, selected, isInvalid]) + return ( @@ -420,7 +505,7 @@ export function Switch() { export function Radio() { const t = useTheme() const {selected, hovered, focused, disabled, isInvalid} = - React.useContext(ItemContext) + useContext(ItemContext) const {baseStyles, baseHoverStyles, indicatorStyles} = createSharedToggleStyles({ theme: t, @@ -437,29 +522,27 @@ export function Radio() { a.align_center, a.rounded_full, t.atoms.border_contrast_high, + a.transition_color, { borderWidth: 1, - height: 24, - width: 24, + height: 25, + width: 25, + margin: -1, }, baseStyles, hovered ? baseHoverStyles : {}, ]}> - {selected ? ( + {selected && ( - ) : null} + )} ) } diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx index 367122585b..77e12b2046 100644 --- a/src/components/forms/ToggleButton.tsx +++ b/src/components/forms/ToggleButton.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import {useMemo} from 'react' import { type AccessibilityProps, type TextStyle, @@ -20,6 +20,9 @@ export type GroupProps = Omit & { multiple?: boolean } +/** + * @deprecated - use SegmentedControl + */ export function Group({children, multiple, ...props}: GroupProps) { const t = useTheme() return ( @@ -39,6 +42,9 @@ export function Group({children, multiple, ...props}: GroupProps) { ) } +/** + * @deprecated - use SegmentedControl + */ export function Button({children, ...props}: ItemProps) { return ( @@ -51,7 +57,7 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) { const t = useTheme() const state = Toggle.useItemContext() - const {baseStyles, hoverStyles, activeStyles} = React.useMemo(() => { + const {baseStyles, hoverStyles, activeStyles} = useMemo(() => { const base: ViewStyle[] = [] const hover: ViewStyle[] = [] const active: ViewStyle[] = [] @@ -112,11 +118,14 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) { ) } +/** + * @deprecated - use SegmentedControl + */ export function ButtonText({children}: {children: React.ReactNode}) { const t = useTheme() const state = Toggle.useItemContext() - const textStyles = React.useMemo(() => { + const textStyles = useMemo(() => { const text: TextStyle[] = [] if (state.selected) { text.push(t.atoms.text_inverted) diff --git a/src/components/icons/Chevron.tsx b/src/components/icons/Chevron.tsx index 4d252ee3ca..b033e3c66b 100644 --- a/src/components/icons/Chevron.tsx +++ b/src/components/icons/Chevron.tsx @@ -19,3 +19,10 @@ export const ChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const ChevronTopBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M11.293 4.293a1 1 0 0 1 1.414 0l4 4a1 1 0 0 1-1.414 1.414L12 6.414 8.707 9.707a1 1 0 0 1-1.414-1.414l4-4Zm-4 10a1 1 0 0 1 1.414 0L12 17.586l3.293-3.293a1 1 0 0 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 0-1.414Z', }) + +/** + * NOTE: Use with size `2xs` + */ +export const TinyChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M10.928 18.882c.757.499 1.786.417 2.452-.25l9-9a1.953 1.953 0 0 0-2.76-2.76L12 14.493l-7.62-7.62a1.952 1.952 0 0 0-2.76 2.76l9 9 .308.25Z', +}) diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx index bc1e045a48..0f208240f3 100644 --- a/src/components/icons/common.tsx +++ b/src/components/icons/common.tsx @@ -13,6 +13,7 @@ export type Props = { } & Omit export const sizes = { + '2xs': 8, xs: 12, sm: 16, md: 20, diff --git a/src/components/moderation/ReportDialog/index.tsx b/src/components/moderation/ReportDialog/index.tsx index a264d12bc2..2331db3ea6 100644 --- a/src/components/moderation/ReportDialog/index.tsx +++ b/src/components/moderation/ReportDialog/index.tsx @@ -685,7 +685,7 @@ function CategoryCard({ }, [onSelect, option]) return ( diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx index 4f46351b27..572e356075 100644 --- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx +++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx @@ -1,17 +1,31 @@ +import {useEffect, useMemo, useState} from 'react' import {Keyboard, type StyleProp, type ViewStyle} from 'react-native' import {type AnimatedStyle} from 'react-native-reanimated' import {type AppBskyFeedPostgate} from '@atproto/api' -import {msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import deepEqual from 'lodash.isequal' +import {isNetworkError} from '#/lib/strings/errors' +import {logger} from '#/logger' import {isNative} from '#/platform/detection' -import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate' -import {native} from '#/alf' +import {usePostInteractionSettingsMutation} from '#/state/queries/post-interaction-settings' +import {createPostgateRecord} from '#/state/queries/postgate/util' +import {usePreferencesQuery} from '#/state/queries/preferences' +import { + type ThreadgateAllowUISetting, + threadgateAllowUISettingToAllowRecordValue, + threadgateRecordToAllowUISetting, +} from '#/state/queries/threadgate' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog' -import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' -import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' +import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron' +import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe' +import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' +import * as Tooltip from '#/components/Tooltip' +import {Text} from '#/components/Typography' +import {useThreadgateNudged} from '#/storage/hooks/threadgate-nudged' export function ThreadgateBtn({ postgate, @@ -29,15 +43,87 @@ export function ThreadgateBtn({ }) { const {_} = useLingui() const control = Dialog.useDialogControl() + const [threadgateNudged, setThreadgateNudged] = useThreadgateNudged() + const [showTooltip, setShowTooltip] = useState(false) + const [tooltipWasShown] = useState(!threadgateNudged) + + useEffect(() => { + if (!threadgateNudged) { + const timeout = setTimeout(() => { + setShowTooltip(true) + }, 1000) + return () => clearTimeout(timeout) + } + }, [threadgateNudged]) + + const onDismissTooltip = (visible: boolean) => { + if (visible) return + setThreadgateNudged(true) + setShowTooltip(false) + } + + const {data: preferences} = usePreferencesQuery() + const [persist, setPersist] = useState(false) const onPress = () => { + logger.metric('composer:threadgate:open', { + nudged: tooltipWasShown, + }) + if (isNative && Keyboard.isVisible()) { Keyboard.dismiss() } + setShowTooltip(false) + setThreadgateNudged(true) + control.open() } + const prefThreadgateAllowUISettings = threadgateRecordToAllowUISetting({ + $type: 'app.bsky.feed.threadgate', + post: '', + createdAt: new Date().toISOString(), + allow: preferences?.postInteractionSettings.threadgateAllowRules, + }) + const prefPostgate = createPostgateRecord({ + post: '', + embeddingRules: + preferences?.postInteractionSettings?.postgateEmbeddingRules || [], + }) + + const isDirty = useMemo(() => { + const everybody = [{type: 'everybody'}] + return ( + !deepEqual( + threadgateAllowUISettings, + prefThreadgateAllowUISettings ?? everybody, + ) || + !deepEqual(postgate.embeddingRules, prefPostgate?.embeddingRules ?? []) + ) + }, [ + prefThreadgateAllowUISettings, + prefPostgate, + threadgateAllowUISettings, + postgate, + ]) + + const {mutate: persistChanges, isPending: isSaving} = + usePostInteractionSettingsMutation({ + onError: err => { + if (!isNetworkError(err)) { + logger.error('Failed to persist threadgate settings', { + safeMessage: err, + }) + } + }, + onSettled: () => { + control.close(() => { + setPersist(false) + }) + }, + }) + const anyoneCanReply = threadgateAllowUISettings.length === 1 && threadgateAllowUISettings[0].type === 'everybody' @@ -45,39 +131,59 @@ export function ThreadgateBtn({ !postgate.embeddingRules || postgate.embeddingRules.length === 0 const anyoneCanInteract = anyoneCanReply && anyoneCanQuote const label = anyoneCanInteract - ? _(msg`Anybody can interact`) + ? _(msg`Anyone can interact`) : _(msg`Interaction limited`) return ( <> - + + + + + + + Psst! You can edit who can interact with this post. + + + + { - control.close() + if (persist) { + persistChanges({ + threadgateAllowRules: threadgateAllowUISettingToAllowRecordValue( + threadgateAllowUISettings, + ), + postgateEmbeddingRules: postgate.embeddingRules ?? [], + }) + } else { + control.close() + } }} + isSaving={isSaving} postgate={postgate} onChangePostgate={onChangePostgate} threadgateAllowUISettings={threadgateAllowUISettings} onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings} + isDirty={isDirty} + persist={persist} + onChangePersist={setPersist} /> ) diff --git a/src/view/com/home/HomeHeader.tsx b/src/view/com/home/HomeHeader.tsx index 4ae3445499..4a2cf881f3 100644 --- a/src/view/com/home/HomeHeader.tsx +++ b/src/view/com/home/HomeHeader.tsx @@ -61,6 +61,7 @@ export function HomeHeader( items={items} dragProgress={props.dragProgress} dragState={props.dragState} + transparent /> ) diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx index a0d7b3e785..ef1e938379 100644 --- a/src/view/com/home/HomeHeaderLayoutMobile.tsx +++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx @@ -77,6 +77,7 @@ export function HomeHeaderLayoutMobile({ style={[ a.justify_center, {marginRight: -Layout.BUTTON_VISUAL_ALIGNMENT_OFFSET}, + a.bg_transparent, ]}> diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index 5809e71065..f78b9650e1 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -42,23 +42,28 @@ import {sanitizeHandle} from '#/lib/strings/handles' import {niceDate} from '#/lib/strings/time' import {s} from '#/lib/styles' import {logger} from '#/logger' +import {useProfileShadow} from '#/state/cache/profile-shadow' import {type FeedNotification} from '#/state/queries/notifications/feed' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' -import {useAgent} from '#/state/session' +import {useAgent, useSession} from '#/state/session' import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' import {Post} from '#/view/com/post/Post' import {formatCount} from '#/view/com/util/numeric/format' import {TimeElapsed} from '#/view/com/util/TimeElapsed' +import * as Toast from '#/view/com/util/Toast' import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, platform, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' +import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import { ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, } from '#/components/icons/Chevron' import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2' import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' import {StarterPack} from '#/components/icons/StarterPack' import {VerifiedCheck} from '#/components/icons/VerifiedCheck' @@ -180,6 +185,32 @@ let NotificationFeedItem = ({ firstAuthor.profile.displayName || firstAuthor.profile.handle, ) + // Calculate if this is a follow-back notification + const isFollowBack = useMemo(() => { + if (item.type !== 'follow') return false + if ( + item.notification.author.viewer?.following && + bsky.dangerousIsType( + item.notification.record, + AppBskyGraphFollow.isRecord, + ) + ) { + let followingTimestamp + try { + const rkey = new AtUri(item.notification.author.viewer.following).rkey + followingTimestamp = TID.fromStr(rkey).timestamp() + } catch (e) { + return false + } + if (followingTimestamp) { + const followedTimestamp = + new Date(item.notification.record.createdAt).getTime() * 1000 + return followedTimestamp > followingTimestamp + } + } + return false + }, [item]) + if (item.subjectUri && !item.subject && item.type !== 'feedgen-like') { // don't render anything if the target post was deleted or unfindable return @@ -309,30 +340,6 @@ let NotificationFeedItem = ({ ) icon = } else if (item.type === 'follow') { - let isFollowBack = false - - if ( - item.notification.author.viewer?.following && - bsky.dangerousIsType( - item.notification.record, - AppBskyGraphFollow.isRecord, - ) - ) { - let followingTimestamp - try { - const rkey = new AtUri(item.notification.author.viewer.following).rkey - followingTimestamp = TID.fromStr(rkey).timestamp() - } catch (e) { - // For some reason the following URI was invalid. Default to it not being a follow back. - console.error('Invalid following URI') - } - if (followingTimestamp) { - const followedTimestamp = - new Date(item.notification.record.createdAt).getTime() * 1000 - isFollowBack = followedTimestamp > followingTimestamp - } - } - if (isFollowBack && !hasMultipleAuthors) { /* * Follow-backs are ungrouped, grouped follow-backs not supported atm, @@ -663,6 +670,9 @@ let NotificationFeedItem = ({ + {item.type === 'follow' && !hasMultipleAuthors && !isFollowBack ? ( + + ) : null} {item.type === 'post-like' || item.type === 'repost' || item.type === 'like-via-repost' || @@ -732,6 +742,111 @@ function ExpandListPressable({ } } +function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { + const {_} = useLingui() + const {currentAccount, hasSession} = useSession() + const profileShadow = useProfileShadow(profile) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( + profileShadow, + 'ProfileCard', + ) + + // Don't show button if not logged in or for own profile + if (!hasSession || profile.did === currentAccount?.did) { + return null + } + + const onPressFollow = async (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + + try { + await queueFollow() + Toast.show( + _( + msg`Following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, + ), + ) + } catch (err: any) { + if (err?.name !== 'AbortError') { + Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') + } + } + } + + const onPressUnfollow = async (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + + try { + await queueUnfollow() + Toast.show( + _( + msg`No longer following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, + ), + ) + } catch (err: any) { + if (err?.name !== 'AbortError') { + Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') + } + } + } + + // Don't show button if viewer data is missing or user is blocked + if (!profileShadow.viewer) { + return null + } + if ( + profileShadow.viewer.blockedBy || + profileShadow.viewer.blocking || + profileShadow.viewer.blockingByList + ) { + return null + } + + const isFollowing = profileShadow.viewer.following + const followingLabel = _( + msg({ + message: 'Following', + comment: 'User is following this account, click to unfollow', + }), + ) + + return ( + + {isFollowing ? ( + + ) : ( + + )} + + ) +} + function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileView}) { const {_} = useLingui() const agent = useAgent() diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 70358fd102..c07f44ac8b 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -30,6 +30,7 @@ export interface TabBarProps { onPressSelected?: (index: number) => void dragProgress: SharedValue dragState: SharedValue<'idle' | 'dragging' | 'settling'> + transparent?: boolean } const ITEM_PADDING = 10 @@ -46,6 +47,7 @@ export function TabBar({ onPressSelected, dragProgress, dragState, + transparent, }: TabBarProps) { const t = useTheme() const scrollElRef = useAnimatedRef() @@ -313,7 +315,7 @@ export function TabBar({ return ( ) diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index dfb63909e4..b5838f0c19 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -16,15 +16,19 @@ import {ProfileCardWithFollowBtn} from './ProfileCard' function renderItem({ item, index, + contextProfileDid, }: { item: ActorDefs.ProfileView index: number + contextProfileDid: string | undefined }) { return ( ) } @@ -83,6 +87,40 @@ export function ProfileFollowers({name}: {name: string}) { } }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) + const renderItemWithContext = React.useCallback( + ({item, index}: {item: ActorDefs.ProfileView; index: number}) => + renderItem({item, index, contextProfileDid: resolvedDid}), + [resolvedDid], + ) + + // track seen items + const seenItemsRef = React.useRef>(new Set()) + React.useEffect(() => { + seenItemsRef.current.clear() + }, [resolvedDid]) + const onItemSeen = React.useCallback( + (item: ActorDefs.ProfileView) => { + if (seenItemsRef.current.has(item.did)) { + return + } + seenItemsRef.current.add(item.did) + const position = followers.findIndex(p => p.did === item.did) + 1 + if (position === 0) { + return + } + logger.metric( + 'profileCard:seen', + { + profileDid: item.did, + position, + ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), + }, + {statsig: false}, + ) + }, + [followers, resolvedDid], + ) + if (followers.length < 1) { return ( ) } @@ -83,6 +87,40 @@ export function ProfileFollows({name}: {name: string}) { } }, [error, fetchNextPage, hasNextPage, isFetchingNextPage]) + const renderItemWithContext = React.useCallback( + ({item, index}: {item: ActorDefs.ProfileView; index: number}) => + renderItem({item, index, contextProfileDid: resolvedDid}), + [resolvedDid], + ) + + // track seen items + const seenItemsRef = React.useRef>(new Set()) + React.useEffect(() => { + seenItemsRef.current.clear() + }, [resolvedDid]) + const onItemSeen = React.useCallback( + (item: ActorDefs.ProfileView) => { + if (seenItemsRef.current.has(item.did)) { + return + } + seenItemsRef.current.add(item.did) + const position = follows.findIndex(p => p.did === item.did) + 1 + if (position === 0) { + return + } + logger.metric( + 'profileCard:seen', + { + profileDid: item.did, + position, + ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}), + }, + {statsig: false}, + ) + }, + [follows, resolvedDid], + ) + if (follows.length < 1) { return ( (() => { - const ratio = - isNative || !gtMobile - ? Math.min(1 / aspectRatio, minMobileAspectRatio ?? 16 / 9) // 9:16 bounding box - : Math.min(1 / aspectRatio, 1) // 1:1 bounding box + const ratio = isNative + ? Math.min(1 / aspectRatio, minMobileAspectRatio ?? 16 / 9) // 9:16 bounding box + : Math.min(1 / aspectRatio, 1) // 1:1 bounding box return `${ratio * 100}%` - }, [aspectRatio, gtMobile, minMobileAspectRatio]) + }, [aspectRatio, minMobileAspectRatio]) return ( diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx index 45a1d9aa00..a29aa70ab7 100644 --- a/src/view/screens/Storybook/Forms.tsx +++ b/src/view/screens/Storybook/Forms.tsx @@ -4,6 +4,7 @@ import {type TextInput, View} from 'react-native' import {atoms as a} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {DateField, LabelText} from '#/components/forms/DateField' +import * as SegmentedControl from '#/components/forms/SegmentedControl' import * as TextField from '#/components/forms/TextField' import * as Toggle from '#/components/forms/Toggle' import * as ToggleButton from '#/components/forms/ToggleButton' @@ -15,6 +16,9 @@ export function Forms() { const [toggleGroupBValues, setToggleGroupBValues] = React.useState(['a', 'b']) const [toggleGroupCValues, setToggleGroupCValues] = React.useState(['a', 'b']) const [toggleGroupDValues, setToggleGroupDValues] = React.useState(['warn']) + const [segmentedControlValue, setSegmentedControlValue] = React.useState< + 'hide' | 'warn' | 'show' + >('warn') const [value, setValue] = React.useState('') const [date, setDate] = React.useState('2001-01-01') @@ -155,6 +159,15 @@ export function Forms() { + + + Click me + + + + Click me + + Show + - - - - Hide - - - Warn - - - Show - - - + +

SegmentedControl

+ + + + Hide + + + Warn + + + Show + +
) diff --git a/src/view/shell/Composer.ios.tsx b/src/view/shell/Composer.ios.tsx index 393b8f80e6..8a0ee60055 100644 --- a/src/view/shell/Composer.ios.tsx +++ b/src/view/shell/Composer.ios.tsx @@ -3,8 +3,9 @@ import {Modal, View} from 'react-native' import {useDialogStateControlContext} from '#/state/dialogs' import {useComposerState} from '#/state/shell/composer' +import {ComposePost, useComposerCancelRef} from '#/view/com/composer/Composer' import {atoms as a, useTheme} from '#/alf' -import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' +import {SheetCompatProvider as TooltipSheetCompatProvider} from '#/components/Tooltip' export function Composer({}: {winHeight: number}) { const {setFullyExpandedCount} = useDialogStateControlContext() @@ -33,17 +34,19 @@ export function Composer({}: {winHeight: number}) { animationType="slide" onRequestClose={() => ref.current?.onPressCancel()}> - + + + ) diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index df89d95cad..641b90f3e3 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -5,6 +5,7 @@ import {useNavigation, useNavigationState} from '@react-navigation/native' import {getCurrentRoute} from '#/lib/routes/helpers' import {type NavigationProp} from '#/lib/routes/types' +import {logger} from '#/logger' import {emitSoftReset} from '#/state/events' import {usePinnedFeedsInfos} from '#/state/queries/feed' import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' @@ -75,6 +76,14 @@ export function DesktopFeeds() { key={feedInfo.uri} label={feedInfo.displayName} {...createStaticClick(() => { + logger.metric( + 'desktopFeeds:feed:click', + { + feedUri: feedInfo.uri, + feedDescriptor: feed, + }, + {statsig: false}, + ) setSelectedFeed(feed) navigation.navigate('Home') if (route.name === 'Home' && feed === selectedFeed) { diff --git a/yarn.lock b/yarn.lock index 57b8930398..345b999698 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11348,10 +11348,10 @@ expo-haptics@~15.0.7: resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.7.tgz#384bb873d7eca7b141f85e4f300b75eab68ebfe9" integrity sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ== -expo-image-crop-tool@^0.1.8: - version "0.1.8" - resolved "https://registry.yarnpkg.com/expo-image-crop-tool/-/expo-image-crop-tool-0.1.8.tgz#3e9f34825cf5d7dad1ef2786615571b078ece4e7" - integrity sha512-UlS1zV7JewUzuZzVT9aA0vFD1+dt+pU60ILgt3ntQl4G9SeDJ9bB/+ylz9dzn6BjZecUQkGJmbCQ3H7jGZeZMA== +expo-image-crop-tool@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/expo-image-crop-tool/-/expo-image-crop-tool-0.4.0.tgz#c376b0695e8b2bf6b38fff5595ce30aaf9cddd64" + integrity sha512-2KZI016tb2i0yb0ZRMdH8h1I4YofD78fG/l6KrQTFzy4DtKaQlmJwU2VSJ8AYV5/nxusbHxgro7RQnr1BQ5lJg== expo-image-loader@~6.0.0: version "6.0.0"