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
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
- uses: maxim-lobanov/setup-xcode@v1 - uses: maxim-lobanov/setup-xcode@v1
with: with:
xcode-version: "26.0" xcode-version: "26.4"
- name: ☕️ Setup Cocoapods - name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1 uses: maxim-lobanov/setup-cocoapods@v1
@@ -197,7 +197,7 @@ jobs:
- uses: maxim-lobanov/setup-xcode@v1 - uses: maxim-lobanov/setup-xcode@v1
with: with:
xcode-version: "26.0" xcode-version: "26.4"
- name: ☕️ Setup Cocoapods - name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1 uses: maxim-lobanov/setup-cocoapods@v1
+2 -2
View File
@@ -54,7 +54,7 @@ module.exports = function (_config) {
}, },
icon: './assets/app-icons/ios_icon_default_next.png', icon: './assets/app-icons/ios_icon_default_next.png',
userInterfaceStyle: 'automatic', userInterfaceStyle: 'automatic',
primaryColor: '#1083fe', primaryColor: '#006AFF',
newArchEnabled: false, newArchEnabled: false,
ios: { ios: {
supportsTablet: false, supportsTablet: false,
@@ -64,6 +64,7 @@ module.exports = function (_config) {
}, },
icon: IOS_ICON_FILE, icon: IOS_ICON_FILE,
infoPlist: { infoPlist: {
CADisableMinimumFrameDurationOnPhone: true,
UIBackgroundModes: ['remote-notification'], UIBackgroundModes: ['remote-notification'],
NSCameraUsageDescription: NSCameraUsageDescription:
'Used for profile pictures, posts, and other kinds of content.', 'Used for profile pictures, posts, and other kinds of content.',
@@ -296,7 +297,6 @@ module.exports = function (_config) {
'./plugins/withAndroidManifestFCMIconPlugin.js', './plugins/withAndroidManifestFCMIconPlugin.js',
'./plugins/withAndroidManifestIntentQueriesPlugin.js', './plugins/withAndroidManifestIntentQueriesPlugin.js',
'./plugins/withAndroidStylesAccentColorPlugin.js', './plugins/withAndroidStylesAccentColorPlugin.js',
'./plugins/withAndroidDayNightThemePlugin.js',
'./plugins/withAndroidNoJitpackPlugin.js', './plugins/withAndroidNoJitpackPlugin.js',
'./plugins/shareExtension/withShareExtensions.js', './plugins/shareExtension/withShareExtensions.js',
'./plugins/notificationsExtension/withNotificationsExtension.js', './plugins/notificationsExtension/withNotificationsExtension.js',
+4 -1
View File
@@ -81,13 +81,15 @@
"icons:optimize": "svgo -f ./assets/icons" "icons:optimize": "svgo -f ./assets/icons"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.19.5", "@atproto/api": "^0.19.6",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.7", "@bsky.app/alf": "^0.1.7",
"@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",
@@ -180,6 +182,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",
-27
View File
@@ -1,27 +0,0 @@
// Based on https://github.com/expo/expo/pull/33957
// Could be removed once the app has been updated to Expo 53
const {withAndroidStyles} = require('@expo/config-plugins')
module.exports = function withAndroidDayNightThemePlugin(appConfig) {
const cleanupList = new Set([
'colorPrimary',
'android:editTextBackground',
'android:textColor',
'android:editTextStyle',
])
return withAndroidStyles(appConfig, config => {
config.modResults.resources.style = config.modResults.resources.style
?.map(style => {
if (style.$.name === 'AppTheme' && style.item != null) {
style.item = style.item.filter(item => !cleanupList.has(item.$.name))
}
return style
})
.filter(style => {
return style.$.name !== 'ResetEditText'
})
return config
})
}
+2 -3
View File
@@ -16,7 +16,6 @@ import * as Sentry from '@sentry/react-native'
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder' import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
import {QueryProvider} from '#/lib/react-query' import {QueryProvider} from '#/lib/react-query'
import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext' import {ThemeProvider} from '#/lib/ThemeContext'
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation' import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
import I18nProvider from '#/locale/i18nProvider' import I18nProvider from '#/locale/i18nProvider'
@@ -58,7 +57,7 @@ import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls' import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Shell} from '#/view/shell' import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf' import {atoms as a, ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu' import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
@@ -175,7 +174,7 @@ function InnerApp() {
<EmailVerificationProvider> <EmailVerificationProvider>
<HideBottomBarBorderProvider> <HideBottomBarBorderProvider>
<GestureHandlerRootView <GestureHandlerRootView
style={s.h100pct}> style={a.h_full}>
<GlobalGestureEventsProvider> <GlobalGestureEventsProvider>
<IntentDialogProvider> <IntentDialogProvider>
<TranslateOnDeviceProvider> <TranslateOnDeviceProvider>
+37 -1
View File
@@ -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,
}
}
+1
View File
@@ -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',
} }
+1
View File
@@ -110,6 +110,7 @@ const Context = createContext<AnalyticsBaseContextType>({
}, },
}, },
}) })
Context.displayName = 'AnalyticsContext'
/** /**
* Ensures that deviceId is set and migrated from legacy storage. Handled on * Ensures that deviceId is set and migrated from legacy storage. Handled on
@@ -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, LayoutAnimationConfig,
LinearTransition, LinearTransition,
} from 'react-native-reanimated' } 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 {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native' 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 {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {type FeedDescriptor} from '#/state/queries/post-feed' import {type FeedDescriptor} from '#/state/queries/post-feed'
import {useProfilesQuery} from '#/state/queries/profile'
import {useSuggestedFollowsByActorWithDismiss} from '#/state/queries/suggested-follows' import {useSuggestedFollowsByActorWithDismiss} from '#/state/queries/suggested-follows'
import {useGetSuggestedUsersForDiscoverQuery} from '#/state/queries/trending/useGetSuggestedUsersForDiscoverQuery'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {type SeenPost} from '#/state/userActionHistory'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import { import {
atoms as a, 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 {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import {ProgressGuideList} from '#/components/ProgressGuide/List'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {type Metrics, useAnalytics} from '#/analytics' import {type Metrics, useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env' import {IS_IOS} from '#/env'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog' import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
import {ProgressGuideList} from './ProgressGuide/List'
const DISMISS_ANIMATION_DURATION = 200 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}) { export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const [feedType, feedUriOrDid] = feed.split('|') const [feedType, feedUriOrDid] = feed.split('|')
@@ -229,11 +138,9 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
} }
export function SuggestedFollowsHome() { export function SuggestedFollowsHome() {
const { const {isLoading, data, error} = useGetSuggestedUsersForDiscoverQuery()
isLoading: isSuggestionsLoading,
profiles: experimentalProfiles, const profiles = data?.actors
error: experimentalError,
} = useExperimentalSuggestedUsersQuery()
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set()) const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
@@ -247,12 +154,12 @@ export function SuggestedFollowsHome() {
recId?: string recId?: string
}> = [] }> = []
for (const profile of experimentalProfiles) { for (const profile of profiles ?? []) {
result.push({actor: profile, recId: undefined}) result.push({actor: profile, recId: data?.recId})
} }
return result return result
}, [experimentalProfiles]) }, [data?.recId, profiles])
const filteredProfiles = useMemo(() => { const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did)) return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
@@ -260,10 +167,10 @@ export function SuggestedFollowsHome() {
return ( return (
<ProfileGrid <ProfileGrid
isSuggestionsLoading={isSuggestionsLoading} isSuggestionsLoading={isLoading}
profiles={filteredProfiles} profiles={filteredProfiles}
totalProfileCount={allProfiles.length} totalProfileCount={allProfiles.length}
error={experimentalError} error={error}
viewContext="feed" viewContext="feed"
onDismiss={onDismiss} onDismiss={onDismiss}
/> />
@@ -136,6 +136,8 @@ export const BookmarkButton = memo(function BookmarkButton({
<PostControlButton <PostControlButton
testID="postBookmarkBtn" testID="postBookmarkBtn"
big={big} big={big}
active={isBookmarked}
activeColor={t.palette.primary_500}
label={ label={
isBookmarked isBookmarked
? _(msg`Remove from saved posts`) ? _(msg`Remove from saved posts`)
@@ -143,10 +145,7 @@ export const BookmarkButton = memo(function BookmarkButton({
} }
onPress={onHandlePress} onPress={onHandlePress}
hitSlop={hitSlop}> hitSlop={hitSlop}>
<PostControlButtonIcon <PostControlButtonIcon icon={isBookmarked ? BookmarkFilled : Bookmark} />
fill={isBookmarked ? t.palette.primary_500 : undefined}
icon={isBookmarked ? BookmarkFilled : Bookmark}
/>
</PostControlButton> </PostControlButton>
) )
}) })
@@ -130,8 +130,11 @@ export function PostControlButtonText({style, ...props}: TextProps) {
<Text <Text
style={[ style={[
color, color,
a.user_select_none,
big ? a.text_md : a.text_sm, big ? a.text_md : a.text_sm,
active && a.font_semi_bold, active && a.font_semi_bold,
// prevent layout shift on android
{includeFontPadding: false, textAlignVertical: 'center'},
style, style,
]} ]}
{...props} {...props}
+11 -4
View File
@@ -24,7 +24,7 @@ import {
ProgressGuideAction, ProgressGuideAction,
useProgressGuideControls, useProgressGuideControls,
} from '#/state/shell/progress-guide' } 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 {Reply as Bubble} from '#/components/icons/Reply'
import {useFormatPostStatCount} from '#/components/PostControls/util' import {useFormatPostStatCount} from '#/components/PostControls/util'
import * as Skele from '#/components/Skeleton' import * as Skele from '#/components/Skeleton'
@@ -74,6 +74,7 @@ let PostControls = ({
forceGoogleTranslate?: boolean forceGoogleTranslate?: boolean
}): React.ReactNode => { }): React.ReactNode => {
const ax = useAnalytics() const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const {openComposer} = useOpenComposer() const {openComposer} = useOpenComposer()
const {feedDescriptor} = useFeedFeedbackContext() const {feedDescriptor} = useFeedFeedbackContext()
@@ -270,6 +271,8 @@ let PostControls = ({
<PostControlButton <PostControlButton
testID="likeBtn" testID="likeBtn"
big={big} big={big}
active={Boolean(post.viewer?.like)}
activeColor={t.palette.pink}
onPress={() => requireAuth(() => onPressToggleLike())} onPress={() => requireAuth(() => onPressToggleLike())}
label={ label={
post.viewer?.like post.viewer?.like
@@ -296,10 +299,14 @@ let PostControls = ({
hasBeenToggled={hasLikeIconBeenToggled} hasBeenToggled={hasLikeIconBeenToggled}
/> />
<CountWheel <CountWheel
likeCount={post.likeCount ?? 0} count={post.likeCount ?? 0}
big={big} isToggled={Boolean(post.viewer?.like)}
isLiked={Boolean(post.viewer?.like)}
hasBeenToggled={hasLikeIconBeenToggled} hasBeenToggled={hasLikeIconBeenToggled}
renderCount={({count}) => (
<PostControlButtonText>
{formatPostStatCount(count)}
</PostControlButtonText>
)}
/> />
</PostControlButton> </PostControlButton>
</View> </View>
@@ -8,7 +8,7 @@ import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearch} from '#/state/queries/actor-search' import {useActorSearch} from '#/state/queries/actor-search'
import {usePreferencesQuery} from '#/state/queries/preferences' 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 {useSession} from '#/state/session'
import {type Follow10ProgressGuide} from '#/state/shell/progress-guide' import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'
import {type ListMethods} from '#/view/com/util/List' import {type ListMethods} from '#/view/com/util/List'
@@ -141,7 +141,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
data: suggestions, data: suggestions,
isFetching: isFetchingSuggestions, isFetching: isFetchingSuggestions,
error: suggestionsError, error: suggestionsError,
} = useGetSuggestedUsersQuery({ } = useGetSuggestedUsersForSeeMoreQuery({
category: selectedInterest, category: selectedInterest,
limit: 50, 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 {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`}
+20 -46
View File
@@ -8,10 +8,7 @@ import Animated, {
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {decideShouldRoll} from '#/lib/custom-animations/util' import {decideShouldRoll} from '#/lib/custom-animations/util'
import {s} from '#/lib/styles' import {atoms as a} from '#/alf'
import {Text} from '#/view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
import {useFormatPostStatCount} from '#/components/PostControls/util'
const animationConfig = { const animationConfig = {
duration: 400, duration: 400,
@@ -87,89 +84,66 @@ function ExitingDown() {
} }
export function CountWheel({ export function CountWheel({
likeCount, count,
big, isToggled,
isLiked,
hasBeenToggled, hasBeenToggled,
renderCount,
}: { }: {
likeCount: number count: number
big?: boolean isToggled: boolean
isLiked: boolean
hasBeenToggled: boolean hasBeenToggled: boolean
renderCount: (props: {count: number}) => React.ReactNode
}) { }) {
const t = useTheme()
const shouldAnimate = !useReducedMotion() && hasBeenToggled const shouldAnimate = !useReducedMotion() && hasBeenToggled
const shouldRoll = decideShouldRoll(isLiked, likeCount) const shouldRoll = decideShouldRoll(isToggled, count)
// Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting // Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting
// animation // animation
// The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would // The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would
// be unnecessary // be unnecessary
const [key, setKey] = useState(0) const [key, setKey] = useState(0)
const [prevCount, setPrevCount] = useState(likeCount) const [prevCount, setPrevCount] = useState(count)
const prevIsLiked = useRef(isLiked) const prevIsToggled = useRef(isToggled)
const formatPostStatCount = useFormatPostStatCount()
const formattedCount = formatPostStatCount(likeCount)
const formattedPrevCount = formatPostStatCount(prevCount)
useEffect(() => { useEffect(() => {
if (isLiked === prevIsLiked.current) { if (isToggled === prevIsToggled.current) {
return return
} }
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1 const newPrevCount = isToggled ? count - 1 : count + 1
setKey(prev => prev + 1) setKey(prev => prev + 1)
setPrevCount(newPrevCount) setPrevCount(newPrevCount)
prevIsLiked.current = isLiked prevIsToggled.current = isToggled
}, [isLiked, likeCount]) }, [isToggled, count])
const enteringAnimation = const enteringAnimation =
shouldAnimate && shouldRoll shouldAnimate && shouldRoll
? isLiked ? isToggled
? EnteringUp ? EnteringUp
: EnteringDown : EnteringDown
: undefined : undefined
const exitingAnimation = const exitingAnimation =
shouldAnimate && shouldRoll shouldAnimate && shouldRoll
? isLiked ? isToggled
? ExitingUp ? ExitingUp
: ExitingDown : ExitingDown
: undefined : undefined
return ( return (
<LayoutAnimationConfig skipEntering skipExiting> <LayoutAnimationConfig skipEntering skipExiting>
{likeCount > 0 ? ( {count > 0 ? (
<View style={[a.justify_center]}> <View style={[a.justify_center]}>
<Animated.View entering={enteringAnimation} key={key}> <Animated.View entering={enteringAnimation} key={key}>
<Text {renderCount({count})}
testID="likeCount"
style={[
big ? a.text_md : a.text_sm,
a.user_select_none,
isLiked
? [a.font_semi_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
</Animated.View> </Animated.View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? ( {shouldAnimate && (count > 1 || !isToggled) ? (
<Animated.View <Animated.View
entering={exitingAnimation} entering={exitingAnimation}
// Add 2 to the key so there are never duplicates // Add 2 to the key so there are never duplicates
key={key + 2} key={key + 2}
style={[a.absolute, {width: 50, opacity: 0}]} style={[a.absolute, {width: 50, opacity: 0}]}
aria-disabled={true}> aria-disabled={true}>
<Text {renderCount({count: prevCount})}
style={[
big ? a.text_md : a.text_sm,
a.user_select_none,
isLiked
? [a.font_semi_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
</Animated.View> </Animated.View>
) : null} ) : null}
</View> </View>
+19 -46
View File
@@ -3,10 +3,6 @@ import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated' import {useReducedMotion} from 'react-native-reanimated'
import {decideShouldRoll} from '#/lib/custom-animations/util' import {decideShouldRoll} from '#/lib/custom-animations/util'
import {s} from '#/lib/styles'
import {Text} from '#/view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
import {useFormatPostStatCount} from '#/components/PostControls/util'
const animationConfig = { const animationConfig = {
duration: 400, duration: 400,
@@ -35,50 +31,46 @@ const exitingDownKeyframe = [
] ]
export function CountWheel({ export function CountWheel({
likeCount, count,
big, isToggled,
isLiked,
hasBeenToggled, hasBeenToggled,
renderCount,
}: { }: {
likeCount: number count: number
big?: boolean isToggled: boolean
isLiked: boolean
hasBeenToggled: boolean hasBeenToggled: boolean
renderCount: (props: {count: number}) => React.ReactNode
}) { }) {
const t = useTheme()
const shouldAnimate = !useReducedMotion() && hasBeenToggled const shouldAnimate = !useReducedMotion() && hasBeenToggled
const shouldRoll = decideShouldRoll(isLiked, likeCount) const shouldRoll = decideShouldRoll(isToggled, count)
const countView = useRef<HTMLDivElement>(null) const countView = useRef<HTMLDivElement>(null)
const prevCountView = useRef<HTMLDivElement>(null) const prevCountView = useRef<HTMLDivElement>(null)
const [prevCount, setPrevCount] = useState(likeCount) const [prevCount, setPrevCount] = useState(count)
const prevIsLiked = useRef(isLiked) const prevIsToggled = useRef(isToggled)
const formatPostStatCount = useFormatPostStatCount()
const formattedCount = formatPostStatCount(likeCount)
const formattedPrevCount = formatPostStatCount(prevCount)
useEffect(() => { useEffect(() => {
if (isLiked === prevIsLiked.current) { if (isToggled === prevIsToggled.current) {
return return
} }
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1 const newPrevCount = isToggled ? count - 1 : count + 1
if (shouldAnimate && shouldRoll) { if (shouldAnimate && shouldRoll) {
countView.current?.animate?.( countView.current?.animate?.(
isLiked ? enteringUpKeyframe : enteringDownKeyframe, isToggled ? enteringUpKeyframe : enteringDownKeyframe,
animationConfig, animationConfig,
) )
prevCountView.current?.animate?.( prevCountView.current?.animate?.(
isLiked ? exitingUpKeyframe : exitingDownKeyframe, isToggled ? exitingUpKeyframe : exitingDownKeyframe,
animationConfig, animationConfig,
) )
setPrevCount(newPrevCount) setPrevCount(newPrevCount)
} }
prevIsLiked.current = isLiked prevIsToggled.current = isToggled
}, [isLiked, likeCount, shouldAnimate, shouldRoll]) }, [isToggled, count, shouldAnimate, shouldRoll])
if (likeCount < 1) { if (count < 1) {
return null return null
} }
@@ -87,34 +79,15 @@ export function CountWheel({
<View <View
// @ts-expect-error is div // @ts-expect-error is div
ref={countView}> ref={countView}>
<Text {renderCount({count})}
testID="likeCount"
style={[
big ? a.text_md : a.text_sm,
a.user_select_none,
isLiked
? [a.font_semi_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
</View> </View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? ( {shouldAnimate && (count > 1 || !isToggled) ? (
<View <View
style={{position: 'absolute', opacity: 0}} style={{position: 'absolute', opacity: 0}}
aria-disabled={true} aria-disabled={true}
// @ts-expect-error is div // @ts-expect-error is div
ref={prevCountView}> ref={prevCountView}>
<Text {renderCount({count: prevCount})}
style={[
big ? a.text_md : a.text_sm,
a.user_select_none,
isLiked
? [a.font_semi_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
</View> </View>
) : null} ) : null}
</View> </View>
+2 -3
View File
@@ -5,7 +5,6 @@ import Animated, {
useReducedMotion, useReducedMotion,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {s} from '#/lib/styles'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
import { import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled, Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
@@ -86,7 +85,7 @@ export function AnimatedLikeIcon({
{isLiked ? ( {isLiked ? (
<Animated.View <Animated.View
entering={shouldAnimate ? keyframe.duration(300) : undefined}> entering={shouldAnimate ? keyframe.duration(300) : undefined}>
<HeartIconFilled style={s.likeColor} width={size} /> <HeartIconFilled style={{color: t.palette.pink}} width={size} />
</Animated.View> </Animated.View>
) : ( ) : (
<HeartIconOutline <HeartIconOutline
@@ -100,7 +99,7 @@ export function AnimatedLikeIcon({
entering={circle1Keyframe.duration(300)} entering={circle1Keyframe.duration(300)}
style={{ style={{
position: 'absolute', position: 'absolute',
backgroundColor: s.likeColor.color, backgroundColor: t.palette.pink,
top: 0, top: 0,
left: 0, left: 0,
width: size, width: size,
+2 -3
View File
@@ -2,7 +2,6 @@ import {useEffect, useRef} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated' import {useReducedMotion} from 'react-native-reanimated'
import {s} from '#/lib/styles'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
import { import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled, Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
@@ -74,7 +73,7 @@ export function AnimatedLikeIcon({
{isLiked ? ( {isLiked ? (
// @ts-expect-error is div // @ts-expect-error is div
<View ref={likeIconRef}> <View ref={likeIconRef}>
<HeartIconFilled style={s.likeColor} width={size} /> <HeartIconFilled style={{color: t.palette.pink}} width={size} />
</View> </View>
) : ( ) : (
<HeartIconOutline <HeartIconOutline
@@ -87,7 +86,7 @@ export function AnimatedLikeIcon({
ref={circle1Ref} ref={circle1Ref}
style={{ style={{
position: 'absolute', position: 'absolute',
backgroundColor: s.likeColor.color, backgroundColor: t.palette.pink,
top: 0, top: 0,
left: 0, left: 0,
width: size, width: size,
+1 -1
View File
@@ -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 => {
+1 -124
View File
@@ -1,9 +1,4 @@
import { import {type StyleProp, StyleSheet, type TextStyle} from 'react-native'
Dimensions,
type StyleProp,
StyleSheet,
type TextStyle,
} from 'react-native'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
import {type Theme, type TypographyVariant} from './ThemeContext' import {type Theme, type TypographyVariant} from './ThemeContext'
@@ -61,14 +56,6 @@ export const colors = {
green5: '#082b03', green5: '#082b03',
unreadNotifBg: '#ebf6ff', unreadNotifBg: '#ebf6ff',
brandBlue: '#0066FF',
like: '#ec4899',
}
export const gradients = {
blueLight: {start: '#5A71FA', end: colors.blue3}, // buttons
blue: {start: '#5E55FB', end: colors.blue3}, // fab
blueDark: {start: '#5F45E0', end: colors.blue3}, // avis, banner
} }
/** /**
@@ -78,57 +65,6 @@ export const s = StyleSheet.create({
// helpers // helpers
footerSpacer: {height: 100}, footerSpacer: {height: 100},
contentContainer: {paddingBottom: 200}, contentContainer: {paddingBottom: 200},
contentContainerExtra: {paddingBottom: 300},
border0: {borderWidth: 0},
border1: {borderWidth: 1},
borderTop1: {borderTopWidth: 1},
borderRight1: {borderRightWidth: 1},
borderBottom1: {borderBottomWidth: 1},
borderLeft1: {borderLeftWidth: 1},
hidden: {display: 'none'},
dimmed: {opacity: 0.5},
// font weights
fw600: {fontWeight: '600'},
bold: {fontWeight: '600'},
fw500: {fontWeight: '600'},
semiBold: {fontWeight: '600'},
fw400: {fontWeight: '400'},
normal: {fontWeight: '400'},
fw300: {fontWeight: '400'},
light: {fontWeight: '400'},
// text decoration
underline: {textDecorationLine: 'underline'},
// font variants
tabularNum: {fontVariant: ['tabular-nums']},
// font sizes
f9: {fontSize: 9},
f10: {fontSize: 10},
f11: {fontSize: 11},
f12: {fontSize: 12},
f13: {fontSize: 13},
f14: {fontSize: 14},
f15: {fontSize: 15},
f16: {fontSize: 16},
f17: {fontSize: 17},
f18: {fontSize: 18},
// line heights
['lh13-1']: {lineHeight: 13},
['lh13-1.3']: {lineHeight: 16.9}, // 1.3 of 13px
['lh14-1']: {lineHeight: 14},
['lh14-1.3']: {lineHeight: 18.2}, // 1.3 of 14px
['lh15-1']: {lineHeight: 15},
['lh15-1.3']: {lineHeight: 19.5}, // 1.3 of 15px
['lh16-1']: {lineHeight: 16},
['lh16-1.3']: {lineHeight: 20.8}, // 1.3 of 16px
['lh17-1']: {lineHeight: 17},
['lh17-1.3']: {lineHeight: 22.1}, // 1.3 of 17px
['lh18-1']: {lineHeight: 18},
['lh18-1.3']: {lineHeight: 23.4}, // 1.3 of 18px
// margins // margins
mr2: {marginRight: 2}, mr2: {marginRight: 2},
@@ -171,74 +107,15 @@ export const s = StyleSheet.create({
pb20: {paddingBottom: 20}, pb20: {paddingBottom: 20},
px5: {paddingHorizontal: 5}, px5: {paddingHorizontal: 5},
// flex
flexRow: {flexDirection: 'row'},
flexCol: {flexDirection: 'column'},
flex1: {flex: 1},
flexGrow1: {flexGrow: 1},
alignCenter: {alignItems: 'center'},
alignBaseline: {alignItems: 'baseline'},
justifyCenter: {justifyContent: 'center'},
// position
absolute: {position: 'absolute'},
// dimensions // dimensions
w100pct: {width: '100%'},
h100pct: {height: '100%'},
hContentRegion: IS_WEB ? {minHeight: '100%'} : {height: '100%'}, hContentRegion: IS_WEB ? {minHeight: '100%'} : {height: '100%'},
window: {
width: Dimensions.get('window').width,
height: Dimensions.get('window').height,
},
// text align // text align
textLeft: {textAlign: 'left'},
textCenter: {textAlign: 'center'}, textCenter: {textAlign: 'center'},
textRight: {textAlign: 'right'},
// colors // colors
white: {color: colors.white}, white: {color: colors.white},
black: {color: colors.black}, black: {color: colors.black},
gray1: {color: colors.gray1},
gray2: {color: colors.gray2},
gray3: {color: colors.gray3},
gray4: {color: colors.gray4},
gray5: {color: colors.gray5},
blue1: {color: colors.blue1},
blue2: {color: colors.blue2},
blue3: {color: colors.blue3},
blue4: {color: colors.blue4},
blue5: {color: colors.blue5},
red1: {color: colors.red1},
red2: {color: colors.red2},
red3: {color: colors.red3},
red4: {color: colors.red4},
red5: {color: colors.red5},
pink1: {color: colors.pink1},
pink2: {color: colors.pink2},
pink3: {color: colors.pink3},
pink4: {color: colors.pink4},
pink5: {color: colors.pink5},
purple1: {color: colors.purple1},
purple2: {color: colors.purple2},
purple3: {color: colors.purple3},
purple4: {color: colors.purple4},
purple5: {color: colors.purple5},
green1: {color: colors.green1},
green2: {color: colors.green2},
green3: {color: colors.green3},
green4: {color: colors.green4},
green5: {color: colors.green5},
brandBlue: {color: colors.brandBlue},
likeColor: {color: colors.like},
}) })
export function lh( export function lh(
+5
View File
@@ -0,0 +1,5 @@
import Emojis, {type EmojiMartData} from '@emoji-mart/data'
export async function getEmojis(): Promise<EmojiMartData> {
return Emojis as EmojiMartData
}
+5
View File
@@ -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
}
+12
View File
@@ -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
}, [])
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+60 -59
View File
@@ -966,7 +966,7 @@ msgstr ""
msgid "Add to lists" msgid "Add to lists"
msgstr "" msgstr ""
#: src/components/PostControls/BookmarkButton.tsx:142 #: src/components/PostControls/BookmarkButton.tsx:144
msgid "Add to saved posts" msgid "Add to saved posts"
msgstr "" msgstr ""
@@ -1832,20 +1832,20 @@ msgstr ""
msgid "Browse custom feeds" msgid "Browse custom feeds"
msgstr "" msgstr ""
#: src/components/FeedInterstitials.tsx:627 #: src/components/FeedInterstitials.tsx:534
msgid "Browse more accounts" msgid "Browse more accounts"
msgstr "" msgstr ""
#: src/components/FeedInterstitials.tsx:757 #: src/components/FeedInterstitials.tsx:664
msgid "Browse more feeds on the Explore page" msgid "Browse more feeds on the Explore page"
msgstr "" msgstr ""
#: src/components/FeedInterstitials.tsx:738 #: src/components/FeedInterstitials.tsx:645
#: src/components/FeedInterstitials.tsx:741 #: src/components/FeedInterstitials.tsx:648
msgid "Browse more suggestions" msgid "Browse more suggestions"
msgstr "" msgstr ""
#: src/components/FeedInterstitials.tsx:766 #: src/components/FeedInterstitials.tsx:673
msgid "Browse more suggestions on the Explore page" msgid "Browse more suggestions on the Explore page"
msgstr "" msgstr ""
@@ -1889,7 +1889,7 @@ msgstr ""
#: src/components/LabelingServiceCard/index.tsx:62 #: src/components/LabelingServiceCard/index.tsx:62
#: src/components/moderation/ReportDialog/index.tsx:853 #: src/components/moderation/ReportDialog/index.tsx:853
#: src/screens/Search/components/StarterPackCard.tsx:107 #: src/screens/Search/components/StarterPackCard.tsx:107
#: src/screens/Search/Explore.tsx:969 #: src/screens/Search/Explore.tsx:970
msgid "By {0}" msgid "By {0}"
msgstr "" msgstr ""
@@ -1983,9 +1983,9 @@ msgstr ""
msgid "Cancel search" msgid "Cancel search"
msgstr "" msgstr ""
#: src/components/PostControls/index.tsx:109 #: src/components/PostControls/index.tsx:110
#: src/components/PostControls/index.tsx:140 #: src/components/PostControls/index.tsx:141
#: src/components/PostControls/index.tsx:168 #: src/components/PostControls/index.tsx:169
#: src/state/shell/composer/index.tsx:107 #: src/state/shell/composer/index.tsx:107
msgid "Cannot interact with a blocked user" msgid "Cannot interact with a blocked user"
msgstr "" msgstr ""
@@ -3257,7 +3257,7 @@ msgstr ""
msgid "Discover new custom feeds" msgid "Discover new custom feeds"
msgstr "" msgstr ""
#: src/screens/Search/Explore.tsx:451 #: src/screens/Search/Explore.tsx:452
msgid "Discover new feeds" msgid "Discover new feeds"
msgstr "" msgstr ""
@@ -3294,7 +3294,7 @@ msgstr ""
msgid "Dismiss this section" msgid "Dismiss this section"
msgstr "" msgstr ""
#: src/components/FeedInterstitials.tsx:441 #: src/components/FeedInterstitials.tsx:348
msgid "Dismiss this suggestion" msgid "Dismiss this suggestion"
msgstr "" msgstr ""
@@ -4019,16 +4019,16 @@ msgstr ""
msgid "Failed to load conversations" msgid "Failed to load conversations"
msgstr "" msgstr ""
#: src/screens/Search/Explore.tsx:528 #: src/screens/Search/Explore.tsx:529
#: src/screens/Search/Explore.tsx:573 #: src/screens/Search/Explore.tsx:574
#: src/screens/Search/Explore.tsx:619 #: src/screens/Search/Explore.tsx:620
msgid "Failed to load feeds" msgid "Failed to load feeds"
msgstr "" msgstr ""
#: src/screens/Search/Explore.tsx:487 #: src/screens/Search/Explore.tsx:488
#: src/screens/Search/Explore.tsx:542 #: src/screens/Search/Explore.tsx:543
#: src/screens/Search/Explore.tsx:587 #: src/screens/Search/Explore.tsx:588
#: src/screens/Search/Explore.tsx:633 #: src/screens/Search/Explore.tsx:634
msgid "Failed to load feeds preferences" msgid "Failed to load feeds preferences"
msgstr "" msgstr ""
@@ -4058,14 +4058,14 @@ msgstr ""
msgid "Failed to load preference." msgid "Failed to load preference."
msgstr "" msgstr ""
#: src/screens/Search/Explore.tsx:480 #: src/screens/Search/Explore.tsx:481
#: src/screens/Search/Explore.tsx:535 #: src/screens/Search/Explore.tsx:536
#: src/screens/Search/Explore.tsx:580 #: src/screens/Search/Explore.tsx:581
#: src/screens/Search/Explore.tsx:626 #: src/screens/Search/Explore.tsx:627
msgid "Failed to load suggested feeds" msgid "Failed to load suggested feeds"
msgstr "" msgstr ""
#: src/screens/Search/Explore.tsx:390 #: src/screens/Search/Explore.tsx:391
msgid "Failed to load suggested follows" msgid "Failed to load suggested follows"
msgstr "" msgstr ""
@@ -4127,7 +4127,7 @@ msgstr ""
msgid "Failed to save settings. Please try again." msgid "Failed to save settings. Please try again."
msgstr "" msgstr ""
#: src/screens/Settings/InterestsSettings.tsx:137 #: src/screens/Settings/InterestsSettings.tsx:147
msgctxt "toast" msgctxt "toast"
msgid "Failed to save your interests." msgid "Failed to save your interests."
msgstr "" msgstr ""
@@ -5711,7 +5711,7 @@ msgstr ""
#. Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form #. Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form
#. placeholder {0}: post.likeCount || 0 #. placeholder {0}: post.likeCount || 0
#: src/components/PostControls/index.tsx:284 #: src/components/PostControls/index.tsx:287
msgid "Like ({0, plural, one {# like} other {# likes}})" msgid "Like ({0, plural, one {# like} other {# likes}})"
msgstr "" msgstr ""
@@ -5930,12 +5930,12 @@ msgstr ""
msgid "Live link" msgid "Live link"
msgstr "" msgstr ""
#: src/screens/Search/Explore.tsx:88 #: src/screens/Search/Explore.tsx:90
msgid "Load more" msgid "Load more"
msgstr "" msgstr ""
#: src/screens/Search/Explore.tsx:517 #: src/screens/Search/Explore.tsx:518
#: src/screens/Search/Explore.tsx:608 #: src/screens/Search/Explore.tsx:609
msgid "Load more suggested feeds" msgid "Load more suggested feeds"
msgstr "" msgstr ""
@@ -6112,10 +6112,12 @@ msgstr ""
msgid "Message from server: {0}" msgid "Message from server: {0}"
msgstr "" msgstr ""
#: src/screens/Messages/components/MessageComposer.tsx:166
#: src/screens/Messages/components/MessageInput.tsx:154 #: src/screens/Messages/components/MessageInput.tsx:154
msgid "Message input field" msgid "Message input field"
msgstr "" msgstr ""
#: src/screens/Messages/components/MessageComposer.tsx:69
#: src/screens/Messages/components/MessageInput.tsx:79 #: src/screens/Messages/components/MessageInput.tsx:79
#: src/screens/Messages/components/MessageInput.web.tsx:60 #: src/screens/Messages/components/MessageInput.web.tsx:60
msgid "Message is too long" msgid "Message is too long"
@@ -6684,7 +6686,7 @@ msgid "No results"
msgstr "" msgstr ""
#. placeholder {0}: interestsDisplayNames[selectedInterest] #. placeholder {0}: interestsDisplayNames[selectedInterest]
#: src/screens/Search/Explore.tsx:826 #: src/screens/Search/Explore.tsx:827
msgid "No results for \"{0}\"." msgid "No results for \"{0}\"."
msgstr "" msgstr ""
@@ -6701,7 +6703,7 @@ msgstr ""
msgid "No results found for “<0>{query}</0>”." msgid "No results found for “<0>{query}</0>”."
msgstr "No results found for “<0>{query}</0>”." msgstr "No results found for “<0>{query}</0>”."
#: src/screens/Search/Explore.tsx:830 #: src/screens/Search/Explore.tsx:831
msgid "No results." msgid "No results."
msgstr "" msgstr ""
@@ -6980,6 +6982,7 @@ msgstr ""
msgid "Open drawer menu" msgid "Open drawer menu"
msgstr "" msgstr ""
#: src/screens/Messages/components/MessageComposer.tsx:143
#: src/screens/Messages/components/MessageInput.web.tsx:180 #: src/screens/Messages/components/MessageInput.web.tsx:180
#: src/view/com/composer/Composer.tsx:1979 #: src/view/com/composer/Composer.tsx:1979
msgid "Open emoji picker" msgid "Open emoji picker"
@@ -7595,7 +7598,7 @@ msgctxt "action"
msgid "Post" msgid "Post"
msgstr "" msgstr ""
#: src/screens/PostThread/index.tsx:552 #: src/screens/PostThread/index.tsx:553
msgctxt "description" msgctxt "description"
msgid "Post" msgid "Post"
msgstr "" msgstr ""
@@ -8122,7 +8125,7 @@ msgstr ""
msgid "Remove from saved feeds" msgid "Remove from saved feeds"
msgstr "" msgstr ""
#: src/components/PostControls/BookmarkButton.tsx:141 #: src/components/PostControls/BookmarkButton.tsx:143
#: src/screens/Bookmarks/index.tsx:262 #: src/screens/Bookmarks/index.tsx:262
msgid "Remove from saved posts" msgid "Remove from saved posts"
msgstr "" msgstr ""
@@ -8283,7 +8286,7 @@ msgstr ""
#. Accessibility label for the reply button, verb form followed by number of replies and noun form #. Accessibility label for the reply button, verb form followed by number of replies and noun form
#. placeholder {0}: post.replyCount || 0 #. placeholder {0}: post.replyCount || 0
#: src/components/PostControls/index.tsx:242 #: src/components/PostControls/index.tsx:243
msgid "Reply ({0, plural, one {# reply} other {# replies}})" msgid "Reply ({0, plural, one {# reply} other {# replies}})"
msgstr "" msgstr ""
@@ -8769,23 +8772,19 @@ msgstr ""
msgid "Search for \"{interestsDisplayName}\" (active)" msgid "Search for \"{interestsDisplayName}\" (active)"
msgstr "" msgstr ""
#: src/screens/Search/components/AutocompleteResults.tsx:47 #: src/screens/Search/components/AutocompleteResults.tsx:54
msgid "Search for \"{searchText}\"" msgid "Search for \"{searchText}\""
msgstr "" msgstr ""
#: src/view/shell/desktop/Search.tsx:129
msgid "Search for “{tQuery}”"
msgstr "Search for “{tQuery}”"
#: src/screens/StarterPack/Wizard/index.tsx:552 #: src/screens/StarterPack/Wizard/index.tsx:552
msgid "Search for feeds that you want to suggest to others." msgid "Search for feeds that you want to suggest to others."
msgstr "" msgstr ""
#: src/screens/Search/Explore.tsx:377 #: src/screens/Search/Explore.tsx:378
msgid "Search for more accounts" msgid "Search for more accounts"
msgstr "" msgstr ""
#: src/screens/Search/Explore.tsx:454 #: src/screens/Search/Explore.tsx:455
msgid "Search for more feeds" msgid "Search for more feeds"
msgstr "" msgstr ""
@@ -8874,12 +8873,12 @@ msgstr ""
msgid "See jobs at Bluesky" msgid "See jobs at Bluesky"
msgstr "" msgstr ""
#: src/components/FeedInterstitials.tsx:583 #: src/components/FeedInterstitials.tsx:490
#: src/components/FeedInterstitials.tsx:641 #: src/components/FeedInterstitials.tsx:548
msgid "See more" msgid "See more"
msgstr "" msgstr ""
#: src/components/FeedInterstitials.tsx:564 #: src/components/FeedInterstitials.tsx:471
msgid "See more suggested profiles" msgid "See more suggested profiles"
msgstr "" msgstr ""
@@ -9047,7 +9046,7 @@ msgid "Select your date of birth"
msgstr "" msgstr ""
#: src/screens/Onboarding/StepInterests/index.tsx:69 #: src/screens/Onboarding/StepInterests/index.tsx:69
#: src/screens/Settings/InterestsSettings.tsx:168 #: src/screens/Settings/InterestsSettings.tsx:178
msgid "Select your interests from the options below" msgid "Select your interests from the options below"
msgstr "" msgstr ""
@@ -9087,6 +9086,7 @@ msgstr ""
msgid "Send feedback" msgid "Send feedback"
msgstr "" msgstr ""
#: src/screens/Messages/components/MessageComposer.tsx:216
#: src/screens/Messages/components/MessageInput.tsx:194 #: src/screens/Messages/components/MessageInput.tsx:194
#: src/screens/Messages/components/MessageInput.web.tsx:234 #: src/screens/Messages/components/MessageInput.web.tsx:234
msgid "Send message" msgid "Send message"
@@ -9575,7 +9575,7 @@ msgstr ""
msgid "Some of your verifications are invalid." msgid "Some of your verifications are invalid."
msgstr "" msgstr ""
#: src/components/FeedInterstitials.tsx:720 #: src/components/FeedInterstitials.tsx:627
msgid "Some other feeds you might like" msgid "Some other feeds you might like"
msgstr "" msgstr ""
@@ -9635,7 +9635,7 @@ msgstr ""
msgid "Sorry, we're unable to load account suggestions at this time." msgid "Sorry, we're unable to load account suggestions at this time."
msgstr "" msgstr ""
#: src/App.native.tsx:141 #: src/App.native.tsx:140
#: src/App.web.tsx:119 #: src/App.web.tsx:119
msgid "Sorry! Your session expired. Please sign in again." msgid "Sorry! Your session expired. Please sign in again."
msgstr "" msgstr ""
@@ -9721,7 +9721,7 @@ msgstr ""
msgid "Starter pack is invalid" msgid "Starter pack is invalid"
msgstr "" msgstr ""
#: src/screens/Search/Explore.tsx:663 #: src/screens/Search/Explore.tsx:664
#: src/view/screens/Profile.tsx:241 #: src/view/screens/Profile.tsx:241
msgid "Starter Packs" msgid "Starter Packs"
msgstr "" msgstr ""
@@ -9837,12 +9837,12 @@ msgstr ""
msgid "Suggested" msgid "Suggested"
msgstr "Suggested" msgstr "Suggested"
#: src/screens/Search/Explore.tsx:374 #: src/screens/Search/Explore.tsx:375
msgid "Suggested accounts" msgid "Suggested accounts"
msgstr "" msgstr ""
#. Accounts suggested to the user for them to follow #. Accounts suggested to the user for them to follow
#: src/components/FeedInterstitials.tsx:561 #: src/components/FeedInterstitials.tsx:468
#: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:180 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:180
msgid "Suggested for you" msgid "Suggested for you"
msgstr "" msgstr ""
@@ -10184,7 +10184,7 @@ msgstr ""
msgid "There was an issue fetching notifications. Tap here to try again." msgid "There was an issue fetching notifications. Tap here to try again."
msgstr "" msgstr ""
#: src/screens/Search/Explore.tsx:1025 #: src/screens/Search/Explore.tsx:1026
#: src/view/com/posts/PostFeed.tsx:773 #: src/view/com/posts/PostFeed.tsx:773
msgid "There was an issue fetching posts. Tap here to try again." msgid "There was an issue fetching posts. Tap here to try again."
msgstr "" msgstr ""
@@ -10887,7 +10887,7 @@ msgstr ""
#. Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun #. Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun
#. placeholder {0}: post.likeCount || 0 #. placeholder {0}: post.likeCount || 0
#: src/components/PostControls/index.tsx:276 #: src/components/PostControls/index.tsx:279
msgid "Unlike ({0, plural, one {# like} other {# likes}})" msgid "Unlike ({0, plural, one {# like} other {# likes}})"
msgstr "" msgstr ""
@@ -11486,7 +11486,7 @@ msgid "View your default post interaction settings"
msgstr "" msgstr ""
#: src/view/com/home/HomeHeaderLayout.web.tsx:57 #: src/view/com/home/HomeHeaderLayout.web.tsx:57
#: src/view/com/home/HomeHeaderLayoutMobile.tsx:75 #: src/view/com/home/HomeHeaderLayoutMobile.tsx:82
msgid "View your feeds and explore more" msgid "View your feeds and explore more"
msgstr "" msgstr ""
@@ -11556,8 +11556,8 @@ msgid "We apply the highest privacy standards, and never share or sell your cont
msgstr "" msgstr ""
#: src/view/com/feeds/MissingFeed.tsx:141 #: src/view/com/feeds/MissingFeed.tsx:141
msgid "We could not connect to the service that provides this custom feed. It may be temporarily unavailable and experiencing issues, or permanently unavailable." msgid "We could not connect to the service that provides this custom feed. It may be temporarily experiencing issues, or permanently unavailable."
msgstr "" msgstr "We could not connect to the service that provides this custom feed. It may be temporarily experiencing issues, or permanently unavailable."
#: src/view/com/feeds/MissingFeed.tsx:147 #: src/view/com/feeds/MissingFeed.tsx:147
msgid "We could not find this list. It was probably deleted." msgid "We could not find this list. It was probably deleted."
@@ -11612,7 +11612,7 @@ msgstr ""
msgid "We ran out of posts from your follows. Here's the latest from <0/>." msgid "We ran out of posts from your follows. Here's the latest from <0/>."
msgstr "" msgstr ""
#: src/screens/Settings/InterestsSettings.tsx:161 #: src/screens/Settings/InterestsSettings.tsx:171
msgid "We recommend selecting at least two interests." msgid "We recommend selecting at least two interests."
msgstr "" msgstr ""
@@ -11847,6 +11847,7 @@ msgstr ""
msgid "Would you like to save this as a draft to edit later?" msgid "Would you like to save this as a draft to edit later?"
msgstr "" msgstr ""
#: src/screens/Messages/components/MessageComposer.tsx:167
#: src/screens/Messages/components/MessageInput.tsx:156 #: src/screens/Messages/components/MessageInput.tsx:156
#: src/screens/Messages/components/MessageInput.web.tsx:213 #: src/screens/Messages/components/MessageInput.web.tsx:213
msgid "Write a message" msgid "Write a message"
@@ -12477,11 +12478,11 @@ msgstr ""
#: src/screens/Search/modules/ExploreInterestsCard.tsx:68 #: src/screens/Search/modules/ExploreInterestsCard.tsx:68
#: src/screens/Settings/ContentAndMediaSettings.tsx:94 #: src/screens/Settings/ContentAndMediaSettings.tsx:94
#: src/screens/Settings/ContentAndMediaSettings.tsx:97 #: src/screens/Settings/ContentAndMediaSettings.tsx:97
#: src/screens/Settings/InterestsSettings.tsx:47 #: src/screens/Settings/InterestsSettings.tsx:49
msgid "Your interests" msgid "Your interests"
msgstr "" msgstr ""
#: src/screens/Settings/InterestsSettings.tsx:128 #: src/screens/Settings/InterestsSettings.tsx:138
msgctxt "toast" msgctxt "toast"
msgid "Your interests have been updated!" msgid "Your interests have been updated!"
msgstr "" msgstr ""
@@ -12544,7 +12545,7 @@ msgstr ""
msgid "Your report will be sent to <0>{0}</0>." msgid "Your report will be sent to <0>{0}</0>."
msgstr "" msgstr ""
#: src/screens/Settings/InterestsSettings.tsx:61 #: src/screens/Settings/InterestsSettings.tsx:63
msgid "Your selected interests help us serve you content you care about." msgid "Your selected interests help us serve you content you care about."
msgstr "" msgstr ""
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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>
+2 -1
View File
@@ -52,8 +52,9 @@ import {atoms as a, native, platform, useBreakpoints, web} from '#/alf'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {ListFooter} from '#/components/Lists' import {ListFooter} from '#/components/Lists'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
const PARENT_CHUNK_SIZE = 20 const PARENT_CHUNK_SIZE = IS_NATIVE ? 5 : 20
const CHILDREN_CHUNK_SIZE = 50 const CHILDREN_CHUNK_SIZE = 50
export function PostThread({uri}: {uri: string}) { export function PostThread({uri}: {uri: string}) {
+6 -5
View File
@@ -28,7 +28,10 @@ import {
createGetSuggestedFeedsQueryKey, createGetSuggestedFeedsQueryKey,
useGetSuggestedFeedsQuery, useGetSuggestedFeedsQuery,
} from '#/state/queries/trending/useGetSuggestedFeedsQuery' } from '#/state/queries/trending/useGetSuggestedFeedsQuery'
import {getSuggestedUsersQueryKeyRoot} from '#/state/queries/trending/useGetSuggestedUsersQuery' import {
getSuggestedUsersForExploreQueryKeyRoot,
useGetSuggestedUsersForExploreQuery,
} from '#/state/queries/trending/useGetSuggestedUsersForExploreQuery'
import {createGetTrendsQueryKey} from '#/state/queries/trending/useGetTrendsQuery' import {createGetTrendsQueryKey} from '#/state/queries/trending/useGetTrendsQuery'
import { import {
createSuggestedStarterPacksQueryKey, createSuggestedStarterPacksQueryKey,
@@ -48,7 +51,6 @@ import {ExploreInterestsCard} from '#/screens/Search/modules/ExploreInterestsCar
import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations' import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations'
import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics' import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos' import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos'
import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers'
import {atoms as a, native, platform, useTheme} from '#/alf' import {atoms as a, native, platform, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition' import {Admonition} from '#/components/Admonition'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
@@ -242,9 +244,8 @@ export function Explore({
isLoading: suggestedUsersIsLoading, isLoading: suggestedUsersIsLoading,
error: suggestedUsersError, error: suggestedUsersError,
isRefetching: suggestedUsersIsRefetching, isRefetching: suggestedUsersIsRefetching,
} = useSuggestedUsers({ } = useGetSuggestedUsersForExploreQuery({
category: selectedInterest || (useFullExperience ? null : interests[0]), category: selectedInterest || (useFullExperience ? null : interests[0]),
search: !useFullExperience,
}) })
/* End special language handling */ /* End special language handling */
@@ -316,7 +317,7 @@ export function Explore({
queryKey: createSuggestedStarterPacksQueryKey(), queryKey: createSuggestedStarterPacksQueryKey(),
}), }),
qc.resetQueries({ qc.resetQueries({
queryKey: [getSuggestedUsersQueryKeyRoot], queryKey: [getSuggestedUsersForExploreQueryKeyRoot],
}), }),
qc.resetQueries({ qc.resetQueries({
queryKey: [useActorSearchQueryKeyRoot], queryKey: [useActorSearchQueryKeyRoot],
@@ -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>
)
}
@@ -1,59 +0,0 @@
import {useMemo} from 'react'
import {useInterestsDisplayNames} from '#/lib/interests'
import {useActorSearch} from '#/state/queries/actor-search'
import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery'
/**
* Conditional hook, used in case a user is a non-english speaker, in which
* case we fall back to searching for users instead of our more curated set.
*/
export function useSuggestedUsers({
category = null,
search = false,
}: {
category?: string | null
/**
* If true, we'll search for users using the translated value of `category`,
* based on the user's app language setting
*/
search?: boolean
}) {
const interestsDisplayNames = useInterestsDisplayNames()
const curated = useGetSuggestedUsersQuery({
enabled: !search,
category,
})
const searched = useActorSearch({
enabled: !!search,
// use user's app language translation for this value
query: category ? interestsDisplayNames[category] : '',
limit: 10,
})
return useMemo(() => {
if (search) {
return {
// we're not paginating right now
data: searched?.data
? {
actors: searched.data.pages.flatMap(p => p.actors) ?? [],
recId: undefined,
}
: undefined,
isLoading: searched.isLoading,
error: searched.error,
isRefetching: searched.isRefetching,
refetch: searched.refetch,
}
} else {
return {
data: curated.data,
isLoading: curated.isLoading,
error: curated.error,
isRefetching: curated.isRefetching,
refetch: curated.refetch,
}
}
}, [curated, searched, search])
}
+12 -2
View File
@@ -19,7 +19,9 @@ import {
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {createGetSuggestedFeedsQueryKey} from '#/state/queries/trending/useGetSuggestedFeedsQuery' import {createGetSuggestedFeedsQueryKey} from '#/state/queries/trending/useGetSuggestedFeedsQuery'
import {createGetSuggestedUsersQueryKey} from '#/state/queries/trending/useGetSuggestedUsersQuery' import {createGetSuggestedUsersForDiscoverQueryKey} from '#/state/queries/trending/useGetSuggestedUsersForDiscoverQuery'
import {createGetSuggestedUsersForExploreQueryKey} from '#/state/queries/trending/useGetSuggestedUsersForExploreQuery'
import {createGetSuggestedUsersForSeeMoreQueryKey} from '#/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery'
import {createSuggestedStarterPacksQueryKey} from '#/state/queries/useSuggestedStarterPacksQuery' import {createSuggestedStarterPacksQueryKey} from '#/state/queries/useSuggestedStarterPacksQuery'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {atoms as a, useGutters, useTheme} from '#/alf' import {atoms as a, useGutters, useTheme} from '#/alf'
@@ -120,7 +122,15 @@ function Inner({
await Promise.all([ await Promise.all([
qc.resetQueries({queryKey: createSuggestedStarterPacksQueryKey()}), qc.resetQueries({queryKey: createSuggestedStarterPacksQueryKey()}),
qc.resetQueries({queryKey: createGetSuggestedFeedsQueryKey()}), qc.resetQueries({queryKey: createGetSuggestedFeedsQueryKey()}),
qc.resetQueries({queryKey: createGetSuggestedUsersQueryKey({})}), qc.resetQueries({
queryKey: createGetSuggestedUsersForDiscoverQueryKey({}),
}),
qc.resetQueries({
queryKey: createGetSuggestedUsersForExploreQueryKey({}),
}),
qc.resetQueries({
queryKey: createGetSuggestedUsersForSeeMoreQueryKey({}),
}),
]) ])
Toast.show( Toast.show(
+6 -2
View File
@@ -26,7 +26,9 @@ import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows' import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows'
import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '#/state/queries/suggested-follows' import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '#/state/queries/suggested-follows'
import {findAllProfilesInQueryData as findAllProfilesInSuggestedOnboardingUsersQueryData} from '#/state/queries/trending/useGetSuggestedOnboardingUsersQuery' import {findAllProfilesInQueryData as findAllProfilesInSuggestedOnboardingUsersQueryData} from '#/state/queries/trending/useGetSuggestedOnboardingUsersQuery'
import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersQueryData} from '#/state/queries/trending/useGetSuggestedUsersQuery' import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersForDiscoverQueryData} from '#/state/queries/trending/useGetSuggestedUsersForDiscoverQuery'
import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersForExploreQueryData} from '#/state/queries/trending/useGetSuggestedUsersForExploreQuery'
import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersForSeeMoreQueryData} from '#/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery'
import {findAllProfilesInQueryData as findAllProfilesInPostThreadV2QueryData} from '#/state/queries/usePostThread/queryCache' import {findAllProfilesInQueryData as findAllProfilesInPostThreadV2QueryData} from '#/state/queries/usePostThread/queryCache'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {castAsShadow, type Shadow} from './types' import {castAsShadow, type Shadow} from './types'
@@ -249,7 +251,9 @@ function* findProfilesInCache(
yield* findAllProfilesInProfileFollowersQueryData(queryClient, did) yield* findAllProfilesInProfileFollowersQueryData(queryClient, did)
yield* findAllProfilesInProfileFollowsQueryData(queryClient, did) yield* findAllProfilesInProfileFollowsQueryData(queryClient, did)
yield* findAllProfilesInSuggestedOnboardingUsersQueryData(queryClient, did) yield* findAllProfilesInSuggestedOnboardingUsersQueryData(queryClient, did)
yield* findAllProfilesInSuggestedUsersQueryData(queryClient, did) yield* findAllProfilesInSuggestedUsersForDiscoverQueryData(queryClient, did)
yield* findAllProfilesInSuggestedUsersForExploreQueryData(queryClient, did)
yield* findAllProfilesInSuggestedUsersForSeeMoreQueryData(queryClient, did)
yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did) yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did)
yield* findAllProfilesInActorSearchQueryData(queryClient, did) yield* findAllProfilesInActorSearchQueryData(queryClient, did)
yield* findAllProfilesInListConvosQueryData(queryClient, did) yield* findAllProfilesInListConvosQueryData(queryClient, did)
+1
View File
@@ -99,6 +99,7 @@ export function useProfilesQuery({
}) { }) {
const agent = useAgent() const agent = useAgent()
return useQuery({ return useQuery({
enabled: handles.length > 0,
staleTime: STALE.MINUTES.FIVE, staleTime: STALE.MINUTES.FIVE,
queryKey: profilesQueryKey(handles), queryKey: profilesQueryKey(handles),
queryFn: async () => { queryFn: async () => {
@@ -5,7 +5,6 @@ import {
import {type QueryClient, useQuery} from '@tanstack/react-query' import {type QueryClient, useQuery} from '@tanstack/react-query'
import {createBskyTopicsHeader} from '#/lib/api/feed/utils' import {createBskyTopicsHeader} from '#/lib/api/feed/utils'
import {logger} from '#/logger'
import {getContentLanguages} from '#/state/preferences/languages' import {getContentLanguages} from '#/state/preferences/languages'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
@@ -54,27 +53,8 @@ export function useGetSuggestedOnboardingUsersQuery(props: QueryProps) {
}, },
}, },
) )
// FALLBACK: if no results for 'all', try again with no interests specified
if (!props.category && data.actors.length === 0) {
logger.error(
`Did not get any suggested onboarding users, falling back - interests: ${overrideInterests}`,
)
const {data: fallbackData} =
await agent.app.bsky.unspecced.getSuggestedOnboardingUsers(
{
category: props.category ?? undefined,
limit: props.limit || 10,
},
{
headers: {
'Accept-Language': contentLangs,
},
},
)
return fallbackData
}
return data return {...data, recId: data.recIdStr}
}, },
}) })
} }
@@ -0,0 +1,75 @@
import {
type AppBskyActorDefs,
type AppBskyUnspeccedGetSuggestedUsersForDiscover,
} from '@atproto/api'
import {type QueryClient, useQuery} from '@tanstack/react-query'
import {
aggregateUserInterests,
createBskyTopicsHeader,
} from '#/lib/api/feed/utils'
import {getContentLanguages} from '#/state/preferences/languages'
import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
export type QueryProps = {
limit?: number
}
export const getSuggestedUsersForDiscoverQueryKeyRoot =
'unspecced-suggested-users-for-explore'
export const createGetSuggestedUsersForDiscoverQueryKey = (
props: QueryProps,
) => [getSuggestedUsersForDiscoverQueryKeyRoot, props.limit]
export function useGetSuggestedUsersForDiscoverQuery(props: QueryProps = {}) {
const agent = useAgent()
const {data: preferences} = usePreferencesQuery()
return useQuery({
staleTime: STALE.MINUTES.THREE,
queryKey: createGetSuggestedUsersForDiscoverQueryKey(props),
queryFn: async () => {
const contentLangs = getContentLanguages().join(',')
const userInterests = aggregateUserInterests(preferences)
const {data} =
await agent.app.bsky.unspecced.getSuggestedUsersForDiscover(
{
limit: props.limit || 10,
},
{
headers: {
...createBskyTopicsHeader(userInterests),
'Accept-Language': contentLangs,
},
},
)
return {...data, recId: data.recIdStr}
},
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const responses =
queryClient.getQueriesData<AppBskyUnspeccedGetSuggestedUsersForDiscover.OutputSchema>(
{
queryKey: [getSuggestedUsersForDiscoverQueryKeyRoot],
},
)
for (const [_key, response] of responses) {
if (!response) {
continue
}
for (const actor of response.actors) {
if (actor.did === did) {
yield actor
}
}
}
}
@@ -1,6 +1,6 @@
import { import {
type AppBskyActorDefs, type AppBskyActorDefs,
type AppBskyUnspeccedGetSuggestedUsers, type AppBskyUnspeccedGetSuggestedUsersForExplore,
} from '@atproto/api' } from '@atproto/api'
import {type QueryClient, useQuery} from '@tanstack/react-query' import {type QueryClient, useQuery} from '@tanstack/react-query'
@@ -8,7 +8,6 @@ import {
aggregateUserInterests, aggregateUserInterests,
createBskyTopicsHeader, createBskyTopicsHeader,
} from '#/lib/api/feed/utils' } from '#/lib/api/feed/utils'
import {logger} from '#/logger'
import {getContentLanguages} from '#/state/preferences/languages' import {getContentLanguages} from '#/state/preferences/languages'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
@@ -17,29 +16,26 @@ import {useAgent} from '#/state/session'
export type QueryProps = { export type QueryProps = {
category?: string | null category?: string | null
limit?: number limit?: number
enabled?: boolean
} }
export const getSuggestedUsersQueryKeyRoot = 'unspecced-suggested-users' export const getSuggestedUsersForExploreQueryKeyRoot =
export const createGetSuggestedUsersQueryKey = (props: QueryProps) => [ 'unspecced-suggested-users-for-explore'
getSuggestedUsersQueryKeyRoot, export const createGetSuggestedUsersForExploreQueryKey = (
props.category, props: QueryProps,
props.limit, ) => [getSuggestedUsersForExploreQueryKeyRoot, props.category, props.limit]
]
export function useGetSuggestedUsersQuery(props: QueryProps) { export function useGetSuggestedUsersForExploreQuery(props: QueryProps = {}) {
const agent = useAgent() const agent = useAgent()
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
return useQuery({ return useQuery({
enabled: !!preferences && props.enabled !== false,
staleTime: STALE.MINUTES.THREE, staleTime: STALE.MINUTES.THREE,
queryKey: createGetSuggestedUsersQueryKey(props), queryKey: createGetSuggestedUsersForExploreQueryKey(props),
queryFn: async () => { queryFn: async () => {
const contentLangs = getContentLanguages().join(',') const contentLangs = getContentLanguages().join(',')
const userInterests = aggregateUserInterests(preferences) const userInterests = aggregateUserInterests(preferences)
const {data} = await agent.app.bsky.unspecced.getSuggestedUsers( const {data} = await agent.app.bsky.unspecced.getSuggestedUsersForExplore(
{ {
category: props.category ?? undefined, category: props.category ?? undefined,
limit: props.limit || 10, limit: props.limit || 10,
@@ -51,27 +47,8 @@ export function useGetSuggestedUsersQuery(props: QueryProps) {
}, },
}, },
) )
// FALLBACK: if no results for 'all', try again with no interests specified
if (!props.category && data.actors.length === 0) {
logger.error(
`Did not get any suggested users, falling back - interests: ${userInterests}`,
)
const {data: fallbackData} =
await agent.app.bsky.unspecced.getSuggestedUsers(
{
category: props.category ?? undefined,
limit: props.limit || 10,
},
{
headers: {
'Accept-Language': contentLangs,
},
},
)
return fallbackData
}
return data return {...data, recId: data.recIdStr}
}, },
}) })
} }
@@ -81,9 +58,11 @@ export function* findAllProfilesInQueryData(
did: string, did: string,
): Generator<AppBskyActorDefs.ProfileView, void> { ): Generator<AppBskyActorDefs.ProfileView, void> {
const responses = const responses =
queryClient.getQueriesData<AppBskyUnspeccedGetSuggestedUsers.OutputSchema>({ queryClient.getQueriesData<AppBskyUnspeccedGetSuggestedUsersForExplore.OutputSchema>(
queryKey: [getSuggestedUsersQueryKeyRoot], {
}) queryKey: [getSuggestedUsersForExploreQueryKeyRoot],
},
)
for (const [_key, response] of responses) { for (const [_key, response] of responses) {
if (!response) { if (!response) {
continue continue
@@ -0,0 +1,77 @@
import {
type AppBskyActorDefs,
type AppBskyUnspeccedGetSuggestedUsersForSeeMore,
} from '@atproto/api'
import {type QueryClient, useQuery} from '@tanstack/react-query'
import {
aggregateUserInterests,
createBskyTopicsHeader,
} from '#/lib/api/feed/utils'
import {getContentLanguages} from '#/state/preferences/languages'
import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
export type QueryProps = {
category?: string | null
limit?: number
}
export const getSuggestedUsersForSeeMoreQueryKeyRoot =
'unspecced-suggested-users-for-explore'
export const createGetSuggestedUsersForSeeMoreQueryKey = (
props: QueryProps,
) => [getSuggestedUsersForSeeMoreQueryKeyRoot, props.category, props.limit]
export function useGetSuggestedUsersForSeeMoreQuery(props: QueryProps = {}) {
const agent = useAgent()
const {data: preferences} = usePreferencesQuery()
return useQuery({
staleTime: STALE.MINUTES.THREE,
queryKey: createGetSuggestedUsersForSeeMoreQueryKey(props),
queryFn: async () => {
const contentLangs = getContentLanguages().join(',')
const userInterests = aggregateUserInterests(preferences)
const {data} = await agent.app.bsky.unspecced.getSuggestedUsersForSeeMore(
{
category: props.category ?? undefined,
limit: props.limit || 50,
},
{
headers: {
...createBskyTopicsHeader(userInterests),
'Accept-Language': contentLangs,
},
},
)
return {...data, recId: data.recIdStr}
},
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const responses =
queryClient.getQueriesData<AppBskyUnspeccedGetSuggestedUsersForSeeMore.OutputSchema>(
{
queryKey: [getSuggestedUsersForSeeMoreQueryKeyRoot],
},
)
for (const [_key, response] of responses) {
if (!response) {
continue
}
for (const actor of response.actors) {
if (actor.did === did) {
yield actor
}
}
}
}
+2 -2
View File
@@ -140,8 +140,8 @@ function DialogInner({
{type === 'feed' ? ( {type === 'feed' ? (
<Trans> <Trans>
We could not connect to the service that provides this custom We could not connect to the service that provides this custom
feed. It may be temporarily unavailable and experiencing issues, feed. It may be temporarily experiencing issues, or permanently
or permanently unavailable. unavailable.
</Trans> </Trans>
) : ( ) : (
<Trans>We could not find this list. It was probably deleted.</Trans> <Trans>We could not find this list. It was probably deleted.</Trans>
+8 -1
View File
@@ -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>
+1 -1
View File
@@ -582,7 +582,7 @@ function LightboxFooter({
{altText ? ( {altText ? (
<View accessibilityRole="button" style={styles.footerText}> <View accessibilityRole="button" style={styles.footerText}>
<Text <Text
style={[s.gray3]} style={{color: colors.gray3}}
numberOfLines={isAltExpanded ? undefined : 3} numberOfLines={isAltExpanded ? undefined : 3}
selectable selectable
onPress={() => { onPress={() => {
+1 -1
View File
@@ -202,7 +202,7 @@ function ListItem({
<View style={styles.listItemContent}> <View style={styles.listItemContent}>
<Text <Text
type="lg" type="lg"
style={[s.bold, pal.text]} style={[{fontWeight: '600'}, pal.text]}
numberOfLines={1} numberOfLines={1}
lineHeight={1.2}> lineHeight={1.2}>
{sanitizeDisplayName(list.name)} {sanitizeDisplayName(list.name)}
@@ -272,7 +272,7 @@ let NotificationFeedItem = ({
<HeartIconFilled <HeartIconFilled
size="xl" size="xl"
style={[ style={[
s.likeColor, {color: t.palette.pink},
// {position: 'relative', top: -4} // {position: 'relative', top: -4}
]} ]}
/> />
+2 -2
View File
@@ -60,7 +60,7 @@ export function PostLoadingPlaceholder({
}, },
]} ]}
/> />
<View style={[s.flex1]}> <View style={[a.flex_1]}>
<LoadingPlaceholder width={100} height={6} style={{marginBottom: 10}} /> <LoadingPlaceholder width={100} height={6} style={{marginBottom: 10}} />
<LoadingPlaceholder width="95%" height={6} style={{marginBottom: 8}} /> <LoadingPlaceholder width="95%" height={6} style={{marginBottom: 8}} />
<LoadingPlaceholder width="95%" height={6} style={{marginBottom: 8}} /> <LoadingPlaceholder width="95%" height={6} style={{marginBottom: 8}} />
@@ -238,7 +238,7 @@ export function FeedLoadingPlaceholder({
height={36} height={36}
style={[styles.avatar, {borderRadius: 8}]} style={[styles.avatar, {borderRadius: 8}]}
/> />
<View style={[s.flex1]}> <View style={[a.flex_1]}>
<LoadingPlaceholder width={100} height={8} style={[s.mt5, s.mb10]} /> <LoadingPlaceholder width={100} height={8} style={[s.mt5, s.mb10]} />
<LoadingPlaceholder width={120} height={8} /> <LoadingPlaceholder width={120} height={8} />
</View> </View>

Some files were not shown because too many files have changed in this diff Show More