An ATproto social media client -- with an independent Appview.

Share menu (#7840)

* move post ctrls to #/components

* restructure post controls, basic share menu

* add border radius to searchable people list for android

* Revert "add border radius to searchable people list for android"

This reverts commit 417449086e25b82f5683b12f6405d972f48ce50e.

* add copy link to native share menu

* reorg files again

* open native share menu on long press

* Translation comments

Thanks @surfdude29

* abs path

* update type imports, remove forwardRef

* rm react import

* equal spacing of buttons, extract disco debug

* add better icon

* add right offset to share button for visual alignment

* Add recent chats to share menu (#7853)

* add recent chats to share menu

* Update RecentChats.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update RecentChats.tsx

* add fading edge on andriod

* tweak scrollview

* Add metrics and A/B alt icon to share menu (#8401)

* add metrics

* add a/b tested alt icon

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* More descriptive share text/icon on web (#7854)

* more descriptive share text on web

* revert dev mode changes

* add missing import

* use modified share icon everywhere

* Add back conflicting changes

---------

Co-authored-by: Eric Bailey <git@esb.lol>

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>

authored by samuel.fm

surfdude29
Eric Bailey
and committed by
GitHub
c3f88e0a 5aadb9e4

+1515 -820
+1
assets/icons/arrowOutOfBoxModified_stroke2_corner2_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 13.75a1 1 0 0 1 1 1V18a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3v-3.25a1 1 0 1 1 2 0V18a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-3.25a1 1 0 0 1 1-1ZM12 3a1 1 0 0 1 .707.293l4.5 4.5a1 1 0 1 1-1.414 1.414L13 6.414v8.836a1 1 0 1 1-2 0V6.414L8.207 9.207a1 1 0 1 1-1.414-1.414l4.5-4.5A1 1 0 0 1 12 3Z"/></svg>
+1
assets/icons/arrowShareRight_stroke2_corner2_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M11.839 4.744c0-1.488 1.724-2.277 2.846-1.364l.107.094 7.66 7.256.128.134c.558.652.558 1.62 0 2.272l-.128.135-7.66 7.255c-1.115 1.057-2.953.267-2.953-1.27v-2.748c-3.503.055-5.417.41-6.592.97-.997.474-1.525 1.122-2.084 2.14l-.243.46c-.558 1.088-2.09.583-2.08-.515l.015-.748c.111-3.68.777-6.5 2.546-8.415 1.83-1.98 4.63-2.771 8.438-2.884V4.744Zm2 3.256c0 .79-.604 1.41-1.341 1.494l-.149.01c-3.9.057-6.147.813-7.48 2.254-.963 1.043-1.562 2.566-1.842 4.79.38-.327.826-.622 1.361-.877 1.656-.788 4.08-1.14 7.938-1.169l.153.007A1.5 1.5 0 0 1 13.839 16v2.675L20.884 12l-7.045-6.676V8Z"/></svg>
+35 -2
src/components/Menu/index.tsx
··· 244 244 ) 245 245 } 246 246 247 + /** 248 + * NATIVE ONLY - for adding non-pressable items to the menu 249 + * 250 + * @platform ios, android 251 + */ 252 + export function ContainerItem({ 253 + children, 254 + style, 255 + }: { 256 + children: React.ReactNode 257 + style?: StyleProp<ViewStyle> 258 + }) { 259 + const t = useTheme() 260 + return ( 261 + <View 262 + style={[ 263 + a.flex_row, 264 + a.align_center, 265 + a.gap_sm, 266 + a.px_md, 267 + a.rounded_md, 268 + a.border, 269 + t.atoms.bg_contrast_25, 270 + t.atoms.border_contrast_low, 271 + {paddingVertical: 10}, 272 + style, 273 + ]}> 274 + {children} 275 + </View> 276 + ) 277 + } 278 + 247 279 export function LabelText({children}: {children: React.ReactNode}) { 248 280 const t = useTheme() 249 281 return ( ··· 272 304 style, 273 305 ]}> 274 306 {flattenReactChildren(children).map((child, i) => { 275 - return React.isValidElement(child) && child.type === Item ? ( 307 + return React.isValidElement(child) && 308 + (child.type === Item || child.type === ContainerItem) ? ( 276 309 <React.Fragment key={i}> 277 310 {i > 0 ? ( 278 311 <View style={[a.border_b, t.atoms.border_contrast_low]} /> 279 312 ) : null} 280 313 {React.cloneElement(child, { 281 - // @ts-ignore 314 + // @ts-expect-error cloneElement is not aware of the types 282 315 style: { 283 316 borderRadius: 0, 284 317 borderWidth: 0,
+4
src/components/Menu/index.web.tsx
··· 390 390 /> 391 391 ) 392 392 } 393 + 394 + export function ContainerItem() { 395 + return null 396 + }
+54
src/components/PostControls/DiscoverDebug.tsx
··· 1 + import {Pressable} from 'react-native' 2 + import * as Clipboard from 'expo-clipboard' 3 + import {t} from '@lingui/macro' 4 + 5 + import {IS_INTERNAL} from '#/lib/app-info' 6 + import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' 7 + import {useGate} from '#/lib/statsig/statsig' 8 + import {useSession} from '#/state/session' 9 + import * as Toast from '#/view/com/util/Toast' 10 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 11 + import {Text} from '#/components/Typography' 12 + 13 + export function DiscoverDebug({ 14 + feedContext, 15 + }: { 16 + feedContext: string | undefined 17 + }) { 18 + const {currentAccount} = useSession() 19 + const {gtMobile} = useBreakpoints() 20 + const gate = useGate() 21 + const isDiscoverDebugUser = 22 + IS_INTERNAL || 23 + DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || 24 + gate('debug_show_feedcontext') 25 + const theme = useTheme() 26 + 27 + return ( 28 + isDiscoverDebugUser && 29 + feedContext && ( 30 + <Pressable 31 + accessible={false} 32 + hitSlop={10} 33 + style={[ 34 + a.absolute, 35 + a.bottom_0, 36 + {zIndex: 1000}, 37 + gtMobile ? a.right_0 : a.left_0, 38 + ]} 39 + onPress={e => { 40 + e.stopPropagation() 41 + Clipboard.setStringAsync(feedContext) 42 + Toast.show(t`Copied to clipboard`, 'clipboard-check') 43 + }}> 44 + <Text 45 + style={{ 46 + color: theme.palette.contrast_400, 47 + fontSize: 7, 48 + }}> 49 + {feedContext} 50 + </Text> 51 + </Pressable> 52 + ) 53 + ) 54 + }
+126
src/components/PostControls/PostControlButton.tsx
··· 1 + import {createContext, useContext, useMemo} from 'react' 2 + import {type GestureResponderEvent, type View} from 'react-native' 3 + 4 + import {POST_CTRL_HITSLOP} from '#/lib/constants' 5 + import {useHaptics} from '#/lib/haptics' 6 + import {atoms as a, useTheme} from '#/alf' 7 + import {Button, type ButtonProps} from '#/components/Button' 8 + import {type Props as SVGIconProps} from '#/components/icons/common' 9 + import {Text, type TextProps} from '#/components/Typography' 10 + 11 + const PostControlContext = createContext<{ 12 + big?: boolean 13 + active?: boolean 14 + color?: {color: string} 15 + }>({}) 16 + 17 + // Base button style, which the the other ones extend 18 + export function PostControlButton({ 19 + ref, 20 + onPress, 21 + onLongPress, 22 + children, 23 + big, 24 + active, 25 + activeColor, 26 + ...props 27 + }: ButtonProps & { 28 + ref?: React.Ref<View> 29 + active?: boolean 30 + big?: boolean 31 + color?: string 32 + activeColor?: string 33 + }) { 34 + const t = useTheme() 35 + const playHaptic = useHaptics() 36 + 37 + const ctx = useMemo( 38 + () => ({ 39 + big, 40 + active, 41 + color: { 42 + color: activeColor && active ? activeColor : t.palette.contrast_500, 43 + }, 44 + }), 45 + [big, active, activeColor, t.palette.contrast_500], 46 + ) 47 + 48 + const style = useMemo( 49 + () => [ 50 + a.flex_row, 51 + a.align_center, 52 + a.gap_xs, 53 + a.bg_transparent, 54 + {padding: 5}, 55 + ], 56 + [], 57 + ) 58 + 59 + const handlePress = useMemo(() => { 60 + if (!onPress) return 61 + return (evt: GestureResponderEvent) => { 62 + playHaptic('Light') 63 + onPress(evt) 64 + } 65 + }, [onPress, playHaptic]) 66 + 67 + const handleLongPress = useMemo(() => { 68 + if (!onLongPress) return 69 + return (evt: GestureResponderEvent) => { 70 + playHaptic('Heavy') 71 + onLongPress(evt) 72 + } 73 + }, [onLongPress, playHaptic]) 74 + 75 + return ( 76 + <Button 77 + ref={ref} 78 + onPress={handlePress} 79 + onLongPress={handleLongPress} 80 + style={style} 81 + hoverStyle={t.atoms.bg_contrast_25} 82 + shape="round" 83 + variant="ghost" 84 + color="secondary" 85 + hitSlop={POST_CTRL_HITSLOP} 86 + {...props}> 87 + {typeof children === 'function' ? ( 88 + args => ( 89 + <PostControlContext.Provider value={ctx}> 90 + {children(args)} 91 + </PostControlContext.Provider> 92 + ) 93 + ) : ( 94 + <PostControlContext.Provider value={ctx}> 95 + {children} 96 + </PostControlContext.Provider> 97 + )} 98 + </Button> 99 + ) 100 + } 101 + 102 + export function PostControlButtonIcon({ 103 + icon: Comp, 104 + }: { 105 + icon: React.ComponentType<SVGIconProps> 106 + }) { 107 + const {big, color} = useContext(PostControlContext) 108 + 109 + return <Comp style={[color, a.pointer_events_none]} width={big ? 22 : 18} /> 110 + } 111 + 112 + export function PostControlButtonText({style, ...props}: TextProps) { 113 + const {big, active, color} = useContext(PostControlContext) 114 + 115 + return ( 116 + <Text 117 + style={[ 118 + color, 119 + big ? a.text_md : {fontSize: 15}, 120 + active && a.font_bold, 121 + style, 122 + ]} 123 + {...props} 124 + /> 125 + ) 126 + }
+107
src/components/PostControls/RepostButton.web.tsx
··· 1 + import {msg} from '@lingui/macro' 2 + import {useLingui} from '@lingui/react' 3 + 4 + import {useRequireAuth} from '#/state/session' 5 + import {useSession} from '#/state/session' 6 + import {EventStopper} from '#/view/com/util/EventStopper' 7 + import {formatCount} from '#/view/com/util/numeric/format' 8 + import {useTheme} from '#/alf' 9 + import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote' 10 + import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' 11 + import * as Menu from '#/components/Menu' 12 + import { 13 + PostControlButton, 14 + PostControlButtonIcon, 15 + PostControlButtonText, 16 + } from './PostControlButton' 17 + 18 + interface Props { 19 + isReposted: boolean 20 + repostCount?: number 21 + onRepost: () => void 22 + onQuote: () => void 23 + big?: boolean 24 + embeddingDisabled: boolean 25 + } 26 + 27 + export const RepostButton = ({ 28 + isReposted, 29 + repostCount, 30 + onRepost, 31 + onQuote, 32 + big, 33 + embeddingDisabled, 34 + }: Props) => { 35 + const t = useTheme() 36 + const {_, i18n} = useLingui() 37 + const {hasSession} = useSession() 38 + const requireAuth = useRequireAuth() 39 + 40 + return hasSession ? ( 41 + <EventStopper onKeyDown={false}> 42 + <Menu.Root> 43 + <Menu.Trigger label={_(msg`Repost or quote post`)}> 44 + {({props}) => { 45 + return ( 46 + <PostControlButton 47 + testID="repostBtn" 48 + active={isReposted} 49 + activeColor={t.palette.positive_600} 50 + label={props.accessibilityLabel} 51 + big={big} 52 + {...props}> 53 + <PostControlButtonIcon icon={Repost} /> 54 + {typeof repostCount !== 'undefined' && repostCount > 0 && ( 55 + <PostControlButtonText testID="repostCount"> 56 + {formatCount(i18n, repostCount)} 57 + </PostControlButtonText> 58 + )} 59 + </PostControlButton> 60 + ) 61 + }} 62 + </Menu.Trigger> 63 + <Menu.Outer style={{minWidth: 170}}> 64 + <Menu.Item 65 + label={isReposted ? _(msg`Undo repost`) : _(msg`Repost`)} 66 + testID="repostDropdownRepostBtn" 67 + onPress={onRepost}> 68 + <Menu.ItemText> 69 + {isReposted ? _(msg`Undo repost`) : _(msg`Repost`)} 70 + </Menu.ItemText> 71 + <Menu.ItemIcon icon={Repost} position="right" /> 72 + </Menu.Item> 73 + <Menu.Item 74 + disabled={embeddingDisabled} 75 + label={ 76 + embeddingDisabled 77 + ? _(msg`Quote posts disabled`) 78 + : _(msg`Quote post`) 79 + } 80 + testID="repostDropdownQuoteBtn" 81 + onPress={onQuote}> 82 + <Menu.ItemText> 83 + {embeddingDisabled 84 + ? _(msg`Quote posts disabled`) 85 + : _(msg`Quote post`)} 86 + </Menu.ItemText> 87 + <Menu.ItemIcon icon={Quote} position="right" /> 88 + </Menu.Item> 89 + </Menu.Outer> 90 + </Menu.Root> 91 + </EventStopper> 92 + ) : ( 93 + <PostControlButton 94 + onPress={() => requireAuth(() => {})} 95 + active={isReposted} 96 + activeColor={t.palette.positive_600} 97 + label={_(msg`Repost or quote post`)} 98 + big={big}> 99 + <PostControlButtonIcon icon={Repost} /> 100 + {typeof repostCount !== 'undefined' && repostCount > 0 && ( 101 + <PostControlButtonText testID="repostCount"> 102 + {formatCount(i18n, repostCount)} 103 + </PostControlButtonText> 104 + )} 105 + </PostControlButton> 106 + ) 107 + }
+200
src/components/PostControls/ShareMenu/RecentChats.tsx
··· 1 + import {ScrollView, View} from 'react-native' 2 + import {moderateProfile, type ModerationOpts} from '@atproto/api' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {useNavigation} from '@react-navigation/native' 6 + 7 + import {type NavigationProp} from '#/lib/routes/types' 8 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 9 + import {sanitizeHandle} from '#/lib/strings/handles' 10 + import {logger} from '#/logger' 11 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 12 + import {useListConvosQuery} from '#/state/queries/messages/list-conversations' 13 + import {useSession} from '#/state/session' 14 + import {UserAvatar} from '#/view/com/util/UserAvatar' 15 + import {atoms as a, tokens, useTheme} from '#/alf' 16 + import {Button} from '#/components/Button' 17 + import {useDialogContext} from '#/components/Dialog' 18 + import {Text} from '#/components/Typography' 19 + import {useSimpleVerificationState} from '#/components/verification' 20 + import {VerificationCheck} from '#/components/verification/VerificationCheck' 21 + import type * as bsky from '#/types/bsky' 22 + 23 + export function RecentChats({postUri}: {postUri: string}) { 24 + const control = useDialogContext() 25 + const {_} = useLingui() 26 + const {currentAccount} = useSession() 27 + const {data} = useListConvosQuery({status: 'accepted'}) 28 + const convos = data?.pages[0]?.convos?.slice(0, 10) 29 + const moderationOpts = useModerationOpts() 30 + const navigation = useNavigation<NavigationProp>() 31 + 32 + const onSelectChat = (convoId: string) => { 33 + control.close(() => { 34 + logger.metric('share:press:recentDm', {}, {statsig: true}) 35 + navigation.navigate('MessagesConversation', { 36 + conversation: convoId, 37 + embed: postUri, 38 + }) 39 + }) 40 + } 41 + 42 + if (!moderationOpts) return null 43 + 44 + return ( 45 + <View 46 + style={[a.relative, a.flex_1, {marginHorizontal: tokens.space.md * -1}]}> 47 + <ScrollView 48 + horizontal 49 + style={[a.flex_1, a.pt_2xs, {minHeight: 98}]} 50 + contentContainerStyle={[a.gap_sm, a.px_md]} 51 + showsHorizontalScrollIndicator={false} 52 + fadingEdgeLength={64} 53 + nestedScrollEnabled> 54 + {convos && convos.length > 0 ? ( 55 + convos.map(convo => { 56 + const otherMember = convo.members.find( 57 + member => member.did !== currentAccount?.did, 58 + ) 59 + 60 + if (!otherMember || otherMember.handle === 'missing.invalid') 61 + return null 62 + 63 + return ( 64 + <RecentChatItem 65 + key={convo.id} 66 + profile={otherMember} 67 + onPress={() => onSelectChat(convo.id)} 68 + moderationOpts={moderationOpts} 69 + /> 70 + ) 71 + }) 72 + ) : ( 73 + <> 74 + <ConvoSkeleton /> 75 + <ConvoSkeleton /> 76 + <ConvoSkeleton /> 77 + <ConvoSkeleton /> 78 + <ConvoSkeleton /> 79 + </> 80 + )} 81 + </ScrollView> 82 + {convos && convos.length === 0 && <NoConvos />} 83 + </View> 84 + ) 85 + } 86 + 87 + const WIDTH = 80 88 + 89 + function RecentChatItem({ 90 + profile, 91 + onPress, 92 + moderationOpts, 93 + }: { 94 + profile: bsky.profile.AnyProfileView 95 + onPress: () => void 96 + moderationOpts: ModerationOpts 97 + }) { 98 + const {_} = useLingui() 99 + const t = useTheme() 100 + 101 + const moderation = moderateProfile(profile, moderationOpts) 102 + const name = sanitizeDisplayName( 103 + profile.displayName || sanitizeHandle(profile.handle), 104 + moderation.ui('displayName'), 105 + ) 106 + const verification = useSimpleVerificationState({profile}) 107 + 108 + return ( 109 + <Button 110 + onPress={onPress} 111 + label={_(msg`Send post to ${name}`)} 112 + style={[ 113 + a.flex_col, 114 + {width: WIDTH}, 115 + a.gap_sm, 116 + a.justify_start, 117 + a.align_center, 118 + ]}> 119 + <UserAvatar 120 + avatar={profile.avatar} 121 + size={WIDTH - 8} 122 + type={profile.associated?.labeler ? 'labeler' : 'user'} 123 + moderation={moderation.ui('avatar')} 124 + /> 125 + <View style={[a.flex_row, a.align_center, a.justify_center, a.w_full]}> 126 + <Text 127 + emoji 128 + style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]} 129 + numberOfLines={1}> 130 + {name} 131 + </Text> 132 + {verification.showBadge && ( 133 + <View style={[a.pl_2xs]}> 134 + <VerificationCheck 135 + width={10} 136 + verifier={verification.role === 'verifier'} 137 + /> 138 + </View> 139 + )} 140 + </View> 141 + </Button> 142 + ) 143 + } 144 + 145 + function ConvoSkeleton() { 146 + const t = useTheme() 147 + return ( 148 + <View 149 + style={[ 150 + a.flex_col, 151 + {width: WIDTH, height: WIDTH + 15}, 152 + a.gap_xs, 153 + a.justify_start, 154 + a.align_center, 155 + ]}> 156 + <View 157 + style={[ 158 + t.atoms.bg_contrast_50, 159 + {width: WIDTH - 8, height: WIDTH - 8}, 160 + a.rounded_full, 161 + ]} 162 + /> 163 + <View 164 + style={[ 165 + t.atoms.bg_contrast_50, 166 + {width: WIDTH - 8, height: 10}, 167 + a.rounded_xs, 168 + ]} 169 + /> 170 + </View> 171 + ) 172 + } 173 + 174 + function NoConvos() { 175 + const t = useTheme() 176 + 177 + return ( 178 + <View 179 + style={[ 180 + a.absolute, 181 + a.inset_0, 182 + a.justify_center, 183 + a.align_center, 184 + a.px_2xl, 185 + ]}> 186 + <View 187 + style={[a.absolute, a.inset_0, t.atoms.bg_contrast_25, {opacity: 0.5}]} 188 + /> 189 + <Text 190 + style={[ 191 + a.text_sm, 192 + t.atoms.text_contrast_high, 193 + a.text_center, 194 + a.font_bold, 195 + ]}> 196 + <Trans>Start a conversation, and it will appear here.</Trans> 197 + </Text> 198 + </View> 199 + ) 200 + }
+197
src/components/PostControls/ShareMenu/ShareMenuItems.tsx
··· 1 + import {memo, useMemo} from 'react' 2 + import * as ExpoClipboard from 'expo-clipboard' 3 + import {AtUri} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useNavigation} from '@react-navigation/native' 7 + 8 + import {makeProfileLink} from '#/lib/routes/links' 9 + import {type NavigationProp} from '#/lib/routes/types' 10 + import {shareText, shareUrl} from '#/lib/sharing' 11 + import {toShareUrl} from '#/lib/strings/url-helpers' 12 + import {logger} from '#/logger' 13 + import {useProfileShadow} from '#/state/cache/profile-shadow' 14 + import {useSession} from '#/state/session' 15 + import * as Toast from '#/view/com/util/Toast' 16 + import {useDialogControl} from '#/components/Dialog' 17 + import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' 18 + import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' 19 + import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 20 + import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 21 + import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' 22 + import * as Menu from '#/components/Menu' 23 + import * as Prompt from '#/components/Prompt' 24 + import {useDevMode} from '#/storage/hooks/dev-mode' 25 + import {RecentChats} from './RecentChats' 26 + import {type ShareMenuItemsProps} from './ShareMenuItems.types' 27 + 28 + let ShareMenuItems = ({ 29 + post, 30 + onShare: onShareProp, 31 + }: ShareMenuItemsProps): React.ReactNode => { 32 + const {hasSession, currentAccount} = useSession() 33 + const {_} = useLingui() 34 + const navigation = useNavigation<NavigationProp>() 35 + const pwiWarningShareControl = useDialogControl() 36 + const pwiWarningCopyControl = useDialogControl() 37 + const sendViaChatControl = useDialogControl() 38 + const [devModeEnabled] = useDevMode() 39 + 40 + const postUri = post.uri 41 + const postAuthor = useProfileShadow(post.author) 42 + 43 + const href = useMemo(() => { 44 + const urip = new AtUri(postUri) 45 + return makeProfileLink(postAuthor, 'post', urip.rkey) 46 + }, [postUri, postAuthor]) 47 + 48 + const hideInPWI = useMemo(() => { 49 + return !!postAuthor.labels?.find( 50 + label => label.val === '!no-unauthenticated', 51 + ) 52 + }, [postAuthor]) 53 + 54 + const showLoggedOutWarning = 55 + postAuthor.did !== currentAccount?.did && hideInPWI 56 + 57 + const onSharePost = () => { 58 + logger.metric('share:press:nativeShare', {}, {statsig: true}) 59 + const url = toShareUrl(href) 60 + shareUrl(url) 61 + onShareProp() 62 + } 63 + 64 + const onCopyLink = () => { 65 + logger.metric('share:press:copyLink', {}, {statsig: true}) 66 + const url = toShareUrl(href) 67 + ExpoClipboard.setUrlAsync(url).then(() => 68 + Toast.show(_(msg`Copied to clipboard`), 'clipboard-check'), 69 + ) 70 + onShareProp() 71 + } 72 + 73 + const onSelectChatToShareTo = (conversation: string) => { 74 + navigation.navigate('MessagesConversation', { 75 + conversation, 76 + embed: postUri, 77 + }) 78 + } 79 + 80 + const onShareATURI = () => { 81 + shareText(postUri) 82 + } 83 + 84 + const onShareAuthorDID = () => { 85 + shareText(postAuthor.did) 86 + } 87 + 88 + return ( 89 + <> 90 + <Menu.Outer> 91 + {hasSession && ( 92 + <Menu.Group> 93 + <Menu.ContainerItem> 94 + <RecentChats postUri={postUri} /> 95 + </Menu.ContainerItem> 96 + <Menu.Item 97 + testID="postDropdownSendViaDMBtn" 98 + label={_(msg`Send via direct message`)} 99 + onPress={() => { 100 + logger.metric('share:press:openDmSearch', {}, {statsig: true}) 101 + sendViaChatControl.open() 102 + }}> 103 + <Menu.ItemText> 104 + <Trans>Send via direct message</Trans> 105 + </Menu.ItemText> 106 + <Menu.ItemIcon icon={PaperPlaneIcon} position="right" /> 107 + </Menu.Item> 108 + </Menu.Group> 109 + )} 110 + 111 + <Menu.Group> 112 + <Menu.Item 113 + testID="postDropdownShareBtn" 114 + label={_(msg`Share via...`)} 115 + onPress={() => { 116 + if (showLoggedOutWarning) { 117 + pwiWarningShareControl.open() 118 + } else { 119 + onSharePost() 120 + } 121 + }}> 122 + <Menu.ItemText> 123 + <Trans>Share via...</Trans> 124 + </Menu.ItemText> 125 + <Menu.ItemIcon icon={ArrowOutOfBoxIcon} position="right" /> 126 + </Menu.Item> 127 + 128 + <Menu.Item 129 + testID="postDropdownShareBtn" 130 + label={_(msg`Copy link to post`)} 131 + onPress={() => { 132 + if (showLoggedOutWarning) { 133 + pwiWarningCopyControl.open() 134 + } else { 135 + onCopyLink() 136 + } 137 + }}> 138 + <Menu.ItemText> 139 + <Trans>Copy link to post</Trans> 140 + </Menu.ItemText> 141 + <Menu.ItemIcon icon={ChainLinkIcon} position="right" /> 142 + </Menu.Item> 143 + </Menu.Group> 144 + 145 + {devModeEnabled && ( 146 + <Menu.Group> 147 + <Menu.Item 148 + testID="postAtUriShareBtn" 149 + label={_(msg`Share post at:// URI`)} 150 + onPress={onShareATURI}> 151 + <Menu.ItemText> 152 + <Trans>Share post at:// URI</Trans> 153 + </Menu.ItemText> 154 + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> 155 + </Menu.Item> 156 + <Menu.Item 157 + testID="postAuthorDIDShareBtn" 158 + label={_(msg`Share author DID`)} 159 + onPress={onShareAuthorDID}> 160 + <Menu.ItemText> 161 + <Trans>Share author DID</Trans> 162 + </Menu.ItemText> 163 + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> 164 + </Menu.Item> 165 + </Menu.Group> 166 + )} 167 + </Menu.Outer> 168 + 169 + <Prompt.Basic 170 + control={pwiWarningShareControl} 171 + title={_(msg`Note about sharing`)} 172 + description={_( 173 + msg`This post is only visible to logged-in users. It won't be visible to people who aren't signed in.`, 174 + )} 175 + onConfirm={onSharePost} 176 + confirmButtonCta={_(msg`Share anyway`)} 177 + /> 178 + 179 + <Prompt.Basic 180 + control={pwiWarningCopyControl} 181 + title={_(msg`Note about sharing`)} 182 + description={_( 183 + msg`This post is only visible to logged-in users. It won't be visible to people who aren't signed in.`, 184 + )} 185 + onConfirm={onCopyLink} 186 + confirmButtonCta={_(msg`Copy anyway`)} 187 + /> 188 + 189 + <SendViaChatDialog 190 + control={sendViaChatControl} 191 + onSelectChat={onSelectChatToShareTo} 192 + /> 193 + </> 194 + ) 195 + } 196 + ShareMenuItems = memo(ShareMenuItems) 197 + export {ShareMenuItems}
+22
src/components/PostControls/ShareMenu/ShareMenuItems.types.tsx
··· 1 + import {type PressableProps, type StyleProp, type ViewStyle} from 'react-native' 2 + import { 3 + type AppBskyFeedDefs, 4 + type AppBskyFeedPost, 5 + type AppBskyFeedThreadgate, 6 + type RichText as RichTextAPI, 7 + } from '@atproto/api' 8 + 9 + import {type Shadow} from '#/state/cache/post-shadow' 10 + 11 + export interface ShareMenuItemsProps { 12 + testID: string 13 + post: Shadow<AppBskyFeedDefs.PostView> 14 + record: AppBskyFeedPost.Record 15 + richText: RichTextAPI 16 + style?: StyleProp<ViewStyle> 17 + hitSlop?: PressableProps['hitSlop'] 18 + size?: 'lg' | 'md' | 'sm' 19 + timestamp: string 20 + threadgateRecord?: AppBskyFeedThreadgate.Record 21 + onShare: () => void 22 + }
+192
src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
··· 1 + import {memo, useMemo} from 'react' 2 + import {AtUri} from '@atproto/api' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {useNavigation} from '@react-navigation/native' 6 + import type React from 'react' 7 + 8 + import {makeProfileLink} from '#/lib/routes/links' 9 + import {type NavigationProp} from '#/lib/routes/types' 10 + import {shareText, shareUrl} from '#/lib/sharing' 11 + import {toShareUrl} from '#/lib/strings/url-helpers' 12 + import {logger} from '#/logger' 13 + import {isWeb} from '#/platform/detection' 14 + import {useProfileShadow} from '#/state/cache/profile-shadow' 15 + import {useSession} from '#/state/session' 16 + import {useBreakpoints} from '#/alf' 17 + import {useDialogControl} from '#/components/Dialog' 18 + import {EmbedDialog} from '#/components/dialogs/Embed' 19 + import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' 20 + import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 21 + import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 22 + import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' 23 + import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' 24 + import * as Menu from '#/components/Menu' 25 + import * as Prompt from '#/components/Prompt' 26 + import {useDevMode} from '#/storage/hooks/dev-mode' 27 + import {type ShareMenuItemsProps} from './ShareMenuItems.types' 28 + 29 + let ShareMenuItems = ({ 30 + post, 31 + record, 32 + timestamp, 33 + onShare: onShareProp, 34 + }: ShareMenuItemsProps): React.ReactNode => { 35 + const {hasSession, currentAccount} = useSession() 36 + const {gtMobile} = useBreakpoints() 37 + const {_} = useLingui() 38 + const navigation = useNavigation<NavigationProp>() 39 + const loggedOutWarningPromptControl = useDialogControl() 40 + const embedPostControl = useDialogControl() 41 + const sendViaChatControl = useDialogControl() 42 + const [devModeEnabled] = useDevMode() 43 + 44 + const postUri = post.uri 45 + const postCid = post.cid 46 + const postAuthor = useProfileShadow(post.author) 47 + 48 + const href = useMemo(() => { 49 + const urip = new AtUri(postUri) 50 + return makeProfileLink(postAuthor, 'post', urip.rkey) 51 + }, [postUri, postAuthor]) 52 + 53 + const hideInPWI = useMemo(() => { 54 + return !!postAuthor.labels?.find( 55 + label => label.val === '!no-unauthenticated', 56 + ) 57 + }, [postAuthor]) 58 + 59 + const showLoggedOutWarning = 60 + postAuthor.did !== currentAccount?.did && hideInPWI 61 + 62 + const onCopyLink = () => { 63 + logger.metric('share:press:copyLink', {}, {statsig: true}) 64 + const url = toShareUrl(href) 65 + shareUrl(url) 66 + onShareProp() 67 + } 68 + 69 + const onSelectChatToShareTo = (conversation: string) => { 70 + logger.metric('share:press:dmSelected', {}, {statsig: true}) 71 + navigation.navigate('MessagesConversation', { 72 + conversation, 73 + embed: postUri, 74 + }) 75 + } 76 + 77 + const canEmbed = isWeb && gtMobile && !hideInPWI 78 + 79 + const onShareATURI = () => { 80 + shareText(postUri) 81 + } 82 + 83 + const onShareAuthorDID = () => { 84 + shareText(postAuthor.did) 85 + } 86 + 87 + return ( 88 + <> 89 + <Menu.Outer> 90 + <Menu.Group> 91 + <Menu.Item 92 + testID="postDropdownShareBtn" 93 + label={_(msg`Copy link to post`)} 94 + onPress={() => { 95 + if (showLoggedOutWarning) { 96 + loggedOutWarningPromptControl.open() 97 + } else { 98 + onCopyLink() 99 + } 100 + }}> 101 + <Menu.ItemText> 102 + <Trans>Copy link to post</Trans> 103 + </Menu.ItemText> 104 + <Menu.ItemIcon icon={ChainLinkIcon} position="right" /> 105 + </Menu.Item> 106 + 107 + {hasSession && ( 108 + <Menu.Item 109 + testID="postDropdownSendViaDMBtn" 110 + label={_(msg`Send via direct message`)} 111 + onPress={() => { 112 + logger.metric('share:press:openDmSearch', {}, {statsig: true}) 113 + sendViaChatControl.open() 114 + }}> 115 + <Menu.ItemText> 116 + <Trans>Send via direct message</Trans> 117 + </Menu.ItemText> 118 + <Menu.ItemIcon icon={Send} position="right" /> 119 + </Menu.Item> 120 + )} 121 + 122 + {canEmbed && ( 123 + <Menu.Item 124 + testID="postDropdownEmbedBtn" 125 + label={_(msg`Embed post`)} 126 + onPress={() => { 127 + logger.metric('share:press:embed', {}, {statsig: true}) 128 + embedPostControl.open() 129 + }}> 130 + <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText> 131 + <Menu.ItemIcon icon={CodeBracketsIcon} position="right" /> 132 + </Menu.Item> 133 + )} 134 + </Menu.Group> 135 + 136 + {devModeEnabled && ( 137 + <> 138 + <Menu.Divider /> 139 + <Menu.Group> 140 + <Menu.Item 141 + testID="postAtUriShareBtn" 142 + label={_(msg`Copy post at:// URI`)} 143 + onPress={onShareATURI}> 144 + <Menu.ItemText> 145 + <Trans>Copy post at:// URI</Trans> 146 + </Menu.ItemText> 147 + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> 148 + </Menu.Item> 149 + <Menu.Item 150 + testID="postAuthorDIDShareBtn" 151 + label={_(msg`Copy author DID`)} 152 + onPress={onShareAuthorDID}> 153 + <Menu.ItemText> 154 + <Trans>Copy author DID</Trans> 155 + </Menu.ItemText> 156 + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> 157 + </Menu.Item> 158 + </Menu.Group> 159 + </> 160 + )} 161 + </Menu.Outer> 162 + 163 + <Prompt.Basic 164 + control={loggedOutWarningPromptControl} 165 + title={_(msg`Note about sharing`)} 166 + description={_( 167 + msg`This post is only visible to logged-in users. It won't be visible to people who aren't signed in.`, 168 + )} 169 + onConfirm={onCopyLink} 170 + confirmButtonCta={_(msg`Share anyway`)} 171 + /> 172 + 173 + {canEmbed && ( 174 + <EmbedDialog 175 + control={embedPostControl} 176 + postCid={postCid} 177 + postUri={postUri} 178 + record={record} 179 + postAuthor={postAuthor} 180 + timestamp={timestamp} 181 + /> 182 + )} 183 + 184 + <SendViaChatDialog 185 + control={sendViaChatControl} 186 + onSelectChat={onSelectChatToShareTo} 187 + /> 188 + </> 189 + ) 190 + } 191 + ShareMenuItems = memo(ShareMenuItems) 192 + export {ShareMenuItems}
+119
src/components/PostControls/ShareMenu/index.tsx
··· 1 + import {memo, useMemo, useState} from 'react' 2 + import { 3 + type AppBskyFeedDefs, 4 + type AppBskyFeedPost, 5 + type AppBskyFeedThreadgate, 6 + AtUri, 7 + type RichText as RichTextAPI, 8 + } from '@atproto/api' 9 + import {msg} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 11 + import type React from 'react' 12 + 13 + import {makeProfileLink} from '#/lib/routes/links' 14 + import {shareUrl} from '#/lib/sharing' 15 + import {useGate} from '#/lib/statsig/statsig' 16 + import {toShareUrl} from '#/lib/strings/url-helpers' 17 + import {logger} from '#/logger' 18 + import {type Shadow} from '#/state/cache/post-shadow' 19 + import {EventStopper} from '#/view/com/util/EventStopper' 20 + import {native} from '#/alf' 21 + import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' 22 + import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' 23 + import {useMenuControl} from '#/components/Menu' 24 + import * as Menu from '#/components/Menu' 25 + import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' 26 + import {ShareMenuItems} from './ShareMenuItems' 27 + 28 + let ShareMenuButton = ({ 29 + testID, 30 + post, 31 + big, 32 + record, 33 + richText, 34 + timestamp, 35 + threadgateRecord, 36 + onShare, 37 + }: { 38 + testID: string 39 + post: Shadow<AppBskyFeedDefs.PostView> 40 + big?: boolean 41 + record: AppBskyFeedPost.Record 42 + richText: RichTextAPI 43 + timestamp: string 44 + threadgateRecord?: AppBskyFeedThreadgate.Record 45 + onShare: () => void 46 + }): React.ReactNode => { 47 + const {_} = useLingui() 48 + const gate = useGate() 49 + 50 + const ShareIcon = gate('alt_share_icon') 51 + ? ArrowShareRightIcon 52 + : ArrowOutOfBoxIcon 53 + 54 + const menuControl = useMenuControl() 55 + const [hasBeenOpen, setHasBeenOpen] = useState(false) 56 + const lazyMenuControl = useMemo( 57 + () => ({ 58 + ...menuControl, 59 + open() { 60 + setHasBeenOpen(true) 61 + // HACK. We need the state update to be flushed by the time 62 + // menuControl.open() fires but RN doesn't expose flushSync. 63 + setTimeout(menuControl.open) 64 + 65 + logger.metric( 66 + 'share:open', 67 + {context: big ? 'thread' : 'feed'}, 68 + {statsig: true}, 69 + ) 70 + }, 71 + }), 72 + [menuControl, setHasBeenOpen, big], 73 + ) 74 + 75 + const onNativeLongPress = () => { 76 + logger.metric('share:press:nativeShare', {}, {statsig: true}) 77 + const urip = new AtUri(post.uri) 78 + const href = makeProfileLink(post.author, 'post', urip.rkey) 79 + const url = toShareUrl(href) 80 + shareUrl(url) 81 + onShare() 82 + } 83 + 84 + return ( 85 + <EventStopper onKeyDown={false}> 86 + <Menu.Root control={lazyMenuControl}> 87 + <Menu.Trigger label={_(msg`Open share menu`)}> 88 + {({props}) => { 89 + return ( 90 + <PostControlButton 91 + testID="postShareBtn" 92 + big={big} 93 + label={props.accessibilityLabel} 94 + {...props} 95 + onLongPress={native(onNativeLongPress)}> 96 + <PostControlButtonIcon icon={ShareIcon} /> 97 + </PostControlButton> 98 + ) 99 + }} 100 + </Menu.Trigger> 101 + {hasBeenOpen && ( 102 + // Lazily initialized. Once mounted, they stay mounted. 103 + <ShareMenuItems 104 + testID={testID} 105 + post={post} 106 + record={record} 107 + richText={richText} 108 + timestamp={timestamp} 109 + threadgateRecord={threadgateRecord} 110 + onShare={onShare} 111 + /> 112 + )} 113 + </Menu.Root> 114 + </EventStopper> 115 + ) 116 + } 117 + 118 + ShareMenuButton = memo(ShareMenuButton) 119 + export {ShareMenuButton}
+292
src/components/PostControls/index.tsx
··· 1 + import {memo, useState} from 'react' 2 + import {type StyleProp, View, type ViewStyle} from 'react-native' 3 + import { 4 + type AppBskyFeedDefs, 5 + type AppBskyFeedPost, 6 + type AppBskyFeedThreadgate, 7 + type RichText as RichTextAPI, 8 + } from '@atproto/api' 9 + import {msg, plural} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 11 + 12 + import {CountWheel} from '#/lib/custom-animations/CountWheel' 13 + import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' 14 + import {useHaptics} from '#/lib/haptics' 15 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 16 + import {type Shadow} from '#/state/cache/types' 17 + import {useFeedFeedbackContext} from '#/state/feed-feedback' 18 + import { 19 + usePostLikeMutationQueue, 20 + usePostRepostMutationQueue, 21 + } from '#/state/queries/post' 22 + import {useRequireAuth} from '#/state/session' 23 + import { 24 + ProgressGuideAction, 25 + useProgressGuideControls, 26 + } from '#/state/shell/progress-guide' 27 + import {formatCount} from '#/view/com/util/numeric/format' 28 + import * as Toast from '#/view/com/util/Toast' 29 + import {atoms as a, useBreakpoints} from '#/alf' 30 + import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' 31 + import { 32 + PostControlButton, 33 + PostControlButtonIcon, 34 + PostControlButtonText, 35 + } from './PostControlButton' 36 + import {PostMenuButton} from './PostMenu' 37 + import {RepostButton} from './RepostButton' 38 + import {ShareMenuButton} from './ShareMenu' 39 + 40 + let PostControls = ({ 41 + big, 42 + post, 43 + record, 44 + richText, 45 + feedContext, 46 + reqId, 47 + style, 48 + onPressReply, 49 + onPostReply, 50 + logContext, 51 + threadgateRecord, 52 + onShowLess, 53 + }: { 54 + big?: boolean 55 + post: Shadow<AppBskyFeedDefs.PostView> 56 + record: AppBskyFeedPost.Record 57 + richText: RichTextAPI 58 + feedContext?: string | undefined 59 + reqId?: string | undefined 60 + style?: StyleProp<ViewStyle> 61 + onPressReply: () => void 62 + onPostReply?: (postUri: string | undefined) => void 63 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 64 + threadgateRecord?: AppBskyFeedThreadgate.Record 65 + onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 66 + }): React.ReactNode => { 67 + const {_, i18n} = useLingui() 68 + const {gtMobile} = useBreakpoints() 69 + const {openComposer} = useOpenComposer() 70 + const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext) 71 + const [queueRepost, queueUnrepost] = usePostRepostMutationQueue( 72 + post, 73 + logContext, 74 + ) 75 + const requireAuth = useRequireAuth() 76 + const {sendInteraction} = useFeedFeedbackContext() 77 + const {captureAction} = useProgressGuideControls() 78 + const playHaptic = useHaptics() 79 + const isBlocked = Boolean( 80 + post.author.viewer?.blocking || 81 + post.author.viewer?.blockedBy || 82 + post.author.viewer?.blockingByList, 83 + ) 84 + const replyDisabled = post.viewer?.replyDisabled 85 + 86 + const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false) 87 + 88 + const onPressToggleLike = async () => { 89 + if (isBlocked) { 90 + Toast.show( 91 + _(msg`Cannot interact with a blocked user`), 92 + 'exclamation-circle', 93 + ) 94 + return 95 + } 96 + 97 + try { 98 + setHasLikeIconBeenToggled(true) 99 + if (!post.viewer?.like) { 100 + playHaptic('Light') 101 + sendInteraction({ 102 + item: post.uri, 103 + event: 'app.bsky.feed.defs#interactionLike', 104 + feedContext, 105 + reqId, 106 + }) 107 + captureAction(ProgressGuideAction.Like) 108 + await queueLike() 109 + } else { 110 + await queueUnlike() 111 + } 112 + } catch (e: any) { 113 + if (e?.name !== 'AbortError') { 114 + throw e 115 + } 116 + } 117 + } 118 + 119 + const onRepost = async () => { 120 + if (isBlocked) { 121 + Toast.show( 122 + _(msg`Cannot interact with a blocked user`), 123 + 'exclamation-circle', 124 + ) 125 + return 126 + } 127 + 128 + try { 129 + if (!post.viewer?.repost) { 130 + sendInteraction({ 131 + item: post.uri, 132 + event: 'app.bsky.feed.defs#interactionRepost', 133 + feedContext, 134 + reqId, 135 + }) 136 + await queueRepost() 137 + } else { 138 + await queueUnrepost() 139 + } 140 + } catch (e: any) { 141 + if (e?.name !== 'AbortError') { 142 + throw e 143 + } 144 + } 145 + } 146 + 147 + const onQuote = () => { 148 + if (isBlocked) { 149 + Toast.show( 150 + _(msg`Cannot interact with a blocked user`), 151 + 'exclamation-circle', 152 + ) 153 + return 154 + } 155 + 156 + sendInteraction({ 157 + item: post.uri, 158 + event: 'app.bsky.feed.defs#interactionQuote', 159 + feedContext, 160 + reqId, 161 + }) 162 + openComposer({ 163 + quote: post, 164 + onPost: onPostReply, 165 + }) 166 + } 167 + 168 + const onShare = () => { 169 + sendInteraction({ 170 + item: post.uri, 171 + event: 'app.bsky.feed.defs#interactionShare', 172 + feedContext, 173 + reqId, 174 + }) 175 + } 176 + 177 + return ( 178 + <View style={[a.flex_row, a.justify_between, a.align_center, style]}> 179 + <View 180 + style={[ 181 + big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}], 182 + replyDisabled ? {opacity: 0.5} : undefined, 183 + ]}> 184 + <PostControlButton 185 + testID="replyBtn" 186 + onPress={ 187 + !replyDisabled ? () => requireAuth(() => onPressReply()) : undefined 188 + } 189 + label={_( 190 + msg({ 191 + message: `Reply (${plural(post.replyCount || 0, { 192 + one: '# reply', 193 + other: '# replies', 194 + })})`, 195 + comment: 196 + 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 197 + }), 198 + )} 199 + big={big}> 200 + <PostControlButtonIcon icon={Bubble} /> 201 + {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && ( 202 + <PostControlButtonText> 203 + {formatCount(i18n, post.replyCount)} 204 + </PostControlButtonText> 205 + )} 206 + </PostControlButton> 207 + </View> 208 + <View style={big ? a.align_center : [a.flex_1, a.align_start]}> 209 + <RepostButton 210 + isReposted={!!post.viewer?.repost} 211 + repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)} 212 + onRepost={onRepost} 213 + onQuote={onQuote} 214 + big={big} 215 + embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 216 + /> 217 + </View> 218 + <View style={big ? a.align_center : [a.flex_1, a.align_start]}> 219 + <PostControlButton 220 + testID="likeBtn" 221 + big={big} 222 + onPress={() => requireAuth(() => onPressToggleLike())} 223 + label={ 224 + post.viewer?.like 225 + ? _( 226 + msg({ 227 + message: `Unlike (${plural(post.likeCount || 0, { 228 + one: '# like', 229 + other: '# likes', 230 + })})`, 231 + comment: 232 + 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', 233 + }), 234 + ) 235 + : _( 236 + msg({ 237 + message: `Like (${plural(post.likeCount || 0, { 238 + one: '# like', 239 + other: '# likes', 240 + })})`, 241 + comment: 242 + 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', 243 + }), 244 + ) 245 + }> 246 + <AnimatedLikeIcon 247 + isLiked={Boolean(post.viewer?.like)} 248 + big={big} 249 + hasBeenToggled={hasLikeIconBeenToggled} 250 + /> 251 + <CountWheel 252 + likeCount={post.likeCount ?? 0} 253 + big={big} 254 + isLiked={Boolean(post.viewer?.like)} 255 + hasBeenToggled={hasLikeIconBeenToggled} 256 + /> 257 + </PostControlButton> 258 + </View> 259 + <View style={big ? a.align_center : [a.flex_1, a.align_start]}> 260 + <View style={[!big && a.ml_sm]}> 261 + <ShareMenuButton 262 + testID="postShareBtn" 263 + post={post} 264 + big={big} 265 + record={record} 266 + richText={richText} 267 + timestamp={post.indexedAt} 268 + threadgateRecord={threadgateRecord} 269 + onShare={onShare} 270 + /> 271 + </View> 272 + </View> 273 + <View 274 + style={big ? a.align_center : [gtMobile && a.flex_1, a.align_start]}> 275 + <PostMenuButton 276 + testID="postDropdownBtn" 277 + post={post} 278 + postFeedContext={feedContext} 279 + postReqId={reqId} 280 + big={big} 281 + record={record} 282 + richText={richText} 283 + timestamp={post.indexedAt} 284 + threadgateRecord={threadgateRecord} 285 + onShowLess={onShowLess} 286 + /> 287 + </View> 288 + </View> 289 + ) 290 + } 291 + PostControls = memo(PostControls) 292 + export {PostControls}
+5
src/components/icons/ArrowOutOfBox.tsx
··· 3 3 export const ArrowOutOfBox_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 4 path: 'M12.707 3.293a1 1 0 0 0-1.414 0l-4.5 4.5a1 1 0 0 0 1.414 1.414L11 6.414v8.836a1 1 0 1 0 2 0V6.414l2.793 2.793a1 1 0 1 0 1.414-1.414l-4.5-4.5ZM5 12.75a1 1 0 1 0-2 0V20a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-7.25a1 1 0 1 0-2 0V19H5v-6.25Z', 5 5 }) 6 + 7 + export const ArrowOutOfBoxModified_Stroke2_Corner2_Rounded = 8 + createSinglePathSVG({ 9 + path: 'M20 13.75a1 1 0 0 1 1 1V18a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3v-3.25a1 1 0 1 1 2 0V18a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-3.25a1 1 0 0 1 1-1ZM12 3a1 1 0 0 1 .707.293l4.5 4.5a1 1 0 1 1-1.414 1.414L13 6.414v8.836a1 1 0 1 1-2 0V6.414L8.207 9.207a1 1 0 1 1-1.414-1.414l4.5-4.5A1 1 0 0 1 12 3Z', 10 + })
+5
src/components/icons/ArrowShareRight.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const ArrowShareRight_Stroke2_Corner2_Rounded = createSinglePathSVG({ 4 + path: 'M11.839 4.744c0-1.488 1.724-2.277 2.846-1.364l.107.094 7.66 7.256.128.134c.558.652.558 1.62 0 2.272l-.128.135-7.66 7.255c-1.115 1.057-2.953.267-2.953-1.27v-2.748c-3.503.055-5.417.41-6.592.97-.997.474-1.525 1.122-2.084 2.14l-.243.46c-.558 1.088-2.09.583-2.08-.515l.015-.748c.111-3.68.777-6.5 2.546-8.415 1.83-1.98 4.63-2.771 8.438-2.884V4.744Zm2 3.256c0 .79-.604 1.41-1.341 1.494l-.149.01c-3.9.057-6.147.813-7.48 2.254-.963 1.043-1.562 2.566-1.842 4.79.38-.327.826-.622 1.361-.877 1.656-.788 4.08-1.14 7.938-1.169l.153.007c.754.071 1.36.704 1.36 1.491v2.675L20.884 12l-7.045-6.676V8Z', 5 + })
+5
src/components/icons/ChainLink.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const ChainLink_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M18.535 5.465a5.003 5.003 0 0 0-7.076 0l-.005.005-.752.742a1 1 0 1 1-1.404-1.424l.749-.74a7.003 7.003 0 0 1 9.904 9.905l-.002.003-.737.746a1 1 0 1 1-1.424-1.404l.747-.757a5.003 5.003 0 0 0 0-7.076ZM6.202 9.288a1 1 0 0 1 .01 1.414l-.747.757a5.003 5.003 0 1 0 7.076 7.076l.005-.005.752-.742a1 1 0 1 1 1.404 1.424l-.746.737-.003.002a7.003 7.003 0 0 1-9.904-9.904l.74-.75a1 1 0 0 1 1.413-.009Zm8.505.005a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414-1.414l4-4a1 1 0 0 1 1.414 0Z', 5 + })
+1
src/lib/statsig/gates.ts
··· 1 1 export type Gate = 2 2 // Keep this alphabetic please. 3 + | 'alt_share_icon' 3 4 | 'debug_show_feedcontext' 4 5 | 'debug_subscriptions' 5 6 | 'explore_show_suggested_feeds'
+8
src/logger/metrics.ts
··· 395 395 'live:card:openProfile': {subject: string} 396 396 'live:view:profile': {subject: string} 397 397 'live:view:post': {subject: string; feed?: string} 398 + 399 + 'share:open': {context: 'feed' | 'thread'} 400 + 'share:press:copyLink': {} 401 + 'share:press:nativeShare': {} 402 + 'share:press:openDmSearch': {} 403 + 'share:press:dmSelected': {} 404 + 'share:press:recentDm': {} 405 + 'share:press:embed': {} 398 406 }
+5 -5
src/screens/Hashtag.tsx
··· 1 1 import React from 'react' 2 - import {ListRenderItemInfo, View} from 'react-native' 3 - import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' 2 + import {type ListRenderItemInfo, View} from 'react-native' 3 + import {type PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' 4 4 import {msg} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 import {useFocusEffect} from '@react-navigation/native' 7 - import {NativeStackScreenProps} from '@react-navigation/native-stack' 7 + import {type NativeStackScreenProps} from '@react-navigation/native-stack' 8 8 9 9 import {HITSLOP_10} from '#/lib/constants' 10 10 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 11 - import {CommonNavigatorParams} from '#/lib/routes/types' 11 + import {type CommonNavigatorParams} from '#/lib/routes/types' 12 12 import {shareUrl} from '#/lib/sharing' 13 13 import {cleanError} from '#/lib/strings/errors' 14 14 import {sanitizeHandle} from '#/lib/strings/handles' ··· 21 21 import {List} from '#/view/com/util/List' 22 22 import {atoms as a, web} from '#/alf' 23 23 import {Button, ButtonIcon} from '#/components/Button' 24 - import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 24 + import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 25 25 import * as Layout from '#/components/Layout' 26 26 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 27 27
+1 -1
src/screens/Profile/components/ProfileFeedHeader.tsx
··· 29 29 import * as Dialog from '#/components/Dialog' 30 30 import {Divider} from '#/components/Divider' 31 31 import {useRichText} from '#/components/hooks/useRichText' 32 - import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 32 + import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 33 33 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 34 34 import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 35 35 import {
+23 -7
src/screens/StarterPack/StarterPackScreen.tsx
··· 5 5 AppBskyGraphDefs, 6 6 AppBskyGraphStarterpack, 7 7 AtUri, 8 - ModerationOpts, 8 + type ModerationOpts, 9 9 RichText as RichTextAPI, 10 10 } from '@atproto/api' 11 11 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 12 12 import {msg, Plural, Trans} from '@lingui/macro' 13 13 import {useLingui} from '@lingui/react' 14 14 import {useNavigation} from '@react-navigation/native' 15 - import {NativeStackScreenProps} from '@react-navigation/native-stack' 15 + import {type NativeStackScreenProps} from '@react-navigation/native-stack' 16 16 import {useQueryClient} from '@tanstack/react-query' 17 17 18 18 import {batchedUpdates} from '#/lib/batchedUpdates' 19 19 import {HITSLOP_20} from '#/lib/constants' 20 20 import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' 21 21 import {makeProfileLink, makeStarterPackLink} from '#/lib/routes/links' 22 - import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' 22 + import { 23 + type CommonNavigatorParams, 24 + type NavigationProp, 25 + } from '#/lib/routes/types' 23 26 import {logEvent} from '#/lib/statsig/statsig' 24 27 import {cleanError} from '#/lib/strings/errors' 25 28 import {getStarterPackOgCard} from '#/lib/strings/starter-pack' 26 29 import {logger} from '#/logger' 30 + import {isWeb} from '#/platform/detection' 27 31 import {updateProfileShadow} from '#/state/cache/profile-shadow' 28 32 import {useModerationOpts} from '#/state/preferences/moderation-opts' 29 33 import {getAllListMembers} from '#/state/queries/list-members' ··· 46 50 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 47 51 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 48 52 import {useDialogControl} from '#/components/Dialog' 49 - import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox' 53 + import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' 54 + import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 50 55 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 51 56 import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 52 57 import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' ··· 600 605 <> 601 606 <Menu.Group> 602 607 <Menu.Item 603 - label={_(msg`Share`)} 608 + label={ 609 + isWeb 610 + ? _(msg`Copy link to starter pack`) 611 + : _(msg`Share via...`) 612 + } 604 613 testID="shareStarterPackLinkBtn" 605 614 onPress={onOpenShareDialog}> 606 615 <Menu.ItemText> 607 - <Trans>Share link</Trans> 616 + {isWeb ? ( 617 + <Trans>Copy link</Trans> 618 + ) : ( 619 + <Trans>Share via...</Trans> 620 + )} 608 621 </Menu.ItemText> 609 - <Menu.ItemIcon icon={ArrowOutOfBox} position="right" /> 622 + <Menu.ItemIcon 623 + icon={isWeb ? ChainLinkIcon : ArrowOutOfBoxIcon} 624 + position="right" 625 + /> 610 626 </Menu.Item> 611 627 </Menu.Group> 612 628
+5 -5
src/screens/Topic.tsx
··· 1 1 import React from 'react' 2 - import {ListRenderItemInfo, View} from 'react-native' 3 - import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' 2 + import {type ListRenderItemInfo, View} from 'react-native' 3 + import {type PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' 4 4 import {msg} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 import {useFocusEffect} from '@react-navigation/native' 7 - import {NativeStackScreenProps} from '@react-navigation/native-stack' 7 + import {type NativeStackScreenProps} from '@react-navigation/native-stack' 8 8 9 9 import {HITSLOP_10} from '#/lib/constants' 10 10 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 11 - import {CommonNavigatorParams} from '#/lib/routes/types' 11 + import {type CommonNavigatorParams} from '#/lib/routes/types' 12 12 import {shareUrl} from '#/lib/sharing' 13 13 import {cleanError} from '#/lib/strings/errors' 14 14 import {enforceLen} from '#/lib/strings/helpers' ··· 20 20 import {List} from '#/view/com/util/List' 21 21 import {atoms as a, web} from '#/alf' 22 22 import {Button, ButtonIcon} from '#/components/Button' 23 - import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 23 + import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 24 24 import * as Layout from '#/components/Layout' 25 25 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 26 26
+2 -2
src/screens/VideoFeed/index.tsx
··· 82 82 import {useSetLightStatusBar} from '#/state/shell/light-status-bar' 83 83 import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' 84 84 import {List} from '#/view/com/util/List' 85 - import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' 86 85 import {UserAvatar} from '#/view/com/util/UserAvatar' 87 86 import {Header} from '#/screens/VideoFeed/components/Header' 88 87 import {atoms as a, ios, platform, ThemeProvider, useTheme} from '#/alf' ··· 97 96 import {Link} from '#/components/Link' 98 97 import {ListFooter} from '#/components/Lists' 99 98 import * as Hider from '#/components/moderation/Hider' 99 + import {PostControls} from '#/components/PostControls' 100 100 import {RichText} from '#/components/RichText' 101 101 import {Text} from '#/components/Typography' 102 102 import * as bsky from '#/types/bsky' ··· 861 861 )} 862 862 {record && ( 863 863 <View style={[{left: -5}]}> 864 - <PostCtrls 864 + <PostControls 865 865 richText={richText} 866 866 post={post} 867 867 record={record}
+3 -3
src/view/com/post-thread/PostThreadItem.tsx
··· 43 43 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 44 44 import {Link, TextLink} from '#/view/com/util/Link' 45 45 import {formatCount} from '#/view/com/util/numeric/format' 46 - import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' 47 46 import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' 48 47 import {PostMeta} from '#/view/com/util/PostMeta' 49 48 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' ··· 60 59 import {PostAlerts} from '#/components/moderation/PostAlerts' 61 60 import {PostHider} from '#/components/moderation/PostHider' 62 61 import {type AppModerationCause} from '#/components/Pills' 62 + import {PostControls} from '#/components/PostControls' 63 63 import * as Prompt from '#/components/Prompt' 64 64 import {RichText} from '#/components/RichText' 65 65 import {SubtleWebHover} from '#/components/SubtleWebHover' ··· 494 494 marginLeft: -5, 495 495 }, 496 496 ]}> 497 - <PostCtrls 497 + <PostControls 498 498 big 499 499 post={post} 500 500 record={record} ··· 642 642 /> 643 643 </View> 644 644 )} 645 - <PostCtrls 645 + <PostControls 646 646 post={post} 647 647 record={record} 648 648 richText={richText}
+11 -11
src/view/com/post/Post.tsx
··· 27 27 import {useModerationOpts} from '#/state/preferences/moderation-opts' 28 28 import {precacheProfile} from '#/state/queries/profile' 29 29 import {useSession} from '#/state/session' 30 + import {Link, TextLink} from '#/view/com/util/Link' 31 + import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' 32 + import {PostMeta} from '#/view/com/util/PostMeta' 33 + import {Text} from '#/view/com/util/text/Text' 34 + import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 35 + import {UserInfoText} from '#/view/com/util/UserInfoText' 30 36 import {atoms as a} from '#/alf' 37 + import {ContentHider} from '#/components/moderation/ContentHider' 38 + import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 39 + import {PostAlerts} from '#/components/moderation/PostAlerts' 40 + import {PostControls} from '#/components/PostControls' 31 41 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 32 42 import {RichText} from '#/components/RichText' 33 43 import {SubtleWebHover} from '#/components/SubtleWebHover' 34 44 import * as bsky from '#/types/bsky' 35 - import {ContentHider} from '../../../components/moderation/ContentHider' 36 - import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' 37 - import {PostAlerts} from '../../../components/moderation/PostAlerts' 38 - import {Link, TextLink} from '../util/Link' 39 - import {PostCtrls} from '../util/post-ctrls/PostCtrls' 40 - import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds' 41 - import {PostMeta} from '../util/PostMeta' 42 - import {Text} from '../util/text/Text' 43 - import {PreviewableUserAvatar} from '../util/UserAvatar' 44 - import {UserInfoText} from '../util/UserInfoText' 45 45 46 46 export function Post({ 47 47 post, ··· 255 255 /> 256 256 ) : null} 257 257 </ContentHider> 258 - <PostCtrls 258 + <PostControls 259 259 post={post} 260 260 record={record} 261 261 richText={richText}
+6 -3
src/view/com/posts/PostFeedItem.tsx
··· 37 37 import {useSession} from '#/state/session' 38 38 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 39 39 import {FeedNameText} from '#/view/com/util/FeedInfoText' 40 - import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' 40 + import {Link, TextLink, TextLinkOnWebOnly} from '#/view/com/util/Link' 41 41 import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' 42 42 import {PostMeta} from '#/view/com/util/PostMeta' 43 43 import {Text} from '#/view/com/util/text/Text' ··· 49 49 import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 50 50 import {PostAlerts} from '#/components/moderation/PostAlerts' 51 51 import {type AppModerationCause} from '#/components/Pills' 52 + import {PostControls} from '#/components/PostControls' 53 + import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug' 52 54 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 53 55 import {RichText} from '#/components/RichText' 54 56 import {SubtleWebHover} from '#/components/SubtleWebHover' 55 57 import * as bsky from '#/types/bsky' 56 - import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link' 57 58 58 59 interface FeedItemProps { 59 60 record: AppBskyFeedPost.Record ··· 439 440 post={post} 440 441 threadgateRecord={threadgateRecord} 441 442 /> 442 - <PostCtrls 443 + <PostControls 443 444 post={post} 444 445 record={record} 445 446 richText={richText} ··· 451 452 onShowLess={onShowLess} 452 453 /> 453 454 </View> 455 + 456 + <DiscoverDebug feedContext={feedContext} /> 454 457 </View> 455 458 </Link> 456 459 )
+19 -10
src/view/com/profile/ProfileMenu.tsx
··· 12 12 import {shareText, shareUrl} from '#/lib/sharing' 13 13 import {toShareUrl} from '#/lib/strings/url-helpers' 14 14 import {logger} from '#/logger' 15 + import {isWeb} from '#/platform/detection' 15 16 import {type Shadow} from '#/state/cache/types' 16 17 import {useModalControls} from '#/state/modals' 17 18 import { ··· 26 27 import * as Toast from '#/view/com/util/Toast' 27 28 import {Button, ButtonIcon} from '#/components/Button' 28 29 import {useDialogControl} from '#/components/Dialog' 29 - import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 30 - import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' 31 - import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX' 30 + import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' 31 + import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 32 + import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck' 33 + import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX' 34 + import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 32 35 import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 33 36 import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' 34 37 import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' ··· 236 239 <Menu.Group> 237 240 <Menu.Item 238 241 testID="profileHeaderDropdownShareBtn" 239 - label={_(msg`Share`)} 242 + label={ 243 + isWeb ? _(msg`Copy link to profile`) : _(msg`Share via...`) 244 + } 240 245 onPress={() => { 241 246 if (showLoggedOutWarning) { 242 247 loggedOutWarningPromptControl.open() ··· 245 250 } 246 251 }}> 247 252 <Menu.ItemText> 248 - <Trans>Share</Trans> 253 + {isWeb ? ( 254 + <Trans>Copy link to profile</Trans> 255 + ) : ( 256 + <Trans>Share via...</Trans> 257 + )} 249 258 </Menu.ItemText> 250 - <Menu.ItemIcon icon={Share} /> 259 + <Menu.ItemIcon icon={isWeb ? ChainLinkIcon : ArrowOutOfBoxIcon} /> 251 260 </Menu.Item> 252 261 <Menu.Item 253 262 testID="profileHeaderDropdownSearchBtn" ··· 329 338 <Menu.ItemText> 330 339 <Trans>Remove verification</Trans> 331 340 </Menu.ItemText> 332 - <Menu.ItemIcon icon={CircleX} /> 341 + <Menu.ItemIcon icon={CircleXIcon} /> 333 342 </Menu.Item> 334 343 ) : ( 335 344 <Menu.Item ··· 339 348 <Menu.ItemText> 340 349 <Trans>Verify account</Trans> 341 350 </Menu.ItemText> 342 - <Menu.ItemIcon icon={CircleCheck} /> 351 + <Menu.ItemIcon icon={CircleCheckIcon} /> 343 352 </Menu.Item> 344 353 ))} 345 354 {!isSelf && ( ··· 414 423 <Menu.ItemText> 415 424 <Trans>Copy at:// URI</Trans> 416 425 </Menu.ItemText> 417 - <Menu.ItemIcon icon={Share} /> 426 + <Menu.ItemIcon icon={ClipboardIcon} /> 418 427 </Menu.Item> 419 428 <Menu.Item 420 429 testID="profileHeaderDropdownShareDIDBtn" ··· 423 432 <Menu.ItemText> 424 433 <Trans>Copy DID</Trans> 425 434 </Menu.ItemText> 426 - <Menu.ItemIcon icon={Share} /> 435 + <Menu.ItemIcon icon={ClipboardIcon} /> 427 436 </Menu.Item> 428 437 </Menu.Group> 429 438 </>
+18 -41
src/view/com/util/forms/PostDropdownBtn.tsx src/components/PostControls/PostMenu/index.tsx
··· 1 1 import {memo, useMemo, useState} from 'react' 2 2 import { 3 - Pressable, 4 - type PressableProps, 5 - type StyleProp, 6 - type ViewStyle, 7 - } from 'react-native' 8 - import { 9 3 type AppBskyFeedDefs, 10 4 type AppBskyFeedPost, 11 5 type AppBskyFeedThreadgate, ··· 15 9 import {useLingui} from '@lingui/react' 16 10 import type React from 'react' 17 11 18 - import {useTheme} from '#/lib/ThemeContext' 19 12 import {type Shadow} from '#/state/cache/post-shadow' 20 - import {atoms as a, useTheme as useAlf} from '#/alf' 13 + import {EventStopper} from '#/view/com/util/EventStopper' 21 14 import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 22 15 import {useMenuControl} from '#/components/Menu' 23 16 import * as Menu from '#/components/Menu' 24 - import {EventStopper} from '../EventStopper' 25 - import {PostDropdownMenuItems} from './PostDropdownBtnMenuItems' 17 + import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' 18 + import {PostMenuItems} from './PostMenuItems' 26 19 27 - let PostDropdownBtn = ({ 20 + let PostMenuButton = ({ 28 21 testID, 29 22 post, 30 23 postFeedContext, 31 24 postReqId, 25 + big, 32 26 record, 33 27 richText, 34 - style, 35 - hitSlop, 36 - size, 37 28 timestamp, 38 29 threadgateRecord, 39 30 onShowLess, ··· 42 33 post: Shadow<AppBskyFeedDefs.PostView> 43 34 postFeedContext: string | undefined 44 35 postReqId: string | undefined 36 + big?: boolean 45 37 record: AppBskyFeedPost.Record 46 38 richText: RichTextAPI 47 - style?: StyleProp<ViewStyle> 48 - hitSlop?: PressableProps['hitSlop'] 49 - size?: 'lg' | 'md' | 'sm' 50 39 timestamp: string 51 40 threadgateRecord?: AppBskyFeedThreadgate.Record 52 41 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 53 42 }): React.ReactNode => { 54 - const theme = useTheme() 55 - const alf = useAlf() 56 43 const {_} = useLingui() 57 - const defaultCtrlColor = theme.palette.default.postCtrl 44 + 58 45 const menuControl = useMenuControl() 59 46 const [hasBeenOpen, setHasBeenOpen] = useState(false) 60 47 const lazyMenuControl = useMemo( ··· 73 60 <EventStopper onKeyDown={false}> 74 61 <Menu.Root control={lazyMenuControl}> 75 62 <Menu.Trigger label={_(msg`Open post options menu`)}> 76 - {({props, state}) => { 63 + {({props}) => { 77 64 return ( 78 - <Pressable 79 - {...props} 80 - hitSlop={hitSlop} 81 - testID={testID} 82 - style={[ 83 - style, 84 - a.rounded_full, 85 - (state.hovered || state.pressed) && [ 86 - alf.atoms.bg_contrast_25, 87 - ], 88 - ]}> 89 - <DotsHorizontal 90 - fill={defaultCtrlColor} 91 - style={{pointerEvents: 'none'}} 92 - size={size} 93 - /> 94 - </Pressable> 65 + <PostControlButton 66 + testID="postDropdownBtn" 67 + big={big} 68 + label={props.accessibilityLabel} 69 + {...props}> 70 + <PostControlButtonIcon icon={DotsHorizontal} /> 71 + </PostControlButton> 95 72 ) 96 73 }} 97 74 </Menu.Trigger> 98 75 {hasBeenOpen && ( 99 76 // Lazily initialized. Once mounted, they stay mounted. 100 - <PostDropdownMenuItems 77 + <PostMenuItems 101 78 testID={testID} 102 79 post={post} 103 80 postFeedContext={postFeedContext} ··· 114 91 ) 115 92 } 116 93 117 - PostDropdownBtn = memo(PostDropdownBtn) 118 - export {PostDropdownBtn} 94 + PostMenuButton = memo(PostMenuButton) 95 + export {PostMenuButton}
+10 -137
src/view/com/util/forms/PostDropdownBtnMenuItems.tsx src/components/PostControls/PostMenu/PostMenuItems.tsx
··· 1 - import React, {memo} from 'react' 1 + import {memo, useMemo} from 'react' 2 2 import { 3 3 Platform, 4 4 type PressableProps, ··· 13 13 AtUri, 14 14 type RichText as RichTextAPI, 15 15 } from '@atproto/api' 16 - import {msg, Trans} from '@lingui/macro' 16 + import {msg} from '@lingui/macro' 17 17 import {useLingui} from '@lingui/react' 18 18 import {useNavigation} from '@react-navigation/native' 19 19 ··· 26 26 type CommonNavigatorParams, 27 27 type NavigationProp, 28 28 } from '#/lib/routes/types' 29 - import {shareText, shareUrl} from '#/lib/sharing' 30 29 import {logEvent} from '#/lib/statsig/statsig' 31 30 import {richTextToString} from '#/lib/strings/rich-text-helpers' 32 31 import {toShareUrl} from '#/lib/strings/url-helpers' 33 32 import {getTranslatorLink} from '#/locale/helpers' 34 33 import {logger} from '#/logger' 35 - import {isWeb} from '#/platform/detection' 36 34 import {type Shadow} from '#/state/cache/post-shadow' 37 35 import {useProfileShadow} from '#/state/cache/profile-shadow' 38 36 import {useFeedFeedbackContext} from '#/state/feed-feedback' ··· 52 50 import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' 53 51 import {useSession} from '#/state/session' 54 52 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 55 - import {useBreakpoints} from '#/alf' 53 + import * as Toast from '#/view/com/util/Toast' 56 54 import {useDialogControl} from '#/components/Dialog' 57 55 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 58 - import {EmbedDialog} from '#/components/dialogs/Embed' 59 56 import { 60 57 PostInteractionSettingsDialog, 61 58 usePrefetchPostInteractionSettings, 62 59 } from '#/components/dialogs/PostInteractionSettingsDialog' 63 - import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' 64 - import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 65 60 import {Atom_Stroke2_Corner0_Rounded as AtomIcon} from '#/components/icons/Atom' 66 61 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' 67 62 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 68 - import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets' 69 63 import { 70 64 EmojiSad_Stroke2_Corner0_Rounded as EmojiSad, 71 65 EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile, ··· 75 69 import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 76 70 import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' 77 71 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 78 - import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' 79 72 import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person' 80 73 import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 81 74 import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' ··· 90 83 useReportDialogControl, 91 84 } from '#/components/moderation/ReportDialog' 92 85 import * as Prompt from '#/components/Prompt' 93 - import {useDevMode} from '#/storage/hooks/dev-mode' 94 86 import * as bsky from '#/types/bsky' 95 - import * as Toast from '../Toast' 96 87 97 - let PostDropdownMenuItems = ({ 88 + let PostMenuItems = ({ 98 89 post, 99 90 postFeedContext, 100 91 postReqId, 101 92 record, 102 93 richText, 103 - timestamp, 104 94 threadgateRecord, 105 95 onShowLess, 106 96 }: { ··· 118 108 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 119 109 }): React.ReactNode => { 120 110 const {hasSession, currentAccount} = useSession() 121 - const {gtMobile} = useBreakpoints() 122 111 const {_} = useLingui() 123 112 const langPrefs = useLanguagePrefs() 124 113 const {mutateAsync: deletePostMutate} = usePostDeleteMutation() ··· 134 123 const reportDialogControl = useReportDialogControl() 135 124 const deletePromptControl = useDialogControl() 136 125 const hidePromptControl = useDialogControl() 137 - const loggedOutWarningPromptControl = useDialogControl() 138 - const embedPostControl = useDialogControl() 139 - const sendViaChatControl = useDialogControl() 140 126 const postInteractionSettingsDialogControl = useDialogControl() 141 127 const quotePostDetachConfirmControl = useDialogControl() 142 128 const hideReplyConfirmControl = useDialogControl() 143 129 const {mutateAsync: toggleReplyVisibility} = 144 130 useToggleReplyVisibilityMutation() 145 - const [devModeEnabled] = useDevMode() 146 131 147 132 const postUri = post.uri 148 133 const postCid = post.cid 149 134 const postAuthor = useProfileShadow(post.author) 150 - const quoteEmbed = React.useMemo(() => { 135 + const quoteEmbed = useMemo(() => { 151 136 if (!currentAccount || !post.embed) return 152 137 return getMaybeDetachedQuoteEmbed({ 153 138 viewerDid: currentAccount.did, ··· 181 166 rootPostUri: rootUri, 182 167 }) 183 168 184 - const href = React.useMemo(() => { 169 + const href = useMemo(() => { 185 170 const urip = new AtUri(postUri) 186 171 return makeProfileLink(postAuthor, 'post', urip.rkey) 187 172 }, [postUri, postAuthor]) ··· 273 258 label => label.val === '!no-unauthenticated', 274 259 ) 275 260 276 - const showLoggedOutWarning = 277 - postAuthor.did !== currentAccount?.did && hideInPWI 278 - 279 - const onSharePost = () => { 280 - const url = toShareUrl(href) 281 - shareUrl(url) 282 - } 283 - 284 261 const onPressShowMore = () => { 285 262 feedFeedback.sendInteraction({ 286 263 event: 'app.bsky.feed.defs#requestMore', ··· 308 285 } 309 286 } 310 287 311 - const onSelectChatToShareTo = (conversation: string) => { 312 - navigation.navigate('MessagesConversation', { 313 - conversation, 314 - embed: postUri, 315 - }) 316 - } 317 - 318 288 const onToggleQuotePostAttachment = async () => { 319 289 if (!quoteEmbed) return 320 290 ··· 341 311 } 342 312 343 313 const canHidePostForMe = !isAuthor && !isPostHidden 344 - const canEmbed = isWeb && gtMobile && !hideInPWI 345 314 const canHideReplyForEveryone = 346 315 !isAuthor && isRootPostAuthor && !isPostHidden && isReply 347 316 const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer ··· 417 386 } 418 387 } 419 388 420 - const onShareATURI = () => { 421 - shareText(postUri) 422 - } 423 - 424 - const onShareAuthorDID = () => { 425 - shareText(postAuthor.did) 426 - } 427 - 428 389 const onReportMisclassification = () => { 429 390 const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl( 430 391 href, ··· 482 443 </Menu.Item> 483 444 </> 484 445 )} 485 - 486 - {hasSession && ( 487 - <Menu.Item 488 - testID="postDropdownSendViaDMBtn" 489 - label={_(msg`Send via direct message`)} 490 - onPress={() => sendViaChatControl.open()}> 491 - <Menu.ItemText> 492 - <Trans>Send via direct message</Trans> 493 - </Menu.ItemText> 494 - <Menu.ItemIcon icon={Send} position="right" /> 495 - </Menu.Item> 496 - )} 497 - 498 - <Menu.Item 499 - testID="postDropdownShareBtn" 500 - label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} 501 - onPress={() => { 502 - if (showLoggedOutWarning) { 503 - loggedOutWarningPromptControl.open() 504 - } else { 505 - onSharePost() 506 - } 507 - }}> 508 - <Menu.ItemText> 509 - {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} 510 - </Menu.ItemText> 511 - <Menu.ItemIcon icon={Share} position="right" /> 512 - </Menu.Item> 513 - 514 - {canEmbed && ( 515 - <Menu.Item 516 - testID="postDropdownEmbedBtn" 517 - label={_(msg`Embed post`)} 518 - onPress={() => embedPostControl.open()}> 519 - <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText> 520 - <Menu.ItemIcon icon={CodeBrackets} position="right" /> 521 - </Menu.Item> 522 - )} 523 446 </Menu.Group> 524 447 525 448 {hasSession && feedFeedback.enabled && ( ··· 550 473 DISCOVER_DEBUG_DIDS[currentAccount?.did ?? ''] && ( 551 474 <Menu.Item 552 475 testID="postDropdownReportMisclassificationBtn" 553 - label={_(msg`Assign topic - help train Discover!`)} 476 + label={_(msg`Assign topic for algo`)} 554 477 onPress={onReportMisclassification}> 555 - <Menu.ItemText> 556 - {_(msg`Assign topic - help train Discover!`)} 557 - </Menu.ItemText> 478 + <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText> 558 479 <Menu.ItemIcon icon={AtomIcon} position="right" /> 559 480 </Menu.Item> 560 481 )} ··· 747 668 </> 748 669 )} 749 670 </Menu.Group> 750 - 751 - {devModeEnabled ? ( 752 - <> 753 - <Menu.Divider /> 754 - <Menu.Group> 755 - <Menu.Item 756 - testID="postAtUriShareBtn" 757 - label={_(msg`Copy post at:// URI`)} 758 - onPress={onShareATURI}> 759 - <Menu.ItemText>{_(msg`Copy post at:// URI`)}</Menu.ItemText> 760 - <Menu.ItemIcon icon={Share} position="right" /> 761 - </Menu.Item> 762 - <Menu.Item 763 - testID="postAuthorDIDShareBtn" 764 - label={_(msg`Copy author DID`)} 765 - onPress={onShareAuthorDID}> 766 - <Menu.ItemText>{_(msg`Copy author DID`)}</Menu.ItemText> 767 - <Menu.ItemIcon icon={Share} position="right" /> 768 - </Menu.Item> 769 - </Menu.Group> 770 - </> 771 - ) : null} 772 671 </> 773 672 )} 774 673 </Menu.Outer> ··· 802 701 }} 803 702 /> 804 703 805 - <Prompt.Basic 806 - control={loggedOutWarningPromptControl} 807 - title={_(msg`Note about sharing`)} 808 - description={_( 809 - msg`This post is only visible to logged-in users. It won't be visible to people who aren't signed in.`, 810 - )} 811 - onConfirm={onSharePost} 812 - confirmButtonCta={_(msg`Share anyway`)} 813 - /> 814 - 815 - {canEmbed && ( 816 - <EmbedDialog 817 - control={embedPostControl} 818 - postCid={postCid} 819 - postUri={postUri} 820 - record={record} 821 - postAuthor={postAuthor} 822 - timestamp={timestamp} 823 - /> 824 - )} 825 - 826 - <SendViaChatDialog 827 - control={sendViaChatControl} 828 - onSelectChat={onSelectChatToShareTo} 829 - /> 830 - 831 704 <PostInteractionSettingsDialog 832 705 control={postInteractionSettingsDialogControl} 833 706 postUri={post.uri} ··· 868 741 </> 869 742 ) 870 743 } 871 - PostDropdownMenuItems = memo(PostDropdownMenuItems) 872 - export {PostDropdownMenuItems} 744 + PostMenuItems = memo(PostMenuItems) 745 + export {PostMenuItems}
-394
src/view/com/util/post-ctrls/PostCtrls.tsx
··· 1 - import React, {memo} from 'react' 2 - import { 3 - Pressable, 4 - type PressableStateCallbackType, 5 - type StyleProp, 6 - View, 7 - type ViewStyle, 8 - } from 'react-native' 9 - import * as Clipboard from 'expo-clipboard' 10 - import { 11 - type AppBskyFeedDefs, 12 - type AppBskyFeedPost, 13 - type AppBskyFeedThreadgate, 14 - AtUri, 15 - type RichText as RichTextAPI, 16 - } from '@atproto/api' 17 - import {msg, plural} from '@lingui/macro' 18 - import {useLingui} from '@lingui/react' 19 - 20 - import {IS_INTERNAL} from '#/lib/app-info' 21 - import {DISCOVER_DEBUG_DIDS, POST_CTRL_HITSLOP} from '#/lib/constants' 22 - import {CountWheel} from '#/lib/custom-animations/CountWheel' 23 - import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' 24 - import {useHaptics} from '#/lib/haptics' 25 - import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 26 - import {makeProfileLink} from '#/lib/routes/links' 27 - import {shareUrl} from '#/lib/sharing' 28 - import {useGate} from '#/lib/statsig/statsig' 29 - import {toShareUrl} from '#/lib/strings/url-helpers' 30 - import {type Shadow} from '#/state/cache/types' 31 - import {useFeedFeedbackContext} from '#/state/feed-feedback' 32 - import { 33 - usePostLikeMutationQueue, 34 - usePostRepostMutationQueue, 35 - } from '#/state/queries/post' 36 - import {useRequireAuth, useSession} from '#/state/session' 37 - import { 38 - ProgressGuideAction, 39 - useProgressGuideControls, 40 - } from '#/state/shell/progress-guide' 41 - import {atoms as a, useTheme} from '#/alf' 42 - import {useDialogControl} from '#/components/Dialog' 43 - import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox' 44 - import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' 45 - import * as Prompt from '#/components/Prompt' 46 - import {PostDropdownBtn} from '../forms/PostDropdownBtn' 47 - import {formatCount} from '../numeric/format' 48 - import {Text} from '../text/Text' 49 - import * as Toast from '../Toast' 50 - import {RepostButton} from './RepostButton' 51 - 52 - let PostCtrls = ({ 53 - big, 54 - post, 55 - record, 56 - richText, 57 - feedContext, 58 - reqId, 59 - style, 60 - onPressReply, 61 - onPostReply, 62 - logContext, 63 - threadgateRecord, 64 - onShowLess, 65 - }: { 66 - big?: boolean 67 - post: Shadow<AppBskyFeedDefs.PostView> 68 - record: AppBskyFeedPost.Record 69 - richText: RichTextAPI 70 - feedContext?: string | undefined 71 - reqId?: string | undefined 72 - style?: StyleProp<ViewStyle> 73 - onPressReply: () => void 74 - onPostReply?: (postUri: string | undefined) => void 75 - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 76 - threadgateRecord?: AppBskyFeedThreadgate.Record 77 - onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 78 - }): React.ReactNode => { 79 - const t = useTheme() 80 - const {_, i18n} = useLingui() 81 - const {openComposer} = useOpenComposer() 82 - const {currentAccount} = useSession() 83 - const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext) 84 - const [queueRepost, queueUnrepost] = usePostRepostMutationQueue( 85 - post, 86 - logContext, 87 - ) 88 - const requireAuth = useRequireAuth() 89 - const loggedOutWarningPromptControl = useDialogControl() 90 - const {sendInteraction} = useFeedFeedbackContext() 91 - const {captureAction} = useProgressGuideControls() 92 - const playHaptic = useHaptics() 93 - const gate = useGate() 94 - const isDiscoverDebugUser = 95 - IS_INTERNAL || 96 - DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || 97 - gate('debug_show_feedcontext') 98 - const isBlocked = Boolean( 99 - post.author.viewer?.blocking || 100 - post.author.viewer?.blockedBy || 101 - post.author.viewer?.blockingByList, 102 - ) 103 - const replyDisabled = post.viewer?.replyDisabled 104 - 105 - const shouldShowLoggedOutWarning = React.useMemo(() => { 106 - return ( 107 - post.author.did !== currentAccount?.did && 108 - !!post.author.labels?.find(label => label.val === '!no-unauthenticated') 109 - ) 110 - }, [currentAccount, post]) 111 - 112 - const defaultCtrlColor = React.useMemo( 113 - () => ({ 114 - color: t.palette.contrast_500, 115 - }), 116 - [t], 117 - ) as StyleProp<ViewStyle> 118 - 119 - const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = 120 - React.useState(false) 121 - 122 - const onPressToggleLike = async () => { 123 - if (isBlocked) { 124 - Toast.show( 125 - _(msg`Cannot interact with a blocked user`), 126 - 'exclamation-circle', 127 - ) 128 - return 129 - } 130 - 131 - try { 132 - setHasLikeIconBeenToggled(true) 133 - if (!post.viewer?.like) { 134 - playHaptic('Light') 135 - sendInteraction({ 136 - item: post.uri, 137 - event: 'app.bsky.feed.defs#interactionLike', 138 - feedContext, 139 - reqId, 140 - }) 141 - captureAction(ProgressGuideAction.Like) 142 - await queueLike() 143 - } else { 144 - await queueUnlike() 145 - } 146 - } catch (e: any) { 147 - if (e?.name !== 'AbortError') { 148 - throw e 149 - } 150 - } 151 - } 152 - 153 - const onRepost = async () => { 154 - if (isBlocked) { 155 - Toast.show( 156 - _(msg`Cannot interact with a blocked user`), 157 - 'exclamation-circle', 158 - ) 159 - return 160 - } 161 - 162 - try { 163 - if (!post.viewer?.repost) { 164 - sendInteraction({ 165 - item: post.uri, 166 - event: 'app.bsky.feed.defs#interactionRepost', 167 - feedContext, 168 - reqId, 169 - }) 170 - await queueRepost() 171 - } else { 172 - await queueUnrepost() 173 - } 174 - } catch (e: any) { 175 - if (e?.name !== 'AbortError') { 176 - throw e 177 - } 178 - } 179 - } 180 - 181 - const onQuote = () => { 182 - if (isBlocked) { 183 - Toast.show( 184 - _(msg`Cannot interact with a blocked user`), 185 - 'exclamation-circle', 186 - ) 187 - return 188 - } 189 - 190 - sendInteraction({ 191 - item: post.uri, 192 - event: 'app.bsky.feed.defs#interactionQuote', 193 - feedContext, 194 - reqId, 195 - }) 196 - openComposer({ 197 - quote: post, 198 - onPost: onPostReply, 199 - }) 200 - } 201 - 202 - const onShare = () => { 203 - const urip = new AtUri(post.uri) 204 - const href = makeProfileLink(post.author, 'post', urip.rkey) 205 - const url = toShareUrl(href) 206 - shareUrl(url) 207 - sendInteraction({ 208 - item: post.uri, 209 - event: 'app.bsky.feed.defs#interactionShare', 210 - feedContext, 211 - reqId, 212 - }) 213 - } 214 - 215 - const btnStyle = React.useCallback( 216 - ({pressed, hovered}: PressableStateCallbackType) => [ 217 - a.gap_xs, 218 - a.rounded_full, 219 - a.flex_row, 220 - a.justify_center, 221 - a.align_center, 222 - a.overflow_hidden, 223 - {padding: 5}, 224 - (pressed || hovered) && t.atoms.bg_contrast_25, 225 - ], 226 - [t.atoms.bg_contrast_25], 227 - ) 228 - 229 - return ( 230 - <View style={[a.flex_row, a.justify_between, a.align_center, style]}> 231 - <View 232 - style={[ 233 - big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}], 234 - replyDisabled ? {opacity: 0.5} : undefined, 235 - ]}> 236 - <Pressable 237 - testID="replyBtn" 238 - style={btnStyle} 239 - onPress={() => { 240 - if (!replyDisabled) { 241 - playHaptic('Light') 242 - requireAuth(() => onPressReply()) 243 - } 244 - }} 245 - accessibilityRole="button" 246 - accessibilityLabel={_( 247 - msg`Reply (${plural(post.replyCount || 0, { 248 - one: '# reply', 249 - other: '# replies', 250 - })})`, 251 - )} 252 - accessibilityHint="" 253 - hitSlop={POST_CTRL_HITSLOP}> 254 - <Bubble 255 - style={[defaultCtrlColor, {pointerEvents: 'none'}]} 256 - width={big ? 22 : 18} 257 - /> 258 - {typeof post.replyCount !== 'undefined' && post.replyCount > 0 ? ( 259 - <Text 260 - style={[ 261 - defaultCtrlColor, 262 - big ? a.text_md : {fontSize: 15}, 263 - a.user_select_none, 264 - ]}> 265 - {formatCount(i18n, post.replyCount)} 266 - </Text> 267 - ) : undefined} 268 - </Pressable> 269 - </View> 270 - <View style={big ? a.align_center : [a.flex_1, a.align_start]}> 271 - <RepostButton 272 - isReposted={!!post.viewer?.repost} 273 - repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)} 274 - onRepost={onRepost} 275 - onQuote={onQuote} 276 - big={big} 277 - embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 278 - /> 279 - </View> 280 - <View style={big ? a.align_center : [a.flex_1, a.align_start]}> 281 - <Pressable 282 - testID="likeBtn" 283 - style={btnStyle} 284 - onPress={() => requireAuth(() => onPressToggleLike())} 285 - accessibilityRole="button" 286 - accessibilityLabel={ 287 - post.viewer?.like 288 - ? _( 289 - msg`Unlike (${plural(post.likeCount || 0, { 290 - one: '# like', 291 - other: '# likes', 292 - })})`, 293 - ) 294 - : _( 295 - msg`Like (${plural(post.likeCount || 0, { 296 - one: '# like', 297 - other: '# likes', 298 - })})`, 299 - ) 300 - } 301 - accessibilityHint="" 302 - hitSlop={POST_CTRL_HITSLOP}> 303 - <AnimatedLikeIcon 304 - isLiked={Boolean(post.viewer?.like)} 305 - big={big} 306 - hasBeenToggled={hasLikeIconBeenToggled} 307 - /> 308 - <CountWheel 309 - likeCount={post.likeCount ?? 0} 310 - big={big} 311 - isLiked={Boolean(post.viewer?.like)} 312 - hasBeenToggled={hasLikeIconBeenToggled} 313 - /> 314 - </Pressable> 315 - </View> 316 - {big && ( 317 - <> 318 - <View style={a.align_center}> 319 - <Pressable 320 - testID="shareBtn" 321 - style={btnStyle} 322 - onPress={() => { 323 - if (shouldShowLoggedOutWarning) { 324 - loggedOutWarningPromptControl.open() 325 - } else { 326 - onShare() 327 - } 328 - }} 329 - accessibilityRole="button" 330 - accessibilityLabel={_(msg`Share`)} 331 - accessibilityHint="" 332 - hitSlop={POST_CTRL_HITSLOP}> 333 - <ArrowOutOfBox 334 - style={[defaultCtrlColor, {pointerEvents: 'none'}]} 335 - width={22} 336 - /> 337 - </Pressable> 338 - </View> 339 - <Prompt.Basic 340 - control={loggedOutWarningPromptControl} 341 - title={_(msg`Note about sharing`)} 342 - description={_( 343 - msg`This post is only visible to logged-in users. It won't be visible to people who aren't signed in.`, 344 - )} 345 - onConfirm={onShare} 346 - confirmButtonCta={_(msg`Share anyway`)} 347 - /> 348 - </> 349 - )} 350 - <View style={big ? a.align_center : [a.flex_1, a.align_start]}> 351 - <PostDropdownBtn 352 - testID="postDropdownBtn" 353 - post={post} 354 - postFeedContext={feedContext} 355 - postReqId={reqId} 356 - record={record} 357 - richText={richText} 358 - style={{padding: 5}} 359 - hitSlop={POST_CTRL_HITSLOP} 360 - timestamp={post.indexedAt} 361 - threadgateRecord={threadgateRecord} 362 - onShowLess={onShowLess} 363 - /> 364 - </View> 365 - {isDiscoverDebugUser && feedContext && ( 366 - <Pressable 367 - accessible={false} 368 - style={{ 369 - position: 'absolute', 370 - top: 0, 371 - bottom: 0, 372 - right: 0, 373 - display: 'flex', 374 - justifyContent: 'center', 375 - }} 376 - onPress={e => { 377 - e.stopPropagation() 378 - Clipboard.setStringAsync(feedContext) 379 - Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') 380 - }}> 381 - <Text 382 - style={{ 383 - color: t.palette.contrast_400, 384 - fontSize: 7, 385 - }}> 386 - {feedContext} 387 - </Text> 388 - </Pressable> 389 - )} 390 - </View> 391 - ) 392 - } 393 - PostCtrls = memo(PostCtrls) 394 - export {PostCtrls}
+37 -52
src/view/com/util/post-ctrls/RepostButton.tsx src/components/PostControls/RepostButton.tsx
··· 1 - import React, {memo, useCallback} from 'react' 1 + import {memo, useCallback} from 'react' 2 2 import {View} from 'react-native' 3 3 import {msg, plural, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 - import {POST_CTRL_HITSLOP} from '#/lib/constants' 7 6 import {useHaptics} from '#/lib/haptics' 8 7 import {useRequireAuth} from '#/state/session' 8 + import {formatCount} from '#/view/com/util/numeric/format' 9 9 import {atoms as a, useTheme} from '#/alf' 10 10 import {Button, ButtonText} from '#/components/Button' 11 11 import * as Dialog from '#/components/Dialog' 12 12 import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote' 13 13 import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' 14 14 import {Text} from '#/components/Typography' 15 - import {formatCount} from '../numeric/format' 15 + import { 16 + PostControlButton, 17 + PostControlButtonIcon, 18 + PostControlButtonText, 19 + } from './PostControlButton' 16 20 17 21 interface Props { 18 22 isReposted: boolean ··· 35 39 const {_, i18n} = useLingui() 36 40 const requireAuth = useRequireAuth() 37 41 const dialogControl = Dialog.useDialogControl() 38 - const playHaptic = useHaptics() 39 - const color = React.useMemo( 40 - () => ({ 41 - color: isReposted ? t.palette.positive_600 : t.palette.contrast_500, 42 - }), 43 - [t, isReposted], 44 - ) 42 + 45 43 return ( 46 44 <> 47 - <Button 45 + <PostControlButton 48 46 testID="repostBtn" 49 - onPress={() => { 50 - playHaptic('Light') 51 - requireAuth(() => dialogControl.open()) 52 - }} 53 - onLongPress={() => { 54 - playHaptic('Heavy') 55 - requireAuth(() => onQuote()) 56 - }} 57 - style={[ 58 - a.flex_row, 59 - a.align_center, 60 - a.gap_xs, 61 - a.bg_transparent, 62 - {padding: 5}, 63 - ]} 64 - hoverStyle={t.atoms.bg_contrast_25} 47 + active={isReposted} 48 + activeColor={t.palette.positive_600} 49 + big={big} 50 + onPress={() => requireAuth(() => dialogControl.open())} 51 + onLongPress={() => requireAuth(() => onQuote())} 65 52 label={ 66 53 isReposted 67 54 ? _( 68 - msg`Undo repost (${plural(repostCount || 0, { 69 - one: '# repost', 70 - other: '# reposts', 71 - })})`, 55 + msg({ 56 + message: `Undo repost (${plural(repostCount || 0, { 57 + one: '# repost', 58 + other: '# reposts', 59 + })})`, 60 + comment: 61 + 'Accessibility label for the repost button when the post has been reposted, verb followed by number of reposts and noun', 62 + }), 72 63 ) 73 64 : _( 74 - msg`Repost (${plural(repostCount || 0, { 75 - one: '# repost', 76 - other: '# reposts', 77 - })})`, 65 + msg({ 66 + message: `Repost (${plural(repostCount || 0, { 67 + one: '# repost', 68 + other: '# reposts', 69 + })})`, 70 + comment: 71 + 'Accessibility label for the repost button when the post has not been reposted, verb form followed by number of reposts and noun form', 72 + }), 78 73 ) 79 - } 80 - shape="round" 81 - variant="ghost" 82 - color="secondary" 83 - hitSlop={POST_CTRL_HITSLOP}> 84 - <Repost style={color} width={big ? 22 : 18} /> 85 - {typeof repostCount !== 'undefined' && repostCount > 0 ? ( 86 - <Text 87 - testID="repostCount" 88 - style={[ 89 - color, 90 - big ? a.text_md : {fontSize: 15}, 91 - isReposted && a.font_bold, 92 - ]}> 74 + }> 75 + <PostControlButtonIcon icon={Repost} /> 76 + {typeof repostCount !== 'undefined' && repostCount > 0 && ( 77 + <PostControlButtonText testID="repostCount"> 93 78 {formatCount(i18n, repostCount)} 94 - </Text> 95 - ) : undefined} 96 - </Button> 79 + </PostControlButtonText> 80 + )} 81 + </PostControlButton> 97 82 <Dialog.Outer 98 83 control={dialogControl} 99 84 nativeOptions={{preventExpansion: true}}>
-147
src/view/com/util/post-ctrls/RepostButton.web.tsx
··· 1 - import React from 'react' 2 - import {Pressable, View} from 'react-native' 3 - import {msg} from '@lingui/macro' 4 - import {useLingui} from '@lingui/react' 5 - 6 - import {useRequireAuth} from '#/state/session' 7 - import {useSession} from '#/state/session' 8 - import {atoms as a, useTheme} from '#/alf' 9 - import {Button} from '#/components/Button' 10 - import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote' 11 - import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' 12 - import * as Menu from '#/components/Menu' 13 - import {Text} from '#/components/Typography' 14 - import {EventStopper} from '../EventStopper' 15 - import {formatCount} from '../numeric/format' 16 - 17 - interface Props { 18 - isReposted: boolean 19 - repostCount?: number 20 - onRepost: () => void 21 - onQuote: () => void 22 - big?: boolean 23 - embeddingDisabled: boolean 24 - } 25 - 26 - export const RepostButton = ({ 27 - isReposted, 28 - repostCount, 29 - onRepost, 30 - onQuote, 31 - big, 32 - embeddingDisabled, 33 - }: Props) => { 34 - const t = useTheme() 35 - const {_} = useLingui() 36 - const {hasSession} = useSession() 37 - const requireAuth = useRequireAuth() 38 - 39 - const color = React.useMemo( 40 - () => ({ 41 - color: isReposted ? t.palette.positive_600 : t.palette.contrast_500, 42 - }), 43 - [t, isReposted], 44 - ) 45 - 46 - return hasSession ? ( 47 - <EventStopper onKeyDown={false}> 48 - <Menu.Root> 49 - <Menu.Trigger label={_(msg`Repost or quote post`)}> 50 - {({props, state}) => { 51 - return ( 52 - <Pressable 53 - {...props} 54 - style={[ 55 - a.rounded_full, 56 - (state.hovered || state.pressed) && { 57 - backgroundColor: t.palette.contrast_25, 58 - }, 59 - ]}> 60 - <RepostInner 61 - isReposted={isReposted} 62 - color={color} 63 - repostCount={repostCount} 64 - big={big} 65 - /> 66 - </Pressable> 67 - ) 68 - }} 69 - </Menu.Trigger> 70 - <Menu.Outer style={{minWidth: 170}}> 71 - <Menu.Item 72 - label={isReposted ? _(msg`Undo repost`) : _(msg`Repost`)} 73 - testID="repostDropdownRepostBtn" 74 - onPress={onRepost}> 75 - <Menu.ItemText> 76 - {isReposted ? _(msg`Undo repost`) : _(msg`Repost`)} 77 - </Menu.ItemText> 78 - <Menu.ItemIcon icon={Repost} position="right" /> 79 - </Menu.Item> 80 - <Menu.Item 81 - disabled={embeddingDisabled} 82 - label={ 83 - embeddingDisabled 84 - ? _(msg`Quote posts disabled`) 85 - : _(msg`Quote post`) 86 - } 87 - testID="repostDropdownQuoteBtn" 88 - onPress={onQuote}> 89 - <Menu.ItemText> 90 - {embeddingDisabled 91 - ? _(msg`Quote posts disabled`) 92 - : _(msg`Quote post`)} 93 - </Menu.ItemText> 94 - <Menu.ItemIcon icon={Quote} position="right" /> 95 - </Menu.Item> 96 - </Menu.Outer> 97 - </Menu.Root> 98 - </EventStopper> 99 - ) : ( 100 - <Button 101 - onPress={() => { 102 - requireAuth(() => {}) 103 - }} 104 - label={_(msg`Repost or quote post`)} 105 - style={{padding: 0}} 106 - hoverStyle={t.atoms.bg_contrast_25} 107 - shape="round"> 108 - <RepostInner 109 - isReposted={isReposted} 110 - color={color} 111 - repostCount={repostCount} 112 - big={big} 113 - /> 114 - </Button> 115 - ) 116 - } 117 - 118 - const RepostInner = ({ 119 - isReposted, 120 - color, 121 - repostCount, 122 - big, 123 - }: { 124 - isReposted: boolean 125 - color: {color: string} 126 - repostCount?: number 127 - big?: boolean 128 - }) => { 129 - const {i18n} = useLingui() 130 - return ( 131 - <View style={[a.flex_row, a.align_center, a.gap_xs, {padding: 5}]}> 132 - <Repost style={color} width={big ? 22 : 18} /> 133 - {typeof repostCount !== 'undefined' && repostCount > 0 ? ( 134 - <Text 135 - testID="repostCount" 136 - style={[ 137 - color, 138 - big ? a.text_md : {fontSize: 15}, 139 - isReposted && [a.font_bold], 140 - a.user_select_none, 141 - ]}> 142 - {formatCount(i18n, repostCount)} 143 - </Text> 144 - ) : undefined} 145 - </View> 146 - ) 147 - }