diff options
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/AccountList.tsx | 15 | ||||
-rw-r--r-- | src/components/LabelingServiceCard/index.tsx | 7 | ||||
-rw-r--r-- | src/components/ProfileHoverCard/index.web.tsx | 16 | ||||
-rw-r--r-- | src/components/Prompt.tsx | 10 | ||||
-rw-r--r-- | src/components/dialogs/MutedWords.tsx | 4 | ||||
-rw-r--r-- | src/components/dialogs/SwitchAccount.tsx | 3 | ||||
-rw-r--r-- | src/components/dms/ActionsWrapper.tsx | 83 | ||||
-rw-r--r-- | src/components/dms/ActionsWrapper.web.tsx | 86 | ||||
-rw-r--r-- | src/components/dms/ConvoMenu.tsx | 11 | ||||
-rw-r--r-- | src/components/dms/MessageItem.tsx | 198 | ||||
-rw-r--r-- | src/components/dms/MessageMenu.tsx | 141 | ||||
-rw-r--r-- | src/components/dms/NewChat.tsx | 47 | ||||
-rw-r--r-- | src/components/hooks/useRefreshOnFocus.ts | 17 | ||||
-rw-r--r-- | src/components/moderation/LabelsOnMe.tsx | 18 |
14 files changed, 614 insertions, 42 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/LabelingServiceCard/index.tsx b/src/components/LabelingServiceCard/index.tsx index f924f0f59..2bb7ed59c 100644 --- a/src/components/LabelingServiceCard/index.tsx +++ b/src/components/LabelingServiceCard/index.tsx @@ -1,6 +1,6 @@ import React from 'react' import {View} from 'react-native' -import {msg, Trans} from '@lingui/macro' +import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {AppBskyLabelerDefs} from '@atproto/api' @@ -13,7 +13,6 @@ import {RichText} from '#/components/RichText' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron' import {UserAvatar} from '#/view/com/util/UserAvatar' import {sanitizeHandle} from '#/lib/strings/handles' -import {pluralize} from '#/lib/strings/helpers' type LabelingServiceProps = { labeler: AppBskyLabelerDefs.LabelerViewDetailed @@ -69,9 +68,7 @@ export function LikeCount({count}: {count: number}) { t.atoms.text_contrast_medium, {fontWeight: '500'}, ]}> - <Trans> - Liked by {count} {pluralize(count, 'user')} - </Trans> + <Plural value={count} one="Liked by # user" other="Liked by # users" /> </Text> ) } diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index a22436879..305327d8b 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -2,13 +2,12 @@ import React from 'react' import {View} from 'react-native' import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom' -import {msg, Trans} from '@lingui/macro' +import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' 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/preferences/moderation-opts' import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile' import {useSession} from '#/state/session' @@ -371,7 +370,14 @@ function Inner({ const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy const following = formatCount(profile.followsCount || 0) const followers = formatCount(profile.followersCount || 0) - const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') + const pluralizedFollowers = plural(profile.followersCount || 0, { + one: 'follower', + other: 'followers', + }) + const pluralizedFollowings = plural(profile.followsCount || 0, { + one: 'following', + other: 'following', + }) const profileURL = makeProfileLink({ did: profile.did, handle: profile.handle, @@ -448,7 +454,9 @@ function Inner({ onPress={hide}> <Trans> <Text style={[a.text_md, a.font_bold]}>{following} </Text> - <Text style={[t.atoms.text_contrast_medium]}>following</Text> + <Text style={[t.atoms.text_contrast_medium]}> + {pluralizedFollowings} + </Text> </Trans> </InlineLinkText> </View> 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/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 index 777d6c086..16306bb57 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -1,5 +1,5 @@ import React, {useCallback} from 'react' -import {Pressable} from 'react-native' +import {Keyboard, Pressable} from 'react-native' import {AppBskyActorDefs} from '@atproto/api' import {ChatBskyConvoDefs} from '@atproto-labs/api' import {msg, Trans} from '@lingui/macro' @@ -72,7 +72,7 @@ let ConvoMenu = ({ const {mutate: leaveConvo} = useLeaveConvo(convo.id, { onSuccess: () => { if (currentScreen === 'conversation') { - navigation.replace('MessagesList') + navigation.replace('Messages') } }, onError: () => { @@ -88,6 +88,11 @@ let ConvoMenu = ({ {({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, @@ -123,6 +128,7 @@ let ConvoMenu = ({ <Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} /> </Menu.Item> </Menu.Group> + <Menu.Divider /> {/* TODO(samuel): implement these */} <Menu.Group> <Menu.Item @@ -146,6 +152,7 @@ let ConvoMenu = ({ <Menu.ItemIcon icon={Flag} /> </Menu.Item> </Menu.Group> + <Menu.Divider /> <Menu.Group> <Menu.Item label={_(msg`Leave conversation`)} 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 index a6b5d5632..5dde8628c 100644 --- a/src/components/dms/NewChat.tsx +++ b/src/components/dms/NewChat.tsx @@ -20,6 +20,7 @@ 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' @@ -178,7 +179,7 @@ function SearchablePeopleList({ </Text> <TextField.Root> <TextField.Icon icon={Search} /> - <TextField.Input + <Dialog.Input label={_(msg`Search profiles`)} placeholder={_(msg`Search`)} value={searchText} @@ -197,6 +198,7 @@ function SearchablePeopleList({ autoCorrect={false} autoComplete="off" autoCapitalize="none" + autoFocus /> </TextField.Root> </View> @@ -211,20 +213,35 @@ function SearchablePeopleList({ ListHeaderComponent={ <> {listHeader} - {searchText.length > 0 && !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.`) - } - /> + {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.`) + } + /> + ) )} </> } 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/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx index 099769fa7..46825d761 100644 --- a/src/components/moderation/LabelsOnMe.tsx +++ b/src/components/moderation/LabelsOnMe.tsx @@ -1,7 +1,7 @@ import React from 'react' import {StyleProp, View, ViewStyle} from 'react-native' import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' +import {msg, Plural} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useSession} from '#/state/session' @@ -39,7 +39,6 @@ export function LabelsOnMe({ return null } - const labelTarget = isAccount ? _(msg`account`) : _(msg`content`) return ( <View style={[a.flex_row, style]}> <LabelsOnMeDialog control={control} subject={details} labels={labels} /> @@ -54,11 +53,18 @@ export function LabelsOnMe({ }}> <ButtonIcon position="left" icon={CircleInfo} /> <ButtonText style={[a.leading_snug]}> - {labels.length}{' '} - {labels.length === 1 ? ( - <Trans>label has been placed on this {labelTarget}</Trans> + {isAccount ? ( + <Plural + value={labels.length} + one="# label has been placed on this account" + other="# labels has been placed on this account" + /> ) : ( - <Trans>labels have been placed on this {labelTarget}</Trans> + <Plural + value={labels.length} + one="# label has been placed on this content" + other="# labels has been placed on this content" + /> )} </ButtonText> </Button> |