diff options
Diffstat (limited to 'src/components')
22 files changed, 1084 insertions, 58 deletions
diff --git a/src/components/AccountList.tsx b/src/components/AccountList.tsx index 169e7b84f..7d696801e 100644 --- a/src/components/AccountList.tsx +++ b/src/components/AccountList.tsx @@ -16,12 +16,14 @@ export function AccountList({ onSelectAccount, onSelectOther, otherLabel, + pendingDid, }: { onSelectAccount: (account: SessionAccount) => void onSelectOther: () => void otherLabel?: string + pendingDid: string | null }) { - const {isSwitchingAccounts, currentAccount, accounts} = useSession() + const {currentAccount, accounts} = useSession() const t = useTheme() const {_} = useLingui() @@ -31,6 +33,7 @@ export function AccountList({ return ( <View + pointerEvents={pendingDid ? 'none' : 'auto'} style={[ a.rounded_md, a.overflow_hidden, @@ -43,6 +46,7 @@ export function AccountList({ account={account} onSelect={onSelectAccount} isCurrentAccount={account.did === currentAccount?.did} + isPendingAccount={account.did === pendingDid} /> <View style={[a.border_b, t.atoms.border_contrast_low]} /> </React.Fragment> @@ -50,7 +54,7 @@ export function AccountList({ <Button testID="chooseAddAccountBtn" style={[a.flex_1]} - onPress={isSwitchingAccounts ? undefined : onPressAddAccount} + onPress={pendingDid ? undefined : onPressAddAccount} label={_(msg`Login to account that is not listed`)}> {({hovered, pressed}) => ( <View @@ -59,8 +63,7 @@ export function AccountList({ a.flex_row, a.align_center, {height: 48}, - (hovered || pressed || isSwitchingAccounts) && - t.atoms.bg_contrast_25, + (hovered || pressed) && t.atoms.bg_contrast_25, ]}> <Text style={[ @@ -84,10 +87,12 @@ function AccountItem({ account, onSelect, isCurrentAccount, + isPendingAccount, }: { account: SessionAccount onSelect: (account: SessionAccount) => void isCurrentAccount: boolean + isPendingAccount: boolean }) { const t = useTheme() const {_} = useLingui() @@ -115,7 +120,7 @@ function AccountItem({ a.flex_row, a.align_center, {height: 48}, - (hovered || pressed) && t.atoms.bg_contrast_25, + (hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25, ]}> <View style={a.p_md}> <UserAvatar avatar={profile?.avatar} size={24} /> diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 33d777971..dc319eb5c 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -64,7 +64,7 @@ type NonTextElements = export type ButtonProps = Pick< PressableProps, - 'disabled' | 'onPress' | 'testID' + 'disabled' | 'onPress' | 'testID' | 'onLongPress' > & AccessibilityProps & VariantProps & { diff --git a/src/components/Error.tsx b/src/components/Error.tsx index bf689fc07..481532434 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -17,12 +17,14 @@ export function Error({ message, onRetry, onGoBack: onGoBackProp, + hideBackButton, sideBorders = true, }: { title?: string message?: string onRetry?: () => unknown onGoBack?: () => unknown + hideBackButton?: boolean sideBorders?: boolean }) { const navigation = useNavigation<NavigationProp>() @@ -70,7 +72,7 @@ export function Error({ a.text_center, t.atoms.text_contrast_high, {lineHeight: 1.4}, - gtMobile && {width: 450}, + gtMobile ? {width: 450} : [a.w_full, a.px_lg], ]}> {message} </Text> @@ -89,17 +91,19 @@ export function Error({ </ButtonText> </Button> )} - <Button - variant="solid" - color={onRetry ? 'secondary' : 'primary'} - label={_(msg`Return to previous page`)} - onPress={onGoBack} - size="large" - style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> - <ButtonText> - <Trans>Go Back</Trans> - </ButtonText> - </Button> + {!hideBackButton && ( + <Button + variant="solid" + color={onRetry ? 'secondary' : 'primary'} + label={_(msg`Return to previous page`)} + onPress={onGoBack} + size="large" + style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> + <ButtonText> + <Trans>Go Back</Trans> + </ButtonText> + </Button> + )} </View> </CenteredView> ) diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx index b5419697b..721e877be 100644 --- a/src/components/Lists.tsx +++ b/src/components/Lists.tsx @@ -134,6 +134,7 @@ let ListMaybePlaceholder = ({ emptyType = 'page', onRetry, onGoBack, + hideBackButton, sideBorders, }: { isLoading: boolean @@ -146,6 +147,7 @@ let ListMaybePlaceholder = ({ emptyType?: 'page' | 'results' onRetry?: () => Promise<unknown> onGoBack?: () => void + hideBackButton?: boolean sideBorders?: boolean }): React.ReactNode => { const t = useTheme() @@ -179,6 +181,7 @@ let ListMaybePlaceholder = ({ onRetry={onRetry} onGoBack={onGoBack} sideBorders={sideBorders} + hideBackButton={hideBackButton} /> ) } @@ -198,6 +201,7 @@ let ListMaybePlaceholder = ({ } onRetry={onRetry} onGoBack={onGoBack} + hideBackButton={hideBackButton} sideBorders={sideBorders} /> ) diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index 051e95b95..3be69b348 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -1,27 +1,29 @@ import React from 'react' -import {View, Pressable, ViewStyle, StyleProp} from 'react-native' +import {Pressable, StyleProp, View, ViewStyle} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import flattenReactChildren from 'react-keyed-flatten-children' +import {isNative} from 'platform/detection' import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useInteractionState} from '#/components/hooks/useInteractionState' -import {Text} from '#/components/Typography' - import {Context} from '#/components/Menu/context' import { ContextType, - TriggerProps, - ItemProps, GroupProps, - ItemTextProps, ItemIconProps, + ItemProps, + ItemTextProps, + TriggerProps, } from '#/components/Menu/types' -import {Button, ButtonText} from '#/components/Button' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {isNative} from 'platform/detection' +import {Text} from '#/components/Typography' -export {useDialogControl as useMenuControl} from '#/components/Dialog' +export { + type DialogControlProps as MenuControlProps, + useDialogControl as useMenuControl, +} from '#/components/Dialog' export function useMemoControlContext() { return React.useContext(Context) diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index 60b234203..031250dde 100644 --- a/src/components/Menu/index.web.tsx +++ b/src/components/Menu/index.web.tsx @@ -1,27 +1,26 @@ /* eslint-disable react/prop-types */ import React from 'react' -import {View, Pressable, ViewStyle, StyleProp} from 'react-native' -import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import {Pressable, StyleProp, View, ViewStyle} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import {atoms as a, flatten, useTheme, web} from '#/alf' import * as Dialog from '#/components/Dialog' import {useInteractionState} from '#/components/hooks/useInteractionState' -import {atoms as a, useTheme, flatten, web} from '#/alf' -import {Text} from '#/components/Typography' - +import {Context} from '#/components/Menu/context' import { ContextType, - TriggerProps, - ItemProps, GroupProps, - ItemTextProps, ItemIconProps, + ItemProps, + ItemTextProps, RadixPassThroughTriggerProps, + TriggerProps, } from '#/components/Menu/types' -import {Context} from '#/components/Menu/context' import {Portal} from '#/components/Portal' +import {Text} from '#/components/Typography' export function useMenuControl(): Dialog.DialogControlProps { const id = React.useId() @@ -135,10 +134,22 @@ export function Trigger({children, label}: TriggerProps) { }, props: { ...props, - // disable on web, use `onPress` - onPointerDown: () => false, - onPress: () => - control.isOpen ? control.close() : control.open(), + // No-op override to prevent false positive that interprets mobile scroll as a tap. + // This requires the custom onPress handler below to compensate. + // https://github.com/radix-ui/primitives/issues/1912 + onPointerDown: undefined, + onPress: () => { + if (window.event instanceof KeyboardEvent) { + // The onPointerDown hack above is not relevant to this press, so don't do anything. + return + } + // Compensate for the disabled onPointerDown above by triggering it manually. + if (control.isOpen) { + control.close() + } else { + control.open() + } + }, onFocus: onFocus, onBlur: onBlur, onMouseEnter, diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx index d696f986b..03b397b2b 100644 --- a/src/components/Portal.tsx +++ b/src/components/Portal.tsx @@ -34,10 +34,17 @@ export function createPortalGroup() { setOutlet(<>{Object.values(map.current)}</>) }, []) + const contextValue = React.useMemo( + () => ({ + outlet, + append, + remove, + }), + [outlet, append, remove], + ) + return ( - <Context.Provider value={{outlet, append, remove}}> - {props.children} - </Context.Provider> + <Context.Provider value={contextValue}>{props.children}</Context.Provider> ) } diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index 2cd1228d8..a22436879 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -9,7 +9,7 @@ import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {pluralize} from '#/lib/strings/helpers' -import {useModerationOpts} from '#/state/queries/preferences' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile' import {useSession} from '#/state/session' import {useProfileShadow} from 'state/cache/profile-shadow' @@ -72,10 +72,10 @@ type Action = | 'unhovered-long-enough' | 'finished-animating-hide' -const SHOW_DELAY = 400 +const SHOW_DELAY = 500 const SHOW_DURATION = 300 const HIDE_DELAY = 150 -const HIDE_DURATION = 150 +const HIDE_DURATION = 200 export function ProfileHoverCardInner(props: ProfileHoverCardProps) { const {refs, floatingStyles} = useFloating({ @@ -244,12 +244,20 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) { } }, [prefetchProfileQuery, props.did]) - const onPointerEnterTarget = React.useCallback(() => { + const didFireHover = React.useRef(false) + const onPointerMoveTarget = React.useCallback(() => { prefetchIfNeeded() - dispatch('hovered-target') + // Conceptually we want something like onPointerEnter, + // but we want to ignore entering only due to scrolling. + // So instead we hover on the first onPointerMove. + if (!didFireHover.current) { + didFireHover.current = true + dispatch('hovered-target') + } }, [prefetchIfNeeded]) const onPointerLeaveTarget = React.useCallback(() => { + didFireHover.current = false dispatch('unhovered-target') }, []) @@ -280,7 +288,7 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) { return ( <div ref={refs.setReference} - onPointerEnter={onPointerEnterTarget} + onPointerMove={onPointerMoveTarget} onPointerLeave={onPointerLeaveTarget} onMouseUp={onPress} style={{ diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 0a171674d..92e848e8e 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -43,7 +43,9 @@ export function Outer({ <Dialog.ScrollableInner accessibilityLabelledBy={titleId} accessibilityDescribedBy={descriptionId} - style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}> + style={[ + gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full, + ]}> {children} </Dialog.ScrollableInner> </Context.Provider> @@ -60,12 +62,16 @@ export function TitleText({children}: React.PropsWithChildren<{}>) { ) } -export function DescriptionText({children}: React.PropsWithChildren<{}>) { +export function DescriptionText({ + children, + selectable, +}: React.PropsWithChildren<{selectable?: boolean}>) { const t = useTheme() const {descriptionId} = React.useContext(Context) return ( <Text nativeID={descriptionId} + selectable={selectable} style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high, a.pb_lg]}> {children} </Text> diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx index 024188ec4..57389ba2b 100644 --- a/src/components/dialogs/GifSelect.tsx +++ b/src/components/dialogs/GifSelect.tsx @@ -1,5 +1,5 @@ import React, {useCallback, useMemo, useRef, useState} from 'react' -import {Keyboard, TextInput, View} from 'react-native' +import {TextInput, View} from 'react-native' import {Image} from 'expo-image' import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' import {msg, Trans} from '@lingui/macro' @@ -216,7 +216,7 @@ function GifList({ keyExtractor={(item: Gif) => item.id} // @ts-expect-error web only style={isWeb && {minHeight: '100vh'}} - onScrollBeginDrag={() => Keyboard.dismiss()} + keyboardDismissMode="on-drag" ListFooterComponent={ hasData ? ( <ListFooter diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx index 0eced11e3..534263422 100644 --- a/src/components/dialogs/MutedWords.tsx +++ b/src/components/dialogs/MutedWords.tsx @@ -37,12 +37,12 @@ export function MutedWordsDialog() { return ( <Dialog.Outer control={control}> <Dialog.Handle /> - <MutedWordsInner control={control} /> + <MutedWordsInner /> </Dialog.Outer> ) } -function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) { +function MutedWordsInner() { const t = useTheme() const {_} = useLingui() const {gtMobile} = useBreakpoints() diff --git a/src/components/dialogs/SwitchAccount.tsx b/src/components/dialogs/SwitchAccount.tsx index 55628a790..0bd4bcb8c 100644 --- a/src/components/dialogs/SwitchAccount.tsx +++ b/src/components/dialogs/SwitchAccount.tsx @@ -18,7 +18,7 @@ export function SwitchAccountDialog({ }) { const {_} = useLingui() const {currentAccount} = useSession() - const {onPressSwitchAccount} = useAccountSwitcher() + const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() const {setShowLoggedOut} = useLoggedOutViewControls() const onSelectAccount = useCallback( @@ -54,6 +54,7 @@ export function SwitchAccountDialog({ onSelectAccount={onSelectAccount} onSelectOther={onPressAddAccount} otherLabel={_(msg`Add account`)} + pendingDid={pendingDid} /> </View> </Dialog.ScrollableInner> diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx new file mode 100644 index 000000000..19a3e0424 --- /dev/null +++ b/src/components/dms/ActionsWrapper.tsx @@ -0,0 +1,83 @@ +import React, {useCallback} from 'react' +import {Keyboard, Pressable, View} from 'react-native' +import Animated, { + cancelAnimation, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' +import {ChatBskyConvoDefs} from '@atproto-labs/api' + +import {useHaptics} from 'lib/haptics' +import {atoms as a} from '#/alf' +import {MessageMenu} from '#/components/dms/MessageMenu' +import {useMenuControl} from '#/components/Menu' + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable) + +export function ActionsWrapper({ + message, + isFromSelf, + children, +}: { + message: ChatBskyConvoDefs.MessageView + isFromSelf: boolean + children: React.ReactNode +}) { + const playHaptic = useHaptics() + const menuControl = useMenuControl() + + const scale = useSharedValue(1) + const animationDidComplete = useSharedValue(false) + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{scale: scale.value}], + })) + + // Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this + // function + const open = useCallback(() => { + Keyboard.dismiss() + menuControl.open() + }, [menuControl]) + + const shrink = useCallback(() => { + 'worklet' + cancelAnimation(scale) + scale.value = withTiming(1, {duration: 200}, () => { + animationDidComplete.value = false + }) + }, [animationDidComplete, scale]) + + const grow = React.useCallback(() => { + 'worklet' + scale.value = withTiming(1.05, {duration: 750}, finished => { + if (!finished) return + animationDidComplete.value = true + runOnJS(playHaptic)() + runOnJS(open)() + + shrink() + }) + }, [scale, animationDidComplete, playHaptic, shrink, open]) + + return ( + <View + style={[ + { + maxWidth: '65%', + }, + isFromSelf ? a.self_end : a.self_start, + ]}> + <AnimatedPressable + style={animatedStyle} + unstable_pressDelay={200} + onPressIn={grow} + onTouchEnd={shrink}> + {children} + </AnimatedPressable> + <MessageMenu message={message} control={menuControl} hideTrigger={true} /> + </View> + ) +} diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx new file mode 100644 index 000000000..f4c85ab94 --- /dev/null +++ b/src/components/dms/ActionsWrapper.web.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {ChatBskyConvoDefs} from '@atproto-labs/api' + +import {atoms as a} from '#/alf' +import {MessageMenu} from '#/components/dms/MessageMenu' +import {useMenuControl} from '#/components/Menu' + +export function ActionsWrapper({ + message, + isFromSelf, + children, +}: { + message: ChatBskyConvoDefs.MessageView + isFromSelf: boolean + children: React.ReactNode +}) { + const menuControl = useMenuControl() + const viewRef = React.useRef(null) + + const [showActions, setShowActions] = React.useState(false) + + const onMouseEnter = React.useCallback(() => { + setShowActions(true) + }, []) + + const onMouseLeave = React.useCallback(() => { + setShowActions(false) + }, []) + + // We need to handle the `onFocus` separately because we want to know if there is a related target (the element + // that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed. + const onFocus = React.useCallback<React.FocusEventHandler>(e => { + if (e.nativeEvent.relatedTarget == null) return + setShowActions(true) + }, []) + + return ( + <View + // @ts-expect-error web only + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + onFocus={onFocus} + onBlur={onMouseLeave} + style={StyleSheet.flatten([a.flex_1, a.flex_row])} + ref={viewRef}> + {isFromSelf && ( + <View + style={[ + a.mr_xl, + a.justify_center, + { + marginLeft: 'auto', + }, + ]}> + <MessageMenu + message={message} + control={menuControl} + triggerOpacity={showActions || menuControl.isOpen ? 1 : 0} + onTriggerPress={onMouseEnter} + // @ts-expect-error web only + onMouseLeave={onMouseLeave} + /> + </View> + )} + <View + style={{ + maxWidth: '65%', + }}> + {children} + </View> + {!isFromSelf && ( + <View style={[a.flex_row, a.align_center, a.ml_xl]}> + <MessageMenu + message={message} + control={menuControl} + triggerOpacity={showActions || menuControl.isOpen ? 1 : 0} + onTriggerPress={onMouseEnter} + // @ts-expect-error web only + onMouseLeave={onMouseLeave} + /> + </View> + )} + </View> + ) +} diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx new file mode 100644 index 000000000..16306bb57 --- /dev/null +++ b/src/components/dms/ConvoMenu.tsx @@ -0,0 +1,184 @@ +import React, {useCallback} from 'react' +import {Keyboard, Pressable} from 'react-native' +import {AppBskyActorDefs} from '@atproto/api' +import {ChatBskyConvoDefs} from '@atproto-labs/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {NavigationProp} from '#/lib/routes/types' +import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' +import { + useMuteConvo, + useUnmuteConvo, +} from '#/state/queries/messages/mute-conversation' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useTheme} from '#/alf' +import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft' +import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' +import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' +import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' +import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' +import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck' +import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' + +let ConvoMenu = ({ + convo, + profile, + onUpdateConvo, + control, + hideTrigger, + currentScreen, +}: { + convo: ChatBskyConvoDefs.ConvoView + profile: AppBskyActorDefs.ProfileViewBasic + onUpdateConvo?: (convo: ChatBskyConvoDefs.ConvoView) => void + control?: Menu.MenuControlProps + hideTrigger?: boolean + currentScreen: 'list' | 'conversation' +}): React.ReactNode => { + const navigation = useNavigation<NavigationProp>() + const {_} = useLingui() + const t = useTheme() + const leaveConvoControl = Prompt.usePromptControl() + + const onNavigateToProfile = useCallback(() => { + navigation.navigate('Profile', {name: profile.did}) + }, [navigation, profile.did]) + + const {mutate: muteConvo} = useMuteConvo(convo.id, { + onSuccess: data => { + onUpdateConvo?.(data.convo) + Toast.show(_(msg`Chat muted`)) + }, + onError: () => { + Toast.show(_(msg`Could not mute chat`)) + }, + }) + + const {mutate: unmuteConvo} = useUnmuteConvo(convo.id, { + onSuccess: data => { + onUpdateConvo?.(data.convo) + Toast.show(_(msg`Chat unmuted`)) + }, + onError: () => { + Toast.show(_(msg`Could not unmute chat`)) + }, + }) + + const {mutate: leaveConvo} = useLeaveConvo(convo.id, { + onSuccess: () => { + if (currentScreen === 'conversation') { + navigation.replace('Messages') + } + }, + onError: () => { + Toast.show(_(msg`Could not leave chat`)) + }, + }) + + return ( + <> + <Menu.Root control={control}> + {!hideTrigger && ( + <Menu.Trigger label={_(msg`Chat settings`)}> + {({props, state}) => ( + <Pressable + {...props} + onPress={() => { + Keyboard.dismiss() + // eslint-disable-next-line react/prop-types -- eslint is confused by the name `props` + props.onPress() + }} + style={[ + a.p_sm, + a.rounded_sm, + (state.hovered || state.pressed) && t.atoms.bg_contrast_25, + // make sure pfp is in the middle + {marginLeft: -10}, + ]}> + <DotsHorizontal size="lg" style={t.atoms.text} /> + </Pressable> + )} + </Menu.Trigger> + )} + <Menu.Outer> + <Menu.Group> + <Menu.Item + label={_(msg`Go to user's profile`)} + onPress={onNavigateToProfile}> + <Menu.ItemText> + <Trans>Go to profile</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Person} /> + </Menu.Item> + <Menu.Item + label={_(msg`Mute notifications`)} + onPress={() => (convo?.muted ? unmuteConvo() : muteConvo())}> + <Menu.ItemText> + {convo?.muted ? ( + <Trans>Unmute notifications</Trans> + ) : ( + <Trans>Mute notifications</Trans> + )} + </Menu.ItemText> + <Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} /> + </Menu.Item> + </Menu.Group> + <Menu.Divider /> + {/* TODO(samuel): implement these */} + <Menu.Group> + <Menu.Item + label={_(msg`Block account`)} + onPress={() => {}} + disabled> + <Menu.ItemText> + <Trans>Block account</Trans> + </Menu.ItemText> + <Menu.ItemIcon + icon={profile.viewer?.blocking ? PersonCheck : PersonX} + /> + </Menu.Item> + <Menu.Item + label={_(msg`Report account`)} + onPress={() => {}} + disabled> + <Menu.ItemText> + <Trans>Report account</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Flag} /> + </Menu.Item> + </Menu.Group> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + label={_(msg`Leave conversation`)} + onPress={leaveConvoControl.open}> + <Menu.ItemText> + <Trans>Leave conversation</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ArrowBoxLeft} /> + </Menu.Item> + </Menu.Group> + </Menu.Outer> + </Menu.Root> + + <Prompt.Basic + control={leaveConvoControl} + title={_(msg`Leave conversation`)} + description={_( + msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for other participants.`, + )} + confirmButtonCta={_(msg`Leave`)} + confirmButtonColor="negative" + onConfirm={() => leaveConvo()} + /> + </> + ) +} +ConvoMenu = React.memo(ConvoMenu) + +export {ConvoMenu} diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx new file mode 100644 index 000000000..f8f5197ca --- /dev/null +++ b/src/components/dms/MessageItem.tsx @@ -0,0 +1,198 @@ +import React, {useCallback, useMemo, useRef} from 'react' +import {LayoutAnimation, StyleProp, TextStyle, View} from 'react-native' +import {ChatBskyConvoDefs} from '@atproto-labs/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useSession} from '#/state/session' +import {TimeElapsed} from 'view/com/util/TimeElapsed' +import {atoms as a, useTheme} from '#/alf' +import {ActionsWrapper} from '#/components/dms/ActionsWrapper' +import {Text} from '#/components/Typography' + +export let MessageItem = ({ + item, + next, + pending, +}: { + item: ChatBskyConvoDefs.MessageView + next: + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | null + pending?: boolean +}): React.ReactNode => { + const t = useTheme() + const {currentAccount} = useSession() + + const isFromSelf = item.sender?.did === currentAccount?.did + + const isNextFromSelf = + ChatBskyConvoDefs.isMessageView(next) && + next.sender?.did === currentAccount?.did + + const isLastInGroup = useMemo(() => { + // TODO this means it's a placeholder. Let's figure out the right way to do this though! + if (item.id.length > 13) { + return false + } + + // if the next message is from a different sender, then it's the last in the group + if (isFromSelf ? !isNextFromSelf : isNextFromSelf) { + return true + } + + // or, if there's a 3 minute gap between this message and the next + if (ChatBskyConvoDefs.isMessageView(next)) { + const thisDate = new Date(item.sentAt) + const nextDate = new Date(next.sentAt) + + const diff = nextDate.getTime() - thisDate.getTime() + + // 3 minutes + return diff > 3 * 60 * 1000 + } + + return true + }, [item, next, isFromSelf, isNextFromSelf]) + + const lastInGroupRef = useRef(isLastInGroup) + if (lastInGroupRef.current !== isLastInGroup) { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + lastInGroupRef.current = isLastInGroup + } + + const pendingColor = + t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800 + + return ( + <View> + <ActionsWrapper isFromSelf={isFromSelf} message={item}> + <View + style={[ + a.py_sm, + a.my_2xs, + a.rounded_md, + { + paddingLeft: 14, + paddingRight: 14, + backgroundColor: isFromSelf + ? pending + ? pendingColor + : t.palette.primary_500 + : t.palette.contrast_50, + borderRadius: 17, + }, + isFromSelf + ? {borderBottomRightRadius: isLastInGroup ? 2 : 17} + : {borderBottomLeftRadius: isLastInGroup ? 2 : 17}, + ]}> + <Text + style={[ + a.text_md, + a.leading_snug, + isFromSelf && {color: t.palette.white}, + pending && t.name !== 'light' && {color: t.palette.primary_300}, + ]}> + {item.text} + </Text> + </View> + </ActionsWrapper> + <MessageItemMetadata + message={item} + isLastInGroup={isLastInGroup} + style={isFromSelf ? a.text_right : a.text_left} + /> + </View> + ) +} + +MessageItem = React.memo(MessageItem) + +let MessageItemMetadata = ({ + message, + isLastInGroup, + style, +}: { + message: ChatBskyConvoDefs.MessageView + isLastInGroup: boolean + style: StyleProp<TextStyle> +}): React.ReactNode => { + const t = useTheme() + const {_} = useLingui() + + const relativeTimestamp = useCallback( + (timestamp: string) => { + const date = new Date(timestamp) + const now = new Date() + + const time = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: 'numeric', + hour12: true, + }).format(date) + + const diff = now.getTime() - date.getTime() + + // if under 1 minute + if (diff < 1000 * 60) { + return _(msg`Now`) + } + + // if in the last day + if (localDateString(now) === localDateString(date)) { + return time + } + + // if yesterday + const yesterday = new Date(now) + yesterday.setDate(yesterday.getDate() - 1) + + if (localDateString(yesterday) === localDateString(date)) { + return _(msg`Yesterday, ${time}`) + } + + return new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: 'numeric', + hour12: true, + day: 'numeric', + month: 'numeric', + year: 'numeric', + }).format(date) + }, + [_], + ) + + if (!isLastInGroup) { + return null + } + + return ( + <TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}> + {({timeElapsed}) => ( + <Text + style={[ + t.atoms.text_contrast_medium, + a.text_xs, + a.mt_2xs, + a.mb_lg, + style, + ]}> + {timeElapsed} + </Text> + )} + </TimeElapsed> + ) +} + +MessageItemMetadata = React.memo(MessageItemMetadata) + +function localDateString(date: Date) { + // can't use toISOString because it should be in local time + const mm = date.getMonth() + const dd = date.getDate() + const yyyy = date.getFullYear() + // not padding with 0s because it's not necessary, it's just used for comparison + return `${yyyy}-${mm}-${dd}` +} diff --git a/src/components/dms/MessageMenu.tsx b/src/components/dms/MessageMenu.tsx new file mode 100644 index 000000000..d2a7d147d --- /dev/null +++ b/src/components/dms/MessageMenu.tsx @@ -0,0 +1,141 @@ +import React from 'react' +import {LayoutAnimation, Pressable, View} from 'react-native' +import * as Clipboard from 'expo-clipboard' +import {ChatBskyConvoDefs} from '@atproto-labs/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useChat} from 'state/messages' +import {ConvoStatus} from 'state/messages/convo' +import {useSession} from 'state/session' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useTheme} from '#/alf' +import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' +import {usePromptControl} from '#/components/Prompt' +import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard' + +export let MessageMenu = ({ + message, + control, + hideTrigger, + triggerOpacity, +}: { + hideTrigger?: boolean + triggerOpacity?: number + onTriggerPress?: () => void + message: ChatBskyConvoDefs.MessageView + control: Menu.MenuControlProps +}): React.ReactNode => { + const {_} = useLingui() + const t = useTheme() + const {currentAccount} = useSession() + const chat = useChat() + const deleteControl = usePromptControl() + const retryDeleteControl = usePromptControl() + + const isFromSelf = message.sender?.did === currentAccount?.did + + const onCopyPostText = React.useCallback(() => { + // use when we have rich text + // const str = richTextToString(richText, true) + + Clipboard.setStringAsync(message.text) + Toast.show(_(msg`Copied to clipboard`)) + }, [_, message.text]) + + const onDelete = React.useCallback(() => { + if (chat.status !== ConvoStatus.Ready) return + + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + chat + .deleteMessage(message.id) + .then(() => Toast.show(_(msg`Message deleted`))) + .catch(() => retryDeleteControl.open()) + }, [_, chat, message.id, retryDeleteControl]) + + const onReport = React.useCallback(() => { + // TODO report the message + }, []) + + return ( + <> + <Menu.Root control={control}> + {!hideTrigger && ( + <View style={{opacity: triggerOpacity}}> + <Menu.Trigger label={_(msg`Chat settings`)}> + {({props, state}) => ( + <Pressable + {...props} + style={[ + a.p_sm, + a.rounded_full, + (state.hovered || state.pressed) && t.atoms.bg_contrast_25, + ]}> + <DotsHorizontal size="sm" style={t.atoms.text} /> + </Pressable> + )} + </Menu.Trigger> + </View> + )} + + <Menu.Outer> + <Menu.Group> + <Menu.Item + testID="messageDropdownCopyBtn" + label={_(msg`Copy message text`)} + onPress={onCopyPostText}> + <Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText> + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> + </Menu.Item> + </Menu.Group> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + testID="messageDropdownDeleteBtn" + label={_(msg`Delete message for me`)} + onPress={deleteControl.open}> + <Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText> + <Menu.ItemIcon icon={Trash} position="right" /> + </Menu.Item> + {!isFromSelf && ( + <Menu.Item + testID="messageDropdownReportBtn" + label={_(msg`Report message`)} + onPress={onReport}> + <Menu.ItemText>{_(msg`Report`)}</Menu.ItemText> + <Menu.ItemIcon icon={Warning} position="right" /> + </Menu.Item> + )} + </Menu.Group> + </Menu.Outer> + </Menu.Root> + + <Prompt.Basic + control={deleteControl} + title={_(msg`Delete message`)} + description={_( + msg`Are you sure you want to delete this message? The message will be deleted for you, but not for other participants.`, + )} + confirmButtonCta={_(msg`Delete`)} + confirmButtonColor="negative" + onConfirm={onDelete} + /> + + <Prompt.Basic + control={retryDeleteControl} + title={_(msg`Failed to delete message`)} + description={_( + msg`An error occurred while trying to delete the message. Please try again.`, + )} + confirmButtonCta={_(msg`Retry`)} + confirmButtonColor="negative" + onConfirm={onDelete} + /> + </> + ) +} +MessageMenu = React.memo(MessageMenu) diff --git a/src/components/dms/NewChat.tsx b/src/components/dms/NewChat.tsx new file mode 100644 index 000000000..5dde8628c --- /dev/null +++ b/src/components/dms/NewChat.tsx @@ -0,0 +1,255 @@ +import React, {useCallback, useMemo, useRef, useState} from 'react' +import {Keyboard, View} from 'react-native' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' +import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {isWeb} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' +import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete' +import {FAB} from '#/view/com/util/fab/FAB' +import * as Toast from '#/view/com/util/Toast' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme, web} from '#/alf' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {Button} from '../Button' +import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope' +import {ListMaybePlaceholder} from '../Lists' +import {Text} from '../Typography' + +export function NewChat({ + control, + onNewChat, +}: { + control: Dialog.DialogControlProps + onNewChat: (chatId: string) => void +}) { + const t = useTheme() + const {_} = useLingui() + + const {mutate: createChat} = useGetConvoForMembers({ + onSuccess: data => { + onNewChat(data.convo.id) + }, + onError: error => { + Toast.show(error.message) + }, + }) + + const onCreateChat = useCallback( + (did: string) => { + control.close(() => createChat([did])) + }, + [control, createChat], + ) + + return ( + <> + <FAB + testID="newChatFAB" + onPress={control.open} + icon={<Plus size="lg" fill={t.palette.white} />} + accessibilityRole="button" + accessibilityLabel={_(msg`New chat`)} + accessibilityHint="" + /> + + <Dialog.Outer + control={control} + testID="newChatDialog" + nativeOptions={{sheet: {snapPoints: ['100%']}}}> + <Dialog.Handle /> + <SearchablePeopleList onCreateChat={onCreateChat} /> + </Dialog.Outer> + </> + ) +} + +function SearchablePeopleList({ + onCreateChat, +}: { + onCreateChat: (did: string) => void +}) { + const t = useTheme() + const {_} = useLingui() + const moderationOpts = useModerationOpts() + const control = Dialog.useDialogContext() + const listRef = useRef<BottomSheetFlatListMethods>(null) + + const [searchText, setSearchText] = useState('') + + const { + data: actorAutocompleteData, + isFetching, + isError, + refetch, + } = useActorAutocompleteQuery(searchText, true) + + const renderItem = useCallback( + ({item: profile}: {item: AppBskyActorDefs.ProfileView}) => { + if (!moderationOpts) return null + const moderation = moderateProfile(profile, moderationOpts) + return ( + <Button + label={profile.displayName || sanitizeHandle(profile.handle)} + onPress={() => onCreateChat(profile.did)}> + {({hovered, pressed}) => ( + <View + style={[ + a.flex_1, + a.px_md, + a.py_sm, + a.gap_md, + a.align_center, + a.flex_row, + a.rounded_sm, + pressed + ? t.atoms.bg_contrast_25 + : hovered + ? t.atoms.bg_contrast_50 + : t.atoms.bg, + ]}> + <UserAvatar + size={40} + avatar={profile.avatar} + moderation={moderation.ui('avatar')} + type={profile.associated?.labeler ? 'labeler' : 'user'} + /> + <View style={{flex: 1}}> + <Text + style={[t.atoms.text, a.font_bold, a.leading_snug]} + numberOfLines={1}> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + )} + </Text> + <Text style={t.atoms.text_contrast_high} numberOfLines={1}> + {sanitizeHandle(profile.handle, '@')} + </Text> + </View> + </View> + )} + </Button> + ) + }, + [ + moderationOpts, + onCreateChat, + t.atoms.bg_contrast_25, + t.atoms.bg_contrast_50, + t.atoms.bg, + t.atoms.text, + t.atoms.text_contrast_high, + ], + ) + + const listHeader = useMemo(() => { + return ( + <View style={[a.relative, a.mb_lg]}> + {/* cover top corners */} + <View + style={[ + a.absolute, + a.inset_0, + { + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, + }, + t.atoms.bg, + ]} + /> + <Dialog.Close /> + <Text + style={[ + a.text_2xl, + a.font_bold, + a.leading_tight, + a.pb_lg, + web(a.pt_lg), + ]}> + <Trans>Start a new chat</Trans> + </Text> + <TextField.Root> + <TextField.Icon icon={Search} /> + <Dialog.Input + label={_(msg`Search profiles`)} + placeholder={_(msg`Search`)} + value={searchText} + onChangeText={text => { + setSearchText(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + returnKeyType="search" + clearButtonMode="while-editing" + maxLength={50} + onKeyPress={({nativeEvent}) => { + if (nativeEvent.key === 'Escape') { + control.close() + } + }} + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + autoFocus + /> + </TextField.Root> + </View> + ) + }, [t.atoms.bg, _, control, searchText]) + + return ( + <Dialog.InnerFlatList + ref={listRef} + data={actorAutocompleteData} + renderItem={renderItem} + ListHeaderComponent={ + <> + {listHeader} + {searchText.length === 0 ? ( + <View style={[a.pt_4xl, a.align_center, a.px_lg]}> + <Envelope width={64} fill={t.palette.contrast_200} /> + <Text + style={[ + a.text_lg, + a.text_center, + a.mt_md, + t.atoms.text_contrast_low, + ]}> + <Trans>Search for someone to start a conversation with.</Trans> + </Text> + </View> + ) : ( + !actorAutocompleteData?.length && ( + <ListMaybePlaceholder + isLoading={isFetching} + isError={isError} + onRetry={refetch} + hideBackButton={true} + emptyType="results" + sideBorders={false} + emptyMessage={ + isError + ? _(msg`No search results found for "${searchText}".`) + : _(msg`Could not load profiles. Please try again later.`) + } + /> + ) + )} + </> + } + stickyHeaderIndices={[0]} + keyExtractor={(item: AppBskyActorDefs.ProfileView) => item.did} + // @ts-expect-error web only + style={isWeb && {minHeight: '100vh'}} + onScrollBeginDrag={() => Keyboard.dismiss()} + /> + ) +} diff --git a/src/components/hooks/useRefreshOnFocus.ts b/src/components/hooks/useRefreshOnFocus.ts new file mode 100644 index 000000000..6bf7ac8b1 --- /dev/null +++ b/src/components/hooks/useRefreshOnFocus.ts @@ -0,0 +1,17 @@ +import {useCallback, useRef} from 'react' +import {useFocusEffect} from '@react-navigation/native' + +export function useRefreshOnFocus<T>(refetch: () => Promise<T>) { + const firstTimeRef = useRef(true) + + useFocusEffect( + useCallback(() => { + if (firstTimeRef.current) { + firstTimeRef.current = false + return + } + + refetch() + }, [refetch]), + ) +} diff --git a/src/components/icons/ArrowBoxLeft.tsx b/src/components/icons/ArrowBoxLeft.tsx new file mode 100644 index 000000000..011bf6afa --- /dev/null +++ b/src/components/icons/ArrowBoxLeft.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ArrowBoxLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z', +}) diff --git a/src/components/icons/PaperPlane.tsx b/src/components/icons/PaperPlane.tsx new file mode 100644 index 000000000..eef38638c --- /dev/null +++ b/src/components/icons/PaperPlane.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const PaperPlane_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3.374 3.22a1 1 0 0 1 1.073-.114l16 8a1 1 0 0 1 0 1.788l-16 8a1 1 0 0 1-1.417-1.136L4.97 12 3.03 4.243a1 1 0 0 1 .344-1.023ZM6.781 13l-1.284 5.133L17.764 12 5.497 5.867 6.781 11H9a1 1 0 1 1 0 2H6.78Z', +}) diff --git a/src/components/icons/Plus.tsx b/src/components/icons/Plus.tsx index d0698f7f4..71bcee533 100644 --- a/src/components/icons/Plus.tsx +++ b/src/components/icons/Plus.tsx @@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE' export const PlusLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M12 3a1 1 0 0 1 1 1v7h7a1 1 0 1 1 0 2h-7v7a1 1 0 1 1-2 0v-7H4a1 1 0 1 1 0-2h7V4a1 1 0 0 1 1-1Z', }) + +export const PlusSmall_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 6a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H7a1 1 0 1 1 0-2h4V7a1 1 0 0 1 1-1Z', +}) |