parent
00595591c4
commit
a5b89dffa6
|
@ -2,12 +2,14 @@ module.exports = {
|
|||
root: true,
|
||||
extends: [
|
||||
'@react-native-community',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-native-a11y/ios',
|
||||
'prettier',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint', 'detox'],
|
||||
plugins: ['@typescript-eslint', 'detox', 'react'],
|
||||
rules: {
|
||||
'react/no-unescaped-entities': 0,
|
||||
'react-native/no-inline-styles': 0,
|
||||
},
|
||||
ignorePatterns: [
|
||||
|
|
|
@ -181,6 +181,7 @@
|
|||
"eslint": "^8.19.0",
|
||||
"eslint-plugin-detox": "^1.0.0",
|
||||
"eslint-plugin-ft-flow": "^2.0.3",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-native-a11y": "^3.3.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"husky": "^8.0.3",
|
||||
|
|
|
@ -51,181 +51,179 @@ interface Selection {
|
|||
end: number
|
||||
}
|
||||
|
||||
export const TextInput = forwardRef(
|
||||
(
|
||||
{
|
||||
richtext,
|
||||
placeholder,
|
||||
suggestedLinks,
|
||||
autocompleteView,
|
||||
setRichText,
|
||||
onPhotoPasted,
|
||||
onSuggestedLinksChanged,
|
||||
onError,
|
||||
...props
|
||||
}: TextInputProps,
|
||||
ref,
|
||||
) => {
|
||||
const pal = usePalette('default')
|
||||
const textInput = useRef<PasteInputRef>(null)
|
||||
const textInputSelection = useRef<Selection>({start: 0, end: 0})
|
||||
const theme = useTheme()
|
||||
export const TextInput = forwardRef(function TextInputImpl(
|
||||
{
|
||||
richtext,
|
||||
placeholder,
|
||||
suggestedLinks,
|
||||
autocompleteView,
|
||||
setRichText,
|
||||
onPhotoPasted,
|
||||
onSuggestedLinksChanged,
|
||||
onError,
|
||||
...props
|
||||
}: TextInputProps,
|
||||
ref,
|
||||
) {
|
||||
const pal = usePalette('default')
|
||||
const textInput = useRef<PasteInputRef>(null)
|
||||
const textInputSelection = useRef<Selection>({start: 0, end: 0})
|
||||
const theme = useTheme()
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => textInput.current?.focus(),
|
||||
blur: () => {
|
||||
textInput.current?.blur()
|
||||
},
|
||||
}))
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => textInput.current?.focus(),
|
||||
blur: () => {
|
||||
textInput.current?.blur()
|
||||
},
|
||||
}))
|
||||
|
||||
const onChangeText = useCallback(
|
||||
(newText: string) => {
|
||||
/*
|
||||
* This is a hack to bump the rendering of our styled
|
||||
* `textDecorated` to _after_ whatever processing is happening
|
||||
* within the `PasteInput` library. Without this, the elements in
|
||||
* `textDecorated` are not correctly painted to screen.
|
||||
*
|
||||
* NB: we tried a `0` timeout as well, but only positive values worked.
|
||||
*
|
||||
* @see https://github.com/bluesky-social/social-app/issues/929
|
||||
*/
|
||||
setTimeout(async () => {
|
||||
const newRt = new RichText({text: newText})
|
||||
newRt.detectFacetsWithoutResolution()
|
||||
setRichText(newRt)
|
||||
const onChangeText = useCallback(
|
||||
(newText: string) => {
|
||||
/*
|
||||
* This is a hack to bump the rendering of our styled
|
||||
* `textDecorated` to _after_ whatever processing is happening
|
||||
* within the `PasteInput` library. Without this, the elements in
|
||||
* `textDecorated` are not correctly painted to screen.
|
||||
*
|
||||
* NB: we tried a `0` timeout as well, but only positive values worked.
|
||||
*
|
||||
* @see https://github.com/bluesky-social/social-app/issues/929
|
||||
*/
|
||||
setTimeout(async () => {
|
||||
const newRt = new RichText({text: newText})
|
||||
newRt.detectFacetsWithoutResolution()
|
||||
setRichText(newRt)
|
||||
|
||||
const prefix = getMentionAt(
|
||||
newText,
|
||||
textInputSelection.current?.start || 0,
|
||||
)
|
||||
if (prefix) {
|
||||
autocompleteView.setActive(true)
|
||||
autocompleteView.setPrefix(prefix.value)
|
||||
} else {
|
||||
autocompleteView.setActive(false)
|
||||
}
|
||||
const prefix = getMentionAt(
|
||||
newText,
|
||||
textInputSelection.current?.start || 0,
|
||||
)
|
||||
if (prefix) {
|
||||
autocompleteView.setActive(true)
|
||||
autocompleteView.setPrefix(prefix.value)
|
||||
} else {
|
||||
autocompleteView.setActive(false)
|
||||
}
|
||||
|
||||
const set: Set<string> = new Set()
|
||||
const set: Set<string> = new Set()
|
||||
|
||||
if (newRt.facets) {
|
||||
for (const facet of newRt.facets) {
|
||||
for (const feature of facet.features) {
|
||||
if (AppBskyRichtextFacet.isLink(feature)) {
|
||||
if (isUriImage(feature.uri)) {
|
||||
const res = await downloadAndResize({
|
||||
uri: feature.uri,
|
||||
width: POST_IMG_MAX.width,
|
||||
height: POST_IMG_MAX.height,
|
||||
mode: 'contain',
|
||||
maxSize: POST_IMG_MAX.size,
|
||||
timeout: 15e3,
|
||||
})
|
||||
if (newRt.facets) {
|
||||
for (const facet of newRt.facets) {
|
||||
for (const feature of facet.features) {
|
||||
if (AppBskyRichtextFacet.isLink(feature)) {
|
||||
if (isUriImage(feature.uri)) {
|
||||
const res = await downloadAndResize({
|
||||
uri: feature.uri,
|
||||
width: POST_IMG_MAX.width,
|
||||
height: POST_IMG_MAX.height,
|
||||
mode: 'contain',
|
||||
maxSize: POST_IMG_MAX.size,
|
||||
timeout: 15e3,
|
||||
})
|
||||
|
||||
if (res !== undefined) {
|
||||
onPhotoPasted(res.path)
|
||||
}
|
||||
} else {
|
||||
set.add(feature.uri)
|
||||
if (res !== undefined) {
|
||||
onPhotoPasted(res.path)
|
||||
}
|
||||
} else {
|
||||
set.add(feature.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEqual(set, suggestedLinks)) {
|
||||
onSuggestedLinksChanged(set)
|
||||
}
|
||||
}, 1)
|
||||
},
|
||||
[
|
||||
setRichText,
|
||||
autocompleteView,
|
||||
suggestedLinks,
|
||||
onSuggestedLinksChanged,
|
||||
onPhotoPasted,
|
||||
],
|
||||
)
|
||||
|
||||
const onPaste = useCallback(
|
||||
async (err: string | undefined, files: PastedFile[]) => {
|
||||
if (err) {
|
||||
return onError(cleanError(err))
|
||||
}
|
||||
|
||||
const uris = files.map(f => f.uri)
|
||||
const uri = uris.find(isUriImage)
|
||||
|
||||
if (uri) {
|
||||
onPhotoPasted(uri)
|
||||
if (!isEqual(set, suggestedLinks)) {
|
||||
onSuggestedLinksChanged(set)
|
||||
}
|
||||
},
|
||||
[onError, onPhotoPasted],
|
||||
)
|
||||
}, 1)
|
||||
},
|
||||
[
|
||||
setRichText,
|
||||
autocompleteView,
|
||||
suggestedLinks,
|
||||
onSuggestedLinksChanged,
|
||||
onPhotoPasted,
|
||||
],
|
||||
)
|
||||
|
||||
const onSelectionChange = useCallback(
|
||||
(evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
|
||||
// NOTE we track the input selection using a ref to avoid excessive renders -prf
|
||||
textInputSelection.current = evt.nativeEvent.selection
|
||||
},
|
||||
[textInputSelection],
|
||||
)
|
||||
const onPaste = useCallback(
|
||||
async (err: string | undefined, files: PastedFile[]) => {
|
||||
if (err) {
|
||||
return onError(cleanError(err))
|
||||
}
|
||||
|
||||
const onSelectAutocompleteItem = useCallback(
|
||||
(item: string) => {
|
||||
onChangeText(
|
||||
insertMentionAt(
|
||||
richtext.text,
|
||||
textInputSelection.current?.start || 0,
|
||||
item,
|
||||
),
|
||||
)
|
||||
autocompleteView.setActive(false)
|
||||
},
|
||||
[onChangeText, richtext, autocompleteView],
|
||||
)
|
||||
const uris = files.map(f => f.uri)
|
||||
const uri = uris.find(isUriImage)
|
||||
|
||||
const textDecorated = useMemo(() => {
|
||||
let i = 0
|
||||
if (uri) {
|
||||
onPhotoPasted(uri)
|
||||
}
|
||||
},
|
||||
[onError, onPhotoPasted],
|
||||
)
|
||||
|
||||
return Array.from(richtext.segments()).map(segment => (
|
||||
<Text
|
||||
key={i++}
|
||||
style={[
|
||||
!segment.facet ? pal.text : pal.link,
|
||||
styles.textInputFormatting,
|
||||
]}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
))
|
||||
}, [richtext, pal.link, pal.text])
|
||||
const onSelectionChange = useCallback(
|
||||
(evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
|
||||
// NOTE we track the input selection using a ref to avoid excessive renders -prf
|
||||
textInputSelection.current = evt.nativeEvent.selection
|
||||
},
|
||||
[textInputSelection],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<PasteInput
|
||||
testID="composerTextInput"
|
||||
ref={textInput}
|
||||
onChangeText={onChangeText}
|
||||
onPaste={onPaste}
|
||||
onSelectionChange={onSelectionChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
autoFocus={true}
|
||||
allowFontScaling
|
||||
multiline
|
||||
style={[pal.text, styles.textInput, styles.textInputFormatting]}
|
||||
{...props}>
|
||||
{textDecorated}
|
||||
</PasteInput>
|
||||
<Autocomplete
|
||||
view={autocompleteView}
|
||||
onSelect={onSelectAutocompleteItem}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
const onSelectAutocompleteItem = useCallback(
|
||||
(item: string) => {
|
||||
onChangeText(
|
||||
insertMentionAt(
|
||||
richtext.text,
|
||||
textInputSelection.current?.start || 0,
|
||||
item,
|
||||
),
|
||||
)
|
||||
autocompleteView.setActive(false)
|
||||
},
|
||||
[onChangeText, richtext, autocompleteView],
|
||||
)
|
||||
|
||||
const textDecorated = useMemo(() => {
|
||||
let i = 0
|
||||
|
||||
return Array.from(richtext.segments()).map(segment => (
|
||||
<Text
|
||||
key={i++}
|
||||
style={[
|
||||
!segment.facet ? pal.text : pal.link,
|
||||
styles.textInputFormatting,
|
||||
]}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
))
|
||||
}, [richtext, pal.link, pal.text])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<PasteInput
|
||||
testID="composerTextInput"
|
||||
ref={textInput}
|
||||
onChangeText={onChangeText}
|
||||
onPaste={onPaste}
|
||||
onSelectionChange={onSelectionChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
autoFocus={true}
|
||||
allowFontScaling
|
||||
multiline
|
||||
style={[pal.text, styles.textInput, styles.textInputFormatting]}
|
||||
{...props}>
|
||||
{textDecorated}
|
||||
</PasteInput>
|
||||
<Autocomplete
|
||||
view={autocompleteView}
|
||||
onSelect={onSelectAutocompleteItem}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
|
|
|
@ -37,135 +37,130 @@ interface TextInputProps {
|
|||
|
||||
export const textInputWebEmitter = new EventEmitter()
|
||||
|
||||
export const TextInput = React.forwardRef(
|
||||
(
|
||||
export const TextInput = React.forwardRef(function TextInputImpl(
|
||||
{
|
||||
richtext,
|
||||
placeholder,
|
||||
suggestedLinks,
|
||||
autocompleteView,
|
||||
setRichText,
|
||||
onPhotoPasted,
|
||||
onPressPublish,
|
||||
onSuggestedLinksChanged,
|
||||
}: // onError, TODO
|
||||
TextInputProps,
|
||||
ref,
|
||||
) {
|
||||
const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
|
||||
|
||||
React.useEffect(() => {
|
||||
textInputWebEmitter.addListener('publish', onPressPublish)
|
||||
return () => {
|
||||
textInputWebEmitter.removeListener('publish', onPressPublish)
|
||||
}
|
||||
}, [onPressPublish])
|
||||
React.useEffect(() => {
|
||||
textInputWebEmitter.addListener('photo-pasted', onPhotoPasted)
|
||||
return () => {
|
||||
textInputWebEmitter.removeListener('photo-pasted', onPhotoPasted)
|
||||
}
|
||||
}, [onPhotoPasted])
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
richtext,
|
||||
placeholder,
|
||||
suggestedLinks,
|
||||
autocompleteView,
|
||||
setRichText,
|
||||
onPhotoPasted,
|
||||
onPressPublish,
|
||||
onSuggestedLinksChanged,
|
||||
}: // onError, TODO
|
||||
TextInputProps,
|
||||
ref,
|
||||
) => {
|
||||
const modeClass = useColorSchemeStyle(
|
||||
'ProseMirror-light',
|
||||
'ProseMirror-dark',
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
textInputWebEmitter.addListener('publish', onPressPublish)
|
||||
return () => {
|
||||
textInputWebEmitter.removeListener('publish', onPressPublish)
|
||||
}
|
||||
}, [onPressPublish])
|
||||
React.useEffect(() => {
|
||||
textInputWebEmitter.addListener('photo-pasted', onPhotoPasted)
|
||||
return () => {
|
||||
textInputWebEmitter.removeListener('photo-pasted', onPhotoPasted)
|
||||
}
|
||||
}, [onPhotoPasted])
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
Document,
|
||||
LinkDecorator,
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'mention',
|
||||
},
|
||||
suggestion: createSuggestion({autocompleteView}),
|
||||
}),
|
||||
Paragraph,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
Text,
|
||||
History,
|
||||
Hardbreak,
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: modeClass,
|
||||
},
|
||||
handlePaste: (_, event) => {
|
||||
const items = event.clipboardData?.items
|
||||
|
||||
if (items === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
getImageFromUri(items, (uri: string) => {
|
||||
textInputWebEmitter.emit('photo-pasted', uri)
|
||||
})
|
||||
},
|
||||
handleKeyDown: (_, event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') {
|
||||
textInputWebEmitter.emit('publish')
|
||||
}
|
||||
extensions: [
|
||||
Document,
|
||||
LinkDecorator,
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'mention',
|
||||
},
|
||||
suggestion: createSuggestion({autocompleteView}),
|
||||
}),
|
||||
Paragraph,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
Text,
|
||||
History,
|
||||
Hardbreak,
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: modeClass,
|
||||
},
|
||||
content: textToEditorJson(richtext.text.toString()),
|
||||
autofocus: 'end',
|
||||
editable: true,
|
||||
injectCSS: true,
|
||||
onUpdate({editor: editorProp}) {
|
||||
const json = editorProp.getJSON()
|
||||
handlePaste: (_, event) => {
|
||||
const items = event.clipboardData?.items
|
||||
|
||||
const newRt = new RichText({text: editorJsonToText(json).trim()})
|
||||
newRt.detectFacetsWithoutResolution()
|
||||
setRichText(newRt)
|
||||
if (items === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const set: Set<string> = new Set()
|
||||
getImageFromUri(items, (uri: string) => {
|
||||
textInputWebEmitter.emit('photo-pasted', uri)
|
||||
})
|
||||
},
|
||||
handleKeyDown: (_, event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') {
|
||||
textInputWebEmitter.emit('publish')
|
||||
}
|
||||
},
|
||||
},
|
||||
content: textToEditorJson(richtext.text.toString()),
|
||||
autofocus: 'end',
|
||||
editable: true,
|
||||
injectCSS: true,
|
||||
onUpdate({editor: editorProp}) {
|
||||
const json = editorProp.getJSON()
|
||||
|
||||
if (newRt.facets) {
|
||||
for (const facet of newRt.facets) {
|
||||
for (const feature of facet.features) {
|
||||
if (AppBskyRichtextFacet.isLink(feature)) {
|
||||
set.add(feature.uri)
|
||||
}
|
||||
const newRt = new RichText({text: editorJsonToText(json).trim()})
|
||||
newRt.detectFacetsWithoutResolution()
|
||||
setRichText(newRt)
|
||||
|
||||
const set: Set<string> = new Set()
|
||||
|
||||
if (newRt.facets) {
|
||||
for (const facet of newRt.facets) {
|
||||
for (const feature of facet.features) {
|
||||
if (AppBskyRichtextFacet.isLink(feature)) {
|
||||
set.add(feature.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEqual(set, suggestedLinks)) {
|
||||
onSuggestedLinksChanged(set)
|
||||
}
|
||||
},
|
||||
if (!isEqual(set, suggestedLinks)) {
|
||||
onSuggestedLinksChanged(set)
|
||||
}
|
||||
},
|
||||
[modeClass],
|
||||
)
|
||||
},
|
||||
[modeClass],
|
||||
)
|
||||
|
||||
const onEmojiInserted = React.useCallback(
|
||||
(emoji: Emoji) => {
|
||||
editor?.chain().focus().insertContent(emoji.native).run()
|
||||
},
|
||||
[editor],
|
||||
)
|
||||
React.useEffect(() => {
|
||||
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
|
||||
return () => {
|
||||
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
|
||||
}
|
||||
}, [onEmojiInserted])
|
||||
const onEmojiInserted = React.useCallback(
|
||||
(emoji: Emoji) => {
|
||||
editor?.chain().focus().insertContent(emoji.native).run()
|
||||
},
|
||||
[editor],
|
||||
)
|
||||
React.useEffect(() => {
|
||||
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
|
||||
return () => {
|
||||
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
|
||||
}
|
||||
}, [onEmojiInserted])
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => {}, // TODO
|
||||
blur: () => {}, // TODO
|
||||
}))
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => {}, // TODO
|
||||
blur: () => {}, // TODO
|
||||
}))
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<EditorContent editor={editor} />
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<EditorContent editor={editor} />
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
function editorJsonToText(json: JSONContent): string {
|
||||
let text = ''
|
||||
|
|
|
@ -94,7 +94,7 @@ export function createSuggestion({
|
|||
}
|
||||
|
||||
const MentionList = forwardRef<MentionListRef, SuggestionProps>(
|
||||
(props: SuggestionProps, ref) => {
|
||||
function MentionListImpl(props: SuggestionProps, ref) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const pal = usePalette('default')
|
||||
const {getGraphemeString} = useGrapheme()
|
||||
|
|
|
@ -24,7 +24,7 @@ interface Props {
|
|||
testID?: string
|
||||
}
|
||||
export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
|
||||
(
|
||||
function PagerImpl(
|
||||
{
|
||||
children,
|
||||
tabBarPosition = 'top',
|
||||
|
@ -34,7 +34,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
|
|||
testID,
|
||||
}: React.PropsWithChildren<Props>,
|
||||
ref,
|
||||
) => {
|
||||
) {
|
||||
const [selectedPage, setSelectedPage] = React.useState(0)
|
||||
const pagerView = React.useRef<PagerView>(null)
|
||||
|
||||
|
|
|
@ -14,51 +14,49 @@ interface Props {
|
|||
renderTabBar: RenderTabBarFn
|
||||
onPageSelected?: (index: number) => void
|
||||
}
|
||||
export const Pager = React.forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
tabBarPosition = 'top',
|
||||
initialPage = 0,
|
||||
renderTabBar,
|
||||
onPageSelected,
|
||||
}: React.PropsWithChildren<Props>,
|
||||
ref,
|
||||
) => {
|
||||
const [selectedPage, setSelectedPage] = React.useState(initialPage)
|
||||
export const Pager = React.forwardRef(function PagerImpl(
|
||||
{
|
||||
children,
|
||||
tabBarPosition = 'top',
|
||||
initialPage = 0,
|
||||
renderTabBar,
|
||||
onPageSelected,
|
||||
}: React.PropsWithChildren<Props>,
|
||||
ref,
|
||||
) {
|
||||
const [selectedPage, setSelectedPage] = React.useState(initialPage)
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
setPage: (index: number) => setSelectedPage(index),
|
||||
}))
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
setPage: (index: number) => setSelectedPage(index),
|
||||
}))
|
||||
|
||||
const onTabBarSelect = React.useCallback(
|
||||
(index: number) => {
|
||||
setSelectedPage(index)
|
||||
onPageSelected?.(index)
|
||||
},
|
||||
[setSelectedPage, onPageSelected],
|
||||
)
|
||||
const onTabBarSelect = React.useCallback(
|
||||
(index: number) => {
|
||||
setSelectedPage(index)
|
||||
onPageSelected?.(index)
|
||||
},
|
||||
[setSelectedPage, onPageSelected],
|
||||
)
|
||||
|
||||
return (
|
||||
<View>
|
||||
{tabBarPosition === 'top' &&
|
||||
renderTabBar({
|
||||
selectedPage,
|
||||
onSelect: onTabBarSelect,
|
||||
})}
|
||||
{React.Children.map(children, (child, i) => (
|
||||
<View
|
||||
style={selectedPage === i ? undefined : s.hidden}
|
||||
key={`page-${i}`}>
|
||||
{child}
|
||||
</View>
|
||||
))}
|
||||
{tabBarPosition === 'bottom' &&
|
||||
renderTabBar({
|
||||
selectedPage,
|
||||
onSelect: onTabBarSelect,
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
return (
|
||||
<View>
|
||||
{tabBarPosition === 'top' &&
|
||||
renderTabBar({
|
||||
selectedPage,
|
||||
onSelect: onTabBarSelect,
|
||||
})}
|
||||
{React.Children.map(children, (child, i) => (
|
||||
<View
|
||||
style={selectedPage === i ? undefined : s.hidden}
|
||||
key={`page-${i}`}>
|
||||
{child}
|
||||
</View>
|
||||
))}
|
||||
{tabBarPosition === 'bottom' &&
|
||||
renderTabBar({
|
||||
selectedPage,
|
||||
onSelect: onTabBarSelect,
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -39,179 +39,177 @@ interface ProfileView {
|
|||
type Item = Heading | RefWrapper | SuggestWrapper | ProfileView
|
||||
|
||||
export const Suggestions = observer(
|
||||
forwardRef(
|
||||
(
|
||||
{
|
||||
foafs,
|
||||
suggestedActors,
|
||||
}: {
|
||||
foafs: FoafsModel
|
||||
suggestedActors: SuggestedActorsModel
|
||||
},
|
||||
flatListRef: ForwardedRef<FlatList>,
|
||||
) => {
|
||||
const pal = usePalette('default')
|
||||
const [refreshing, setRefreshing] = React.useState(false)
|
||||
const data = React.useMemo(() => {
|
||||
let items: Item[] = []
|
||||
|
||||
if (foafs.popular.length > 0) {
|
||||
items = items
|
||||
.concat([
|
||||
{
|
||||
_reactKey: '__popular_heading__',
|
||||
type: 'heading',
|
||||
title: 'In Your Network',
|
||||
},
|
||||
])
|
||||
.concat(
|
||||
foafs.popular.map(ref => ({
|
||||
_reactKey: `popular-${ref.did}`,
|
||||
type: 'ref',
|
||||
ref,
|
||||
})),
|
||||
)
|
||||
}
|
||||
if (suggestedActors.hasContent) {
|
||||
items = items
|
||||
.concat([
|
||||
{
|
||||
_reactKey: '__suggested_heading__',
|
||||
type: 'heading',
|
||||
title: 'Suggested Follows',
|
||||
},
|
||||
])
|
||||
.concat(
|
||||
suggestedActors.suggestions.map(suggested => ({
|
||||
_reactKey: `suggested-${suggested.did}`,
|
||||
type: 'suggested',
|
||||
suggested,
|
||||
})),
|
||||
)
|
||||
}
|
||||
for (const source of foafs.sources) {
|
||||
const item = foafs.foafs.get(source)
|
||||
if (!item || item.follows.length === 0) {
|
||||
continue
|
||||
}
|
||||
items = items
|
||||
.concat([
|
||||
{
|
||||
_reactKey: `__${item.did}_heading__`,
|
||||
type: 'heading',
|
||||
title: `Followed by ${sanitizeDisplayName(
|
||||
item.displayName || sanitizeHandle(item.handle),
|
||||
)}`,
|
||||
},
|
||||
])
|
||||
.concat(
|
||||
item.follows.slice(0, 10).map(view => ({
|
||||
_reactKey: `${item.did}-${view.did}`,
|
||||
type: 'profile-view',
|
||||
view,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
}, [
|
||||
foafs.popular,
|
||||
suggestedActors.hasContent,
|
||||
suggestedActors.suggestions,
|
||||
foafs.sources,
|
||||
foafs.foafs,
|
||||
])
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await foafs.fetch()
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [foafs, setRefreshing])
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
({item}: {item: Item}) => {
|
||||
if (item.type === 'heading') {
|
||||
return (
|
||||
<Text type="title" style={[styles.heading, pal.text]}>
|
||||
{item.title}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
if (item.type === 'ref') {
|
||||
return (
|
||||
<View style={[styles.card, pal.view, pal.border]}>
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.ref.did}
|
||||
profile={item.ref}
|
||||
noBg
|
||||
noBorder
|
||||
followers={
|
||||
item.ref.followers
|
||||
? (item.ref.followers as AppBskyActorDefs.ProfileView[])
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (item.type === 'profile-view') {
|
||||
return (
|
||||
<View style={[styles.card, pal.view, pal.border]}>
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.view.did}
|
||||
profile={item.view}
|
||||
noBg
|
||||
noBorder
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (item.type === 'suggested') {
|
||||
return (
|
||||
<View style={[styles.card, pal.view, pal.border]}>
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.suggested.did}
|
||||
profile={item.suggested}
|
||||
noBg
|
||||
noBorder
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
[pal],
|
||||
)
|
||||
|
||||
if (foafs.isLoading || suggestedActors.isLoading) {
|
||||
return (
|
||||
<CenteredView>
|
||||
<ProfileCardFeedLoadingPlaceholder />
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={data}
|
||||
keyExtractor={item => item._reactKey}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={15}
|
||||
/>
|
||||
)
|
||||
forwardRef(function SuggestionsImpl(
|
||||
{
|
||||
foafs,
|
||||
suggestedActors,
|
||||
}: {
|
||||
foafs: FoafsModel
|
||||
suggestedActors: SuggestedActorsModel
|
||||
},
|
||||
),
|
||||
flatListRef: ForwardedRef<FlatList>,
|
||||
) {
|
||||
const pal = usePalette('default')
|
||||
const [refreshing, setRefreshing] = React.useState(false)
|
||||
const data = React.useMemo(() => {
|
||||
let items: Item[] = []
|
||||
|
||||
if (foafs.popular.length > 0) {
|
||||
items = items
|
||||
.concat([
|
||||
{
|
||||
_reactKey: '__popular_heading__',
|
||||
type: 'heading',
|
||||
title: 'In Your Network',
|
||||
},
|
||||
])
|
||||
.concat(
|
||||
foafs.popular.map(ref => ({
|
||||
_reactKey: `popular-${ref.did}`,
|
||||
type: 'ref',
|
||||
ref,
|
||||
})),
|
||||
)
|
||||
}
|
||||
if (suggestedActors.hasContent) {
|
||||
items = items
|
||||
.concat([
|
||||
{
|
||||
_reactKey: '__suggested_heading__',
|
||||
type: 'heading',
|
||||
title: 'Suggested Follows',
|
||||
},
|
||||
])
|
||||
.concat(
|
||||
suggestedActors.suggestions.map(suggested => ({
|
||||
_reactKey: `suggested-${suggested.did}`,
|
||||
type: 'suggested',
|
||||
suggested,
|
||||
})),
|
||||
)
|
||||
}
|
||||
for (const source of foafs.sources) {
|
||||
const item = foafs.foafs.get(source)
|
||||
if (!item || item.follows.length === 0) {
|
||||
continue
|
||||
}
|
||||
items = items
|
||||
.concat([
|
||||
{
|
||||
_reactKey: `__${item.did}_heading__`,
|
||||
type: 'heading',
|
||||
title: `Followed by ${sanitizeDisplayName(
|
||||
item.displayName || sanitizeHandle(item.handle),
|
||||
)}`,
|
||||
},
|
||||
])
|
||||
.concat(
|
||||
item.follows.slice(0, 10).map(view => ({
|
||||
_reactKey: `${item.did}-${view.did}`,
|
||||
type: 'profile-view',
|
||||
view,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
}, [
|
||||
foafs.popular,
|
||||
suggestedActors.hasContent,
|
||||
suggestedActors.suggestions,
|
||||
foafs.sources,
|
||||
foafs.foafs,
|
||||
])
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await foafs.fetch()
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [foafs, setRefreshing])
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
({item}: {item: Item}) => {
|
||||
if (item.type === 'heading') {
|
||||
return (
|
||||
<Text type="title" style={[styles.heading, pal.text]}>
|
||||
{item.title}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
if (item.type === 'ref') {
|
||||
return (
|
||||
<View style={[styles.card, pal.view, pal.border]}>
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.ref.did}
|
||||
profile={item.ref}
|
||||
noBg
|
||||
noBorder
|
||||
followers={
|
||||
item.ref.followers
|
||||
? (item.ref.followers as AppBskyActorDefs.ProfileView[])
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (item.type === 'profile-view') {
|
||||
return (
|
||||
<View style={[styles.card, pal.view, pal.border]}>
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.view.did}
|
||||
profile={item.view}
|
||||
noBg
|
||||
noBorder
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (item.type === 'suggested') {
|
||||
return (
|
||||
<View style={[styles.card, pal.view, pal.border]}>
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.suggested.did}
|
||||
profile={item.suggested}
|
||||
noBg
|
||||
noBorder
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
[pal],
|
||||
)
|
||||
|
||||
if (foafs.isLoading || suggestedActors.isLoading) {
|
||||
return (
|
||||
<CenteredView>
|
||||
<ProfileCardFeedLoadingPlaceholder />
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={data}
|
||||
keyExtractor={item => item._reactKey}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={15}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
|
|
@ -12,34 +12,32 @@ interface PressableWithHover extends PressableProps {
|
|||
hoverStyle: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
export const PressableWithHover = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
style,
|
||||
hoverStyle,
|
||||
...props
|
||||
}: PropsWithChildren<PressableWithHover>,
|
||||
ref: Ref<any>,
|
||||
) => {
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
export const PressableWithHover = forwardRef(function PressableWithHoverImpl(
|
||||
{
|
||||
children,
|
||||
style,
|
||||
hoverStyle,
|
||||
...props
|
||||
}: PropsWithChildren<PressableWithHover>,
|
||||
ref: Ref<any>,
|
||||
) {
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
|
||||
const onHoverIn = useCallback(() => setIsHovering(true), [setIsHovering])
|
||||
const onHoverOut = useCallback(() => setIsHovering(false), [setIsHovering])
|
||||
style =
|
||||
typeof style !== 'function' && isHovering
|
||||
? addStyle(style, hoverStyle)
|
||||
: style
|
||||
const onHoverIn = useCallback(() => setIsHovering(true), [setIsHovering])
|
||||
const onHoverOut = useCallback(() => setIsHovering(false), [setIsHovering])
|
||||
style =
|
||||
typeof style !== 'function' && isHovering
|
||||
? addStyle(style, hoverStyle)
|
||||
: style
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
style={style}
|
||||
onHoverIn={onHoverIn}
|
||||
onHoverOut={onHoverOut}
|
||||
ref={ref}>
|
||||
{children}
|
||||
</Pressable>
|
||||
)
|
||||
},
|
||||
)
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
style={style}
|
||||
onHoverIn={onHoverIn}
|
||||
onHoverOut={onHoverOut}
|
||||
ref={ref}>
|
||||
{children}
|
||||
</Pressable>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -42,100 +42,98 @@ export const ViewSelector = React.forwardRef<
|
|||
onRefresh?: () => void
|
||||
onEndReached?: (info: {distanceFromEnd: number}) => void
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
sections,
|
||||
items,
|
||||
refreshing,
|
||||
renderHeader,
|
||||
renderItem,
|
||||
ListFooterComponent,
|
||||
onSelectView,
|
||||
onScroll,
|
||||
onRefresh,
|
||||
onEndReached,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const pal = usePalette('default')
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(0)
|
||||
const flatListRef = React.useRef<FlatList>(null)
|
||||
|
||||
// events
|
||||
// =
|
||||
|
||||
const keyExtractor = React.useCallback((item: any) => item._reactKey, [])
|
||||
|
||||
const onPressSelection = React.useCallback(
|
||||
(index: number) => setSelectedIndex(clamp(index, 0, sections.length)),
|
||||
[setSelectedIndex, sections],
|
||||
)
|
||||
useEffect(() => {
|
||||
onSelectView?.(selectedIndex)
|
||||
}, [selectedIndex, onSelectView])
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToTop: () => {
|
||||
flatListRef.current?.scrollToOffset({offset: 0})
|
||||
},
|
||||
}))
|
||||
|
||||
// rendering
|
||||
// =
|
||||
|
||||
const renderItemInternal = React.useCallback(
|
||||
({item}: {item: any}) => {
|
||||
if (item === HEADER_ITEM) {
|
||||
if (renderHeader) {
|
||||
return renderHeader()
|
||||
}
|
||||
return <View />
|
||||
} else if (item === SELECTOR_ITEM) {
|
||||
return (
|
||||
<Selector
|
||||
items={sections}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={onPressSelection}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return renderItem(item)
|
||||
}
|
||||
},
|
||||
[sections, selectedIndex, onPressSelection, renderHeader, renderItem],
|
||||
)
|
||||
|
||||
const data = React.useMemo(
|
||||
() => [HEADER_ITEM, SELECTOR_ITEM, ...items],
|
||||
[items],
|
||||
)
|
||||
return (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItemInternal}
|
||||
ListFooterComponent={ListFooterComponent}
|
||||
// NOTE sticky header disabled on android due to major performance issues -prf
|
||||
stickyHeaderIndices={isAndroid ? undefined : STICKY_HEADER_INDICES}
|
||||
onScroll={onScroll}
|
||||
onEndReached={onEndReached}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing!}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
onEndReachedThreshold={0.6}
|
||||
contentContainerStyle={s.contentContainer}
|
||||
removeClippedSubviews={true}
|
||||
scrollIndicatorInsets={{right: 1}} // fixes a bug where the scroll indicator is on the middle of the screen https://github.com/bluesky-social/social-app/pull/464
|
||||
/>
|
||||
)
|
||||
>(function ViewSelectorImpl(
|
||||
{
|
||||
sections,
|
||||
items,
|
||||
refreshing,
|
||||
renderHeader,
|
||||
renderItem,
|
||||
ListFooterComponent,
|
||||
onSelectView,
|
||||
onScroll,
|
||||
onRefresh,
|
||||
onEndReached,
|
||||
},
|
||||
)
|
||||
ref,
|
||||
) {
|
||||
const pal = usePalette('default')
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(0)
|
||||
const flatListRef = React.useRef<FlatList>(null)
|
||||
|
||||
// events
|
||||
// =
|
||||
|
||||
const keyExtractor = React.useCallback((item: any) => item._reactKey, [])
|
||||
|
||||
const onPressSelection = React.useCallback(
|
||||
(index: number) => setSelectedIndex(clamp(index, 0, sections.length)),
|
||||
[setSelectedIndex, sections],
|
||||
)
|
||||
useEffect(() => {
|
||||
onSelectView?.(selectedIndex)
|
||||
}, [selectedIndex, onSelectView])
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToTop: () => {
|
||||
flatListRef.current?.scrollToOffset({offset: 0})
|
||||
},
|
||||
}))
|
||||
|
||||
// rendering
|
||||
// =
|
||||
|
||||
const renderItemInternal = React.useCallback(
|
||||
({item}: {item: any}) => {
|
||||
if (item === HEADER_ITEM) {
|
||||
if (renderHeader) {
|
||||
return renderHeader()
|
||||
}
|
||||
return <View />
|
||||
} else if (item === SELECTOR_ITEM) {
|
||||
return (
|
||||
<Selector
|
||||
items={sections}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={onPressSelection}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return renderItem(item)
|
||||
}
|
||||
},
|
||||
[sections, selectedIndex, onPressSelection, renderHeader, renderItem],
|
||||
)
|
||||
|
||||
const data = React.useMemo(
|
||||
() => [HEADER_ITEM, SELECTOR_ITEM, ...items],
|
||||
[items],
|
||||
)
|
||||
return (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItemInternal}
|
||||
ListFooterComponent={ListFooterComponent}
|
||||
// NOTE sticky header disabled on android due to major performance issues -prf
|
||||
stickyHeaderIndices={isAndroid ? undefined : STICKY_HEADER_INDICES}
|
||||
onScroll={onScroll}
|
||||
onEndReached={onEndReached}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing!}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
onEndReachedThreshold={0.6}
|
||||
contentContainerStyle={s.contentContainer}
|
||||
removeClippedSubviews={true}
|
||||
scrollIndicatorInsets={{right: 1}} // fixes a bug where the scroll indicator is on the middle of the screen https://github.com/bluesky-social/social-app/pull/464
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export function Selector({
|
||||
selectedIndex,
|
||||
|
|
|
@ -38,7 +38,7 @@ export function CenteredView({
|
|||
return <View style={style} {...props} />
|
||||
}
|
||||
|
||||
export const FlatList = React.forwardRef(function <ItemT>(
|
||||
export const FlatList = React.forwardRef(function FlatListImpl<ItemT>(
|
||||
{
|
||||
contentContainerStyle,
|
||||
style,
|
||||
|
@ -99,7 +99,7 @@ export const FlatList = React.forwardRef(function <ItemT>(
|
|||
)
|
||||
})
|
||||
|
||||
export const ScrollView = React.forwardRef(function (
|
||||
export const ScrollView = React.forwardRef(function ScrollViewImpl(
|
||||
{contentContainerStyle, ...props}: React.PropsWithChildren<ScrollViewProps>,
|
||||
ref: React.Ref<RNScrollView>,
|
||||
) {
|
||||
|
|
|
@ -26,7 +26,7 @@ type PropsInner = TriggerableAnimatedProps & {
|
|||
export const TriggerableAnimated = React.forwardRef<
|
||||
TriggerableAnimatedRef,
|
||||
TriggerableAnimatedProps
|
||||
>(({children, ...props}, ref) => {
|
||||
>(function TriggerableAnimatedImpl({children, ...props}, ref) {
|
||||
const [anim, setAnim] = React.useState<TriggeredAnimation | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
|
|
@ -2,13 +2,12 @@ import React from 'react'
|
|||
import {isNative} from 'platform/detection'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
|
||||
export const withBreakpoints =
|
||||
<P extends object>(
|
||||
Mobile: React.ComponentType<P>,
|
||||
Tablet: React.ComponentType<P>,
|
||||
Desktop: React.ComponentType<P>,
|
||||
): React.FC<P> =>
|
||||
(props: P) => {
|
||||
export const withBreakpoints = <P extends object>(
|
||||
Mobile: React.ComponentType<P>,
|
||||
Tablet: React.ComponentType<P>,
|
||||
Desktop: React.ComponentType<P>,
|
||||
): React.FC<P> =>
|
||||
function WithBreakpoints(props: P) {
|
||||
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
|
||||
|
||||
if (isMobile || isNative) {
|
||||
|
|
|
@ -9906,7 +9906,7 @@ eslint-plugin-react-native@^4.0.0:
|
|||
"@babel/traverse" "^7.7.4"
|
||||
eslint-plugin-react-native-globals "^0.1.1"
|
||||
|
||||
eslint-plugin-react@^7.27.1, eslint-plugin-react@^7.30.1:
|
||||
eslint-plugin-react@^7.27.1, eslint-plugin-react@^7.30.1, eslint-plugin-react@^7.33.2:
|
||||
version "7.33.2"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz#69ee09443ffc583927eafe86ffebb470ee737608"
|
||||
integrity sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==
|
||||
|
|
Loading…
Reference in New Issue