Merge remote-tracking branch 'origin/main' into app-1934

This commit is contained in:
vineyardbovines
2026-04-08 09:40:13 -04:00
104 changed files with 12573 additions and 7618 deletions
@@ -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>
)
}
+6
View File
@@ -0,0 +1,6 @@
export * from './Autocomplete'
export * from './AutocompleteItemEmoji'
export * from './AutocompleteItemProfile'
export * from './types'
export * from './useAutocomplete'
export * from './util'
+48
View File
@@ -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],
)
}
+12
View File
@@ -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}`)
}
}
+432
View File
@@ -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
}
+11 -104
View File
@@ -7,7 +7,7 @@ import Animated, {
LayoutAnimationConfig,
LinearTransition,
} from 'react-native-reanimated'
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
import {type AppBskyFeedDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
@@ -15,11 +15,9 @@ import {type NavigationProp} from '#/lib/routes/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {useProfilesQuery} from '#/state/queries/profile'
import {useSuggestedFollowsByActorWithDismiss} from '#/state/queries/suggested-follows'
import {useGetSuggestedUsersForDiscoverQuery} from '#/state/queries/trending/useGetSuggestedUsersForDiscoverQuery'
import {useSession} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {type SeenPost} from '#/state/userActionHistory'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {
atoms as a,
@@ -37,12 +35,12 @@ import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Has
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {ProgressGuideList} from '#/components/ProgressGuide/List'
import {Text} from '#/components/Typography'
import {type Metrics, useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env'
import type * as bsky from '#/types/bsky'
import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
import {ProgressGuideList} from './ProgressGuide/List'
const DISMISS_ANIMATION_DURATION = 200
@@ -109,95 +107,6 @@ export function SuggestedFeedsCardPlaceholder() {
)
}
function getRank(seenPost: SeenPost): string {
let tier: string
if (seenPost.feedContext === 'popfriends') {
tier = 'a'
} else if (seenPost.feedContext?.startsWith('cluster')) {
tier = 'b'
} else if (seenPost.feedContext === 'popcluster') {
tier = 'c'
} else if (seenPost.feedContext?.startsWith('ntpc')) {
tier = 'd'
} else if (seenPost.feedContext?.startsWith('t-')) {
tier = 'e'
} else if (seenPost.feedContext === 'nettop') {
tier = 'f'
} else {
tier = 'g'
}
let score = Math.round(
Math.log(
1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount,
),
)
if (seenPost.isFollowedBy || Math.random() > 0.9) {
score *= 2
}
const rank = 100 - score
return `${tier}-${rank}`
}
function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
const rankA = getRank(postA)
const rankB = getRank(postB)
// Yes, we're comparing strings here.
// The "larger" string means a worse rank.
if (rankA > rankB) {
return 1
} else if (rankA < rankB) {
return -1
} else {
return 0
}
}
function useExperimentalSuggestedUsersQuery() {
const {currentAccount} = useSession()
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = useMemo(() => {
const {likes, follows, followSuggestions, seen} = userActionSnapshot
const likeDids = likes
.map(l => new AtUri(l))
.map(uri => uri.host)
.filter(did => !follows.includes(did))
let suggestedDids: string[] = []
if (followSuggestions.length > 0) {
suggestedDids = [
// It's ok if these will pick the same item (weighed by its frequency)
/* eslint-disable react-hooks/purity */
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
/* eslint-enable react-hooks/purity */
]
}
const seenDids = seen
.sort(sortSeenPosts)
.map(l => new AtUri(l.uri))
.map(uri => uri.host)
return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter(
did => did !== currentAccount?.did,
)
}, [userActionSnapshot, currentAccount])
const {data, isLoading, error} = useProfilesQuery({
handles: dids.slice(0, 16),
})
const profiles = data
? data.profiles.filter(profile => {
return !profile.viewer?.following
})
: []
return {
isLoading,
error,
profiles: profiles.slice(0, 6),
}
}
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
const {currentAccount} = useSession()
const [feedType, feedUriOrDid] = feed.split('|')
@@ -229,11 +138,9 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
}
export function SuggestedFollowsHome() {
const {
isLoading: isSuggestionsLoading,
profiles: experimentalProfiles,
error: experimentalError,
} = useExperimentalSuggestedUsersQuery()
const {isLoading, data, error} = useGetSuggestedUsersForDiscoverQuery()
const profiles = data?.actors
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
@@ -247,12 +154,12 @@ export function SuggestedFollowsHome() {
recId?: string
}> = []
for (const profile of experimentalProfiles) {
result.push({actor: profile, recId: undefined})
for (const profile of profiles ?? []) {
result.push({actor: profile, recId: data?.recId})
}
return result
}, [experimentalProfiles])
}, [data?.recId, profiles])
const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
@@ -260,10 +167,10 @@ export function SuggestedFollowsHome() {
return (
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
isSuggestionsLoading={isLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
error={experimentalError}
error={error}
viewContext="feed"
onDismiss={onDismiss}
/>
@@ -136,6 +136,8 @@ export const BookmarkButton = memo(function BookmarkButton({
<PostControlButton
testID="postBookmarkBtn"
big={big}
active={isBookmarked}
activeColor={t.palette.primary_500}
label={
isBookmarked
? _(msg`Remove from saved posts`)
@@ -143,10 +145,7 @@ export const BookmarkButton = memo(function BookmarkButton({
}
onPress={onHandlePress}
hitSlop={hitSlop}>
<PostControlButtonIcon
fill={isBookmarked ? t.palette.primary_500 : undefined}
icon={isBookmarked ? BookmarkFilled : Bookmark}
/>
<PostControlButtonIcon icon={isBookmarked ? BookmarkFilled : Bookmark} />
</PostControlButton>
)
})
@@ -130,8 +130,11 @@ export function PostControlButtonText({style, ...props}: TextProps) {
<Text
style={[
color,
a.user_select_none,
big ? a.text_md : a.text_sm,
active && a.font_semi_bold,
// prevent layout shift on android
{includeFontPadding: false, textAlignVertical: 'center'},
style,
]}
{...props}
+11 -4
View File
@@ -24,7 +24,7 @@ import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {atoms as a, useBreakpoints} from '#/alf'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Reply as Bubble} from '#/components/icons/Reply'
import {useFormatPostStatCount} from '#/components/PostControls/util'
import * as Skele from '#/components/Skeleton'
@@ -74,6 +74,7 @@ let PostControls = ({
forceGoogleTranslate?: boolean
}): React.ReactNode => {
const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui()
const {openComposer} = useOpenComposer()
const {feedDescriptor} = useFeedFeedbackContext()
@@ -270,6 +271,8 @@ let PostControls = ({
<PostControlButton
testID="likeBtn"
big={big}
active={Boolean(post.viewer?.like)}
activeColor={t.palette.pink}
onPress={() => requireAuth(() => onPressToggleLike())}
label={
post.viewer?.like
@@ -296,10 +299,14 @@ let PostControls = ({
hasBeenToggled={hasLikeIconBeenToggled}
/>
<CountWheel
likeCount={post.likeCount ?? 0}
big={big}
isLiked={Boolean(post.viewer?.like)}
count={post.likeCount ?? 0}
isToggled={Boolean(post.viewer?.like)}
hasBeenToggled={hasLikeIconBeenToggled}
renderCount={({count}) => (
<PostControlButtonText>
{formatPostStatCount(count)}
</PostControlButtonText>
)}
/>
</PostControlButton>
</View>
@@ -8,7 +8,7 @@ import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearch} from '#/state/queries/actor-search'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery'
import {useGetSuggestedUsersForSeeMoreQuery} from '#/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery'
import {useSession} from '#/state/session'
import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'
import {type ListMethods} from '#/view/com/util/List'
@@ -141,7 +141,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
data: suggestions,
isFetching: isFetchingSuggestions,
error: suggestionsError,
} = useGetSuggestedUsersQuery({
} = useGetSuggestedUsersForSeeMoreQuery({
category: selectedInterest,
limit: 50,
})
+166
View File
@@ -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}
/>
)
}
+5 -5
View File
@@ -3,6 +3,7 @@ import {type TextInput, View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {HITSLOP_10} from '#/lib/constants'
import {mergeRefs} from '#/lib/merge-refs'
import {listenFocusSearch} from '#/state/events'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
@@ -18,7 +19,7 @@ type Props = Omit<TextField.InputProps, 'label'> & {
*/
onClearText?: () => void
hotkey?: boolean
ref?: React.RefObject<TextInput | null>
ref?: React.Ref<TextInput>
}
export function SearchInput({
@@ -33,21 +34,20 @@ export function SearchInput({
const {t: l} = useLingui()
const showClear = value && value.length > 0
const internalRef = useRef<TextInput>(null)
const inputRef = ref ?? internalRef
useEffect(() => {
if (!hotkey) return
return listenFocusSearch(() => {
inputRef.current?.focus()
internalRef.current?.focus()
})
}, [hotkey, inputRef])
}, [hotkey])
return (
<View style={[a.w_full, a.relative]}>
<TextField.Root>
<TextField.Icon icon={MagnifyingGlassIcon} />
<TextField.Input
inputRef={inputRef}
inputRef={mergeRefs([internalRef, ref])}
label={label || l`Search`}
value={value}
placeholder={l`Search`}