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
This commit is contained in:
+108
-18
@@ -15,7 +15,6 @@ import {
|
||||
linkRequiresWarning,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {shouldClickOpenNewTab} from '#/platform/urls'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonProps} from '#/components/Button'
|
||||
@@ -55,6 +54,12 @@ type BaseLinkProps = Pick<
|
||||
*/
|
||||
onPress?: (e: GestureResponderEvent) => void | false
|
||||
|
||||
/**
|
||||
* Callback for when the link is long pressed (on native). Prevent default
|
||||
* and return `false` to exit early and prevent default long press hander.
|
||||
*/
|
||||
onLongPress?: (e: GestureResponderEvent) => void | false
|
||||
|
||||
/**
|
||||
* Web-only attribute. Sets `download` attr on web.
|
||||
*/
|
||||
@@ -72,6 +77,7 @@ export function useLink({
|
||||
action = 'push',
|
||||
disableMismatchWarning,
|
||||
onPress: outerOnPress,
|
||||
onLongPress: outerOnLongPress,
|
||||
shareOnLongPress,
|
||||
}: BaseLinkProps & {
|
||||
displayText: string
|
||||
@@ -175,8 +181,14 @@ export function useLink({
|
||||
}
|
||||
}, [disableMismatchWarning, displayText, href, isExternal, openModal])
|
||||
|
||||
const onLongPress =
|
||||
isNative && isExternal && shareOnLongPress ? handleLongPress : undefined
|
||||
const onLongPress = React.useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
const exitEarlyIfFalse = outerOnLongPress?.(e)
|
||||
if (exitEarlyIfFalse === false) return
|
||||
return isNative && shareOnLongPress ? handleLongPress() : undefined
|
||||
},
|
||||
[outerOnLongPress, handleLongPress, shareOnLongPress],
|
||||
)
|
||||
|
||||
return {
|
||||
isExternal,
|
||||
@@ -202,14 +214,16 @@ export function Link({
|
||||
to,
|
||||
action = 'push',
|
||||
onPress: outerOnPress,
|
||||
onLongPress: outerOnLongPress,
|
||||
download,
|
||||
...rest
|
||||
}: LinkProps) {
|
||||
const {href, isExternal, onPress} = useLink({
|
||||
const {href, isExternal, onPress, onLongPress} = useLink({
|
||||
to,
|
||||
displayText: typeof children === 'string' ? children : '',
|
||||
action,
|
||||
onPress: outerOnPress,
|
||||
onLongPress: outerOnLongPress,
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -220,6 +234,7 @@ export function Link({
|
||||
accessibilityRole="link"
|
||||
href={href}
|
||||
onPress={download ? undefined : onPress}
|
||||
onLongPress={onLongPress}
|
||||
{...web({
|
||||
hrefAttrs: {
|
||||
target: download ? undefined : isExternal ? 'blank' : undefined,
|
||||
@@ -241,7 +256,7 @@ export type InlineLinkProps = React.PropsWithChildren<
|
||||
TextStyleProp &
|
||||
Pick<TextProps, 'selectable' | 'numberOfLines'>
|
||||
> &
|
||||
Pick<ButtonProps, 'label'> & {
|
||||
Pick<ButtonProps, 'label' | 'accessibilityHint'> & {
|
||||
disableUnderline?: boolean
|
||||
title?: TextProps['title']
|
||||
}
|
||||
@@ -253,6 +268,7 @@ export function InlineLinkText({
|
||||
disableMismatchWarning,
|
||||
style,
|
||||
onPress: outerOnPress,
|
||||
onLongPress: outerOnLongPress,
|
||||
download,
|
||||
selectable,
|
||||
label,
|
||||
@@ -268,6 +284,7 @@ export function InlineLinkText({
|
||||
action,
|
||||
disableMismatchWarning,
|
||||
onPress: outerOnPress,
|
||||
onLongPress: outerOnLongPress,
|
||||
shareOnLongPress,
|
||||
})
|
||||
const {
|
||||
@@ -319,6 +336,21 @@ export function InlineLinkText({
|
||||
)
|
||||
}
|
||||
|
||||
export function WebOnlyInlineLinkText({
|
||||
children,
|
||||
to,
|
||||
onPress,
|
||||
...props
|
||||
}: Omit<InlineLinkProps, 'onLongPress'>) {
|
||||
return isWeb ? (
|
||||
<InlineLinkText {...props} to={to} onPress={onPress}>
|
||||
{children}
|
||||
</InlineLinkText>
|
||||
) : (
|
||||
<Text {...props}>{children}</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to create a static `onPress` handler for a `Link` that would otherwise link to a URI
|
||||
*
|
||||
@@ -327,7 +359,10 @@ export function InlineLinkText({
|
||||
*/
|
||||
export function createStaticClick(
|
||||
onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
|
||||
): Pick<BaseLinkProps, 'to' | 'onPress'> {
|
||||
): {
|
||||
to: BaseLinkProps['to']
|
||||
onPress: Exclude<BaseLinkProps['onPress'], undefined>
|
||||
} {
|
||||
return {
|
||||
to: '#',
|
||||
onPress(e: GestureResponderEvent) {
|
||||
@@ -338,17 +373,72 @@ export function createStaticClick(
|
||||
}
|
||||
}
|
||||
|
||||
export function WebOnlyInlineLinkText({
|
||||
children,
|
||||
to,
|
||||
onPress,
|
||||
...props
|
||||
}: InlineLinkProps) {
|
||||
return isWeb ? (
|
||||
<InlineLinkText {...props} to={to} onPress={onPress}>
|
||||
{children}
|
||||
</InlineLinkText>
|
||||
) : (
|
||||
<Text {...props}>{children}</Text>
|
||||
/**
|
||||
* Utility to create a static `onPress` handler for a `Link`, but only if the
|
||||
* click was not modified in some way e.g. `Cmd` or a middle click.
|
||||
*
|
||||
* On native, this behaves the same as `createStaticClick` because there are no
|
||||
* options to "modify" the click in this sense.
|
||||
*
|
||||
* Example:
|
||||
* `<Link {...createStaticClick(e => {...})} />`
|
||||
*/
|
||||
export function createStaticClickIfUnmodified(
|
||||
onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
|
||||
): {onPress: Exclude<BaseLinkProps['onPress'], undefined>} {
|
||||
return {
|
||||
onPress(e: GestureResponderEvent) {
|
||||
if (!isWeb || !isModifiedClickEvent(e)) {
|
||||
e.preventDefault()
|
||||
onPressHandler(e)
|
||||
return false
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the click event has a meta key pressed, indicating the user
|
||||
* intends to deviate from default behavior.
|
||||
*/
|
||||
export function isClickEventWithMetaKey(e: GestureResponderEvent) {
|
||||
if (!isWeb) return false
|
||||
const event = e as unknown as MouseEvent
|
||||
return event.metaKey || event.altKey || event.ctrlKey || event.shiftKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the web click target is anything other than `_self`
|
||||
*/
|
||||
export function isClickTargetExternal(e: GestureResponderEvent) {
|
||||
if (!isWeb) return false
|
||||
const event = e as unknown as MouseEvent
|
||||
const el = event.currentTarget as HTMLAnchorElement
|
||||
return el && el.target && el.target !== '_self'
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a click event has been modified in some way from its default
|
||||
* behavior, e.g. `Cmd` or a middle click.
|
||||
* {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button}
|
||||
*/
|
||||
export function isModifiedClickEvent(e: GestureResponderEvent): boolean {
|
||||
if (!isWeb) return false
|
||||
const event = e as unknown as MouseEvent
|
||||
const isPrimaryButton = event.button === 0
|
||||
return (
|
||||
isClickEventWithMetaKey(e) || isClickTargetExternal(e) || !isPrimaryButton
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a click event has been modified in a way that should indiciate
|
||||
* that the user intends to open a new tab.
|
||||
* {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button}
|
||||
*/
|
||||
export function shouldClickOpenNewTab(e: GestureResponderEvent) {
|
||||
if (!isWeb) return false
|
||||
const event = e as unknown as MouseEvent
|
||||
const isMiddleClick = isWeb && event.button === 1
|
||||
return isClickEventWithMetaKey(e) || isClickTargetExternal(e) || isMiddleClick
|
||||
}
|
||||
|
||||
@@ -47,7 +47,12 @@ export function Root({
|
||||
return <Context.Provider value={context}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function Trigger({children, label, role = 'button'}: TriggerProps) {
|
||||
export function Trigger({
|
||||
children,
|
||||
label,
|
||||
role = 'button',
|
||||
hint,
|
||||
}: TriggerProps) {
|
||||
const context = useMenuContext()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const {
|
||||
@@ -65,11 +70,13 @@ export function Trigger({children, label, role = 'button'}: TriggerProps) {
|
||||
pressed,
|
||||
},
|
||||
props: {
|
||||
ref: null,
|
||||
onPress: context.control.open,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onPressIn,
|
||||
onPressOut,
|
||||
accessibilityHint: hint,
|
||||
accessibilityLabel: label,
|
||||
accessibilityRole: role,
|
||||
},
|
||||
|
||||
@@ -110,7 +110,12 @@ const RadixTriggerPassThrough = React.forwardRef(
|
||||
)
|
||||
RadixTriggerPassThrough.displayName = 'RadixTriggerPassThrough'
|
||||
|
||||
export function Trigger({children, label, role = 'button'}: TriggerProps) {
|
||||
export function Trigger({
|
||||
children,
|
||||
label,
|
||||
role = 'button',
|
||||
hint,
|
||||
}: TriggerProps) {
|
||||
const {control} = useMenuContext()
|
||||
const {
|
||||
state: hovered,
|
||||
@@ -153,6 +158,7 @@ export function Trigger({children, label, role = 'button'}: TriggerProps) {
|
||||
onBlur: onBlur,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
accessibilityHint: hint,
|
||||
accessibilityLabel: label,
|
||||
accessibilityRole: role,
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ export type ItemContextType = {
|
||||
}
|
||||
|
||||
export type RadixPassThroughTriggerProps = {
|
||||
ref: React.RefObject<any>
|
||||
id: string
|
||||
type: 'button'
|
||||
disabled: boolean
|
||||
@@ -37,6 +38,7 @@ export type RadixPassThroughTriggerProps = {
|
||||
export type TriggerProps = {
|
||||
children(props: TriggerChildProps): React.ReactNode
|
||||
label: string
|
||||
hint?: string
|
||||
role?: AccessibilityRole
|
||||
}
|
||||
export type TriggerChildProps =
|
||||
@@ -59,11 +61,13 @@ export type TriggerChildProps =
|
||||
* object is empty.
|
||||
*/
|
||||
props: {
|
||||
ref: null
|
||||
onPress: () => void
|
||||
onFocus: () => void
|
||||
onBlur: () => void
|
||||
onPressIn: () => void
|
||||
onPressOut: () => void
|
||||
accessibilityHint?: string
|
||||
accessibilityLabel: string
|
||||
accessibilityRole: AccessibilityRole
|
||||
}
|
||||
@@ -85,6 +89,7 @@ export type TriggerChildProps =
|
||||
onBlur: () => void
|
||||
onMouseEnter: () => void
|
||||
onMouseLeave: () => void
|
||||
accessibilityHint?: string
|
||||
accessibilityLabel: string
|
||||
accessibilityRole: AccessibilityRole
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import React from 'react'
|
||||
import {TextStyle} from 'react-native'
|
||||
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {toShortUrl} from '#/lib/strings/url-helpers'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf'
|
||||
import {atoms as a, flatten, TextStyleProp} from '#/alf'
|
||||
import {isOnlyEmoji} from '#/alf/typography'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {InlineLinkText, LinkProps} from '#/components/Link'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
|
||||
import {RichTextTag} from '#/components/RichTextTag'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
|
||||
const WORD_WRAP = {wordWrap: 1}
|
||||
@@ -149,10 +143,9 @@ export function RichText({
|
||||
els.push(
|
||||
<RichTextTag
|
||||
key={key}
|
||||
text={segment.text}
|
||||
display={segment.text}
|
||||
tag={tag.tag}
|
||||
style={interactiveStyles}
|
||||
selectable={selectable}
|
||||
textStyle={interactiveStyles}
|
||||
authorHandle={authorHandle}
|
||||
/>,
|
||||
)
|
||||
@@ -177,82 +170,3 @@ export function RichText({
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
function RichTextTag({
|
||||
text,
|
||||
tag,
|
||||
style,
|
||||
selectable,
|
||||
authorHandle,
|
||||
}: {
|
||||
text: string
|
||||
tag: string
|
||||
selectable?: boolean
|
||||
authorHandle?: string
|
||||
} & TextStyleProp) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const control = useTagMenuControl()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const navigateToPage = React.useCallback(() => {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
})
|
||||
}, [navigation, tag])
|
||||
|
||||
const openDialog = React.useCallback(() => {
|
||||
control.open()
|
||||
}, [control])
|
||||
|
||||
/*
|
||||
* N.B. On web, this is wrapped in another pressable comopnent with a11y
|
||||
* labels, etc. That's why only some of these props are applied here.
|
||||
*/
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TagMenu control={control} tag={tag} authorHandle={authorHandle}>
|
||||
<Text
|
||||
emoji
|
||||
selectable={selectable}
|
||||
{...native({
|
||||
accessibilityLabel: _(msg`Hashtag: #${tag}`),
|
||||
accessibilityHint: _(msg`Long press to open tag menu for #${tag}`),
|
||||
accessibilityRole: isNative ? 'button' : undefined,
|
||||
onPress: navigateToPage,
|
||||
onLongPress: openDialog,
|
||||
})}
|
||||
{...web({
|
||||
onMouseEnter: onHoverIn,
|
||||
onMouseLeave: onHoverOut,
|
||||
})}
|
||||
// @ts-ignore
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[
|
||||
web({
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
{color: t.palette.primary_500},
|
||||
(hovered || focused) && {
|
||||
...web({
|
||||
outline: 0,
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: t.palette.primary_500,
|
||||
}),
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
{text}
|
||||
</Text>
|
||||
</TagMenu>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, Text as RNText, TextStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useRemoveMutedWordsMutation,
|
||||
useUpsertMutedWordsMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import {
|
||||
createStaticClick,
|
||||
createStaticClickIfUnmodified,
|
||||
InlineLinkText,
|
||||
} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Menu from '#/components/Menu'
|
||||
|
||||
export function RichTextTag({
|
||||
tag,
|
||||
display,
|
||||
authorHandle,
|
||||
textStyle,
|
||||
}: {
|
||||
tag: string
|
||||
display: string
|
||||
authorHandle?: string
|
||||
textStyle: StyleProp<TextStyle>
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {isLoading: isPreferencesLoading, data: preferences} =
|
||||
usePreferencesQuery()
|
||||
const {
|
||||
mutateAsync: upsertMutedWord,
|
||||
variables: optimisticUpsert,
|
||||
reset: resetUpsert,
|
||||
} = useUpsertMutedWordsMutation()
|
||||
const {
|
||||
mutateAsync: removeMutedWords,
|
||||
variables: optimisticRemove,
|
||||
reset: resetRemove,
|
||||
} = useRemoveMutedWordsMutation()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const label = _(msg`Hashtag ${tag}`)
|
||||
const hint = isNative
|
||||
? _(msg`Long press to open tag menu for #${tag}`)
|
||||
: _(msg`Click to open tag menu for ${tag}`)
|
||||
|
||||
const isMuted = Boolean(
|
||||
(preferences?.moderationPrefs.mutedWords?.find(
|
||||
m => m.value === tag && m.targets.includes('tag'),
|
||||
) ??
|
||||
optimisticUpsert?.find(
|
||||
m => m.value === tag && m.targets.includes('tag'),
|
||||
)) &&
|
||||
!optimisticRemove?.find(m => m?.value === tag),
|
||||
)
|
||||
|
||||
/*
|
||||
* Mute word records that exactly match the tag in question.
|
||||
*/
|
||||
const removeableMuteWords = React.useMemo(() => {
|
||||
return (
|
||||
preferences?.moderationPrefs.mutedWords?.filter(word => {
|
||||
return word.value === tag
|
||||
}) || []
|
||||
)
|
||||
}, [tag, preferences?.moderationPrefs?.mutedWords])
|
||||
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={label} hint={hint}>
|
||||
{({props: menuProps}) => (
|
||||
<InlineLinkText
|
||||
to={{
|
||||
screen: 'Hashtag',
|
||||
params: {tag: encodeURIComponent(tag)},
|
||||
}}
|
||||
{...menuProps}
|
||||
onPress={e => {
|
||||
if (isWeb) {
|
||||
return createStaticClickIfUnmodified(() => {
|
||||
if (!isNative) {
|
||||
menuProps.onPress()
|
||||
}
|
||||
}).onPress(e)
|
||||
}
|
||||
}}
|
||||
onLongPress={createStaticClick(menuProps.onPress).onPress}
|
||||
accessibilityHint={hint}
|
||||
label={label}
|
||||
style={textStyle}>
|
||||
{isNative ? (
|
||||
display
|
||||
) : (
|
||||
<RNText ref={menuProps.ref}>{display}</RNText>
|
||||
)}
|
||||
</InlineLinkText>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`See ${tag} posts`)}
|
||||
onPress={() => {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
})
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>See #{tag} posts</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Search} />
|
||||
</Menu.Item>
|
||||
{authorHandle && !isInvalidHandle(authorHandle) && (
|
||||
<Menu.Item
|
||||
label={_(msg`See ${tag} posts by user`)}
|
||||
onPress={() => {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
author: authorHandle,
|
||||
})
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>See #{tag} posts by user</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Person} />
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
label={isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`)}
|
||||
onPress={() => {
|
||||
if (isMuted) {
|
||||
resetUpsert()
|
||||
removeMutedWords(removeableMuteWords)
|
||||
} else {
|
||||
resetRemove()
|
||||
upsertMutedWord([
|
||||
{value: tag, targets: ['tag'], actorTarget: 'all'},
|
||||
])
|
||||
}
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
{isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={isPreferencesLoading ? Loader : Mute} />
|
||||
</Menu.Item>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useRemoveMutedWordsMutation,
|
||||
useUpsertMutedWordsMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import {createStaticClick, Link} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function useTagMenuControl() {
|
||||
return Dialog.useDialogControl()
|
||||
}
|
||||
|
||||
export function TagMenu({
|
||||
children,
|
||||
control,
|
||||
tag,
|
||||
authorHandle,
|
||||
}: React.PropsWithChildren<{
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
/**
|
||||
* This should be the sanitized tag value from the facet itself, not the
|
||||
* "display" value with a leading `#`.
|
||||
*/
|
||||
tag: string
|
||||
authorHandle?: string
|
||||
}>) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<TagMenuInner
|
||||
control={control}
|
||||
tag={tag}
|
||||
authorHandle={authorHandle}
|
||||
navigation={navigation}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TagMenuInner({
|
||||
control,
|
||||
tag,
|
||||
authorHandle,
|
||||
navigation,
|
||||
}: {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
tag: string
|
||||
authorHandle?: string
|
||||
// Passed down because on native, we don't use real portals (and context would be wrong).
|
||||
navigation: NavigationProp
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {isLoading: isPreferencesLoading, data: preferences} =
|
||||
usePreferencesQuery()
|
||||
const {
|
||||
mutateAsync: upsertMutedWord,
|
||||
variables: optimisticUpsert,
|
||||
reset: resetUpsert,
|
||||
} = useUpsertMutedWordsMutation()
|
||||
const {
|
||||
mutateAsync: removeMutedWords,
|
||||
variables: optimisticRemove,
|
||||
reset: resetRemove,
|
||||
} = useRemoveMutedWordsMutation()
|
||||
const displayTag = '#' + tag
|
||||
|
||||
const isMuted = Boolean(
|
||||
(preferences?.moderationPrefs.mutedWords?.find(
|
||||
m => m.value === tag && m.targets.includes('tag'),
|
||||
) ??
|
||||
optimisticUpsert?.find(
|
||||
m => m.value === tag && m.targets.includes('tag'),
|
||||
)) &&
|
||||
!optimisticRemove?.find(m => m?.value === tag),
|
||||
)
|
||||
|
||||
/*
|
||||
* Mute word records that exactly match the tag in question.
|
||||
*/
|
||||
const removeableMuteWords = React.useMemo(() => {
|
||||
return (
|
||||
preferences?.moderationPrefs.mutedWords?.filter(word => {
|
||||
return word.value === tag
|
||||
}) || []
|
||||
)
|
||||
}, [tag, preferences?.moderationPrefs?.mutedWords])
|
||||
|
||||
return (
|
||||
<Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
|
||||
{isPreferencesLoading ? (
|
||||
<View style={[a.w_full, a.align_center]}>
|
||||
<Loader size="lg" />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
a.mb_md,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Link
|
||||
label={_(msg`View all posts with tag ${displayTag}`)}
|
||||
{...createStaticClick(() => {
|
||||
control.close(() => {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
})
|
||||
})
|
||||
})}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_start,
|
||||
a.gap_md,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
]}>
|
||||
<Search size="lg" style={[t.atoms.text_contrast_medium]} />
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
native({top: 2}),
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
See{' '}
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{displayTag}
|
||||
</Text>{' '}
|
||||
posts
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</Link>
|
||||
|
||||
{authorHandle && !isInvalidHandle(authorHandle) && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
<Link
|
||||
label={_(
|
||||
msg`View all posts by @${authorHandle} with tag ${displayTag}`,
|
||||
)}
|
||||
{...createStaticClick(() => {
|
||||
control.close(() => {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
author: authorHandle,
|
||||
})
|
||||
})
|
||||
})}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_start,
|
||||
a.gap_md,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
]}>
|
||||
<Person size="lg" style={[t.atoms.text_contrast_medium]} />
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
native({top: 2}),
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
See{' '}
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{displayTag}
|
||||
</Text>{' '}
|
||||
posts by this user
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{preferences ? (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
<Button
|
||||
label={
|
||||
isMuted
|
||||
? _(msg`Unmute all ${displayTag} posts`)
|
||||
: _(msg`Mute all ${displayTag} posts`)
|
||||
}
|
||||
onPress={() => {
|
||||
control.close(() => {
|
||||
if (isMuted) {
|
||||
resetUpsert()
|
||||
removeMutedWords(removeableMuteWords)
|
||||
} else {
|
||||
resetRemove()
|
||||
upsertMutedWord([
|
||||
{
|
||||
value: tag,
|
||||
targets: ['tag'],
|
||||
actorTarget: 'all',
|
||||
},
|
||||
])
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_start,
|
||||
a.gap_md,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
]}>
|
||||
<Mute size="lg" style={[t.atoms.text_contrast_medium]} />
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
native({top: 2}),
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{displayTag}
|
||||
</Text>{' '}
|
||||
<Trans>posts</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
label={_(msg`Close this dialog`)}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Inner>
|
||||
)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||
import {enforceLen} from '#/lib/strings/helpers'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useRemoveMutedWordsMutation,
|
||||
useUpsertMutedWordsMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
|
||||
import {web} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
export function useTagMenuControl(): Dialog.DialogControlProps {
|
||||
return {
|
||||
id: '',
|
||||
// @ts-ignore
|
||||
ref: null,
|
||||
open: () => {
|
||||
throw new Error(`TagMenu controls are only available on native platforms`)
|
||||
},
|
||||
close: () => {
|
||||
throw new Error(`TagMenu controls are only available on native platforms`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function TagMenu({
|
||||
children,
|
||||
tag,
|
||||
authorHandle,
|
||||
}: React.PropsWithChildren<{
|
||||
/**
|
||||
* This should be the sanitized tag value from the facet itself, not the
|
||||
* "display" value with a leading `#`.
|
||||
*/
|
||||
tag: string
|
||||
authorHandle?: string
|
||||
}>) {
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
|
||||
useUpsertMutedWordsMutation()
|
||||
const {mutateAsync: removeMutedWords, variables: optimisticRemove} =
|
||||
useRemoveMutedWordsMutation()
|
||||
const isMuted = Boolean(
|
||||
(preferences?.moderationPrefs.mutedWords?.find(
|
||||
m => m.value === tag && m.targets.includes('tag'),
|
||||
) ??
|
||||
optimisticUpsert?.find(
|
||||
m => m.value === tag && m.targets.includes('tag'),
|
||||
)) &&
|
||||
!optimisticRemove?.find(m => m?.value === tag),
|
||||
)
|
||||
const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
|
||||
|
||||
/*
|
||||
* Mute word records that exactly match the tag in question.
|
||||
*/
|
||||
const removeableMuteWords = React.useMemo(() => {
|
||||
return (
|
||||
preferences?.moderationPrefs.mutedWords?.filter(word => {
|
||||
return word.value === tag
|
||||
}) || []
|
||||
)
|
||||
}, [tag, preferences?.moderationPrefs?.mutedWords])
|
||||
|
||||
const dropdownItems = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: _(msg`See ${truncatedTag} posts`),
|
||||
onPress() {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
})
|
||||
},
|
||||
testID: 'tagMenuSearch',
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'magnifyingglass',
|
||||
},
|
||||
android: '',
|
||||
web: 'magnifying-glass',
|
||||
},
|
||||
},
|
||||
authorHandle &&
|
||||
!isInvalidHandle(authorHandle) && {
|
||||
label: _(msg`See ${truncatedTag} posts by user`),
|
||||
onPress() {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
author: authorHandle,
|
||||
})
|
||||
},
|
||||
testID: 'tagMenuSearchByUser',
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'magnifyingglass',
|
||||
},
|
||||
android: '',
|
||||
web: ['far', 'user'],
|
||||
},
|
||||
},
|
||||
preferences && {
|
||||
label: 'separator',
|
||||
},
|
||||
preferences && {
|
||||
label: isMuted
|
||||
? _(msg`Unmute ${truncatedTag}`)
|
||||
: _(msg`Mute ${truncatedTag}`),
|
||||
onPress() {
|
||||
if (isMuted) {
|
||||
removeMutedWords(removeableMuteWords)
|
||||
} else {
|
||||
upsertMutedWord([
|
||||
{value: tag, targets: ['tag'], actorTarget: 'all'},
|
||||
])
|
||||
}
|
||||
},
|
||||
testID: 'tagMenuMute',
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'speaker.slash',
|
||||
},
|
||||
android: 'ic_menu_sort_alphabetically',
|
||||
web: isMuted ? 'eye' : ['far', 'eye-slash'],
|
||||
},
|
||||
},
|
||||
].filter(Boolean)
|
||||
}, [
|
||||
_,
|
||||
authorHandle,
|
||||
isMuted,
|
||||
navigation,
|
||||
preferences,
|
||||
tag,
|
||||
truncatedTag,
|
||||
upsertMutedWord,
|
||||
removeMutedWords,
|
||||
removeableMuteWords,
|
||||
])
|
||||
|
||||
return (
|
||||
<EventStopper>
|
||||
<NativeDropdown
|
||||
accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
|
||||
accessibilityHint=""
|
||||
// @ts-ignore
|
||||
items={dropdownItems}
|
||||
triggerStyle={web({
|
||||
textAlign: 'left',
|
||||
})}>
|
||||
{children}
|
||||
</NativeDropdown>
|
||||
</EventStopper>
|
||||
)
|
||||
}
|
||||
+1
-13
@@ -1,4 +1,4 @@
|
||||
import {GestureResponderEvent, Linking} from 'react-native'
|
||||
import {Linking} from 'react-native'
|
||||
|
||||
import {isNative, isWeb} from './detection'
|
||||
|
||||
@@ -24,15 +24,3 @@ export function clearHash() {
|
||||
window.location.hash = ''
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldClickOpenNewTab(e: GestureResponderEvent) {
|
||||
/**
|
||||
* A `GestureResponderEvent`, but cast to `any` to avoid using a bunch
|
||||
* of @ts-ignore below.
|
||||
*/
|
||||
const event = e as any
|
||||
const isMiddleClick = isWeb && event.button === 1
|
||||
const isMetaKey =
|
||||
isWeb && (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
|
||||
return isMetaKey || isMiddleClick
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {s} from '#/lib/styles'
|
||||
import {logger} from '#/logger'
|
||||
import {shouldClickOpenNewTab} from '#/platform/urls'
|
||||
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
|
||||
import {
|
||||
useAddSavedFeedsMutation,
|
||||
@@ -29,6 +28,7 @@ import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {shouldClickOpenNewTab} from '#/components/Link'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '../util/text/Text'
|
||||
|
||||
Reference in New Issue
Block a user