* Add bare minimum hashtags support (#2804) * Add bare minimum hashtags support As atproto/api already parses hashtags, this is as simple as hooking it up like link segments. This is "bare minimum" because: - Opening hashtag "#foo" is actually just a search for "foo" right now to work around #2491. - There is no integration in the composer. This hasn't stopped people from using hashtags already, and can be added later. - This change itself only had to hook things up - thank you for having already put the hashtag parsing in place. * Remove workaround for hash search not working now that it's fixed * Add RichTextTag and TagMenu * Sketch * Remove hackfix * Some cleanup * Sketch web * Mobile design * Mobile handling of tags search * Web only * Fix navigation woes * Use new callback * Hook it up * Integrate muted tags * Fix dropdown styles * Type error * Use close callback * Fix styles * Cleanup, install latest sdk * Quick muted words screen * Targets * Dir structure * Icons, list view * Move to dialog * Add removal confirmation * Swap copy * Improve checkboxees * Update matching, add tests * Moderate embeds * Create global dialogs concept again to prevent flashing * Add access from moderation screen * Highlight tags on native * Add web highlighting * Add close to web modal * Adjust close color * Rename toggles and adjust logic * Icon update * Load states * Improve regex * Improve regex * Improve regex * Revert link test * Hyphenated words * Improve matching * Enhance * Some tweaks * Muted words modal changes * Handle invalid handles, handle long tags * Remove main regex * Better test * Space/punct check drop to includes * Lowercase post text before comparison * Add better real world test case --------- Co-authored-by: Kisaragi Hiu <mail@kisaragi-hiu.com>
319 lines
7.3 KiB
TypeScript
319 lines
7.3 KiB
TypeScript
import React from 'react'
|
|
import {
|
|
View,
|
|
TextInput,
|
|
TextInputProps,
|
|
TextStyle,
|
|
ViewStyle,
|
|
StyleSheet,
|
|
AccessibilityProps,
|
|
} from 'react-native'
|
|
|
|
import {HITSLOP_20} from 'lib/constants'
|
|
import {useTheme, atoms as a, web, tokens, android} from '#/alf'
|
|
import {Text} from '#/components/Typography'
|
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
|
import {Props as SVGIconProps} from '#/components/icons/common'
|
|
|
|
const Context = React.createContext<{
|
|
inputRef: React.RefObject<TextInput> | null
|
|
isInvalid: boolean
|
|
hovered: boolean
|
|
onHoverIn: () => void
|
|
onHoverOut: () => void
|
|
focused: boolean
|
|
onFocus: () => void
|
|
onBlur: () => void
|
|
}>({
|
|
inputRef: null,
|
|
isInvalid: false,
|
|
hovered: false,
|
|
onHoverIn: () => {},
|
|
onHoverOut: () => {},
|
|
focused: false,
|
|
onFocus: () => {},
|
|
onBlur: () => {},
|
|
})
|
|
|
|
export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
|
|
|
|
export function Root({children, isInvalid = false}: RootProps) {
|
|
const inputRef = React.useRef<TextInput>(null)
|
|
const {
|
|
state: hovered,
|
|
onIn: onHoverIn,
|
|
onOut: onHoverOut,
|
|
} = useInteractionState()
|
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
|
|
|
const context = React.useMemo(
|
|
() => ({
|
|
inputRef,
|
|
hovered,
|
|
onHoverIn,
|
|
onHoverOut,
|
|
focused,
|
|
onFocus,
|
|
onBlur,
|
|
isInvalid,
|
|
}),
|
|
[
|
|
inputRef,
|
|
hovered,
|
|
onHoverIn,
|
|
onHoverOut,
|
|
focused,
|
|
onFocus,
|
|
onBlur,
|
|
isInvalid,
|
|
],
|
|
)
|
|
|
|
return (
|
|
<Context.Provider value={context}>
|
|
<View
|
|
style={[a.flex_row, a.align_center, a.relative, a.flex_1, a.px_md]}
|
|
{...web({
|
|
onClick: () => inputRef.current?.focus(),
|
|
onMouseOver: onHoverIn,
|
|
onMouseOut: onHoverOut,
|
|
})}>
|
|
{children}
|
|
</View>
|
|
</Context.Provider>
|
|
)
|
|
}
|
|
|
|
export function useSharedInputStyles() {
|
|
const t = useTheme()
|
|
return React.useMemo(() => {
|
|
const hover: ViewStyle[] = [
|
|
{
|
|
borderColor: t.palette.contrast_100,
|
|
},
|
|
]
|
|
const focus: ViewStyle[] = [
|
|
{
|
|
backgroundColor: t.palette.contrast_50,
|
|
borderColor: t.palette.primary_500,
|
|
},
|
|
]
|
|
const error: ViewStyle[] = [
|
|
{
|
|
backgroundColor:
|
|
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
|
|
borderColor:
|
|
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
|
|
},
|
|
]
|
|
const errorHover: ViewStyle[] = [
|
|
{
|
|
backgroundColor:
|
|
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
|
|
borderColor: tokens.color.red_500,
|
|
},
|
|
]
|
|
|
|
return {
|
|
chromeHover: StyleSheet.flatten(hover),
|
|
chromeFocus: StyleSheet.flatten(focus),
|
|
chromeError: StyleSheet.flatten(error),
|
|
chromeErrorHover: StyleSheet.flatten(errorHover),
|
|
}
|
|
}, [t])
|
|
}
|
|
|
|
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
|
|
label: string
|
|
value: string
|
|
onChangeText: (value: string) => void
|
|
isInvalid?: boolean
|
|
}
|
|
|
|
export function createInput(Component: typeof TextInput) {
|
|
return function Input({
|
|
label,
|
|
placeholder,
|
|
value,
|
|
onChangeText,
|
|
isInvalid,
|
|
...rest
|
|
}: InputProps) {
|
|
const t = useTheme()
|
|
const ctx = React.useContext(Context)
|
|
const withinRoot = Boolean(ctx.inputRef)
|
|
|
|
const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
|
|
useSharedInputStyles()
|
|
|
|
if (!withinRoot) {
|
|
return (
|
|
<Root isInvalid={isInvalid}>
|
|
<Input
|
|
label={label}
|
|
placeholder={placeholder}
|
|
value={value}
|
|
onChangeText={onChangeText}
|
|
isInvalid={isInvalid}
|
|
{...rest}
|
|
/>
|
|
</Root>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Component
|
|
accessibilityHint={undefined}
|
|
{...rest}
|
|
accessibilityLabel={label}
|
|
ref={ctx.inputRef}
|
|
value={value}
|
|
onChangeText={onChangeText}
|
|
onFocus={ctx.onFocus}
|
|
onBlur={ctx.onBlur}
|
|
placeholder={placeholder || label}
|
|
placeholderTextColor={t.palette.contrast_500}
|
|
hitSlop={HITSLOP_20}
|
|
style={[
|
|
a.relative,
|
|
a.z_20,
|
|
a.flex_1,
|
|
a.text_md,
|
|
t.atoms.text,
|
|
a.px_xs,
|
|
{
|
|
// paddingVertical doesn't work w/multiline - esb
|
|
paddingTop: 14,
|
|
paddingBottom: 14,
|
|
lineHeight: a.text_md.fontSize * 1.1875,
|
|
textAlignVertical: rest.multiline ? 'top' : undefined,
|
|
minHeight: rest.multiline ? 80 : undefined,
|
|
},
|
|
android({
|
|
paddingBottom: 16,
|
|
}),
|
|
]}
|
|
/>
|
|
|
|
<View
|
|
style={[
|
|
a.z_10,
|
|
a.absolute,
|
|
a.inset_0,
|
|
a.rounded_sm,
|
|
t.atoms.bg_contrast_25,
|
|
{borderColor: 'transparent', borderWidth: 2},
|
|
ctx.hovered ? chromeHover : {},
|
|
ctx.focused ? chromeFocus : {},
|
|
ctx.isInvalid || isInvalid ? chromeError : {},
|
|
(ctx.isInvalid || isInvalid) && (ctx.hovered || ctx.focused)
|
|
? chromeErrorHover
|
|
: {},
|
|
]}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
}
|
|
|
|
export const Input = createInput(TextInput)
|
|
|
|
export function Label({
|
|
nativeID,
|
|
children,
|
|
}: React.PropsWithChildren<{nativeID?: string}>) {
|
|
const t = useTheme()
|
|
return (
|
|
<Text
|
|
nativeID={nativeID}
|
|
style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium, a.mb_sm]}>
|
|
{children}
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
|
|
const t = useTheme()
|
|
const ctx = React.useContext(Context)
|
|
const {hover, focus, errorHover, errorFocus} = React.useMemo(() => {
|
|
const hover: TextStyle[] = [
|
|
{
|
|
color: t.palette.contrast_800,
|
|
},
|
|
]
|
|
const focus: TextStyle[] = [
|
|
{
|
|
color: t.palette.primary_500,
|
|
},
|
|
]
|
|
const errorHover: TextStyle[] = [
|
|
{
|
|
color: t.palette.negative_500,
|
|
},
|
|
]
|
|
const errorFocus: TextStyle[] = [
|
|
{
|
|
color: t.palette.negative_500,
|
|
},
|
|
]
|
|
|
|
return {
|
|
hover,
|
|
focus,
|
|
errorHover,
|
|
errorFocus,
|
|
}
|
|
}, [t])
|
|
|
|
return (
|
|
<View style={[a.z_20, a.pr_xs]}>
|
|
<Comp
|
|
size="md"
|
|
style={[
|
|
{color: t.palette.contrast_500, pointerEvents: 'none'},
|
|
ctx.hovered ? hover : {},
|
|
ctx.focused ? focus : {},
|
|
ctx.isInvalid && ctx.hovered ? errorHover : {},
|
|
ctx.isInvalid && ctx.focused ? errorFocus : {},
|
|
]}
|
|
/>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export function Suffix({
|
|
children,
|
|
label,
|
|
accessibilityHint,
|
|
}: React.PropsWithChildren<{
|
|
label: string
|
|
accessibilityHint?: AccessibilityProps['accessibilityHint']
|
|
}>) {
|
|
const t = useTheme()
|
|
const ctx = React.useContext(Context)
|
|
return (
|
|
<Text
|
|
accessibilityLabel={label}
|
|
accessibilityHint={accessibilityHint}
|
|
style={[
|
|
a.z_20,
|
|
a.pr_sm,
|
|
a.text_md,
|
|
t.atoms.text_contrast_medium,
|
|
{
|
|
pointerEvents: 'none',
|
|
},
|
|
web({
|
|
marginTop: -2,
|
|
}),
|
|
ctx.hovered || ctx.focused
|
|
? {
|
|
color: t.palette.contrast_800,
|
|
}
|
|
: {},
|
|
]}>
|
|
{children}
|
|
</Text>
|
|
)
|
|
}
|