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

Update hashtag menu to use `Menu`, convert to native link for additional a11y and click handling (#7529)

* Make tag a normal link on web

* Replace old TagMenu with new RichTextTag component, expand and improve click utils

* Clarify intents

* Ensure we're passing down hint

* ope

* DRY

authored by

Eric Bailey and committed by
GitHub
9df5caf3 c8d062f1

+294 -577
+108 -18
src/components/Link.tsx
··· 15 15 linkRequiresWarning, 16 16 } from '#/lib/strings/url-helpers' 17 17 import {isNative, isWeb} from '#/platform/detection' 18 - import {shouldClickOpenNewTab} from '#/platform/urls' 19 18 import {useModalControls} from '#/state/modals' 20 19 import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf' 21 20 import {Button, ButtonProps} from '#/components/Button' ··· 56 55 onPress?: (e: GestureResponderEvent) => void | false 57 56 58 57 /** 58 + * Callback for when the link is long pressed (on native). Prevent default 59 + * and return `false` to exit early and prevent default long press hander. 60 + */ 61 + onLongPress?: (e: GestureResponderEvent) => void | false 62 + 63 + /** 59 64 * Web-only attribute. Sets `download` attr on web. 60 65 */ 61 66 download?: string ··· 72 77 action = 'push', 73 78 disableMismatchWarning, 74 79 onPress: outerOnPress, 80 + onLongPress: outerOnLongPress, 75 81 shareOnLongPress, 76 82 }: BaseLinkProps & { 77 83 displayText: string ··· 175 181 } 176 182 }, [disableMismatchWarning, displayText, href, isExternal, openModal]) 177 183 178 - const onLongPress = 179 - isNative && isExternal && shareOnLongPress ? handleLongPress : undefined 184 + const onLongPress = React.useCallback( 185 + (e: GestureResponderEvent) => { 186 + const exitEarlyIfFalse = outerOnLongPress?.(e) 187 + if (exitEarlyIfFalse === false) return 188 + return isNative && shareOnLongPress ? handleLongPress() : undefined 189 + }, 190 + [outerOnLongPress, handleLongPress, shareOnLongPress], 191 + ) 180 192 181 193 return { 182 194 isExternal, ··· 202 214 to, 203 215 action = 'push', 204 216 onPress: outerOnPress, 217 + onLongPress: outerOnLongPress, 205 218 download, 206 219 ...rest 207 220 }: LinkProps) { 208 - const {href, isExternal, onPress} = useLink({ 221 + const {href, isExternal, onPress, onLongPress} = useLink({ 209 222 to, 210 223 displayText: typeof children === 'string' ? children : '', 211 224 action, 212 225 onPress: outerOnPress, 226 + onLongPress: outerOnLongPress, 213 227 }) 214 228 215 229 return ( ··· 220 234 accessibilityRole="link" 221 235 href={href} 222 236 onPress={download ? undefined : onPress} 237 + onLongPress={onLongPress} 223 238 {...web({ 224 239 hrefAttrs: { 225 240 target: download ? undefined : isExternal ? 'blank' : undefined, ··· 241 256 TextStyleProp & 242 257 Pick<TextProps, 'selectable' | 'numberOfLines'> 243 258 > & 244 - Pick<ButtonProps, 'label'> & { 259 + Pick<ButtonProps, 'label' | 'accessibilityHint'> & { 245 260 disableUnderline?: boolean 246 261 title?: TextProps['title'] 247 262 } ··· 253 268 disableMismatchWarning, 254 269 style, 255 270 onPress: outerOnPress, 271 + onLongPress: outerOnLongPress, 256 272 download, 257 273 selectable, 258 274 label, ··· 268 284 action, 269 285 disableMismatchWarning, 270 286 onPress: outerOnPress, 287 + onLongPress: outerOnLongPress, 271 288 shareOnLongPress, 272 289 }) 273 290 const { ··· 319 336 ) 320 337 } 321 338 339 + export function WebOnlyInlineLinkText({ 340 + children, 341 + to, 342 + onPress, 343 + ...props 344 + }: Omit<InlineLinkProps, 'onLongPress'>) { 345 + return isWeb ? ( 346 + <InlineLinkText {...props} to={to} onPress={onPress}> 347 + {children} 348 + </InlineLinkText> 349 + ) : ( 350 + <Text {...props}>{children}</Text> 351 + ) 352 + } 353 + 322 354 /** 323 355 * Utility to create a static `onPress` handler for a `Link` that would otherwise link to a URI 324 356 * ··· 327 359 */ 328 360 export function createStaticClick( 329 361 onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>, 330 - ): Pick<BaseLinkProps, 'to' | 'onPress'> { 362 + ): { 363 + to: BaseLinkProps['to'] 364 + onPress: Exclude<BaseLinkProps['onPress'], undefined> 365 + } { 331 366 return { 332 367 to: '#', 333 368 onPress(e: GestureResponderEvent) { ··· 338 373 } 339 374 } 340 375 341 - export function WebOnlyInlineLinkText({ 342 - children, 343 - to, 344 - onPress, 345 - ...props 346 - }: InlineLinkProps) { 347 - return isWeb ? ( 348 - <InlineLinkText {...props} to={to} onPress={onPress}> 349 - {children} 350 - </InlineLinkText> 351 - ) : ( 352 - <Text {...props}>{children}</Text> 376 + /** 377 + * Utility to create a static `onPress` handler for a `Link`, but only if the 378 + * click was not modified in some way e.g. `Cmd` or a middle click. 379 + * 380 + * On native, this behaves the same as `createStaticClick` because there are no 381 + * options to "modify" the click in this sense. 382 + * 383 + * Example: 384 + * `<Link {...createStaticClick(e => {...})} />` 385 + */ 386 + export function createStaticClickIfUnmodified( 387 + onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>, 388 + ): {onPress: Exclude<BaseLinkProps['onPress'], undefined>} { 389 + return { 390 + onPress(e: GestureResponderEvent) { 391 + if (!isWeb || !isModifiedClickEvent(e)) { 392 + e.preventDefault() 393 + onPressHandler(e) 394 + return false 395 + } 396 + }, 397 + } 398 + } 399 + 400 + /** 401 + * Determines if the click event has a meta key pressed, indicating the user 402 + * intends to deviate from default behavior. 403 + */ 404 + export function isClickEventWithMetaKey(e: GestureResponderEvent) { 405 + if (!isWeb) return false 406 + const event = e as unknown as MouseEvent 407 + return event.metaKey || event.altKey || event.ctrlKey || event.shiftKey 408 + } 409 + 410 + /** 411 + * Determines if the web click target is anything other than `_self` 412 + */ 413 + export function isClickTargetExternal(e: GestureResponderEvent) { 414 + if (!isWeb) return false 415 + const event = e as unknown as MouseEvent 416 + const el = event.currentTarget as HTMLAnchorElement 417 + return el && el.target && el.target !== '_self' 418 + } 419 + 420 + /** 421 + * Determines if a click event has been modified in some way from its default 422 + * behavior, e.g. `Cmd` or a middle click. 423 + * {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button} 424 + */ 425 + export function isModifiedClickEvent(e: GestureResponderEvent): boolean { 426 + if (!isWeb) return false 427 + const event = e as unknown as MouseEvent 428 + const isPrimaryButton = event.button === 0 429 + return ( 430 + isClickEventWithMetaKey(e) || isClickTargetExternal(e) || !isPrimaryButton 353 431 ) 354 432 } 433 + 434 + /** 435 + * Determines if a click event has been modified in a way that should indiciate 436 + * that the user intends to open a new tab. 437 + * {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button} 438 + */ 439 + export function shouldClickOpenNewTab(e: GestureResponderEvent) { 440 + if (!isWeb) return false 441 + const event = e as unknown as MouseEvent 442 + const isMiddleClick = isWeb && event.button === 1 443 + return isClickEventWithMetaKey(e) || isClickTargetExternal(e) || isMiddleClick 444 + }
+8 -1
src/components/Menu/index.tsx
··· 47 47 return <Context.Provider value={context}>{children}</Context.Provider> 48 48 } 49 49 50 - export function Trigger({children, label, role = 'button'}: TriggerProps) { 50 + export function Trigger({ 51 + children, 52 + label, 53 + role = 'button', 54 + hint, 55 + }: TriggerProps) { 51 56 const context = useMenuContext() 52 57 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 53 58 const { ··· 65 70 pressed, 66 71 }, 67 72 props: { 73 + ref: null, 68 74 onPress: context.control.open, 69 75 onFocus, 70 76 onBlur, 71 77 onPressIn, 72 78 onPressOut, 79 + accessibilityHint: hint, 73 80 accessibilityLabel: label, 74 81 accessibilityRole: role, 75 82 },
+7 -1
src/components/Menu/index.web.tsx
··· 110 110 ) 111 111 RadixTriggerPassThrough.displayName = 'RadixTriggerPassThrough' 112 112 113 - export function Trigger({children, label, role = 'button'}: TriggerProps) { 113 + export function Trigger({ 114 + children, 115 + label, 116 + role = 'button', 117 + hint, 118 + }: TriggerProps) { 114 119 const {control} = useMenuContext() 115 120 const { 116 121 state: hovered, ··· 153 158 onBlur: onBlur, 154 159 onMouseEnter, 155 160 onMouseLeave, 161 + accessibilityHint: hint, 156 162 accessibilityLabel: label, 157 163 accessibilityRole: role, 158 164 },
+5
src/components/Menu/types.ts
··· 19 19 } 20 20 21 21 export type RadixPassThroughTriggerProps = { 22 + ref: React.RefObject<any> 22 23 id: string 23 24 type: 'button' 24 25 disabled: boolean ··· 37 38 export type TriggerProps = { 38 39 children(props: TriggerChildProps): React.ReactNode 39 40 label: string 41 + hint?: string 40 42 role?: AccessibilityRole 41 43 } 42 44 export type TriggerChildProps = ··· 59 61 * object is empty. 60 62 */ 61 63 props: { 64 + ref: null 62 65 onPress: () => void 63 66 onFocus: () => void 64 67 onBlur: () => void 65 68 onPressIn: () => void 66 69 onPressOut: () => void 70 + accessibilityHint?: string 67 71 accessibilityLabel: string 68 72 accessibilityRole: AccessibilityRole 69 73 } ··· 85 89 onBlur: () => void 86 90 onMouseEnter: () => void 87 91 onMouseLeave: () => void 92 + accessibilityHint?: string 88 93 accessibilityLabel: string 89 94 accessibilityRole: AccessibilityRole 90 95 }
+4 -90
src/components/RichText.tsx
··· 1 1 import React from 'react' 2 2 import {TextStyle} from 'react-native' 3 3 import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' 4 - import {msg} from '@lingui/macro' 5 - import {useLingui} from '@lingui/react' 6 - import {useNavigation} from '@react-navigation/native' 7 4 8 - import {NavigationProp} from '#/lib/routes/types' 9 5 import {toShortUrl} from '#/lib/strings/url-helpers' 10 - import {isNative} from '#/platform/detection' 11 - import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf' 6 + import {atoms as a, flatten, TextStyleProp} from '#/alf' 12 7 import {isOnlyEmoji} from '#/alf/typography' 13 - import {useInteractionState} from '#/components/hooks/useInteractionState' 14 8 import {InlineLinkText, LinkProps} from '#/components/Link' 15 9 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 16 - import {TagMenu, useTagMenuControl} from '#/components/TagMenu' 10 + import {RichTextTag} from '#/components/RichTextTag' 17 11 import {Text, TextProps} from '#/components/Typography' 18 12 19 13 const WORD_WRAP = {wordWrap: 1} ··· 149 143 els.push( 150 144 <RichTextTag 151 145 key={key} 152 - text={segment.text} 146 + display={segment.text} 153 147 tag={tag.tag} 154 - style={interactiveStyles} 155 - selectable={selectable} 148 + textStyle={interactiveStyles} 156 149 authorHandle={authorHandle} 157 150 />, 158 151 ) ··· 177 170 </Text> 178 171 ) 179 172 } 180 - 181 - function RichTextTag({ 182 - text, 183 - tag, 184 - style, 185 - selectable, 186 - authorHandle, 187 - }: { 188 - text: string 189 - tag: string 190 - selectable?: boolean 191 - authorHandle?: string 192 - } & TextStyleProp) { 193 - const t = useTheme() 194 - const {_} = useLingui() 195 - const control = useTagMenuControl() 196 - const { 197 - state: hovered, 198 - onIn: onHoverIn, 199 - onOut: onHoverOut, 200 - } = useInteractionState() 201 - const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 202 - const navigation = useNavigation<NavigationProp>() 203 - 204 - const navigateToPage = React.useCallback(() => { 205 - navigation.push('Hashtag', { 206 - tag: encodeURIComponent(tag), 207 - }) 208 - }, [navigation, tag]) 209 - 210 - const openDialog = React.useCallback(() => { 211 - control.open() 212 - }, [control]) 213 - 214 - /* 215 - * N.B. On web, this is wrapped in another pressable comopnent with a11y 216 - * labels, etc. That's why only some of these props are applied here. 217 - */ 218 - 219 - return ( 220 - <React.Fragment> 221 - <TagMenu control={control} tag={tag} authorHandle={authorHandle}> 222 - <Text 223 - emoji 224 - selectable={selectable} 225 - {...native({ 226 - accessibilityLabel: _(msg`Hashtag: #${tag}`), 227 - accessibilityHint: _(msg`Long press to open tag menu for #${tag}`), 228 - accessibilityRole: isNative ? 'button' : undefined, 229 - onPress: navigateToPage, 230 - onLongPress: openDialog, 231 - })} 232 - {...web({ 233 - onMouseEnter: onHoverIn, 234 - onMouseLeave: onHoverOut, 235 - })} 236 - // @ts-ignore 237 - onFocus={onFocus} 238 - onBlur={onBlur} 239 - style={[ 240 - web({ 241 - cursor: 'pointer', 242 - }), 243 - {color: t.palette.primary_500}, 244 - (hovered || focused) && { 245 - ...web({ 246 - outline: 0, 247 - textDecorationLine: 'underline', 248 - textDecorationColor: t.palette.primary_500, 249 - }), 250 - }, 251 - style, 252 - ]}> 253 - {text} 254 - </Text> 255 - </TagMenu> 256 - </React.Fragment> 257 - ) 258 - }
+160
src/components/RichTextTag.tsx
··· 1 + import React from 'react' 2 + import {StyleProp, Text as RNText, TextStyle} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {useNavigation} from '@react-navigation/native' 6 + 7 + import {NavigationProp} from '#/lib/routes/types' 8 + import {isInvalidHandle} from '#/lib/strings/handles' 9 + import {isNative, isWeb} from '#/platform/detection' 10 + import { 11 + usePreferencesQuery, 12 + useRemoveMutedWordsMutation, 13 + useUpsertMutedWordsMutation, 14 + } from '#/state/queries/preferences' 15 + import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 16 + import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 17 + import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' 18 + import { 19 + createStaticClick, 20 + createStaticClickIfUnmodified, 21 + InlineLinkText, 22 + } from '#/components/Link' 23 + import {Loader} from '#/components/Loader' 24 + import * as Menu from '#/components/Menu' 25 + 26 + export function RichTextTag({ 27 + tag, 28 + display, 29 + authorHandle, 30 + textStyle, 31 + }: { 32 + tag: string 33 + display: string 34 + authorHandle?: string 35 + textStyle: StyleProp<TextStyle> 36 + }) { 37 + const {_} = useLingui() 38 + const {isLoading: isPreferencesLoading, data: preferences} = 39 + usePreferencesQuery() 40 + const { 41 + mutateAsync: upsertMutedWord, 42 + variables: optimisticUpsert, 43 + reset: resetUpsert, 44 + } = useUpsertMutedWordsMutation() 45 + const { 46 + mutateAsync: removeMutedWords, 47 + variables: optimisticRemove, 48 + reset: resetRemove, 49 + } = useRemoveMutedWordsMutation() 50 + const navigation = useNavigation<NavigationProp>() 51 + const label = _(msg`Hashtag ${tag}`) 52 + const hint = isNative 53 + ? _(msg`Long press to open tag menu for #${tag}`) 54 + : _(msg`Click to open tag menu for ${tag}`) 55 + 56 + const isMuted = Boolean( 57 + (preferences?.moderationPrefs.mutedWords?.find( 58 + m => m.value === tag && m.targets.includes('tag'), 59 + ) ?? 60 + optimisticUpsert?.find( 61 + m => m.value === tag && m.targets.includes('tag'), 62 + )) && 63 + !optimisticRemove?.find(m => m?.value === tag), 64 + ) 65 + 66 + /* 67 + * Mute word records that exactly match the tag in question. 68 + */ 69 + const removeableMuteWords = React.useMemo(() => { 70 + return ( 71 + preferences?.moderationPrefs.mutedWords?.filter(word => { 72 + return word.value === tag 73 + }) || [] 74 + ) 75 + }, [tag, preferences?.moderationPrefs?.mutedWords]) 76 + 77 + return ( 78 + <Menu.Root> 79 + <Menu.Trigger label={label} hint={hint}> 80 + {({props: menuProps}) => ( 81 + <InlineLinkText 82 + to={{ 83 + screen: 'Hashtag', 84 + params: {tag: encodeURIComponent(tag)}, 85 + }} 86 + {...menuProps} 87 + onPress={e => { 88 + if (isWeb) { 89 + return createStaticClickIfUnmodified(() => { 90 + if (!isNative) { 91 + menuProps.onPress() 92 + } 93 + }).onPress(e) 94 + } 95 + }} 96 + onLongPress={createStaticClick(menuProps.onPress).onPress} 97 + accessibilityHint={hint} 98 + label={label} 99 + style={textStyle}> 100 + {isNative ? ( 101 + display 102 + ) : ( 103 + <RNText ref={menuProps.ref}>{display}</RNText> 104 + )} 105 + </InlineLinkText> 106 + )} 107 + </Menu.Trigger> 108 + <Menu.Outer> 109 + <Menu.Group> 110 + <Menu.Item 111 + label={_(msg`See ${tag} posts`)} 112 + onPress={() => { 113 + navigation.push('Hashtag', { 114 + tag: encodeURIComponent(tag), 115 + }) 116 + }}> 117 + <Menu.ItemText> 118 + <Trans>See #{tag} posts</Trans> 119 + </Menu.ItemText> 120 + <Menu.ItemIcon icon={Search} /> 121 + </Menu.Item> 122 + {authorHandle && !isInvalidHandle(authorHandle) && ( 123 + <Menu.Item 124 + label={_(msg`See ${tag} posts by user`)} 125 + onPress={() => { 126 + navigation.push('Hashtag', { 127 + tag: encodeURIComponent(tag), 128 + author: authorHandle, 129 + }) 130 + }}> 131 + <Menu.ItemText> 132 + <Trans>See #{tag} posts by user</Trans> 133 + </Menu.ItemText> 134 + <Menu.ItemIcon icon={Person} /> 135 + </Menu.Item> 136 + )} 137 + </Menu.Group> 138 + <Menu.Divider /> 139 + <Menu.Item 140 + label={isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`)} 141 + onPress={() => { 142 + if (isMuted) { 143 + resetUpsert() 144 + removeMutedWords(removeableMuteWords) 145 + } else { 146 + resetRemove() 147 + upsertMutedWord([ 148 + {value: tag, targets: ['tag'], actorTarget: 'all'}, 149 + ]) 150 + } 151 + }}> 152 + <Menu.ItemText> 153 + {isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`)} 154 + </Menu.ItemText> 155 + <Menu.ItemIcon icon={isPreferencesLoading ? Loader : Mute} /> 156 + </Menu.Item> 157 + </Menu.Outer> 158 + </Menu.Root> 159 + ) 160 + }
-290
src/components/TagMenu/index.tsx
··· 1 - import React from 'react' 2 - import {View} from 'react-native' 3 - import {msg, Trans} from '@lingui/macro' 4 - import {useLingui} from '@lingui/react' 5 - import {useNavigation} from '@react-navigation/native' 6 - 7 - import {NavigationProp} from '#/lib/routes/types' 8 - import {isInvalidHandle} from '#/lib/strings/handles' 9 - import { 10 - usePreferencesQuery, 11 - useRemoveMutedWordsMutation, 12 - useUpsertMutedWordsMutation, 13 - } from '#/state/queries/preferences' 14 - import {atoms as a, native, useTheme} from '#/alf' 15 - import {Button, ButtonText} from '#/components/Button' 16 - import * as Dialog from '#/components/Dialog' 17 - import {Divider} from '#/components/Divider' 18 - import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 19 - import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 20 - import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' 21 - import {createStaticClick, Link} from '#/components/Link' 22 - import {Loader} from '#/components/Loader' 23 - import {Text} from '#/components/Typography' 24 - 25 - export function useTagMenuControl() { 26 - return Dialog.useDialogControl() 27 - } 28 - 29 - export function TagMenu({ 30 - children, 31 - control, 32 - tag, 33 - authorHandle, 34 - }: React.PropsWithChildren<{ 35 - control: Dialog.DialogOuterProps['control'] 36 - /** 37 - * This should be the sanitized tag value from the facet itself, not the 38 - * "display" value with a leading `#`. 39 - */ 40 - tag: string 41 - authorHandle?: string 42 - }>) { 43 - const navigation = useNavigation<NavigationProp>() 44 - return ( 45 - <> 46 - {children} 47 - <Dialog.Outer control={control}> 48 - <Dialog.Handle /> 49 - <TagMenuInner 50 - control={control} 51 - tag={tag} 52 - authorHandle={authorHandle} 53 - navigation={navigation} 54 - /> 55 - </Dialog.Outer> 56 - </> 57 - ) 58 - } 59 - 60 - function TagMenuInner({ 61 - control, 62 - tag, 63 - authorHandle, 64 - navigation, 65 - }: { 66 - control: Dialog.DialogOuterProps['control'] 67 - tag: string 68 - authorHandle?: string 69 - // Passed down because on native, we don't use real portals (and context would be wrong). 70 - navigation: NavigationProp 71 - }) { 72 - const {_} = useLingui() 73 - const t = useTheme() 74 - const {isLoading: isPreferencesLoading, data: preferences} = 75 - usePreferencesQuery() 76 - const { 77 - mutateAsync: upsertMutedWord, 78 - variables: optimisticUpsert, 79 - reset: resetUpsert, 80 - } = useUpsertMutedWordsMutation() 81 - const { 82 - mutateAsync: removeMutedWords, 83 - variables: optimisticRemove, 84 - reset: resetRemove, 85 - } = useRemoveMutedWordsMutation() 86 - const displayTag = '#' + tag 87 - 88 - const isMuted = Boolean( 89 - (preferences?.moderationPrefs.mutedWords?.find( 90 - m => m.value === tag && m.targets.includes('tag'), 91 - ) ?? 92 - optimisticUpsert?.find( 93 - m => m.value === tag && m.targets.includes('tag'), 94 - )) && 95 - !optimisticRemove?.find(m => m?.value === tag), 96 - ) 97 - 98 - /* 99 - * Mute word records that exactly match the tag in question. 100 - */ 101 - const removeableMuteWords = React.useMemo(() => { 102 - return ( 103 - preferences?.moderationPrefs.mutedWords?.filter(word => { 104 - return word.value === tag 105 - }) || [] 106 - ) 107 - }, [tag, preferences?.moderationPrefs?.mutedWords]) 108 - 109 - return ( 110 - <Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}> 111 - {isPreferencesLoading ? ( 112 - <View style={[a.w_full, a.align_center]}> 113 - <Loader size="lg" /> 114 - </View> 115 - ) : ( 116 - <> 117 - <View 118 - style={[ 119 - a.rounded_md, 120 - a.border, 121 - a.mb_md, 122 - t.atoms.border_contrast_low, 123 - t.atoms.bg_contrast_25, 124 - ]}> 125 - <Link 126 - label={_(msg`View all posts with tag ${displayTag}`)} 127 - {...createStaticClick(() => { 128 - control.close(() => { 129 - navigation.push('Hashtag', { 130 - tag: encodeURIComponent(tag), 131 - }) 132 - }) 133 - })}> 134 - <View 135 - style={[ 136 - a.w_full, 137 - a.flex_row, 138 - a.align_center, 139 - a.justify_start, 140 - a.gap_md, 141 - a.px_lg, 142 - a.py_md, 143 - ]}> 144 - <Search size="lg" style={[t.atoms.text_contrast_medium]} /> 145 - <Text 146 - numberOfLines={1} 147 - ellipsizeMode="middle" 148 - style={[ 149 - a.flex_1, 150 - a.text_md, 151 - a.font_bold, 152 - native({top: 2}), 153 - t.atoms.text_contrast_medium, 154 - ]}> 155 - <Trans> 156 - See{' '} 157 - <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 158 - {displayTag} 159 - </Text>{' '} 160 - posts 161 - </Trans> 162 - </Text> 163 - </View> 164 - </Link> 165 - 166 - {authorHandle && !isInvalidHandle(authorHandle) && ( 167 - <> 168 - <Divider /> 169 - 170 - <Link 171 - label={_( 172 - msg`View all posts by @${authorHandle} with tag ${displayTag}`, 173 - )} 174 - {...createStaticClick(() => { 175 - control.close(() => { 176 - navigation.push('Hashtag', { 177 - tag: encodeURIComponent(tag), 178 - author: authorHandle, 179 - }) 180 - }) 181 - })}> 182 - <View 183 - style={[ 184 - a.w_full, 185 - a.flex_row, 186 - a.align_center, 187 - a.justify_start, 188 - a.gap_md, 189 - a.px_lg, 190 - a.py_md, 191 - ]}> 192 - <Person size="lg" style={[t.atoms.text_contrast_medium]} /> 193 - <Text 194 - numberOfLines={1} 195 - ellipsizeMode="middle" 196 - style={[ 197 - a.flex_1, 198 - a.text_md, 199 - a.font_bold, 200 - native({top: 2}), 201 - t.atoms.text_contrast_medium, 202 - ]}> 203 - <Trans> 204 - See{' '} 205 - <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 206 - {displayTag} 207 - </Text>{' '} 208 - posts by this user 209 - </Trans> 210 - </Text> 211 - </View> 212 - </Link> 213 - </> 214 - )} 215 - 216 - {preferences ? ( 217 - <> 218 - <Divider /> 219 - 220 - <Button 221 - label={ 222 - isMuted 223 - ? _(msg`Unmute all ${displayTag} posts`) 224 - : _(msg`Mute all ${displayTag} posts`) 225 - } 226 - onPress={() => { 227 - control.close(() => { 228 - if (isMuted) { 229 - resetUpsert() 230 - removeMutedWords(removeableMuteWords) 231 - } else { 232 - resetRemove() 233 - upsertMutedWord([ 234 - { 235 - value: tag, 236 - targets: ['tag'], 237 - actorTarget: 'all', 238 - }, 239 - ]) 240 - } 241 - }) 242 - }}> 243 - <View 244 - style={[ 245 - a.w_full, 246 - a.flex_row, 247 - a.align_center, 248 - a.justify_start, 249 - a.gap_md, 250 - a.px_lg, 251 - a.py_md, 252 - ]}> 253 - <Mute size="lg" style={[t.atoms.text_contrast_medium]} /> 254 - <Text 255 - numberOfLines={1} 256 - ellipsizeMode="middle" 257 - style={[ 258 - a.flex_1, 259 - a.text_md, 260 - a.font_bold, 261 - native({top: 2}), 262 - t.atoms.text_contrast_medium, 263 - ]}> 264 - {isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '} 265 - <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 266 - {displayTag} 267 - </Text>{' '} 268 - <Trans>posts</Trans> 269 - </Text> 270 - </View> 271 - </Button> 272 - </> 273 - ) : null} 274 - </View> 275 - 276 - <Button 277 - label={_(msg`Close this dialog`)} 278 - size="small" 279 - variant="ghost" 280 - color="secondary" 281 - onPress={() => control.close()}> 282 - <ButtonText> 283 - <Trans>Cancel</Trans> 284 - </ButtonText> 285 - </Button> 286 - </> 287 - )} 288 - </Dialog.Inner> 289 - ) 290 - }
-163
src/components/TagMenu/index.web.tsx
··· 1 - import React from 'react' 2 - import {msg} from '@lingui/macro' 3 - import {useLingui} from '@lingui/react' 4 - import {useNavigation} from '@react-navigation/native' 5 - 6 - import {NavigationProp} from '#/lib/routes/types' 7 - import {isInvalidHandle} from '#/lib/strings/handles' 8 - import {enforceLen} from '#/lib/strings/helpers' 9 - import { 10 - usePreferencesQuery, 11 - useRemoveMutedWordsMutation, 12 - useUpsertMutedWordsMutation, 13 - } from '#/state/queries/preferences' 14 - import {EventStopper} from '#/view/com/util/EventStopper' 15 - import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown' 16 - import {web} from '#/alf' 17 - import * as Dialog from '#/components/Dialog' 18 - 19 - export function useTagMenuControl(): Dialog.DialogControlProps { 20 - return { 21 - id: '', 22 - // @ts-ignore 23 - ref: null, 24 - open: () => { 25 - throw new Error(`TagMenu controls are only available on native platforms`) 26 - }, 27 - close: () => { 28 - throw new Error(`TagMenu controls are only available on native platforms`) 29 - }, 30 - } 31 - } 32 - 33 - export function TagMenu({ 34 - children, 35 - tag, 36 - authorHandle, 37 - }: React.PropsWithChildren<{ 38 - /** 39 - * This should be the sanitized tag value from the facet itself, not the 40 - * "display" value with a leading `#`. 41 - */ 42 - tag: string 43 - authorHandle?: string 44 - }>) { 45 - const {_} = useLingui() 46 - const navigation = useNavigation<NavigationProp>() 47 - const {data: preferences} = usePreferencesQuery() 48 - const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} = 49 - useUpsertMutedWordsMutation() 50 - const {mutateAsync: removeMutedWords, variables: optimisticRemove} = 51 - useRemoveMutedWordsMutation() 52 - const isMuted = Boolean( 53 - (preferences?.moderationPrefs.mutedWords?.find( 54 - m => m.value === tag && m.targets.includes('tag'), 55 - ) ?? 56 - optimisticUpsert?.find( 57 - m => m.value === tag && m.targets.includes('tag'), 58 - )) && 59 - !optimisticRemove?.find(m => m?.value === tag), 60 - ) 61 - const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle') 62 - 63 - /* 64 - * Mute word records that exactly match the tag in question. 65 - */ 66 - const removeableMuteWords = React.useMemo(() => { 67 - return ( 68 - preferences?.moderationPrefs.mutedWords?.filter(word => { 69 - return word.value === tag 70 - }) || [] 71 - ) 72 - }, [tag, preferences?.moderationPrefs?.mutedWords]) 73 - 74 - const dropdownItems = React.useMemo(() => { 75 - return [ 76 - { 77 - label: _(msg`See ${truncatedTag} posts`), 78 - onPress() { 79 - navigation.push('Hashtag', { 80 - tag: encodeURIComponent(tag), 81 - }) 82 - }, 83 - testID: 'tagMenuSearch', 84 - icon: { 85 - ios: { 86 - name: 'magnifyingglass', 87 - }, 88 - android: '', 89 - web: 'magnifying-glass', 90 - }, 91 - }, 92 - authorHandle && 93 - !isInvalidHandle(authorHandle) && { 94 - label: _(msg`See ${truncatedTag} posts by user`), 95 - onPress() { 96 - navigation.push('Hashtag', { 97 - tag: encodeURIComponent(tag), 98 - author: authorHandle, 99 - }) 100 - }, 101 - testID: 'tagMenuSearchByUser', 102 - icon: { 103 - ios: { 104 - name: 'magnifyingglass', 105 - }, 106 - android: '', 107 - web: ['far', 'user'], 108 - }, 109 - }, 110 - preferences && { 111 - label: 'separator', 112 - }, 113 - preferences && { 114 - label: isMuted 115 - ? _(msg`Unmute ${truncatedTag}`) 116 - : _(msg`Mute ${truncatedTag}`), 117 - onPress() { 118 - if (isMuted) { 119 - removeMutedWords(removeableMuteWords) 120 - } else { 121 - upsertMutedWord([ 122 - {value: tag, targets: ['tag'], actorTarget: 'all'}, 123 - ]) 124 - } 125 - }, 126 - testID: 'tagMenuMute', 127 - icon: { 128 - ios: { 129 - name: 'speaker.slash', 130 - }, 131 - android: 'ic_menu_sort_alphabetically', 132 - web: isMuted ? 'eye' : ['far', 'eye-slash'], 133 - }, 134 - }, 135 - ].filter(Boolean) 136 - }, [ 137 - _, 138 - authorHandle, 139 - isMuted, 140 - navigation, 141 - preferences, 142 - tag, 143 - truncatedTag, 144 - upsertMutedWord, 145 - removeMutedWords, 146 - removeableMuteWords, 147 - ]) 148 - 149 - return ( 150 - <EventStopper> 151 - <NativeDropdown 152 - accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)} 153 - accessibilityHint="" 154 - // @ts-ignore 155 - items={dropdownItems} 156 - triggerStyle={web({ 157 - textAlign: 'left', 158 - })}> 159 - {children} 160 - </NativeDropdown> 161 - </EventStopper> 162 - ) 163 - }
+1 -13
src/platform/urls.tsx
··· 1 - import {GestureResponderEvent, Linking} from 'react-native' 1 + import {Linking} from 'react-native' 2 2 3 3 import {isNative, isWeb} from './detection' 4 4 ··· 24 24 window.location.hash = '' 25 25 } 26 26 } 27 - 28 - export function shouldClickOpenNewTab(e: GestureResponderEvent) { 29 - /** 30 - * A `GestureResponderEvent`, but cast to `any` to avoid using a bunch 31 - * of @ts-ignore below. 32 - */ 33 - const event = e as any 34 - const isMiddleClick = isWeb && event.button === 1 35 - const isMetaKey = 36 - isWeb && (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) 37 - return isMetaKey || isMiddleClick 38 - }
+1 -1
src/view/com/feeds/FeedSourceCard.tsx
··· 17 17 import {sanitizeHandle} from '#/lib/strings/handles' 18 18 import {s} from '#/lib/styles' 19 19 import {logger} from '#/logger' 20 - import {shouldClickOpenNewTab} from '#/platform/urls' 21 20 import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' 22 21 import { 23 22 useAddSavedFeedsMutation, ··· 29 28 import * as Toast from '#/view/com/util/Toast' 30 29 import {useTheme} from '#/alf' 31 30 import {atoms as a} from '#/alf' 31 + import {shouldClickOpenNewTab} from '#/components/Link' 32 32 import * as Prompt from '#/components/Prompt' 33 33 import {RichText} from '#/components/RichText' 34 34 import {Text} from '../util/text/Text'