New rich text composer + autocomplete (#10159)
This commit is contained in:
@@ -88,6 +88,8 @@
|
|||||||
"@bsky.app/expo-image-crop-tool": "^0.5.0",
|
"@bsky.app/expo-image-crop-tool": "^0.5.0",
|
||||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||||
|
"@bsky.app/sift": "^0.3.1",
|
||||||
|
"@bsky.app/tapper": "^0.5.0",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
@@ -179,6 +181,7 @@
|
|||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-text-encoding": "^1.0.6",
|
"fast-text-encoding": "^1.0.6",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
"hls.js": "^1.6.2",
|
"hls.js": "^1.6.2",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
"js-sha256": "^0.9.0",
|
"js-sha256": "^0.9.0",
|
||||||
|
|||||||
+37
-1
@@ -1,3 +1,39 @@
|
|||||||
import {StyleSheet} from 'react-native'
|
import {type DimensionValue, StyleSheet} from 'react-native'
|
||||||
|
|
||||||
export const flatten = StyleSheet.flatten
|
export const flatten = StyleSheet.flatten
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce a style value to a number. Padding values are typed as
|
||||||
|
* `DimensionValue` (numbers, percentages, "auto", etc.) but our ALF atoms
|
||||||
|
* are always plain numbers. Non-numeric values are treated as 0.
|
||||||
|
*/
|
||||||
|
function num(v: unknown): number {
|
||||||
|
return typeof v === 'number' ? v : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaddingStyle {
|
||||||
|
padding?: DimensionValue
|
||||||
|
paddingHorizontal?: DimensionValue
|
||||||
|
paddingVertical?: DimensionValue
|
||||||
|
paddingTop?: DimensionValue
|
||||||
|
paddingBottom?: DimensionValue
|
||||||
|
paddingLeft?: DimensionValue
|
||||||
|
paddingRight?: DimensionValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract resolved padding values from a style object. Returns numbers for
|
||||||
|
* each side, resolving shorthand properties (padding → paddingVertical →
|
||||||
|
* paddingTop/paddingBottom, etc.). Values are expected to be numbers — any
|
||||||
|
* non-numeric `DimensionValue` (e.g. percentages) is treated as 0.
|
||||||
|
*/
|
||||||
|
export function extractPadding(style: PaddingStyle | PaddingStyle[]) {
|
||||||
|
const s = flatten(style as any) ?? {}
|
||||||
|
const base = num(s.padding)
|
||||||
|
return {
|
||||||
|
paddingTop: num(s.paddingTop) || num(s.paddingVertical) || base,
|
||||||
|
paddingBottom: num(s.paddingBottom) || num(s.paddingVertical) || base,
|
||||||
|
paddingLeft: num(s.paddingLeft) || num(s.paddingHorizontal) || base,
|
||||||
|
paddingRight: num(s.paddingRight) || num(s.paddingHorizontal) || base,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export enum Features {
|
|||||||
LiveNowBetaDisable = 'live_now_beta:disable',
|
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||||
ImageUploadsHighResolution = 'image_uploads:high_resolution',
|
ImageUploadsHighResolution = 'image_uploads:high_resolution',
|
||||||
GroupChatsEnable = 'group_chats:enable',
|
GroupChatsEnable = 'group_chats:enable',
|
||||||
|
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||||
|
|
||||||
AATest = 'aa-test',
|
AATest = 'aa-test',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import {useCallback} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {Sift, type UseSiftReturn} from '@bsky.app/sift'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {type AutocompleteItem} from '#/components/Autocomplete/types'
|
||||||
|
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
|
||||||
|
import {Portal} from '#/components/Portal'
|
||||||
|
import {IS_WEB} from '#/env'
|
||||||
|
import {AutocompleteItemEmoji} from './AutocompleteItemEmoji'
|
||||||
|
import {AutocompleteItemProfile} from './AutocompleteItemProfile'
|
||||||
|
import {AutocompleteItemSearch} from './AutocompleteItemSearch'
|
||||||
|
|
||||||
|
function renderItem(
|
||||||
|
item: Parameters<Parameters<typeof Sift<AutocompleteItem>>[0]['render']>[0],
|
||||||
|
) {
|
||||||
|
switch (item.item.type) {
|
||||||
|
case 'profile':
|
||||||
|
return <AutocompleteItemProfile {...item} />
|
||||||
|
case 'emoji':
|
||||||
|
return <AutocompleteItemEmoji {...item} />
|
||||||
|
case 'search':
|
||||||
|
return <AutocompleteItemSearch {...item} />
|
||||||
|
default:
|
||||||
|
return <View />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Autocomplete({
|
||||||
|
inverted,
|
||||||
|
sift,
|
||||||
|
data,
|
||||||
|
render = renderItem,
|
||||||
|
onSelect,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
inverted?: boolean
|
||||||
|
sift: UseSiftReturn
|
||||||
|
data: AutocompleteItem[]
|
||||||
|
render?: Parameters<typeof Sift<AutocompleteItem>>[0]['render']
|
||||||
|
onSelect: (item: AutocompleteItem) => void
|
||||||
|
onDismiss: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
sift.updatePosition()
|
||||||
|
}, [sift])
|
||||||
|
|
||||||
|
useOnKeyboard('keyboardDidShow', updatePosition)
|
||||||
|
useOnKeyboard('keyboardDidHide', updatePosition)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<Sift
|
||||||
|
inverted={inverted}
|
||||||
|
sift={sift}
|
||||||
|
data={data}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
style={[
|
||||||
|
a.overflow_hidden,
|
||||||
|
a.rounded_md,
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg,
|
||||||
|
a.w_full,
|
||||||
|
IS_WEB
|
||||||
|
? {
|
||||||
|
maxWidth: 300,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
]}
|
||||||
|
render={render}
|
||||||
|
/>
|
||||||
|
</Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {SiftItem} from '@bsky.app/sift'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {type AutocompleteItemProps} from './types'
|
||||||
|
|
||||||
|
export function AutocompleteItemEmoji({
|
||||||
|
active,
|
||||||
|
props,
|
||||||
|
item,
|
||||||
|
}: AutocompleteItemProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
if (item.type !== 'emoji') return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiftItem
|
||||||
|
{...props}
|
||||||
|
style={s => [
|
||||||
|
{paddingVertical: 6, paddingHorizontal: 10},
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [],
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_xl, a.leading_tight]}>{item.value}</Text>
|
||||||
|
<Text style={[a.text_md, a.leading_tight]}>:{item.emoji.id}:</Text>
|
||||||
|
</SiftItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {SiftItem} from '@bsky.app/sift'
|
||||||
|
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
|
import {type AutocompleteItemProps} from './types'
|
||||||
|
|
||||||
|
export function AutocompleteItemProfile({
|
||||||
|
active,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
props,
|
||||||
|
item,
|
||||||
|
}: AutocompleteItemProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
|
if (item.type !== 'profile' || !moderationOpts) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiftItem
|
||||||
|
{...props}
|
||||||
|
style={s => [
|
||||||
|
a.py_sm,
|
||||||
|
a.px_md,
|
||||||
|
active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [],
|
||||||
|
isFirst && {
|
||||||
|
paddingTop: a.py_sm.paddingTop * 1.2,
|
||||||
|
},
|
||||||
|
isLast && {
|
||||||
|
paddingBottom: a.py_sm.paddingTop * 1.2,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<ProfileCard.Header>
|
||||||
|
<ProfileCard.Avatar
|
||||||
|
disabledPreview
|
||||||
|
profile={item.profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
<ProfileCard.NameAndHandle
|
||||||
|
profile={item.profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
</ProfileCard.Header>
|
||||||
|
</SiftItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
import {SiftItem} from '@bsky.app/sift'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/components/icons/MagnifyingGlass'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {type AutocompleteItemProps} from './types'
|
||||||
|
|
||||||
|
export function AutocompleteItemSearch({
|
||||||
|
active,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
props,
|
||||||
|
item,
|
||||||
|
}: AutocompleteItemProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
if (item.type !== 'search') return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiftItem
|
||||||
|
{...props}
|
||||||
|
style={s => [
|
||||||
|
a.py_sm,
|
||||||
|
a.px_md,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
active || s.hovered || s.pressed ? [t.atoms.bg_contrast_25] : [],
|
||||||
|
isFirst && {
|
||||||
|
paddingTop: a.py_sm.paddingTop * 1.2,
|
||||||
|
},
|
||||||
|
isLast && {
|
||||||
|
paddingBottom: a.py_sm.paddingTop * 1.2,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.align_center,
|
||||||
|
{
|
||||||
|
width: 40,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<MagnifyingGlassIcon fill={t.atoms.text_contrast_low.color} size="xl" />
|
||||||
|
</View>
|
||||||
|
<Text style={[a.text_md, a.leading_snug]}>{item.value}</Text>
|
||||||
|
</SiftItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './Autocomplete'
|
||||||
|
export * from './AutocompleteItemEmoji'
|
||||||
|
export * from './AutocompleteItemProfile'
|
||||||
|
export * from './types'
|
||||||
|
export * from './useAutocomplete'
|
||||||
|
export * from './util'
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {type Sift} from '@bsky.app/sift'
|
||||||
|
import {type Emoji} from '@emoji-mart/data'
|
||||||
|
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
export type AutocompleteProfile = {
|
||||||
|
key: string
|
||||||
|
type: 'profile'
|
||||||
|
value: string
|
||||||
|
profile: bsky.profile.AnyProfileView
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutocompleteTag = {
|
||||||
|
key: string
|
||||||
|
type: 'tag'
|
||||||
|
value: string
|
||||||
|
tag: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutocompleteEmoji = {
|
||||||
|
key: string
|
||||||
|
type: 'emoji'
|
||||||
|
value: string
|
||||||
|
emoji: Emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutocompleteSearch = {
|
||||||
|
key: string
|
||||||
|
type: 'search'
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutocompleteItem =
|
||||||
|
| AutocompleteProfile
|
||||||
|
| AutocompleteTag
|
||||||
|
| AutocompleteEmoji
|
||||||
|
| AutocompleteSearch
|
||||||
|
|
||||||
|
export type AutocompleteItemType = AutocompleteItem['type']
|
||||||
|
|
||||||
|
export type AutocompleteItemProps = Parameters<
|
||||||
|
Parameters<typeof Sift<AutocompleteItem>>[0]['render']
|
||||||
|
>[0]
|
||||||
|
|
||||||
|
export type AutocompleteApi = {
|
||||||
|
query: string
|
||||||
|
items: AutocompleteItem[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import {useCallback} from 'react'
|
||||||
|
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
||||||
|
import {keepPreviousData, useQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {STALE} from '#/state/queries'
|
||||||
|
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import {
|
||||||
|
type AutocompleteApi,
|
||||||
|
type AutocompleteItem,
|
||||||
|
type AutocompleteItemType,
|
||||||
|
type AutocompleteProfile,
|
||||||
|
} from '#/components/Autocomplete/types'
|
||||||
|
import {useEmojiSearch} from './useEmojiSearch'
|
||||||
|
|
||||||
|
const DEFAULT_MOD_OPTS = {
|
||||||
|
userDid: undefined,
|
||||||
|
prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutocomplete({
|
||||||
|
type,
|
||||||
|
query: q,
|
||||||
|
limit,
|
||||||
|
showSearchFallback = false,
|
||||||
|
}: {
|
||||||
|
type: AutocompleteItemType
|
||||||
|
query: string
|
||||||
|
limit?: number
|
||||||
|
showSearchFallback?: boolean
|
||||||
|
}): AutocompleteApi {
|
||||||
|
const agent = useAgent()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const emojiSearch = useEmojiSearch()
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
staleTime: STALE.MINUTES.ONE,
|
||||||
|
queryKey: [
|
||||||
|
'autocomplete',
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
query: q,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
async queryFn() {
|
||||||
|
if (type === 'profile') {
|
||||||
|
// TODO return recents
|
||||||
|
if (!q) return []
|
||||||
|
|
||||||
|
// Going from "foo" to "foo." should not clear matches.
|
||||||
|
q = q.toLowerCase().trim().replace(/\.$/, '')
|
||||||
|
|
||||||
|
const res = await agent.searchActorsTypeahead({
|
||||||
|
q,
|
||||||
|
limit: limit || 8,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (res?.data.actors || []).map(profile => ({
|
||||||
|
key: profile.did,
|
||||||
|
type: 'profile' as const,
|
||||||
|
value: '@' + profile.handle,
|
||||||
|
profile,
|
||||||
|
}))
|
||||||
|
} else if (type === 'emoji') {
|
||||||
|
return emojiSearch(q, limit || 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
select: useCallback(
|
||||||
|
(items: AutocompleteItem[]) => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
let results: AutocompleteItem[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (seen.has(item.key)) continue
|
||||||
|
seen.add(item.key)
|
||||||
|
|
||||||
|
if (item.type === 'profile') {
|
||||||
|
const moderated = moderateProfileItem({
|
||||||
|
query: q,
|
||||||
|
item,
|
||||||
|
moderationOpts: moderationOpts || DEFAULT_MOD_OPTS,
|
||||||
|
})
|
||||||
|
if (moderated) results.push(moderated)
|
||||||
|
} else {
|
||||||
|
results.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSearchFallback && q) {
|
||||||
|
results.unshift({
|
||||||
|
key: `search-${q}`,
|
||||||
|
type: 'search' as const,
|
||||||
|
value: q,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
},
|
||||||
|
[q, showSearchFallback, moderationOpts],
|
||||||
|
),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
query: q,
|
||||||
|
items: query.data || [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moderateProfileItem({
|
||||||
|
query,
|
||||||
|
item,
|
||||||
|
moderationOpts,
|
||||||
|
}: {
|
||||||
|
query: string
|
||||||
|
item: AutocompleteProfile
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
}) {
|
||||||
|
const modui = moderateProfile(item.profile, moderationOpts).ui('profileList')
|
||||||
|
const isExactMatch = query && item.profile.handle.toLowerCase() === query
|
||||||
|
|
||||||
|
if (
|
||||||
|
(isExactMatch && !moduiContainsHideableOffense(modui)) ||
|
||||||
|
!modui.filter ||
|
||||||
|
isJustAMute(modui)
|
||||||
|
) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {useCallback} from 'react'
|
||||||
|
import {type Emoji} from '@emoji-mart/data'
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
|
|
||||||
|
import {useGetEmojis} from '#/lib/useGetEmojis'
|
||||||
|
import {type AutocompleteEmoji} from '#/components/Autocomplete/types'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Lazily loaded Fuse instance for emoji search. Built once on first search,
|
||||||
|
* then reused for all subsequent searches.
|
||||||
|
*/
|
||||||
|
let emojiFuseInstance: Fuse<Emoji> | null = null
|
||||||
|
|
||||||
|
export function useEmojiSearch(): (
|
||||||
|
query: string,
|
||||||
|
limit?: number,
|
||||||
|
) => Promise<AutocompleteEmoji[]> {
|
||||||
|
const getEmojis = useGetEmojis()
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
async (query: string, limit: number = 8) => {
|
||||||
|
if (!emojiFuseInstance) {
|
||||||
|
const data = await getEmojis()
|
||||||
|
emojiFuseInstance = new Fuse(Object.values(data.emojis), {
|
||||||
|
keys: ['search'],
|
||||||
|
threshold: 0.3,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = emojiFuseInstance.search(query, {limit})
|
||||||
|
return results.map(result => ({
|
||||||
|
key: result.item.id,
|
||||||
|
type: 'emoji' as const,
|
||||||
|
value: result.item.skins[0].native,
|
||||||
|
emoji: result.item,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
[getEmojis],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function parseAutocompleteItemType(type: string) {
|
||||||
|
switch (type) {
|
||||||
|
case 'mention':
|
||||||
|
return 'profile'
|
||||||
|
case 'tag':
|
||||||
|
return 'tag'
|
||||||
|
case 'emoji':
|
||||||
|
return 'emoji'
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown autocomplete item type: ${type}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import {useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'
|
||||||
|
import {
|
||||||
|
type TextInput,
|
||||||
|
type TextInputSubmitEditingEvent,
|
||||||
|
View,
|
||||||
|
} from 'react-native'
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
|
import {useSift, type UseSiftReturn} from '@bsky.app/sift'
|
||||||
|
import {
|
||||||
|
facets,
|
||||||
|
type TapperActiveFacet,
|
||||||
|
type TapperFacet,
|
||||||
|
useTapper,
|
||||||
|
} from '@bsky.app/tapper'
|
||||||
|
|
||||||
|
import {mergeRefs} from '#/lib/merge-refs'
|
||||||
|
import {
|
||||||
|
atoms as a,
|
||||||
|
type TextStyleProp,
|
||||||
|
useAlf,
|
||||||
|
type ViewStyleProp,
|
||||||
|
web,
|
||||||
|
} from '#/alf'
|
||||||
|
import {normalizeTextStyles} from '#/alf/typography'
|
||||||
|
import {
|
||||||
|
Autocomplete as AutocompleteBase,
|
||||||
|
AutocompleteItemEmoji,
|
||||||
|
AutocompleteItemProfile,
|
||||||
|
parseAutocompleteItemType,
|
||||||
|
useAutocomplete,
|
||||||
|
} from '#/components/Autocomplete'
|
||||||
|
import {
|
||||||
|
AutosizedTextarea,
|
||||||
|
type AutosizedTextareaProps,
|
||||||
|
} from '#/components/forms/AutosizedTextarea'
|
||||||
|
import {Span, Text} from '#/components/Typography'
|
||||||
|
import {IS_IOS, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
|
||||||
|
|
||||||
|
export type SubmitRequest =
|
||||||
|
| {
|
||||||
|
platform: 'web'
|
||||||
|
shiftKey: boolean
|
||||||
|
metaKey: boolean
|
||||||
|
nativeEvent: KeyboardEvent
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
platform: 'native'
|
||||||
|
nativeEvent: TextInputSubmitEditingEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imperative API exposed via `internalApiRef` prop for parent components that
|
||||||
|
* need to control the composer programmatically, e.g. to clear the input or
|
||||||
|
* insert text at the current cursor position.
|
||||||
|
*/
|
||||||
|
export type ComposerInternalApi = {
|
||||||
|
input?: ReturnType<typeof useTapper>['input']
|
||||||
|
clear: () => void
|
||||||
|
insert(text: string): void
|
||||||
|
setAutocompleteAnchor: (node: View | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComposerInternalApiRef() {
|
||||||
|
return useRef<ComposerInternalApi>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ─── Composer ─────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ComposerProps = Omit<
|
||||||
|
AutosizedTextareaProps,
|
||||||
|
| 'value'
|
||||||
|
| 'onChange'
|
||||||
|
| 'onChangeText'
|
||||||
|
| 'onSelectionChange'
|
||||||
|
| 'selection'
|
||||||
|
| 'style'
|
||||||
|
| 'onSubmitEditing'
|
||||||
|
> & {
|
||||||
|
label: string
|
||||||
|
ref?: React.RefObject<TextInput>
|
||||||
|
internalApiRef?: React.Ref<ComposerInternalApi>
|
||||||
|
outerStyle?: ViewStyleProp['style']
|
||||||
|
contentTextStyle?: TextStyleProp['style']
|
||||||
|
contentPaddingStyle?: {
|
||||||
|
paddingTop?: number
|
||||||
|
paddingBottom?: number
|
||||||
|
paddingLeft?: number
|
||||||
|
paddingRight?: number
|
||||||
|
}
|
||||||
|
onChange?: (text: string) => void
|
||||||
|
onActiveFacet?: (activeFacet: TapperActiveFacet | null) => void
|
||||||
|
onFacetCommitted?: (facet: TapperFacet) => void
|
||||||
|
onRequestSubmit?: (request: SubmitRequest) => void
|
||||||
|
autocompletePlacement?: Exclude<
|
||||||
|
Parameters<typeof useSift>[0],
|
||||||
|
undefined
|
||||||
|
>['placement']
|
||||||
|
disableEmojiFacets?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Composer({
|
||||||
|
label,
|
||||||
|
ref,
|
||||||
|
internalApiRef,
|
||||||
|
outerStyle,
|
||||||
|
contentTextStyle,
|
||||||
|
contentPaddingStyle,
|
||||||
|
onChange: onChangeOuter,
|
||||||
|
onActiveFacet: onActiveFacetOuter,
|
||||||
|
onFacetCommitted: onFacetCommittedOuter,
|
||||||
|
onRequestSubmit,
|
||||||
|
autocompletePlacement,
|
||||||
|
defaultValue,
|
||||||
|
disableEmojiFacets = !IS_WEB,
|
||||||
|
...rest
|
||||||
|
}: ComposerProps) {
|
||||||
|
const {theme: t, fonts} = useAlf()
|
||||||
|
const insets = useSafeAreaInsets()
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Meat and potatoes
|
||||||
|
*/
|
||||||
|
const tapper = useTapper({
|
||||||
|
initialText: defaultValue ?? '',
|
||||||
|
facets: disableEmojiFacets
|
||||||
|
? {
|
||||||
|
mention: facets.mention,
|
||||||
|
tag: facets.tag,
|
||||||
|
url: facets.url,
|
||||||
|
}
|
||||||
|
: facets,
|
||||||
|
})
|
||||||
|
const sift = useSift({
|
||||||
|
offset: a.p_sm.padding,
|
||||||
|
placement: autocompletePlacement,
|
||||||
|
dynamicWidth: IS_WEB,
|
||||||
|
insets,
|
||||||
|
})
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Active facet state for controlling the visibility of the Autocomplete.
|
||||||
|
*/
|
||||||
|
const [activeFacet, setActiveFacet] = useState<TapperActiveFacet | null>(null)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Reanimated shared value for syncing scroll on all platforms.
|
||||||
|
*/
|
||||||
|
const inputScrollSharedValue = useSharedValue(0)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Expose imperative internal API
|
||||||
|
*/
|
||||||
|
useImperativeHandle(
|
||||||
|
internalApiRef,
|
||||||
|
() => ({
|
||||||
|
input: tapper.input,
|
||||||
|
clear: () => {
|
||||||
|
tapper.inputProps.onChangeText('')
|
||||||
|
inputScrollSharedValue.value = 0
|
||||||
|
},
|
||||||
|
insert: tapper.insert,
|
||||||
|
setAutocompleteAnchor: sift.refs.setAnchor,
|
||||||
|
}),
|
||||||
|
[tapper.input, tapper.insert, inputScrollSharedValue, sift.refs.setAnchor],
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Skip the initial mount to avoid an unnecessary re-render — the parent
|
||||||
|
* already knows the initial value since it passed `initialText`.
|
||||||
|
*/
|
||||||
|
const isFirstRender = useRef(true)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstRender.current) {
|
||||||
|
isFirstRender.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChangeOuter?.(tapper.state.text)
|
||||||
|
}, [tapper.state.text, onChangeOuter])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Tapper callbacks
|
||||||
|
*/
|
||||||
|
const callbackRefs = useRef({
|
||||||
|
onActiveFacetOuter,
|
||||||
|
onFacetCommittedOuter,
|
||||||
|
})
|
||||||
|
callbackRefs.current = {
|
||||||
|
onActiveFacetOuter,
|
||||||
|
onFacetCommittedOuter,
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
const offActiveFacet = tapper.on('activeFacet', facet => {
|
||||||
|
setActiveFacet(facet)
|
||||||
|
callbackRefs.current.onActiveFacetOuter?.(facet)
|
||||||
|
})
|
||||||
|
const offFacetCommitted = tapper.on('facetCommitted', facet => {
|
||||||
|
callbackRefs.current.onFacetCommittedOuter?.(facet)
|
||||||
|
})
|
||||||
|
const offAfterInsert = tapper.on('afterInsert', () => {
|
||||||
|
tapper.input.focus()
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
offActiveFacet()
|
||||||
|
offFacetCommitted()
|
||||||
|
offAfterInsert()
|
||||||
|
}
|
||||||
|
}, [tapper.on, tapper.input])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Styles
|
||||||
|
*/
|
||||||
|
const previewScrollStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{translateY: -inputScrollSharedValue.value}],
|
||||||
|
}))
|
||||||
|
const textStyle = useMemo(() => {
|
||||||
|
const ts = normalizeTextStyles(
|
||||||
|
[a.leading_snug, t.atoms.text, contentTextStyle],
|
||||||
|
{
|
||||||
|
fontScale: fonts.scaleMultiplier,
|
||||||
|
fontFamily: fonts.family,
|
||||||
|
flags: {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* On iOS, having a lineHeight on the Text component causes the text to be
|
||||||
|
* vertically misaligned with the TextInput.
|
||||||
|
*
|
||||||
|
* This only seems to be an issue on iOS, and not on Android or web. It's
|
||||||
|
* possible that this is a bug in React Native's Text component on iOS,
|
||||||
|
* but in the meantime, we'll just remove the lineHeight on iOS to ensure
|
||||||
|
* the text is properly aligned.
|
||||||
|
*/
|
||||||
|
if (IS_IOS) {
|
||||||
|
delete ts.lineHeight
|
||||||
|
}
|
||||||
|
return ts
|
||||||
|
}, [contentTextStyle, fonts])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Web keyboard handling
|
||||||
|
*/
|
||||||
|
const isComposing = useRef(false)
|
||||||
|
const onKeyPressWeb = (e: React.KeyboardEvent | any) => {
|
||||||
|
if (IS_WEB_TOUCH_DEVICE) return
|
||||||
|
if (isComposing.current) return
|
||||||
|
|
||||||
|
/*
|
||||||
|
* On Safari, the final keydown to dismiss an IME is also "Enter" with
|
||||||
|
* keyCode 229. Chrome/Firefox don't have this problem.
|
||||||
|
*
|
||||||
|
* @see https://github.com/bluesky-social/social-app/issues/4178
|
||||||
|
*/
|
||||||
|
if (e.key === 'Enter' && e.keyCode === 229) return
|
||||||
|
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
onRequestSubmit?.({
|
||||||
|
platform: 'web',
|
||||||
|
shiftKey: e.shiftKey,
|
||||||
|
metaKey: e.metaKey,
|
||||||
|
nativeEvent: e.nativeEvent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sift popover positioning
|
||||||
|
*/
|
||||||
|
const updateAutocompletePosition = () => {
|
||||||
|
sift.updatePosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
const textContent = (
|
||||||
|
<Text style={[textStyle, web({whiteSpace: 'pre-wrap'})]}>
|
||||||
|
{tapper.state.nodes.map((node, i) => {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'text':
|
||||||
|
return <Span key={i}>{node.value}</Span>
|
||||||
|
case 'trigger':
|
||||||
|
case 'facet':
|
||||||
|
return (
|
||||||
|
<Span
|
||||||
|
key={i}
|
||||||
|
ref={IS_WEB ? sift.refs.setAnchor : undefined}
|
||||||
|
style={
|
||||||
|
node.type === 'facet' && {
|
||||||
|
color: t.palette.primary_500,
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
{node.raw}
|
||||||
|
</Span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={[a.relative, outerStyle]}>
|
||||||
|
{IS_WEB && (
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
contentPaddingStyle,
|
||||||
|
{position: 'absolute', left: 0, right: 0},
|
||||||
|
previewScrollStyle,
|
||||||
|
]}>
|
||||||
|
{textContent}
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<AutosizedTextarea
|
||||||
|
placeholderTextColor={t.palette.contrast_500}
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityHint={label}
|
||||||
|
onSubmitEditing={e => {
|
||||||
|
onRequestSubmit?.({platform: 'native', nativeEvent: e})
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
textStyle,
|
||||||
|
contentPaddingStyle,
|
||||||
|
a.z_20,
|
||||||
|
{
|
||||||
|
color: 'transparent',
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
web({
|
||||||
|
caretColor: textStyle.color ?? 'black',
|
||||||
|
overscrollBehavior: 'none',
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
{...rest}
|
||||||
|
{...tapper.inputProps}
|
||||||
|
{...sift.targetProps}
|
||||||
|
ref={mergeRefs([ref, tapper.inputProps.ref, sift.targetProps.ref])}
|
||||||
|
onBlur={e => {
|
||||||
|
rest.onBlur?.(e)
|
||||||
|
setActiveFacet(null)
|
||||||
|
}}
|
||||||
|
onKeyPress={IS_WEB ? onKeyPressWeb : undefined}
|
||||||
|
onScroll={e => {
|
||||||
|
if (IS_WEB) {
|
||||||
|
inputScrollSharedValue.value = (e.target as any).scrollTop
|
||||||
|
} else {
|
||||||
|
inputScrollSharedValue.value = e.nativeEvent.contentOffset.y
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
// @ts-ignore web only
|
||||||
|
onCompositionStart={() => {
|
||||||
|
isComposing.current = true
|
||||||
|
}}
|
||||||
|
// @ts-ignore web only
|
||||||
|
onCompositionEnd={() => {
|
||||||
|
isComposing.current = false
|
||||||
|
}}
|
||||||
|
onUpdateHeight={updateAutocompletePosition}>
|
||||||
|
{IS_WEB ? null : textContent}
|
||||||
|
</AutosizedTextarea>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{activeFacet && activeFacet.type !== 'url' && (
|
||||||
|
<AutocompleteInner
|
||||||
|
sift={sift}
|
||||||
|
activeFacet={activeFacet}
|
||||||
|
onDismiss={() => setActiveFacet(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ─── Autocomplete (private) ───────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
function AutocompleteInner({
|
||||||
|
sift,
|
||||||
|
activeFacet,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
sift: UseSiftReturn
|
||||||
|
activeFacet: TapperActiveFacet
|
||||||
|
onDismiss: () => void
|
||||||
|
}) {
|
||||||
|
const {items} = useAutocomplete({
|
||||||
|
type: parseAutocompleteItemType(activeFacet.type),
|
||||||
|
query: activeFacet.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
activeFacet?.type === 'emoji' &&
|
||||||
|
!!activeFacet.value.length &&
|
||||||
|
activeFacet.raw.endsWith(':')
|
||||||
|
) {
|
||||||
|
if (items?.[0]) {
|
||||||
|
activeFacet.replace(items[0].value, {noTrailingSpace: true})
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [items, activeFacet])
|
||||||
|
|
||||||
|
return items && items.length ? (
|
||||||
|
<AutocompleteBase
|
||||||
|
inverted={!IS_WEB}
|
||||||
|
sift={sift}
|
||||||
|
data={items}
|
||||||
|
render={props => {
|
||||||
|
if (props.item.type === 'profile') {
|
||||||
|
return <AutocompleteItemProfile {...props} />
|
||||||
|
}
|
||||||
|
if (props.item.type === 'emoji') {
|
||||||
|
return <AutocompleteItemEmoji {...props} />
|
||||||
|
}
|
||||||
|
return <View />
|
||||||
|
}}
|
||||||
|
onSelect={item => {
|
||||||
|
activeFacet.replace(item.value)
|
||||||
|
onDismiss()
|
||||||
|
}}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {useMemo, useRef, useState} from 'react'
|
||||||
|
import {
|
||||||
|
TextInput,
|
||||||
|
type TextInputContentSizeChangeEvent,
|
||||||
|
type TextInputProps,
|
||||||
|
} from 'react-native'
|
||||||
|
|
||||||
|
import {mergeRefs} from '#/lib/merge-refs'
|
||||||
|
import {atoms as a, extractPadding, useAlf, web} from '#/alf'
|
||||||
|
import {normalizeTextStyles} from '#/alf/typography'
|
||||||
|
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
|
||||||
|
|
||||||
|
export type AutosizedTextareaProps = Omit<TextInputProps, 'multiline'> & {
|
||||||
|
ref?: React.Ref<TextInput>
|
||||||
|
label: string
|
||||||
|
minRows?: number
|
||||||
|
maxRows?: number
|
||||||
|
onUpdateHeight?: (height: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutosizedTextarea({
|
||||||
|
ref,
|
||||||
|
label,
|
||||||
|
minRows = 1,
|
||||||
|
maxRows,
|
||||||
|
onUpdateHeight,
|
||||||
|
|
||||||
|
onChangeText: onChangeTextOuter,
|
||||||
|
onContentSizeChange: onContentSizeChangeOuter,
|
||||||
|
style: outerStyle,
|
||||||
|
...rest
|
||||||
|
}: AutosizedTextareaProps) {
|
||||||
|
const {theme: t, fonts} = useAlf()
|
||||||
|
const internalRef = useRef<TextInput>(null)
|
||||||
|
const {style, minInputHeight, maxInputHeight, verticalContentPadding} =
|
||||||
|
useMemo(() => {
|
||||||
|
const normalizedStyles = normalizeTextStyles(
|
||||||
|
[a.text_md, a.leading_snug, t.atoms.text, outerStyle],
|
||||||
|
{
|
||||||
|
fontScale: fonts.scaleMultiplier,
|
||||||
|
fontFamily: fonts.family,
|
||||||
|
flags: {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const lineHeight = normalizedStyles.lineHeight || 20
|
||||||
|
const {paddingTop, paddingBottom} = extractPadding(normalizedStyles ?? {})
|
||||||
|
const verticalContentPadding = paddingTop + paddingBottom
|
||||||
|
const minInputHeight = lineHeight * minRows + verticalContentPadding
|
||||||
|
const maxInputHeight = maxRows
|
||||||
|
? lineHeight * maxRows + verticalContentPadding
|
||||||
|
: Infinity
|
||||||
|
|
||||||
|
/*
|
||||||
|
* iOS: minHeight/maxHeight works fine natively.
|
||||||
|
* Web + Android: we set an explicit initial height and resize dynamically
|
||||||
|
* (web via DOM measurement, Android via onContentSizeChange state).
|
||||||
|
*
|
||||||
|
* iOS also seems to need 1px headroom to actually expand to the correct
|
||||||
|
* maxHeight
|
||||||
|
*/
|
||||||
|
const heightConstraints = IS_IOS
|
||||||
|
? {minHeight: minInputHeight, maxHeight: maxInputHeight + 1}
|
||||||
|
: {height: minInputHeight}
|
||||||
|
|
||||||
|
return {
|
||||||
|
style: {
|
||||||
|
...normalizedStyles,
|
||||||
|
...heightConstraints,
|
||||||
|
},
|
||||||
|
minInputHeight,
|
||||||
|
maxInputHeight,
|
||||||
|
verticalContentPadding,
|
||||||
|
}
|
||||||
|
}, [t, fonts, outerStyle, minRows, maxRows])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Web handling
|
||||||
|
*/
|
||||||
|
const prevWebHeight = useRef(0)
|
||||||
|
const handleResizeWeb = () => {
|
||||||
|
const el = internalRef.current as unknown as HTMLTextAreaElement
|
||||||
|
if (!el) return
|
||||||
|
// collapse to get natural scroll height
|
||||||
|
el.style.height = '0px'
|
||||||
|
const scrollHeight = Math.ceil(el.scrollHeight)
|
||||||
|
const nextHeight = Math.min(
|
||||||
|
Math.max(scrollHeight, minInputHeight),
|
||||||
|
maxInputHeight,
|
||||||
|
)
|
||||||
|
// immediately update height to prevent flicker
|
||||||
|
el.style.height = `${nextHeight}px`
|
||||||
|
el.style.overflowY = scrollHeight > maxInputHeight ? 'auto' : 'hidden'
|
||||||
|
if (nextHeight !== prevWebHeight.current) {
|
||||||
|
prevWebHeight.current = nextHeight
|
||||||
|
onUpdateHeight?.(nextHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onChangeText = (text: string) => {
|
||||||
|
if (IS_WEB) handleResizeWeb()
|
||||||
|
onChangeTextOuter?.(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Native handling
|
||||||
|
*
|
||||||
|
* We track the height as state on native, and on Android, we use this to
|
||||||
|
* directly drive the `height`.
|
||||||
|
*/
|
||||||
|
const [nativeHeight, setNativeHeight] = useState(minInputHeight)
|
||||||
|
const onContentSizeChange = (e: TextInputContentSizeChangeEvent) => {
|
||||||
|
const contentSize = Math.ceil(e.nativeEvent.contentSize.height)
|
||||||
|
// ios reports the content size without padding
|
||||||
|
const height = IS_IOS ? contentSize + verticalContentPadding : contentSize
|
||||||
|
const nextHeight = Math.min(
|
||||||
|
Math.max(height, minInputHeight),
|
||||||
|
maxInputHeight,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (nextHeight !== nativeHeight) {
|
||||||
|
setNativeHeight(nextHeight)
|
||||||
|
onUpdateHeight?.(nextHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
onContentSizeChangeOuter?.(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
multiline
|
||||||
|
placeholderTextColor={t.palette.contrast_500}
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityHint={label}
|
||||||
|
placeholder={label}
|
||||||
|
keyboardAppearance={t.scheme}
|
||||||
|
submitBehavior="newline"
|
||||||
|
scrollEnabled={nativeHeight >= maxInputHeight}
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
a.border_0,
|
||||||
|
{
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
includeFontPadding: false,
|
||||||
|
},
|
||||||
|
web({
|
||||||
|
resize: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}),
|
||||||
|
style,
|
||||||
|
IS_ANDROID ? {height: nativeHeight} : {},
|
||||||
|
]}
|
||||||
|
{...rest}
|
||||||
|
ref={mergeRefs([
|
||||||
|
(node: TextInput | null) => {
|
||||||
|
internalRef.current = node
|
||||||
|
// bop resize on first render
|
||||||
|
if (IS_WEB && node) handleResizeWeb()
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
])}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
onContentSizeChange={onContentSizeChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {type TextInput, View} from 'react-native'
|
|||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {HITSLOP_10} from '#/lib/constants'
|
import {HITSLOP_10} from '#/lib/constants'
|
||||||
|
import {mergeRefs} from '#/lib/merge-refs'
|
||||||
import {listenFocusSearch} from '#/state/events'
|
import {listenFocusSearch} from '#/state/events'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
@@ -18,7 +19,7 @@ type Props = Omit<TextField.InputProps, 'label'> & {
|
|||||||
*/
|
*/
|
||||||
onClearText?: () => void
|
onClearText?: () => void
|
||||||
hotkey?: boolean
|
hotkey?: boolean
|
||||||
ref?: React.RefObject<TextInput | null>
|
ref?: React.Ref<TextInput>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchInput({
|
export function SearchInput({
|
||||||
@@ -33,21 +34,20 @@ export function SearchInput({
|
|||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const showClear = value && value.length > 0
|
const showClear = value && value.length > 0
|
||||||
const internalRef = useRef<TextInput>(null)
|
const internalRef = useRef<TextInput>(null)
|
||||||
const inputRef = ref ?? internalRef
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hotkey) return
|
if (!hotkey) return
|
||||||
return listenFocusSearch(() => {
|
return listenFocusSearch(() => {
|
||||||
inputRef.current?.focus()
|
internalRef.current?.focus()
|
||||||
})
|
})
|
||||||
}, [hotkey, inputRef])
|
}, [hotkey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.w_full, a.relative]}>
|
<View style={[a.w_full, a.relative]}>
|
||||||
<TextField.Root>
|
<TextField.Root>
|
||||||
<TextField.Icon icon={MagnifyingGlassIcon} />
|
<TextField.Icon icon={MagnifyingGlassIcon} />
|
||||||
<TextField.Input
|
<TextField.Input
|
||||||
inputRef={inputRef}
|
inputRef={mergeRefs([internalRef, ref])}
|
||||||
label={label || l`Search`}
|
label={label || l`Search`}
|
||||||
value={value}
|
value={value}
|
||||||
placeholder={l`Search`}
|
placeholder={l`Search`}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* returns a ref callback function that can be used to merge multiple refs into a single ref.
|
* returns a ref callback function that can be used to merge multiple refs into a single ref.
|
||||||
*/
|
*/
|
||||||
export function mergeRefs<T = any>(
|
export function mergeRefs<T = any>(
|
||||||
refs: Array<React.MutableRefObject<T> | React.Ref<T>>,
|
refs: Array<React.MutableRefObject<T> | React.Ref<T> | undefined>,
|
||||||
): React.RefCallback<T> {
|
): React.RefCallback<T> {
|
||||||
return value => {
|
return value => {
|
||||||
refs.forEach(ref => {
|
refs.forEach(ref => {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import Emojis, {type EmojiMartData} from '@emoji-mart/data'
|
||||||
|
|
||||||
|
export async function getEmojis(): Promise<EmojiMartData> {
|
||||||
|
return Emojis as EmojiMartData
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import {type EmojiMartData} from '@emoji-mart/data'
|
||||||
|
|
||||||
|
export async function getEmojis(): Promise<EmojiMartData> {
|
||||||
|
return (await import('@emoji-mart/data')).default as EmojiMartData
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import {useCallback} from 'react'
|
||||||
|
|
||||||
|
import {getEmojis} from './getEmojis'
|
||||||
|
|
||||||
|
let emojis: Awaited<ReturnType<typeof getEmojis>> | null = null
|
||||||
|
|
||||||
|
export function useGetEmojis() {
|
||||||
|
return useCallback(async () => {
|
||||||
|
emojis ??= await getEmojis()
|
||||||
|
return emojis
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {Pressable, View} from 'react-native'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||||
|
|
||||||
|
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||||
|
import {useHaptics} from '#/lib/haptics'
|
||||||
|
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
|
||||||
|
import {useEmail} from '#/state/email-verification'
|
||||||
|
import {
|
||||||
|
useMessageDraft,
|
||||||
|
useSaveMessageDraft,
|
||||||
|
} from '#/state/messages/message-drafts'
|
||||||
|
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||||
|
import {
|
||||||
|
type Emoji,
|
||||||
|
EmojiPicker,
|
||||||
|
type EmojiPickerState,
|
||||||
|
} from '#/view/com/composer/text-input/web/EmojiPicker'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Composer, useComposerInternalApiRef} from '#/components/Composer'
|
||||||
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
|
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
||||||
|
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
||||||
|
import * as Toast from '#/components/Toast'
|
||||||
|
import {IS_WEB} from '#/env'
|
||||||
|
|
||||||
|
export function MessageComposer({
|
||||||
|
onSendMessage,
|
||||||
|
hasEmbed,
|
||||||
|
setEmbed,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onSendMessage: (message: string) => void
|
||||||
|
hasEmbed: boolean
|
||||||
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const playHaptic = useHaptics()
|
||||||
|
const {needsEmailVerification} = useEmail()
|
||||||
|
const editable = !needsEmailVerification
|
||||||
|
const {getDraft, clearDraft} = useMessageDraft()
|
||||||
|
const [emojiPickerState, setEmojiPickerState] = useState<EmojiPickerState>({
|
||||||
|
isOpen: false,
|
||||||
|
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
|
||||||
|
})
|
||||||
|
const composerInternalApiRef = useComposerInternalApiRef()
|
||||||
|
|
||||||
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||||
|
const {
|
||||||
|
state: hovered,
|
||||||
|
onIn: onHoverIn,
|
||||||
|
onOut: onHoverOut,
|
||||||
|
} = useInteractionState()
|
||||||
|
|
||||||
|
const [text, setText] = useState(getDraft)
|
||||||
|
useSaveMessageDraft(text)
|
||||||
|
|
||||||
|
const openEmojiPicker = (pos: any) => {
|
||||||
|
setEmojiPickerState({isOpen: true, pos})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
if (!editable) return
|
||||||
|
if (!hasEmbed && text.trim() === '') return
|
||||||
|
if (countGraphemes(text) > MAX_DM_GRAPHEME_LENGTH) {
|
||||||
|
Toast.show(l`Message is too long`, {
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDraft()
|
||||||
|
onSendMessage(text)
|
||||||
|
playHaptic()
|
||||||
|
setEmbed(undefined)
|
||||||
|
composerInternalApiRef.current?.clear()
|
||||||
|
|
||||||
|
if (IS_WEB) {
|
||||||
|
composerInternalApiRef.current?.input?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onEmojiInserted(emoji: Emoji) {
|
||||||
|
composerInternalApiRef.current?.insert(emoji.native)
|
||||||
|
}
|
||||||
|
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
|
||||||
|
return () => {
|
||||||
|
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<View
|
||||||
|
collapsable={false}
|
||||||
|
ref={
|
||||||
|
IS_WEB
|
||||||
|
? undefined
|
||||||
|
: node => {
|
||||||
|
composerInternalApiRef.current?.setAutocompleteAnchor(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// @ts-expect-error web only
|
||||||
|
onMouseEnter={onHoverIn}
|
||||||
|
onMouseLeave={onHoverOut}
|
||||||
|
style={[a.w_full, a.flex_row, a.gap_sm]}>
|
||||||
|
{IS_WEB && (
|
||||||
|
<Pressable
|
||||||
|
onPress={e => {
|
||||||
|
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
|
||||||
|
openEmojiPicker?.({
|
||||||
|
top: py,
|
||||||
|
left: px,
|
||||||
|
right: px,
|
||||||
|
bottom: py,
|
||||||
|
nextFocusRef: {
|
||||||
|
current: composerInternalApiRef.current?.input?.element,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
a.overflow_hidden,
|
||||||
|
a.absolute,
|
||||||
|
a.rounded_full,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
a.z_30,
|
||||||
|
{
|
||||||
|
height: 30,
|
||||||
|
width: 30,
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
accessibilityLabel={l`Open emoji picker`}
|
||||||
|
accessibilityHint="">
|
||||||
|
{state => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.inset_0,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
state.hovered || state.focused || state.pressed
|
||||||
|
? t.atoms.bg.backgroundColor
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<EmojiSmile size="lg" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Composer
|
||||||
|
label={l`Message input field`}
|
||||||
|
placeholder={l`Write a message`}
|
||||||
|
autocompletePlacement="top-start"
|
||||||
|
internalApiRef={composerInternalApiRef}
|
||||||
|
defaultValue={text}
|
||||||
|
editable={editable}
|
||||||
|
autoFocus={IS_WEB}
|
||||||
|
maxRows={12}
|
||||||
|
outerStyle={[
|
||||||
|
a.flex_1,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
borderRadius: 22,
|
||||||
|
},
|
||||||
|
editable &&
|
||||||
|
hovered && {
|
||||||
|
borderColor: t.atoms.border_contrast_medium.borderColor,
|
||||||
|
},
|
||||||
|
editable &&
|
||||||
|
focused && {
|
||||||
|
borderColor: t.palette.primary_500,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
contentTextStyle={[a.text_md, a.leading_snug]}
|
||||||
|
contentPaddingStyle={{
|
||||||
|
paddingLeft: IS_WEB ? 30 + 12 : 12,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 12,
|
||||||
|
paddingRight: 12,
|
||||||
|
}}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChange={setText}
|
||||||
|
onFacetCommitted={facet => {
|
||||||
|
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
|
||||||
|
setEmbed(facet.value)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onRequestSubmit={req => {
|
||||||
|
if (req.platform === 'web' && req.shiftKey) return
|
||||||
|
req.nativeEvent.preventDefault()
|
||||||
|
onSubmit()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{focused || text.length ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={l`Send message`}
|
||||||
|
accessibilityHint=""
|
||||||
|
hitSlop={HITSLOP_10}
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
a.self_end,
|
||||||
|
a.z_30,
|
||||||
|
{
|
||||||
|
height: 44,
|
||||||
|
width: 44,
|
||||||
|
backgroundColor: t.palette.primary_500,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={onSubmit}
|
||||||
|
disabled={!editable}>
|
||||||
|
<PaperPlane
|
||||||
|
fill={t.palette.white}
|
||||||
|
style={[a.relative, {left: 1}]}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{IS_WEB && (
|
||||||
|
<EmojiPicker
|
||||||
|
pinToTop
|
||||||
|
state={emojiPickerState}
|
||||||
|
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
} from '#/view/com/composer/text-input/web/EmojiPicker'
|
} from '#/view/com/composer/text-input/web/EmojiPicker'
|
||||||
import {List, type ListMethods} from '#/view/com/util/List'
|
import {List, type ListMethods} from '#/view/com/util/List'
|
||||||
import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
|
import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
|
||||||
|
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
|
||||||
import {MessageInput} from '#/screens/Messages/components/MessageInput'
|
import {MessageInput} from '#/screens/Messages/components/MessageInput'
|
||||||
import {MessageListError} from '#/screens/Messages/components/MessageListError'
|
import {MessageListError} from '#/screens/Messages/components/MessageListError'
|
||||||
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
|
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
|
||||||
@@ -50,8 +51,8 @@ import {MessageItem} from '#/components/dms/MessageItem'
|
|||||||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_WEB} from '#/env'
|
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||||
import {ChatStatusInfo} from './ChatStatusInfo'
|
import {ChatStatusInfo} from './ChatStatusInfo'
|
||||||
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
|
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ export function MessagesList({
|
|||||||
footer?: React.ReactNode
|
footer?: React.ReactNode
|
||||||
hasAcceptOverride?: boolean
|
hasAcceptOverride?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const ax = useAnalytics()
|
||||||
const convoState = useConvoActive()
|
const convoState = useConvoActive()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const getPost = useGetPost()
|
const getPost = useGetPost()
|
||||||
@@ -457,6 +459,14 @@ export function MessagesList({
|
|||||||
<ConversationFooter
|
<ConversationFooter
|
||||||
convoState={convoState}
|
convoState={convoState}
|
||||||
hasAcceptOverride={hasAcceptOverride}>
|
hasAcceptOverride={hasAcceptOverride}>
|
||||||
|
{ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
|
||||||
|
<MessageComposer
|
||||||
|
onSendMessage={onSendMessage}
|
||||||
|
hasEmbed={!!embedUri}
|
||||||
|
setEmbed={setEmbed}>
|
||||||
|
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
||||||
|
</MessageComposer>
|
||||||
|
) : (
|
||||||
<MessageInput
|
<MessageInput
|
||||||
onSendMessage={onSendMessage}
|
onSendMessage={onSendMessage}
|
||||||
hasEmbed={!!embedUri}
|
hasEmbed={!!embedUri}
|
||||||
@@ -464,6 +474,7 @@ export function MessagesList({
|
|||||||
openEmojiPicker={onOpenEmojiPicker}>
|
openEmojiPicker={onOpenEmojiPicker}>
|
||||||
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
||||||
</MessageInput>
|
</MessageInput>
|
||||||
|
)}
|
||||||
</ConversationFooter>
|
</ConversationFooter>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import {memo} from 'react'
|
import {memo} from 'react'
|
||||||
import {ActivityIndicator, View} from 'react-native'
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
type ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
import {type AppBskyActorDefs} from '@atproto/api'
|
import {type AppBskyActorDefs} from '@atproto/api'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {SearchLinkCard} from '#/view/shell/desktop/Search'
|
import {Link} from '#/view/com/util/Link'
|
||||||
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
import {SearchProfileCard} from '#/screens/Search/components/SearchProfileCard'
|
import {SearchProfileCard} from '#/screens/Search/components/SearchProfileCard'
|
||||||
import {atoms as a, native} from '#/alf'
|
import {atoms as a, native} from '#/alf'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
@@ -76,3 +83,52 @@ let AutocompleteResults = ({
|
|||||||
}
|
}
|
||||||
AutocompleteResults = memo(AutocompleteResults)
|
AutocompleteResults = memo(AutocompleteResults)
|
||||||
export {AutocompleteResults}
|
export {AutocompleteResults}
|
||||||
|
|
||||||
|
let SearchLinkCard = ({
|
||||||
|
label,
|
||||||
|
to,
|
||||||
|
onPress,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
to?: string
|
||||||
|
onPress?: () => void
|
||||||
|
style?: ViewStyle
|
||||||
|
}): React.ReactNode => {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<View
|
||||||
|
style={[pal.border, {paddingVertical: 16, paddingHorizontal: 12}, style]}>
|
||||||
|
<Text type="md" style={[pal.text]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (onPress) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityHint="">
|
||||||
|
{inner}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={to} asAnchor anchorNoUnderline>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
pal.border,
|
||||||
|
{paddingVertical: 16, paddingHorizontal: 12},
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
<Text type="md" style={[pal.text]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import Animated from 'react-native-reanimated'
|
|||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {HITSLOP_10} from '#/lib/constants'
|
import {HITSLOP_10} from '#/lib/constants'
|
||||||
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||||
import {useHaptics} from '#/lib/haptics'
|
import {useHaptics} from '#/lib/haptics'
|
||||||
import {useMinimalShellHeaderTransform} from '#/lib/hooks/useMinimalShellTransform'
|
import {useMinimalShellHeaderTransform} from '#/lib/hooks/useMinimalShellTransform'
|
||||||
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {emitSoftReset} from '#/state/events'
|
import {emitSoftReset} from '#/state/events'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||||
@@ -17,7 +19,7 @@ import {ButtonIcon} from '#/components/Button'
|
|||||||
import {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag'
|
import {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
import {Link} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
import {IS_LIQUID_GLASS} from '#/env'
|
import {IS_DEV, IS_LIQUID_GLASS} from '#/env'
|
||||||
|
|
||||||
export function HomeHeaderLayoutMobile({
|
export function HomeHeaderLayoutMobile({
|
||||||
children,
|
children,
|
||||||
@@ -32,6 +34,7 @@ export function HomeHeaderLayoutMobile({
|
|||||||
const headerMinimalShellTransform = useMinimalShellHeaderTransform()
|
const headerMinimalShellTransform = useMinimalShellHeaderTransform()
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
const playHaptic = useHaptics()
|
const playHaptic = useHaptics()
|
||||||
|
const {navigate} = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -59,8 +62,12 @@ export function HomeHeaderLayoutMobile({
|
|||||||
<PressableScale
|
<PressableScale
|
||||||
targetScale={0.9}
|
targetScale={0.9}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
if (IS_DEV) {
|
||||||
|
navigate('Debug')
|
||||||
|
} else {
|
||||||
playHaptic('Light')
|
playHaptic('Light')
|
||||||
emitSoftReset()
|
emitSoftReset()
|
||||||
|
}
|
||||||
}}>
|
}}>
|
||||||
<Logo width={30} />
|
<Logo width={30} />
|
||||||
</PressableScale>
|
</PressableScale>
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import {type TextInput, View} from 'react-native'
|
|||||||
|
|
||||||
import {APP_LANGUAGES} from '#/lib/../locale/languages'
|
import {APP_LANGUAGES} from '#/lib/../locale/languages'
|
||||||
import {type CountryCode} from '#/lib/international-telephone-codes'
|
import {type CountryCode} from '#/lib/international-telephone-codes'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {AutosizedTextarea} from '#/components/forms/AutosizedTextarea'
|
||||||
import {DateField, LabelText} from '#/components/forms/DateField'
|
import {DateField, LabelText} from '#/components/forms/DateField'
|
||||||
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
@@ -16,6 +17,7 @@ import * as Select from '#/components/Select'
|
|||||||
import {H1, H3} from '#/components/Typography'
|
import {H1, H3} from '#/components/Typography'
|
||||||
|
|
||||||
export function Forms() {
|
export function Forms() {
|
||||||
|
const t = useTheme()
|
||||||
const [toggleGroupAValues, setToggleGroupAValues] = useState(['a'])
|
const [toggleGroupAValues, setToggleGroupAValues] = useState(['a'])
|
||||||
const [toggleGroupBValues, setToggleGroupBValues] = useState(['a', 'b'])
|
const [toggleGroupBValues, setToggleGroupBValues] = useState(['a', 'b'])
|
||||||
const [toggleGroupCValues, setToggleGroupCValues] = useState(['a', 'b'])
|
const [toggleGroupCValues, setToggleGroupCValues] = useState(['a', 'b'])
|
||||||
@@ -36,6 +38,44 @@ export function Forms() {
|
|||||||
<View style={[a.gap_4xl, a.align_start]}>
|
<View style={[a.gap_4xl, a.align_start]}>
|
||||||
<H1>Forms</H1>
|
<H1>Forms</H1>
|
||||||
|
|
||||||
|
<View style={[a.gap_md, a.align_start, a.w_full]}>
|
||||||
|
<AutosizedTextarea
|
||||||
|
label="minRows=1 maxRows=5"
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.p_md,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_medium,
|
||||||
|
]}
|
||||||
|
maxRows={5}
|
||||||
|
/>
|
||||||
|
<AutosizedTextarea
|
||||||
|
label="defaultValue minRows=1 maxRows=2"
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.p_md,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_medium,
|
||||||
|
]}
|
||||||
|
maxRows={2}
|
||||||
|
defaultValue="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec auctor, nisl eget ultricies lacinia, nunc nisl aliquam nisl, eget aliquam nunc nisl eget nunc."
|
||||||
|
/>
|
||||||
|
<AutosizedTextarea
|
||||||
|
label="minRows=3 maxRows=10"
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.p_md,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_medium,
|
||||||
|
]}
|
||||||
|
minRows={3}
|
||||||
|
maxRows={10}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
<Select.Root value={lang} onValueChange={setLang}>
|
<Select.Root value={lang} onValueChange={setLang}>
|
||||||
<Select.Trigger label="Select app language">
|
<Select.Trigger label="Select app language">
|
||||||
<Select.ValueText />
|
<Select.ValueText />
|
||||||
|
|||||||
@@ -1,157 +1,109 @@
|
|||||||
import {memo, useCallback, useState} from 'react'
|
import {useState} from 'react'
|
||||||
import {
|
import {View} from 'react-native'
|
||||||
type StyleProp,
|
import {useSift} from '@bsky.app/sift'
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
type ViewStyle,
|
|
||||||
} from 'react-native'
|
|
||||||
import {useLingui} from '@lingui/react/macro'
|
|
||||||
import {StackActions, useNavigation} from '@react-navigation/native'
|
import {StackActions, useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {atoms as a} from '#/alf'
|
||||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
import {
|
||||||
import {SearchProfileCard} from '#/screens/Search/components/SearchProfileCard'
|
Autocomplete as AutocompleteBase,
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
type AutocompleteItem,
|
||||||
|
useAutocomplete,
|
||||||
|
} from '#/components/Autocomplete'
|
||||||
import {SearchInput} from '#/components/forms/SearchInput'
|
import {SearchInput} from '#/components/forms/SearchInput'
|
||||||
import {Link} from '#/components/Link'
|
|
||||||
import {Loader} from '#/components/Loader'
|
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
|
|
||||||
const WHITESPACE_RE = /\s+/gu
|
export function DesktopSearch() {
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const [active, setActive] = useState(false)
|
||||||
|
const [query, setQuery] = useState<string>('')
|
||||||
|
const showResults = active && !!query.length
|
||||||
|
|
||||||
let SearchLinkCard = ({
|
const sift = useSift({
|
||||||
label,
|
offset: a.p_sm.padding,
|
||||||
to,
|
placement: 'bottom',
|
||||||
onPress,
|
})
|
||||||
style,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
to?: string
|
|
||||||
onPress?: () => void
|
|
||||||
style?: StyleProp<ViewStyle>
|
|
||||||
}): React.ReactNode => {
|
|
||||||
const t = useTheme()
|
|
||||||
|
|
||||||
const inner = (
|
const onFocus = () => {
|
||||||
<View style={[a.py_lg, a.px_md, t.atoms.border_contrast_low, style]}>
|
if (query.length) setActive(true)
|
||||||
<Text style={[a.text_md, t.atoms.text]}>{label}</Text>
|
}
|
||||||
</View>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (onPress || !to) {
|
const onChangeText = (text: string) => {
|
||||||
return (
|
setQuery(text)
|
||||||
<TouchableOpacity
|
if (!active) {
|
||||||
onPress={onPress}
|
setActive(true)
|
||||||
accessibilityLabel={label}
|
}
|
||||||
accessibilityHint="">
|
}
|
||||||
{inner}
|
|
||||||
</TouchableOpacity>
|
const onClearText = () => {
|
||||||
)
|
setQuery('')
|
||||||
|
setActive(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
if (!query.length) return
|
||||||
|
onClearText()
|
||||||
|
sift.elements.input.blur()
|
||||||
|
navigation.dispatch(StackActions.push('Search', {q: query}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelect = (item: AutocompleteItem) => {
|
||||||
|
if (item.type === 'profile') {
|
||||||
|
onClearText()
|
||||||
|
sift.elements.input.blur()
|
||||||
|
navigation.navigate('Profile', {name: item.profile.handle})
|
||||||
|
} else if (item.type === 'search') {
|
||||||
|
onClearText()
|
||||||
|
sift.elements.input.blur()
|
||||||
|
navigation.navigate('Search', {q: item.value})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<View collapsable={false} ref={sift.refs.setAnchor}>
|
||||||
label={label}
|
|
||||||
to={to}
|
|
||||||
style={[a.py_lg, a.px_md, t.atoms.border_contrast_low, style]}
|
|
||||||
hoverStyle={[t.atoms.bg_contrast_25]}>
|
|
||||||
<Text style={[a.text_md, t.atoms.text]}>{label}</Text>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
SearchLinkCard = memo(SearchLinkCard)
|
|
||||||
export {SearchLinkCard}
|
|
||||||
|
|
||||||
export function DesktopSearch() {
|
|
||||||
const t = useTheme()
|
|
||||||
const {t: l} = useLingui()
|
|
||||||
const navigation = useNavigation<NavigationProp>()
|
|
||||||
const [isActive, setIsActive] = useState<boolean>(false)
|
|
||||||
const [query, setQuery] = useState<string>('')
|
|
||||||
const {data: autocompleteData, isFetching} = useActorAutocompleteQuery(
|
|
||||||
query,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
const tQuery = query.replace(WHITESPACE_RE, ' ').trim()
|
|
||||||
|
|
||||||
const moderationOpts = useModerationOpts()
|
|
||||||
|
|
||||||
const onChangeText = useCallback((text: string) => {
|
|
||||||
setQuery(text)
|
|
||||||
setIsActive(text.length > 0)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onPressCancelSearch = useCallback(() => {
|
|
||||||
setQuery('')
|
|
||||||
setIsActive(false)
|
|
||||||
}, [setQuery])
|
|
||||||
|
|
||||||
const onSubmit = useCallback(() => {
|
|
||||||
setIsActive(false)
|
|
||||||
if (!tQuery.length) return
|
|
||||||
navigation.dispatch(StackActions.push('Search', {q: tQuery}))
|
|
||||||
}, [tQuery, navigation])
|
|
||||||
|
|
||||||
const onSearchProfileCardPress = useCallback(() => {
|
|
||||||
setQuery('')
|
|
||||||
setIsActive(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[a.relative, a.w_full, a.z_10, t.atoms.bg]}>
|
|
||||||
<SearchInput
|
<SearchInput
|
||||||
|
hotkey
|
||||||
value={query}
|
value={query}
|
||||||
|
onFocus={onFocus}
|
||||||
onChangeText={onChangeText}
|
onChangeText={onChangeText}
|
||||||
onClearText={onPressCancelSearch}
|
onClearText={onClearText}
|
||||||
onSubmitEditing={onSubmit}
|
onSubmitEditing={onSubmit}
|
||||||
hotkey={true}
|
{...sift.targetProps}
|
||||||
/>
|
/>
|
||||||
{tQuery !== '' && isActive && moderationOpts && (
|
{showResults && (
|
||||||
<View
|
<Inner
|
||||||
style={[
|
query={query}
|
||||||
a.mt_sm,
|
sift={sift}
|
||||||
a.flex_col,
|
onSelect={onSelect}
|
||||||
a.w_full,
|
onDismiss={() => setActive(false)}
|
||||||
a.border,
|
|
||||||
a.rounded_sm,
|
|
||||||
a.zoom_fade_in,
|
|
||||||
t.atoms.bg,
|
|
||||||
t.atoms.shadow_sm,
|
|
||||||
t.atoms.border_contrast_low,
|
|
||||||
{
|
|
||||||
overflow: 'hidden',
|
|
||||||
position: 'absolute',
|
|
||||||
top: '100%',
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<SearchLinkCard
|
|
||||||
label={l`Search for “${tQuery}”`}
|
|
||||||
to={`/search?q=${encodeURIComponent(tQuery)}`}
|
|
||||||
style={(autocompleteData?.length ?? 0) > 0 ? a.border_b : undefined}
|
|
||||||
/>
|
/>
|
||||||
{isFetching && !autocompleteData?.length ? (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.py_lg,
|
|
||||||
a.align_center,
|
|
||||||
a.border_t,
|
|
||||||
t.atoms.border_contrast_low,
|
|
||||||
]}>
|
|
||||||
<Loader size="lg" />
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
autocompleteData?.map(item => (
|
|
||||||
<SearchProfileCard
|
|
||||||
key={item.did}
|
|
||||||
profile={item}
|
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
onPress={onSearchProfileCardPress}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Inner({
|
||||||
|
query,
|
||||||
|
sift,
|
||||||
|
onSelect,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
query: string
|
||||||
|
sift: ReturnType<typeof useSift>
|
||||||
|
onSelect: (item: AutocompleteItem) => void
|
||||||
|
onDismiss: () => void
|
||||||
|
}) {
|
||||||
|
const {items} = useAutocomplete({
|
||||||
|
type: 'profile',
|
||||||
|
query,
|
||||||
|
showSearchFallback: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return items && items.length ? (
|
||||||
|
<AutocompleteBase
|
||||||
|
sift={sift}
|
||||||
|
data={items}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
|||||||
@@ -2411,6 +2411,16 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@bsky.app/react-native-mmkv/-/react-native-mmkv-2.12.5.tgz#eb17d31a6158c74393f617a1763ac223ff3f83a6"
|
resolved "https://registry.yarnpkg.com/@bsky.app/react-native-mmkv/-/react-native-mmkv-2.12.5.tgz#eb17d31a6158c74393f617a1763ac223ff3f83a6"
|
||||||
integrity sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==
|
integrity sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==
|
||||||
|
|
||||||
|
"@bsky.app/sift@^0.3.1":
|
||||||
|
version "0.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@bsky.app/sift/-/sift-0.3.1.tgz#f529832001bcd64950c214e85aec055a1f2edcdb"
|
||||||
|
integrity sha512-jG9GDh0Yh4vBM98BP4HvBp8VBqnt+280tFx9Gh/bWYtZdiN1xfrTkqWqRkvxyujgpXlyoemREUn+7dGs8Bl60A==
|
||||||
|
|
||||||
|
"@bsky.app/tapper@^0.5.0":
|
||||||
|
version "0.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@bsky.app/tapper/-/tapper-0.5.0.tgz#39f3814a063cc0e8ee58c05e09be3d5cb8638f22"
|
||||||
|
integrity sha512-Fb7L2CruOA/k/FgKDOGChr+JKXsf+geAOTZXDevs9oqbSYTrXuI8KrRgaDwPS+FVCp1vAYHC/3esuVv+lbUtnw==
|
||||||
|
|
||||||
"@crowdin/cli@^4.14.1":
|
"@crowdin/cli@^4.14.1":
|
||||||
version "4.14.1"
|
version "4.14.1"
|
||||||
resolved "https://registry.yarnpkg.com/@crowdin/cli/-/cli-4.14.1.tgz#1239922681235b6b14bcacd4fd622bc2217dd6c5"
|
resolved "https://registry.yarnpkg.com/@crowdin/cli/-/cli-4.14.1.tgz#1239922681235b6b14bcacd4fd622bc2217dd6c5"
|
||||||
@@ -9628,6 +9638,11 @@ functions-have-names@^1.2.2, functions-have-names@^1.2.3:
|
|||||||
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
|
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
|
||||||
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
|
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
|
||||||
|
|
||||||
|
fuse.js@^7.1.0:
|
||||||
|
version "7.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.3.0.tgz#68e1ea1c6c0ff262f1801a949a78edbe05b0bc13"
|
||||||
|
integrity sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==
|
||||||
|
|
||||||
gensync@^1.0.0-beta.2:
|
gensync@^1.0.0-beta.2:
|
||||||
version "1.0.0-beta.2"
|
version "1.0.0-beta.2"
|
||||||
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||||
|
|||||||
Reference in New Issue
Block a user