Merge branch 'main' into @tomekzaw/nano-icons-feed-icons
This commit is contained in:
+9
-3
@@ -87,6 +87,7 @@ import {MessagesJoinRequestsScreen} from '#/screens/Messages/JoinRequests'
|
||||
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
|
||||
import {ModerationInboxScreen} from '#/screens/ModerationInbox'
|
||||
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
|
||||
import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
|
||||
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
|
||||
@@ -178,6 +179,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
getComponent={() => ModerationScreen}
|
||||
options={{title: title(msg`Moderation`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationInbox"
|
||||
getComponent={() => ModerationInboxScreen}
|
||||
options={{title: title(msg`Moderation inbox`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ModerationModlists"
|
||||
getComponent={() => ModerationModlistsScreen}
|
||||
@@ -919,7 +925,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
// chat-removed-from-group, chat-join-request-rejected: the convo is
|
||||
// no longer accessible to the recipient, so just open the list.
|
||||
// @ts-expect-error nested navigators aren't typed -sfn
|
||||
navigate('MessagesTab', {screen: 'Messages'})
|
||||
void navigate('MessagesTab', {screen: 'Messages'})
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1006,7 +1012,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
ax.metric('init', {
|
||||
initMs: Math.round(
|
||||
// @ts-ignore Emitted by Metro in the bundle prelude
|
||||
// @ts-expect-error Emitted by Metro in the bundle prelude
|
||||
performance.now() - global.__BUNDLE_START_TIME__,
|
||||
),
|
||||
})
|
||||
@@ -1081,7 +1087,7 @@ function navigate<K extends keyof AllNavigatorParams>(
|
||||
}
|
||||
navigationRef.addListener('state', handler)
|
||||
|
||||
// @ts-ignore I don't know what would make typescript happy but I have a life -prf
|
||||
// @ts-expect-error I don't know what would make typescript happy but I have a life -prf
|
||||
navigationRef.navigate(name, params)
|
||||
}),
|
||||
timeout(1e3),
|
||||
|
||||
+6
-5
@@ -20,14 +20,15 @@ import * as SplashScreen from 'expo-splash-screen'
|
||||
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
import {atoms as a} from '#/alf'
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
import splashImagePointer from '../assets/splash/splash.png'
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
import darkSplashImagePointer from '../assets/splash/splash-dark.png'
|
||||
const splashImageUri = RNImage.resolveAssetSource(splashImagePointer).uri
|
||||
|
||||
const splashImageUri = RNImage.resolveAssetSource(splashImagePointer)!.uri
|
||||
const darkSplashImageUri = RNImage.resolveAssetSource(
|
||||
darkSplashImagePointer,
|
||||
).uri
|
||||
)!.uri
|
||||
|
||||
export const Logo = forwardRef(function LogoImpl(props: SvgProps, ref) {
|
||||
const width = 1000
|
||||
@@ -35,7 +36,7 @@ export const Logo = forwardRef(function LogoImpl(props: SvgProps, ref) {
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
// @ts-ignore it's fiiiiine
|
||||
// @ts-expect-error it's fiiiiine
|
||||
ref={ref}
|
||||
viewBox="0 0 64 66"
|
||||
style={[{width, height}, props.style]}>
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import Svg, {Path} from 'react-native-svg'
|
||||
|
||||
import {atoms as a, flatten} from '#/alf'
|
||||
import {atoms as a, flattenToCSS} from '#/alf'
|
||||
|
||||
const size = 100
|
||||
const ratio = 57 / 64
|
||||
@@ -72,7 +72,7 @@ export function Splash({
|
||||
{!isAnimationComplete && (
|
||||
<div
|
||||
ref={splashRef}
|
||||
style={flatten([
|
||||
style={flattenToCSS([
|
||||
a.fixed,
|
||||
a.inset_0,
|
||||
a.flex,
|
||||
|
||||
@@ -149,7 +149,6 @@ export function RedirectOverlay() {
|
||||
t.atoms.bg,
|
||||
gtMobile ? a.p_2xl : a.p_xl,
|
||||
a.align_center,
|
||||
// @ts-ignore
|
||||
platform({
|
||||
web: {
|
||||
paddingTop: '35vh',
|
||||
|
||||
+6
-1
@@ -3,6 +3,8 @@ import {type FontVariant, type TextStyle} from 'react-native'
|
||||
import {IS_ANDROID, IS_WEB} from '#/env'
|
||||
import {type Device, device} from '#/storage'
|
||||
|
||||
export type MutableTextStyle = {-readonly [K in keyof TextStyle]: TextStyle[K]}
|
||||
|
||||
const WEB_FONT_FAMILIES = `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"`
|
||||
|
||||
const factor = 0.0625 // 1 - (15/16)
|
||||
@@ -37,7 +39,10 @@ export function setFontFamily(fontFamily: Device['fontFamily']) {
|
||||
/*
|
||||
* Unused fonts are commented out, but the files are there if we need them.
|
||||
*/
|
||||
export function applyFonts(style: TextStyle, fontFamily: 'system' | 'theme') {
|
||||
export function applyFonts(
|
||||
style: MutableTextStyle,
|
||||
fontFamily: 'system' | 'theme',
|
||||
) {
|
||||
if (fontFamily === 'theme') {
|
||||
if (IS_ANDROID) {
|
||||
style.fontFamily =
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
import {UITextView} from '@bsky.app/react-native-uitextview'
|
||||
import createEmojiRegex from 'emoji-regex'
|
||||
|
||||
import {type Alf, applyFonts, atoms, flatten} from '#/alf'
|
||||
import {
|
||||
type Alf,
|
||||
applyFonts,
|
||||
atoms,
|
||||
flatten,
|
||||
type MutableTextStyle,
|
||||
} from '#/alf'
|
||||
import {IS_IOS, IS_NATIVE} from '#/env'
|
||||
|
||||
/**
|
||||
@@ -27,7 +33,7 @@ export function normalizeTextStyles(
|
||||
fontFamily: Alf['fonts']['family']
|
||||
} & Pick<Alf, 'flags'>,
|
||||
) {
|
||||
const s = flatten(styles) ?? {}
|
||||
const s: MutableTextStyle = {...flatten(styles)}
|
||||
|
||||
// should always be defined on these components
|
||||
s.fontSize = (s.fontSize || atoms.text_md.fontSize) * fontScale
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Dimensions} from 'react-native'
|
||||
import {Dimensions, type DimensionsPayload} from 'react-native'
|
||||
|
||||
/**
|
||||
* Same as `useWindowDimensions().fontScale`, but avoids rerendering
|
||||
@@ -9,9 +9,12 @@ export function useNativeFontScale() {
|
||||
const [fontScale, setFontScale] = useState(Dimensions.get('window').fontScale)
|
||||
|
||||
useEffect(() => {
|
||||
const sub = Dimensions.addEventListener('change', evt => {
|
||||
setFontScale(evt.window.fontScale)
|
||||
})
|
||||
const sub = Dimensions.addEventListener(
|
||||
'change',
|
||||
(evt: DimensionsPayload) => {
|
||||
if (evt.window) setFontScale(evt.window.fontScale)
|
||||
},
|
||||
)
|
||||
return () => sub.remove()
|
||||
}, [])
|
||||
|
||||
|
||||
+16
-3
@@ -1,6 +1,19 @@
|
||||
import {type DimensionValue, StyleSheet} from 'react-native'
|
||||
import {type DimensionValue, type StyleProp, StyleSheet} from 'react-native'
|
||||
|
||||
export const flatten = StyleSheet.flatten
|
||||
export function flatten<T>(
|
||||
style?: StyleProp<T>,
|
||||
): T extends (infer U)[] ? U : T {
|
||||
return (StyleSheet.flatten(
|
||||
style as unknown as Parameters<typeof StyleSheet.flatten>[0],
|
||||
) ?? {}) as T extends (infer U)[] ? U : T
|
||||
}
|
||||
|
||||
/** Flatten React Native styles passed directly to a web-only DOM component. */
|
||||
export function flattenToCSS(style: unknown): React.CSSProperties {
|
||||
return (StyleSheet.flatten(
|
||||
style as Parameters<typeof StyleSheet.flatten>[0],
|
||||
) ?? {}) as React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a style value to a number. Padding values are typed as
|
||||
@@ -28,7 +41,7 @@ interface PaddingStyle {
|
||||
* non-numeric `DimensionValue` (e.g. percentages) is treated as 0.
|
||||
*/
|
||||
export function extractPadding(style: PaddingStyle | PaddingStyle[]) {
|
||||
const s = flatten(style as any) ?? {}
|
||||
const s = flatten(style)
|
||||
const base = num(s.padding)
|
||||
return {
|
||||
paddingTop: num(s.paddingTop) || num(s.paddingVertical) || base,
|
||||
|
||||
@@ -24,7 +24,7 @@ export function useThemeName(): ThemeName {
|
||||
}
|
||||
|
||||
function getThemeName(
|
||||
colorScheme: ColorSchemeName,
|
||||
colorScheme: ColorSchemeName | null | undefined,
|
||||
colorMode: 'system' | 'light' | 'dark',
|
||||
darkTheme?: ThemeName,
|
||||
) {
|
||||
@@ -39,11 +39,8 @@ function getThemeName(
|
||||
}
|
||||
|
||||
function updateDocument(theme: ThemeName) {
|
||||
// @ts-ignore web only
|
||||
if (IS_WEB && typeof window !== 'undefined') {
|
||||
// @ts-ignore web only
|
||||
const html = window.document.documentElement
|
||||
// @ts-ignore web only
|
||||
const meta = window.document.querySelector('meta[name="theme-color"]')
|
||||
|
||||
// remove any other color mode classes
|
||||
|
||||
@@ -77,22 +77,6 @@ export function getFeatures() {
|
||||
|
||||
export function getFeatureDescription(feature: Features, i18n: I18n) {
|
||||
switch (feature) {
|
||||
case Features.VideoAllow10MinuteEnable:
|
||||
return {
|
||||
key: feature,
|
||||
name: i18n._(
|
||||
msg({
|
||||
message: 'Longer videos',
|
||||
comment: 'Name for a feature flag (longer videos)',
|
||||
}),
|
||||
),
|
||||
description: i18n._(
|
||||
msg({
|
||||
message: 'Enable 10-minute video uploads.',
|
||||
comment: 'Description of a feature flag (10-minute video uploads)',
|
||||
}),
|
||||
),
|
||||
}
|
||||
case Features.CanonicalPostNumberingEnable:
|
||||
return {
|
||||
key: feature,
|
||||
|
||||
@@ -17,15 +17,13 @@ export enum Features {
|
||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
|
||||
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
|
||||
CustomLogoJapanEnable = 'custom_logo:japan:enable',
|
||||
VideoAllow10MinuteEnable = 'video:allow-10-minute:enable',
|
||||
VideoMultipartUploadEnable = 'video:multipart_upload:enable',
|
||||
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
|
||||
FollowSortEnable = 'follow_sort:enable',
|
||||
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
|
||||
CanonicalPostNumberingEnable = 'canonical_post_numbering:enable',
|
||||
ContentVisibilitySettingsEnable = 'content_visibility_settings:enable',
|
||||
ModerationInboxEnable = 'moderation_inbox:enable',
|
||||
|
||||
// values
|
||||
TrendingDiscoverValues = 'trending_discover:values',
|
||||
|
||||
@@ -9,10 +9,6 @@ import {Platform} from 'react-native'
|
||||
import {type Result, type WidenPrimitives} from '@growthbook/growthbook-react'
|
||||
|
||||
import {Logger} from '#/logger'
|
||||
import {
|
||||
getCachedIsBetaUser,
|
||||
subscribeToCachedIsBetaUser,
|
||||
} from '#/state/preferences/beta-user-cache'
|
||||
import {
|
||||
Features,
|
||||
features as feats,
|
||||
@@ -37,7 +33,7 @@ import {type Metrics, metrics} from '#/analytics/metrics'
|
||||
import * as refParams from '#/analytics/misc/refParams'
|
||||
import * as env from '#/env'
|
||||
import {useGeolocationServiceResponse} from '#/geolocation/service'
|
||||
import {device} from '#/storage'
|
||||
import {account, device} from '#/storage'
|
||||
|
||||
export * as utils from '#/analytics/utils'
|
||||
export const features = {init, refresh}
|
||||
@@ -135,7 +131,7 @@ export const setupDeviceId = getAndMigrateDeviceId()
|
||||
|
||||
/**
|
||||
* Reads the per-account cached `isBetaUser` flag for `did`, kept in sync with
|
||||
* PDS preference query results and the beta settings toggle.
|
||||
* writes from `BetaUserStorageSync` and the beta settings toggle.
|
||||
*
|
||||
* This deliberately does not use `useStorage`, whose `useState` seeds once and
|
||||
* only updates via the change listener. The consuming `AnalyticsContext` lives
|
||||
@@ -150,13 +146,17 @@ function useAccountIsBetaUser(did: string | undefined): boolean | undefined {
|
||||
const subscribe = useCallback(
|
||||
(onChange: () => void) => {
|
||||
if (!did) return () => {}
|
||||
return subscribeToCachedIsBetaUser(did, onChange)
|
||||
const sub = account.addOnValueChangedListener(
|
||||
[did, 'isBetaUser'],
|
||||
onChange,
|
||||
)
|
||||
return () => sub.remove()
|
||||
},
|
||||
[did],
|
||||
)
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!did) return undefined
|
||||
return getCachedIsBetaUser(did)
|
||||
return account.get([did, 'isBetaUser'])
|
||||
}, [did])
|
||||
return useSyncExternalStore(subscribe, getSnapshot)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
import {type Platform} from 'react-native'
|
||||
|
||||
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
|
||||
import {
|
||||
type VideoCompressSkipReason,
|
||||
type VideoUploadTransport,
|
||||
} from '#/lib/media/video/types'
|
||||
import {type VideoCompressSkipReason} from '#/lib/media/video/types'
|
||||
import {type NotificationType} from '#/state/queries/notifications/types'
|
||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
|
||||
@@ -1473,7 +1470,6 @@ export type Events = {
|
||||
bytes: number
|
||||
elapsedMs: number
|
||||
throughputBytesPerSec: number
|
||||
transport: VideoUploadTransport
|
||||
}
|
||||
'video:upload:uploadFailed': {
|
||||
uploadId: string
|
||||
@@ -1481,7 +1477,6 @@ export type Events = {
|
||||
bytes: number
|
||||
errorClass: string
|
||||
elapsedMs: number
|
||||
transport: VideoUploadTransport
|
||||
}
|
||||
'video:upload:processingStarted': {
|
||||
uploadId: string
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
export function useMeta(metadata?: MergeableMetadata) {
|
||||
const m = useMemo(() => metadata, [metadata])
|
||||
if (!m) return
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
m.__meta = true
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Pressable} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Pressable} from '#/components/Pressable'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -49,7 +49,7 @@ export function AltBadgeWithDialog({
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_20}
|
||||
onPress={control.open}
|
||||
style={s => [
|
||||
style={({pressed, hovered}) => [
|
||||
a.justify_center,
|
||||
a.rounded_sm,
|
||||
a.p_xs,
|
||||
@@ -62,7 +62,7 @@ export function AltBadgeWithDialog({
|
||||
opacity: 0.8,
|
||||
},
|
||||
pos,
|
||||
s.hovered || s.pressed
|
||||
hovered || pressed
|
||||
? [
|
||||
{
|
||||
opacity: 1,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {useState} from 'react'
|
||||
import {type Insets, Pressable, View} from 'react-native'
|
||||
import {type Insets, View} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
|
||||
import {Pressable} from '#/components/Pressable'
|
||||
import * as Tooltip from '#/components/Tooltip'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
|
||||
@@ -101,6 +101,8 @@ export type ButtonProps = Pick<
|
||||
| 'onPressOut'
|
||||
| 'onFocus'
|
||||
| 'onBlur'
|
||||
| 'onAccessibilityAction'
|
||||
| 'onAccessibilityEscape'
|
||||
> &
|
||||
AccessibilityProps &
|
||||
VariantProps & {
|
||||
@@ -131,7 +133,7 @@ export function useButtonContext() {
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
export const Button = forwardRef<View, ButtonProps>(
|
||||
export const Button = forwardRef<React.ComponentRef<typeof View>, ButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
@@ -575,7 +577,6 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
role="button"
|
||||
accessibilityHint={undefined} // optional
|
||||
{...rest}
|
||||
// @ts-ignore - this will always be a pressable
|
||||
ref={ref}
|
||||
aria-label={label}
|
||||
aria-pressed={state.pressed}
|
||||
|
||||
@@ -39,6 +39,9 @@ import {
|
||||
import {Span, Text} from '#/components/Typography'
|
||||
import {IS_IOS, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
|
||||
|
||||
type TextInputInstance = React.ComponentRef<typeof TextInput>
|
||||
type ViewInstance = React.ComponentRef<typeof View>
|
||||
|
||||
export type SubmitRequest =
|
||||
| {
|
||||
platform: 'web'
|
||||
@@ -60,7 +63,7 @@ export type ComposerInternalApi = {
|
||||
input?: ReturnType<typeof useTapper>['input']
|
||||
clear: () => void
|
||||
insert(text: string): void
|
||||
setAutocompleteAnchor: (node: View | null) => void
|
||||
setAutocompleteAnchor: (node: ViewInstance | null) => void
|
||||
}
|
||||
|
||||
export function useComposerInternalApiRef() {
|
||||
@@ -82,7 +85,7 @@ export type ComposerProps = Omit<
|
||||
| 'onSubmitEditing'
|
||||
> & {
|
||||
label: string
|
||||
ref?: React.RefObject<TextInput>
|
||||
ref?: React.RefObject<TextInputInstance>
|
||||
internalApiRef?: React.Ref<ComposerInternalApi>
|
||||
outerStyle?: ViewStyleProp['style']
|
||||
contentTextStyle?: TextStyleProp['style']
|
||||
@@ -139,6 +142,11 @@ export function Composer({
|
||||
placement: autocompletePlacement,
|
||||
dynamicWidth: IS_WEB,
|
||||
})
|
||||
const inputRef = mergeRefs<TextInputInstance>([
|
||||
ref,
|
||||
tapper.inputProps.ref as React.Ref<TextInputInstance>,
|
||||
sift.targetProps.ref as React.Ref<TextInputInstance>,
|
||||
])
|
||||
|
||||
/*
|
||||
* Active facet state for controlling the visibility of the Autocomplete.
|
||||
@@ -306,7 +314,7 @@ export function Composer({
|
||||
style={[a.absolute, a.inset_0, a.z_10, {overflow: 'hidden'}]}
|
||||
ref={node => {
|
||||
if (IS_WEB && node) {
|
||||
// @ts-ignore web only a11y
|
||||
// @ts-expect-error web only a11y
|
||||
node.setAttribute('inert', '')
|
||||
}
|
||||
}}>
|
||||
@@ -345,7 +353,7 @@ export function Composer({
|
||||
{...rest}
|
||||
{...tapper.inputProps}
|
||||
{...sift.targetProps}
|
||||
ref={mergeRefs([ref, tapper.inputProps.ref, sift.targetProps.ref])}
|
||||
ref={inputRef}
|
||||
rawValue={tapper.state.text}
|
||||
onBlur={e => {
|
||||
rest.onBlur?.(e)
|
||||
@@ -359,11 +367,10 @@ export function Composer({
|
||||
inputScrollSharedValue.value = e.nativeEvent.contentOffset.y
|
||||
}
|
||||
}}
|
||||
// @ts-ignore web only
|
||||
// @ts-expect-error web only
|
||||
onCompositionStart={() => {
|
||||
isComposing.current = true
|
||||
}}
|
||||
// @ts-ignore web only
|
||||
onCompositionEnd={() => {
|
||||
isComposing.current = false
|
||||
}}
|
||||
|
||||
@@ -244,7 +244,7 @@ export function Trigger({
|
||||
const context = useContextMenuContext()
|
||||
const playHaptic = useHaptics()
|
||||
const insets = useSafeAreaInsets()
|
||||
const ref = useRef<View>(null)
|
||||
const ref = useRef<React.ComponentRef<typeof View>>(null)
|
||||
const isFocused = useIsFocused()
|
||||
const [image, setImage] = useState<string | null>(null)
|
||||
const [pendingMeasurement, setPendingMeasurement] = useState<{
|
||||
@@ -971,7 +971,10 @@ export function Divider() {
|
||||
)
|
||||
}
|
||||
|
||||
function measureView(view: View | null, insets: EdgeInsets) {
|
||||
function measureView(
|
||||
view: React.ComponentRef<typeof View> | null,
|
||||
insets: EdgeInsets,
|
||||
) {
|
||||
if (!view) return Promise.resolve(null)
|
||||
return new Promise<Measurement>(resolve => {
|
||||
view?.measureInWindow((x, y, width, height) =>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from 'react'
|
||||
import {
|
||||
Keyboard,
|
||||
type KeyboardEventListener,
|
||||
type KeyboardEvent,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
@@ -203,80 +203,85 @@ export function Inner(props: DialogInnerProps) {
|
||||
return <ScrollableInner {...props} />
|
||||
}
|
||||
|
||||
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
||||
function ScrollableInner(
|
||||
{children, contentContainerStyle, header, footer, style, ...props},
|
||||
ref,
|
||||
) {
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
|
||||
useDialogContext()
|
||||
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
|
||||
const insets = useSafeAreaInsets()
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(() =>
|
||||
IS_ANDROID ? (Keyboard.metrics()?.height ?? 0) : 0,
|
||||
)
|
||||
export function ScrollableInner({
|
||||
ref,
|
||||
children,
|
||||
contentContainerStyle,
|
||||
header,
|
||||
footer,
|
||||
style,
|
||||
...props
|
||||
}: DialogInnerProps & {
|
||||
ref?: React.Ref<React.ComponentRef<typeof ScrollView>>
|
||||
}) {
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
|
||||
useDialogContext()
|
||||
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
|
||||
const insets = useSafeAreaInsets()
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(() =>
|
||||
IS_ANDROID ? (Keyboard.metrics()?.height ?? 0) : 0,
|
||||
)
|
||||
|
||||
const keyboardEventHandler = useCallback<KeyboardEventListener>(e => {
|
||||
setKeyboardHeight(e.endCoordinates.height)
|
||||
}, [])
|
||||
useOnKeyboard('keyboardDidShow', keyboardEventHandler)
|
||||
useOnKeyboard('keyboardDidHide', keyboardEventHandler)
|
||||
const keyboardEventHandler = useCallback((e: KeyboardEvent) => {
|
||||
setKeyboardHeight(e.endCoordinates.height)
|
||||
}, [])
|
||||
useOnKeyboard('keyboardDidShow', keyboardEventHandler)
|
||||
useOnKeyboard('keyboardDidHide', keyboardEventHandler)
|
||||
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!IS_ANDROID) {
|
||||
return
|
||||
}
|
||||
const {contentOffset} = e.nativeEvent
|
||||
if (contentOffset.y > 0 && !disableDrag) {
|
||||
setDisableDrag(true)
|
||||
} else if (contentOffset.y <= 1 && disableDrag) {
|
||||
setDisableDrag(false)
|
||||
}
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!IS_ANDROID) {
|
||||
return
|
||||
}
|
||||
const {contentOffset} = e.nativeEvent
|
||||
if (contentOffset.y > 0 && !disableDrag) {
|
||||
setDisableDrag(true)
|
||||
} else if (contentOffset.y <= 1 && disableDrag) {
|
||||
setDisableDrag(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
style={[isHeightConstrained && a.flex_1, style]}
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||
platform({
|
||||
ios: a.pb_2xl,
|
||||
android: {
|
||||
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
|
||||
},
|
||||
}),
|
||||
contentContainerStyle,
|
||||
]}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||
contentInsetAdjustmentBehavior={
|
||||
isAtMaxSnapPoint ? 'automatic' : 'never'
|
||||
}
|
||||
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
||||
{...props}
|
||||
bounces={isAtMaxSnapPoint}
|
||||
scrollEventThrottle={50}
|
||||
// set drag state based on scroll on android.
|
||||
// we want to detect if it's at the top or not, so watch
|
||||
// scrollEndDrag and momentumScrollEnd as well
|
||||
onScroll={android(onScroll)}
|
||||
onScrollEndDrag={android(onScroll)}
|
||||
onMomentumScrollEnd={android(onScroll)}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
||||
// on Android. fine to disable for now, because we don't have any
|
||||
// dialogs that use this that actually scroll -sfn
|
||||
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
||||
{header}
|
||||
{children}
|
||||
</ScrollView>
|
||||
{footer}
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
style={[isHeightConstrained && a.flex_1, style]}
|
||||
contentContainerStyle={[
|
||||
a.pt_2xl,
|
||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||
platform({
|
||||
ios: a.pb_2xl,
|
||||
android: {
|
||||
paddingBottom: keyboardHeight + insets.bottom + tokens.space.xl,
|
||||
},
|
||||
}),
|
||||
contentContainerStyle,
|
||||
]}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||
contentInsetAdjustmentBehavior={
|
||||
isAtMaxSnapPoint ? 'automatic' : 'never'
|
||||
}
|
||||
automaticallyAdjustKeyboardInsets={isAtMaxSnapPoint}
|
||||
{...props}
|
||||
bounces={isAtMaxSnapPoint}
|
||||
scrollEventThrottle={50}
|
||||
// set drag state based on scroll on android.
|
||||
// we want to detect if it's at the top or not, so watch
|
||||
// scrollEndDrag and momentumScrollEnd as well
|
||||
onScroll={android(onScroll)}
|
||||
onScrollEndDrag={android(onScroll)}
|
||||
onMomentumScrollEnd={android(onScroll)}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
||||
// on Android. fine to disable for now, because we don't have any
|
||||
// dialogs that use this that actually scroll -sfn
|
||||
stickyHeaderIndices={ios(header ? [0] : undefined)}>
|
||||
{header}
|
||||
{children}
|
||||
</ScrollView>
|
||||
{footer}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const InnerFlatList = forwardRef<
|
||||
ListMethods,
|
||||
|
||||
@@ -193,7 +193,6 @@ export function Inner({
|
||||
aria-label={label}
|
||||
aria-labelledby={accessibilityLabelledBy}
|
||||
aria-describedby={accessibilityDescribedBy}
|
||||
// @ts-expect-error web only -prf
|
||||
onClick={stopPropagation}
|
||||
onStartShouldSetResponder={_ => true}
|
||||
onTouchEnd={stopPropagation}
|
||||
@@ -239,7 +238,9 @@ export function Inner({
|
||||
export function ScrollableInner({
|
||||
ref: _ref,
|
||||
...props
|
||||
}: DialogInnerProps & {ref?: React.Ref<ScrollView>}) {
|
||||
}: DialogInnerProps & {
|
||||
ref?: React.Ref<React.ComponentRef<typeof ScrollView>>
|
||||
}) {
|
||||
return <Inner {...props} />
|
||||
}
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ export function ProfileGrid({
|
||||
|
||||
// Track seen profiles
|
||||
const seenProfilesRef = useRef<Set<string>>(new Set())
|
||||
const containerRef = useRef<View>(null)
|
||||
const containerRef = useRef<React.ComponentRef<typeof View>>(null)
|
||||
const hasTrackedRef = useRef(false)
|
||||
const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext
|
||||
? 'DiscoverInterstitial'
|
||||
@@ -276,7 +276,7 @@ export function ProfileGrid({
|
||||
},
|
||||
{threshold: 0.5},
|
||||
)
|
||||
// @ts-ignore - web only
|
||||
// @ts-expect-error - web only
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
} else {
|
||||
|
||||
@@ -41,7 +41,7 @@ export function FocusScope({children}: {children: React.ReactNode}) {
|
||||
*/
|
||||
function FocusTrap({children}: {children: React.ReactNode}) {
|
||||
const {_} = useLingui()
|
||||
const child = useRef<View>(null)
|
||||
const child = useRef<React.ComponentRef<typeof View>>(null)
|
||||
|
||||
/*
|
||||
* Here we add a ref to the first child of this component. This currently
|
||||
@@ -66,13 +66,16 @@ function FocusTrap({children}: {children: React.ReactNode}) {
|
||||
})
|
||||
}, [children])
|
||||
|
||||
const focusNode = useCallback((ref: View | null) => {
|
||||
if (!ref) return
|
||||
const node = findNodeHandle(ref)
|
||||
if (node) {
|
||||
AccessibilityInfo.setAccessibilityFocus(node)
|
||||
}
|
||||
}, [])
|
||||
const focusNode = useCallback(
|
||||
(ref: React.ComponentRef<typeof View> | null) => {
|
||||
if (!ref) return
|
||||
const node = findNodeHandle(ref)
|
||||
if (node) {
|
||||
AccessibilityInfo.setAccessibilityFocus(node)
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -20,7 +20,7 @@ export const IS_GLASS_AVAILABLE =
|
||||
*/
|
||||
export const GlassView = IS_GLASS_AVAILABLE ? InnerGlassView : FallbackView
|
||||
|
||||
export type GlassViewProps = ExpoGlassViewProps & {
|
||||
export type GlassViewProps = Omit<ExpoGlassViewProps, 'ref'> & {
|
||||
fallbackStyle?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export function InterestTabs({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const listRef = useRef<ScrollView>(null)
|
||||
const listRef = useRef<React.ComponentRef<typeof ScrollView>>(null)
|
||||
const [totalWidth, setTotalWidth] = useState(0)
|
||||
const [scrollX, setScrollX] = useState(0)
|
||||
const [contentWidth, setContentWidth] = useState(0)
|
||||
|
||||
@@ -42,7 +42,7 @@ export function Outer({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
noBottomBorder?: boolean
|
||||
headerRef?: React.RefObject<View | null>
|
||||
headerRef?: React.RefObject<React.ComponentRef<typeof View> | null>
|
||||
sticky?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -446,9 +446,7 @@ function LightboxGalleryItem({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
avi: {
|
||||
// @ts-ignore web-only
|
||||
maxWidth: `calc(min(400px, 100vw))`,
|
||||
// @ts-ignore web-only
|
||||
maxHeight: `calc(min(400px, 100vh))`,
|
||||
padding: 16,
|
||||
boxSizing: 'border-box',
|
||||
@@ -458,7 +456,6 @@ const styles = StyleSheet.create({
|
||||
// column via ScrollView's default flexGrow.
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
// @ts-ignore web-only -sfn
|
||||
maxHeight: '50vh',
|
||||
},
|
||||
menuBtn: {
|
||||
|
||||
@@ -35,7 +35,7 @@ const TIMING_OUT = {duration: 150}
|
||||
|
||||
export function ImageMenu({onPressShare, onPressSave}: Props) {
|
||||
const {t: l} = useLingui()
|
||||
const triggerRef = useRef<View>(null)
|
||||
const triggerRef = useRef<React.ComponentRef<typeof View>>(null)
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
const [anchor, setAnchor] = useState<Anchor | null>(null)
|
||||
const progress = useSharedValue(0)
|
||||
|
||||
@@ -82,7 +82,7 @@ const ImageItem = ({
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onScroll(e) {
|
||||
'worklet'
|
||||
const nextIsScaled = e.zoomScale > 1
|
||||
const nextIsScaled = (e.zoomScale ?? 1) > 1
|
||||
if (scaled !== nextIsScaled) {
|
||||
scheduleOnRN(handleZoom, nextIsScaled)
|
||||
}
|
||||
@@ -109,7 +109,6 @@ const ImageItem = ({
|
||||
height: number
|
||||
}) {
|
||||
const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
|
||||
// @ts-ignore
|
||||
scrollResponderRef?.scrollResponderZoomTo({
|
||||
...nextZoomRect, // This rect is in screen coordinates
|
||||
animated: true,
|
||||
@@ -218,7 +217,6 @@ const ImageItem = ({
|
||||
return (
|
||||
<GestureDetector gesture={composedGesture}>
|
||||
<Animated.ScrollView
|
||||
// @ts-ignore Something's up with the types here
|
||||
ref={scrollViewRef}
|
||||
pinchGestureEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
|
||||
@@ -11,7 +11,10 @@ import {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import {PixelRatio, StyleSheet, useWindowDimensions, View} from 'react-native'
|
||||
import {SystemBars} from 'react-native-edge-to-edge'
|
||||
import {Gesture} from 'react-native-gesture-handler'
|
||||
import PagerView from 'react-native-pager-view'
|
||||
import PagerView, {
|
||||
type PagerViewOnPageSelectedEvent,
|
||||
type PageScrollStateChangedNativeEvent,
|
||||
} from 'react-native-pager-view'
|
||||
import Animated, {
|
||||
type AnimatableValue,
|
||||
type AnimatedRef,
|
||||
@@ -383,7 +386,7 @@ function ImageView({
|
||||
<PagerView
|
||||
scrollEnabled={!isScaled}
|
||||
initialPage={initialImageIndex}
|
||||
onPageSelected={e => {
|
||||
onPageSelected={(e: PagerViewOnPageSelectedEvent) => {
|
||||
const next = e.nativeEvent.position
|
||||
setImageIndex(prev => {
|
||||
if (metricsContext && prev !== next) {
|
||||
@@ -401,7 +404,7 @@ function ImageView({
|
||||
})
|
||||
setIsScaled(false)
|
||||
}}
|
||||
onPageScrollStateChanged={e => {
|
||||
onPageScrollStateChanged={(e: PageScrollStateChangedNativeEvent) => {
|
||||
setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
|
||||
}}
|
||||
overdrag={true}
|
||||
|
||||
@@ -478,7 +478,7 @@ export function InlineLinkText({
|
||||
onIn: onInteract,
|
||||
onOut: onInteractOut,
|
||||
} = useInteractionState()
|
||||
const flattenedStyle = flatten(style) || {}
|
||||
const flattenedStyle = flatten(style)
|
||||
|
||||
return (
|
||||
<Text
|
||||
@@ -570,7 +570,7 @@ export function SimpleInlineLinkText({
|
||||
onIn: onInteract,
|
||||
onOut: onInteractOut,
|
||||
} = useInteractionState()
|
||||
const flattenedStyle = flatten(style) || {}
|
||||
const flattenedStyle = flatten(style)
|
||||
const isExternal = isExternalUrl(to)
|
||||
|
||||
let href = to
|
||||
|
||||
@@ -11,7 +11,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {DropdownMenu} from 'radix-ui'
|
||||
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {atoms as a, flatten, useTheme, web} from '#/alf'
|
||||
import {atoms as a, flatten, flattenToCSS, useTheme, web} from '#/alf'
|
||||
import type * as Dialog from '#/components/Dialog'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {
|
||||
@@ -413,7 +413,7 @@ export function Divider() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<DropdownMenu.Separator
|
||||
style={flatten([
|
||||
style={flattenToCSS([
|
||||
a.my_xs,
|
||||
t.atoms.bg_contrast_100,
|
||||
a.flex_shrink_0,
|
||||
|
||||
@@ -28,7 +28,7 @@ export type RadixPassThroughTriggerProps = {
|
||||
['aria-controls']?: string
|
||||
['aria-haspopup']?: boolean
|
||||
['aria-expanded']?: AccessibilityProps['aria-expanded']
|
||||
onKeyDown: (e: React.KeyboardEvent) => void
|
||||
onKeyDown: PressableProps['onKeyDown']
|
||||
/**
|
||||
* Radix provides this, but we override on web to use `onPress` instead,
|
||||
* which is less sensitive while scrolling.
|
||||
|
||||
@@ -149,7 +149,7 @@ function canPlayBskyVideoCodecs(): boolean {
|
||||
|
||||
type CachedPromise<T> = Promise<T> & {value: undefined | T}
|
||||
const promiseForHls = import(
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
'hls.js/dist/hls.min'
|
||||
// oxlint-disable-next-line typescript/no-unsafe-member-access
|
||||
).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default>
|
||||
|
||||
@@ -267,7 +267,7 @@ function TranslationResult({
|
||||
? codeToLanguageName(resultSourceLanguage, i18n.locale)
|
||||
: undefined
|
||||
|
||||
const flattenedStyle = flatten(postTextStyle) ?? {}
|
||||
const flattenedStyle = flatten(postTextStyle)
|
||||
const fontSize = flattenedStyle.fontSize
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,7 +27,7 @@ export function PostControlButton({
|
||||
activeColor,
|
||||
...props
|
||||
}: Omit<ButtonProps, 'hitSlop'> & {
|
||||
ref?: React.Ref<View>
|
||||
ref?: React.Ref<React.ComponentRef<typeof View>>
|
||||
active?: boolean
|
||||
big?: boolean
|
||||
color?: string
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Pressable as NativePressable,
|
||||
type PressableStateCallbackType as NativePressableStateCallbackType,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
|
||||
export interface PressableStateCallbackType extends NativePressableStateCallbackType {
|
||||
/** Provided by react-native-web. */
|
||||
readonly focused?: boolean
|
||||
/** Provided by react-native-web. */
|
||||
readonly hovered?: boolean
|
||||
}
|
||||
|
||||
export type PressableProps = Omit<
|
||||
React.ComponentProps<typeof NativePressable>,
|
||||
'children' | 'style'
|
||||
> & {
|
||||
children?:
|
||||
React.ReactNode | ((state: PressableStateCallbackType) => React.ReactNode)
|
||||
style?:
|
||||
| StyleProp<ViewStyle>
|
||||
| ((state: PressableStateCallbackType) => StyleProp<ViewStyle>)
|
||||
}
|
||||
|
||||
/**
|
||||
* React Native Pressable with react-native-web's callback state represented in
|
||||
* its types. The web-only fields are optional because native does not provide
|
||||
* them at runtime.
|
||||
*/
|
||||
export function Pressable({children, style, ...props}: PressableProps) {
|
||||
return (
|
||||
<NativePressable {...props} style={style}>
|
||||
{children}
|
||||
</NativePressable>
|
||||
)
|
||||
}
|
||||
@@ -318,11 +318,10 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
||||
|
||||
return (
|
||||
<View
|
||||
// @ts-ignore View is being used as div
|
||||
ref={refs.setReference}
|
||||
onPointerMove={onPointerMoveTarget}
|
||||
onPointerLeave={onPointerLeaveTarget}
|
||||
// @ts-ignore web only prop
|
||||
// @ts-expect-error web only prop
|
||||
onMouseUp={onPress}
|
||||
style={[a.flex_shrink, props.inline && a.inline]}>
|
||||
{props.children}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {TextInput, View, type ViewToken} from 'react-native'
|
||||
import {type ListViewToken as ViewToken, TextInput, View} from 'react-native'
|
||||
import {type ModerationOpts} from '@bsky/sdk/moderation'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
@@ -139,7 +139,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
|
||||
const [searchText, setSearchText] = useState(lastSearchText)
|
||||
const moderationOpts = useModerationOpts()
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const inputRef = useRef<React.ComponentRef<typeof TextInput>>(null)
|
||||
const [headerHeight, setHeaderHeight] = useState(0)
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
@@ -374,7 +374,7 @@ let Header = ({
|
||||
interestsDisplayNames,
|
||||
}: {
|
||||
guide?: Follow10ProgressGuide
|
||||
inputRef: React.RefObject<TextInput | null>
|
||||
inputRef: React.RefObject<React.ComponentRef<typeof TextInput> | null>
|
||||
listRef: React.RefObject<ListMethods | null>
|
||||
onSelectTab: (v: string) => void
|
||||
searchText: string
|
||||
@@ -679,7 +679,7 @@ function SearchInput({
|
||||
}: {
|
||||
onChangeText: (text: string) => void
|
||||
onEscape: () => void
|
||||
inputRef: React.RefObject<TextInput | null>
|
||||
inputRef: React.RefObject<React.ComponentRef<typeof TextInput> | null>
|
||||
defaultValue: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -93,7 +93,7 @@ export function RichText({
|
||||
|
||||
if (!facets?.length) {
|
||||
if (isOnlyEmoji(text)) {
|
||||
const flattenedStyle = flatten(style) ?? {}
|
||||
const flattenedStyle = flatten(style)
|
||||
const fontSize =
|
||||
(flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier
|
||||
return (
|
||||
@@ -104,7 +104,6 @@ export function RichText({
|
||||
style={[plainStyles, {fontSize}, suffixStyles]}
|
||||
onLayout={onLayout}
|
||||
onTextLayout={onTextLayout}
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={WORD_WRAP}>
|
||||
{text}
|
||||
{suffix ? ' ' : null}
|
||||
@@ -121,7 +120,6 @@ export function RichText({
|
||||
numberOfLines={numberOfLines}
|
||||
onLayout={onLayout}
|
||||
onTextLayout={onTextLayout}
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={WORD_WRAP}>
|
||||
{text}
|
||||
{suffix ? ' ' : null}
|
||||
@@ -150,7 +148,7 @@ export function RichText({
|
||||
selectable={selectable}
|
||||
to={`/profile/${mention.did}`}
|
||||
style={interactiveStyles}
|
||||
// @ts-ignore TODO
|
||||
// @ts-expect-error TODO
|
||||
dataSet={WORD_WRAP}
|
||||
shouldProxy={shouldProxyLinks}
|
||||
onPress={onLinkPress}>
|
||||
@@ -169,7 +167,7 @@ export function RichText({
|
||||
key={key}
|
||||
to={link.uri}
|
||||
style={interactiveStyles}
|
||||
// @ts-ignore TODO
|
||||
// @ts-expect-error TODO
|
||||
dataSet={WORD_WRAP}
|
||||
shareOnLongPress
|
||||
shouldProxy={shouldProxyLinks}
|
||||
@@ -209,7 +207,6 @@ export function RichText({
|
||||
numberOfLines={numberOfLines}
|
||||
onLayout={onLayout}
|
||||
onTextLayout={onTextLayout}
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={WORD_WRAP}>
|
||||
{els}
|
||||
{suffix ? ' ' : null}
|
||||
|
||||
@@ -100,7 +100,16 @@ export function Trigger({children, hitSlop, label}: TriggerProps) {
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
hitSlop={hitSlop}
|
||||
hitSlop={
|
||||
typeof hitSlop === 'number'
|
||||
? {
|
||||
top: hitSlop,
|
||||
right: hitSlop,
|
||||
bottom: hitSlop,
|
||||
left: hitSlop,
|
||||
}
|
||||
: (hitSlop ?? undefined)
|
||||
}
|
||||
label={label}
|
||||
onPress={control.open}
|
||||
style={[a.flex_1, a.justify_between, a.pl_lg, a.pr_md]}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {View} from 'react-native'
|
||||
import {Select as RadixSelect} from 'radix-ui'
|
||||
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {atoms as a, flatten, useTheme, web} from '#/alf'
|
||||
import {atoms as a, flatten, flattenToCSS, useTheme, web} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||
import {
|
||||
@@ -94,7 +94,7 @@ export function Trigger({children, label}: TriggerProps) {
|
||||
onBlur={onBlur}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={flatten([
|
||||
style={flattenToCSS([
|
||||
a.flex,
|
||||
a.relative,
|
||||
t.atoms.bg_contrast_50,
|
||||
@@ -196,7 +196,7 @@ export function Content<T>({
|
||||
return (
|
||||
<RadixSelect.Portal>
|
||||
<RadixSelect.Content
|
||||
style={flatten([t.atoms.bg, a.rounded_sm, a.overflow_hidden])}
|
||||
style={flattenToCSS([t.atoms.bg, a.rounded_sm, a.overflow_hidden])}
|
||||
position="popper"
|
||||
align="center"
|
||||
sideOffset={5}
|
||||
@@ -212,17 +212,17 @@ export function Content<T>({
|
||||
a.overflow_hidden,
|
||||
!reduceMotionEnabled && a.zoom_fade_in,
|
||||
]}>
|
||||
<RadixSelect.ScrollUpButton style={flatten(up)}>
|
||||
<RadixSelect.ScrollUpButton style={flattenToCSS(up)}>
|
||||
<ChevronUpIcon style={[t.atoms.text]} size="xs" />
|
||||
</RadixSelect.ScrollUpButton>
|
||||
<RadixSelect.Viewport style={flatten([a.p_xs])}>
|
||||
<RadixSelect.Viewport style={flattenToCSS([a.p_xs])}>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={valueExtractor(item)}>
|
||||
{renderItem(item, index, selectedValue)}
|
||||
</Fragment>
|
||||
))}
|
||||
</RadixSelect.Viewport>
|
||||
<RadixSelect.ScrollDownButton style={flatten(down)}>
|
||||
<RadixSelect.ScrollDownButton style={flattenToCSS(down)}>
|
||||
<ChevronDownIcon style={[t.atoms.text]} size="xs" />
|
||||
</RadixSelect.ScrollDownButton>
|
||||
</View>
|
||||
@@ -273,7 +273,7 @@ export function Item({ref, value, style, children}: ItemProps) {
|
||||
onMouseLeave={onMouseLeave}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={flatten([
|
||||
style={flattenToCSS([
|
||||
t.atoms.text,
|
||||
a.relative,
|
||||
a.flex,
|
||||
@@ -307,7 +307,7 @@ export const ItemText = function ItemText({children, style}: ItemTextProps) {
|
||||
export function ItemIndicator({icon: Icon = CheckIcon}: ItemIndicatorProps) {
|
||||
return (
|
||||
<RadixSelect.ItemIndicator
|
||||
style={flatten([
|
||||
style={flattenToCSS([
|
||||
a.absolute,
|
||||
{left: 0, width: 30},
|
||||
a.flex,
|
||||
@@ -324,7 +324,7 @@ export function Separator() {
|
||||
|
||||
return (
|
||||
<RadixSelect.Separator
|
||||
style={flatten([
|
||||
style={flattenToCSS([
|
||||
{
|
||||
height: 1,
|
||||
backgroundColor: t.atoms.border_contrast_low.borderColor,
|
||||
|
||||
@@ -2,10 +2,6 @@ import {forwardRef, useCallback, useImperativeHandle, useState} from 'react'
|
||||
import {type ListRenderItemInfo, View} from 'react-native'
|
||||
import {AtUri} from '@atproto/syntax'
|
||||
import {type ModerationOpts} from '@bsky/sdk/moderation'
|
||||
import {
|
||||
type InfiniteData,
|
||||
type UseInfiniteQueryResult,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
@@ -26,9 +22,6 @@ function keyExtractor(item: app.bsky.actor.defs.ProfileView, index: number) {
|
||||
|
||||
interface ProfilesListProps {
|
||||
listUri: string
|
||||
listMembersQuery: UseInfiniteQueryResult<
|
||||
InfiniteData<app.bsky.graph.getList.$OutputBody>
|
||||
>
|
||||
moderationOpts: ModerationOpts
|
||||
headerHeight: number
|
||||
scrollElRef: ListRef
|
||||
|
||||
@@ -27,8 +27,9 @@ import {
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const TooltipPortal = createPortalGroup()
|
||||
const TooltipProviderContext =
|
||||
createContext<React.RefObject<View | null> | null>(null)
|
||||
const TooltipProviderContext = createContext<React.RefObject<React.ComponentRef<
|
||||
typeof View
|
||||
> | null> | null>(null)
|
||||
|
||||
/**
|
||||
* Provider for Tooltip component. Only needed when you need to position the tooltip relative to a container,
|
||||
@@ -37,7 +38,7 @@ const TooltipProviderContext =
|
||||
* Only really necessary on iOS but can work on Android.
|
||||
*/
|
||||
export function SheetCompatProvider({children}: {children: React.ReactNode}) {
|
||||
const ref = useRef<View | null>(null)
|
||||
const ref = useRef<React.ComponentRef<typeof View> | null>(null)
|
||||
return (
|
||||
<GlobalGestureEventsProvider style={[a.flex_1]}>
|
||||
<TooltipPortal.Provider>
|
||||
@@ -154,7 +155,7 @@ export function Outer({
|
||||
export function Target({children}: {children: React.ReactNode}) {
|
||||
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
|
||||
const [hasLaidOut, setHasLaidOut] = useState(false)
|
||||
const targetRef = useRef<View>(null)
|
||||
const targetRef = useRef<React.ComponentRef<typeof View>>(null)
|
||||
const containerRef = useContext(TooltipProviderContext)
|
||||
const keyboardIsOpen = useIsKeyboardVisible()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {View} from 'react-native'
|
||||
import {utils} from '@bsky.app/alf'
|
||||
import {Popover} from 'radix-ui'
|
||||
|
||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||
import {atoms as a, flattenToCSS, useTheme} from '#/alf'
|
||||
import {
|
||||
ARROW_SIZE,
|
||||
BUBBLE_MAX_WIDTH,
|
||||
@@ -85,7 +85,7 @@ export function Content({
|
||||
evt.preventDefault()
|
||||
}
|
||||
}}
|
||||
style={flatten([
|
||||
style={flattenToCSS([
|
||||
a.rounded_sm,
|
||||
{
|
||||
backgroundColor: style.surface,
|
||||
|
||||
@@ -25,13 +25,13 @@ export function OTPInput({
|
||||
label: string
|
||||
value: string
|
||||
onChange: (text: string) => void
|
||||
ref?: React.Ref<TextInput>
|
||||
ref?: React.Ref<React.ComponentRef<typeof TextInput>>
|
||||
numberOfDigits?: number
|
||||
onComplete?: (code: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const innerRef = useRef<TextInput>(null)
|
||||
const innerRef = useRef<React.ComponentRef<typeof TextInput>>(null)
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const [selection, setSelection] = useState({start: 0, end: 0})
|
||||
|
||||
|
||||
@@ -383,7 +383,7 @@ export function ViewMatches({
|
||||
ref={listRef}
|
||||
data={items}
|
||||
renderItem={renderItem}
|
||||
ListFooterComponent={!isEmpty ? <ListFooter height={20} /> : null}
|
||||
ListFooterComponent={!isEmpty ? <ListFooter height={20} /> : undefined}
|
||||
keyExtractor={keyExtractor}
|
||||
keyboardDismissMode="interactive"
|
||||
automaticallyAdjustKeyboardInsets
|
||||
|
||||
@@ -97,7 +97,7 @@ export function SearchablePeopleList({
|
||||
const [headerHeight, setHeaderHeight] = useState(0)
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const {currentAccount} = useSession()
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const inputRef = useRef<React.ComponentRef<typeof TextInput>>(null)
|
||||
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
@@ -634,7 +634,7 @@ function SearchInput({
|
||||
value: string
|
||||
onChangeText: (text: string) => void
|
||||
onEscape: () => void
|
||||
inputRef: React.RefObject<TextInput | null>
|
||||
inputRef: React.RefObject<React.ComponentRef<typeof TextInput> | null>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
@@ -182,9 +182,9 @@ function ListsContent({
|
||||
<View style={[a.align_center, a.py_lg]}>
|
||||
<Loader size="lg" />
|
||||
</View>
|
||||
) : null
|
||||
) : undefined
|
||||
}
|
||||
ListEmptyComponent={!isLoading && data ? <Empty /> : null}
|
||||
ListEmptyComponent={!isLoading && data ? <Empty /> : undefined}
|
||||
webInnerContentContainerStyle={[a.py_0]}
|
||||
style={platform({
|
||||
web: [a.px_2xl, a.pb_md],
|
||||
|
||||
@@ -117,7 +117,7 @@ function Inner({
|
||||
}, [activeNux, setActiveNux])
|
||||
|
||||
if (__DEV__ && typeof window !== 'undefined') {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
window.clearNuxDialog = (id: Nux) => {
|
||||
if (!__DEV__ || !id) return
|
||||
resetNuxs([id])
|
||||
|
||||
@@ -134,7 +134,7 @@ export function AddMembersFlow({
|
||||
const [footerHeight, setFooterHeight] = useState(0)
|
||||
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const inputRef = useRef<React.ComponentRef<typeof TextInput>>(null)
|
||||
|
||||
const [{groupChatDids, groupChatProfiles, searchText}, dispatch] = useReducer(
|
||||
reducer,
|
||||
@@ -502,7 +502,7 @@ export function AddMembersFlow({
|
||||
<View style={[a.flex_1, a.align_center, a.justify_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : null
|
||||
) : undefined
|
||||
}
|
||||
keyExtractor={(item: Item) => item.key}
|
||||
style={[
|
||||
|
||||
@@ -249,7 +249,7 @@ export function InitiateChatFlow({
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const {currentAccount} = useSession()
|
||||
const aa = useAgeAssurance()
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const inputRef = useRef<React.ComponentRef<typeof TextInput>>(null)
|
||||
const accountTooNewPromptControl = Dialog.useDialogControl()
|
||||
|
||||
const {data: convos} = useListConvosQuery({
|
||||
|
||||
@@ -255,7 +255,7 @@ function ReactionTabs({
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const scrollViewRef = useRef<ScrollView>(null)
|
||||
const scrollViewRef = useRef<React.ComponentRef<typeof ScrollView>>(null)
|
||||
const scrollState = useRef({x: 0, width: 0})
|
||||
const tabLayouts = useRef<Map<string, {x: number; width: number}>>(new Map())
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export function UserSearchInput({
|
||||
value: string
|
||||
onChangeText: (text: string) => void
|
||||
onEscape: () => void
|
||||
inputRef: React.RefObject<TextInput | null>
|
||||
inputRef: React.RefObject<React.ComponentRef<typeof TextInput> | null>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
@@ -38,7 +38,6 @@ export function UserSearchInput({
|
||||
fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
|
||||
/>
|
||||
<TextInput
|
||||
// @ts-ignore bottom sheet input types issue - esb
|
||||
ref={inputRef}
|
||||
placeholder={l`Search for people`}
|
||||
value={value}
|
||||
|
||||
@@ -8,23 +8,24 @@ import {
|
||||
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'
|
||||
import {IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
|
||||
|
||||
export type AutosizedTextareaProps = Omit<TextInputProps, 'multiline'> & {
|
||||
ref?: React.Ref<TextInput>
|
||||
ref?: React.Ref<React.ComponentRef<typeof TextInput>>
|
||||
label: string
|
||||
minRows?: number
|
||||
maxRows?: number
|
||||
onUpdateHeight?: (height: number) => void
|
||||
/**
|
||||
* In some cases, like on native, we may not pass in an actual `value` prop.
|
||||
* This prop allows Android to know if the field was cleared and thereby
|
||||
* reset its height. This is a small hack required because we need to
|
||||
* explicitly set the `{height: nativeHeight}` on Android.
|
||||
* This prop allows native platforms to know if the field was cleared and
|
||||
* thereby reset its height. This is a small hack required because Android's
|
||||
* height is driven by state, while iOS needs an explicit minimum height when
|
||||
* cleared programmatically.
|
||||
*
|
||||
* If you notice height calcuation issues after clearing the field on
|
||||
* Android, check if this value is being populated and cleared properly in
|
||||
* the parent component.
|
||||
* If you notice height calculation issues after clearing the field on a
|
||||
* native platform, check if this value is being populated and cleared
|
||||
* properly in the parent component.
|
||||
*/
|
||||
rawValue?: string
|
||||
}
|
||||
@@ -43,7 +44,7 @@ export function AutosizedTextarea({
|
||||
...rest
|
||||
}: AutosizedTextareaProps) {
|
||||
const {theme: t, fonts} = useAlf()
|
||||
const internalRef = useRef<TextInput>(null)
|
||||
const internalRef = useRef<React.ComponentRef<typeof TextInput>>(null)
|
||||
const {style, minInputHeight, maxInputHeight, verticalContentPadding} =
|
||||
useMemo(() => {
|
||||
const normalizedStyles = normalizeTextStyles(
|
||||
@@ -137,13 +138,12 @@ export function AutosizedTextarea({
|
||||
}
|
||||
|
||||
/*
|
||||
* Manual height clearing required for Android because we're forced to set
|
||||
* `{height: nativeHeight}` on Android, and even though `onContentSizeChange`
|
||||
* fires, the height matches the existing height and we aren't able to reset.
|
||||
* Reset native height state after a programmatic clear. Android uses it as
|
||||
* the explicit input height, while iOS uses it to decide when to scroll.
|
||||
*/
|
||||
const prevRawValue = useRef(rawValue || '')
|
||||
useEffect(() => {
|
||||
if (!IS_ANDROID) return // everything else is fine
|
||||
if (!IS_NATIVE) return
|
||||
if (rawValue === undefined) return // uncontrolled
|
||||
if (prevRawValue.current?.length && rawValue === '') {
|
||||
setNativeHeight(minInputHeight)
|
||||
@@ -176,11 +176,12 @@ export function AutosizedTextarea({
|
||||
wordBreak: 'break-word',
|
||||
}),
|
||||
style,
|
||||
IS_ANDROID ? {height: nativeHeight} : {},
|
||||
IS_ANDROID && {height: nativeHeight},
|
||||
IS_IOS && rawValue === '' && {height: minInputHeight},
|
||||
]}
|
||||
{...rest}
|
||||
ref={mergeRefs([
|
||||
(node: TextInput | null) => {
|
||||
(node: React.ComponentRef<typeof TextInput> | null) => {
|
||||
internalRef.current = node
|
||||
// bop resize on first render
|
||||
if (IS_WEB && node) handleResizeWeb()
|
||||
|
||||
@@ -93,7 +93,6 @@ export function DateField({
|
||||
open
|
||||
timeZoneOffsetInMinutes={0}
|
||||
theme={t.scheme}
|
||||
// @ts-ignore TODO
|
||||
buttonColor={t.name === 'light' ? '#000000' : '#ffffff'}
|
||||
date={initialDate}
|
||||
onConfirm={onChangeInternal}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function DateField({
|
||||
<TextField.Icon icon={CalendarDays} />
|
||||
<Input
|
||||
value={value === '' ? '' : toSimpleDateString(value)}
|
||||
inputRef={inputRef as React.Ref<TextInput>}
|
||||
inputRef={inputRef as React.Ref<React.ComponentRef<typeof TextInput>>}
|
||||
label={label}
|
||||
onChange={handleOnChange}
|
||||
testID={testID}
|
||||
|
||||
@@ -19,7 +19,7 @@ type Props = Omit<TextField.InputProps, 'label'> & {
|
||||
*/
|
||||
onClearText?: () => void
|
||||
hotkey?: boolean
|
||||
ref?: React.Ref<TextInput>
|
||||
ref?: React.Ref<React.ComponentRef<typeof TextInput>>
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
@@ -33,7 +33,7 @@ export function SearchInput({
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const showClear = value && value.length > 0
|
||||
const internalRef = useRef<TextInput>(null)
|
||||
const internalRef = useRef<React.ComponentRef<typeof TextInput>>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hotkey) return
|
||||
|
||||
@@ -14,6 +14,8 @@ import {mergeRefs} from '#/lib/merge-refs'
|
||||
import {
|
||||
applyFonts,
|
||||
atoms as a,
|
||||
flatten,
|
||||
type MutableTextStyle,
|
||||
platform,
|
||||
type TextStyleProp,
|
||||
tokens,
|
||||
@@ -26,7 +28,7 @@ import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const Context = createContext<{
|
||||
inputRef: React.RefObject<TextInput | null> | null
|
||||
inputRef: React.RefObject<React.ComponentRef<typeof TextInput> | null> | null
|
||||
isInvalid: boolean
|
||||
hovered: boolean
|
||||
onHoverIn: () => void
|
||||
@@ -55,7 +57,7 @@ export type RootProps = React.PropsWithChildren<
|
||||
>
|
||||
|
||||
export function Root({children, isInvalid = false, style}: RootProps) {
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const inputRef = useRef<React.ComponentRef<typeof TextInput>>(null)
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
@@ -161,7 +163,9 @@ export type InputProps = Omit<
|
||||
value?: string
|
||||
onChangeText?: (value: string) => void
|
||||
isInvalid?: boolean
|
||||
inputRef?: React.RefObject<TextInput | null> | React.ForwardedRef<TextInput>
|
||||
inputRef?:
|
||||
| React.RefObject<React.ComponentRef<typeof TextInput> | null>
|
||||
| React.ForwardedRef<React.ComponentRef<typeof TextInput>>
|
||||
/**
|
||||
* Note: this currently falls back to the label if not specified. However,
|
||||
* most new designs have no placeholder. We should eventually remove this fallback
|
||||
@@ -208,43 +212,41 @@ export function createInput(Component: typeof TextInput) {
|
||||
|
||||
const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean))
|
||||
|
||||
const flattened = StyleSheet.flatten<TextStyle>([
|
||||
a.relative,
|
||||
a.z_20,
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
t.atoms.text,
|
||||
a.px_xs,
|
||||
{
|
||||
// paddingVertical doesn't work w/multiline - esb
|
||||
lineHeight: a.text_md.fontSize * 1.2,
|
||||
textAlignVertical: rest.multiline ? 'top' : undefined,
|
||||
minHeight: rest.multiline ? 80 : undefined,
|
||||
minWidth: 0,
|
||||
paddingTop: 13,
|
||||
paddingBottom: 13,
|
||||
},
|
||||
/*
|
||||
* Margins are needed here to avoid autofill background overlapping the
|
||||
* top and bottom borders - esb
|
||||
*/
|
||||
web({
|
||||
paddingTop: 11,
|
||||
paddingBottom: 11,
|
||||
marginTop: 2,
|
||||
marginBottom: 2,
|
||||
}),
|
||||
style,
|
||||
])
|
||||
const flattened: MutableTextStyle = {
|
||||
...flatten([
|
||||
a.relative,
|
||||
a.z_20,
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
t.atoms.text,
|
||||
a.px_xs,
|
||||
{
|
||||
// paddingVertical doesn't work w/multiline - esb
|
||||
lineHeight: a.text_md.fontSize * 1.2,
|
||||
textAlignVertical: rest.multiline ? 'top' : undefined,
|
||||
minHeight: rest.multiline ? 80 : undefined,
|
||||
minWidth: 0,
|
||||
paddingTop: 13,
|
||||
paddingBottom: 13,
|
||||
},
|
||||
/*
|
||||
* Margins are needed here to avoid autofill background overlapping the
|
||||
* top and bottom borders - esb
|
||||
*/
|
||||
web({
|
||||
paddingTop: 11,
|
||||
paddingBottom: 11,
|
||||
marginTop: 2,
|
||||
marginBottom: 2,
|
||||
}),
|
||||
style,
|
||||
]),
|
||||
}
|
||||
|
||||
applyFonts(flattened, fonts.family)
|
||||
|
||||
// should always be defined on `typography`
|
||||
// @ts-ignore
|
||||
if (flattened.fontSize) {
|
||||
// @ts-ignore
|
||||
flattened.fontSize = Math.round(
|
||||
// @ts-ignore
|
||||
flattened.fontSize * fonts.scaleMultiplier,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {useEffect} from 'react'
|
||||
import {
|
||||
Keyboard,
|
||||
type KeyboardEventListener,
|
||||
type KeyboardEvent,
|
||||
type KeyboardEventName,
|
||||
} from 'react-native'
|
||||
|
||||
export function useOnKeyboard(
|
||||
eventName: KeyboardEventName,
|
||||
cb: KeyboardEventListener,
|
||||
cb: (event: KeyboardEvent) => unknown,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const subscription = Keyboard.addListener(eventName, cb)
|
||||
|
||||
@@ -23,7 +23,7 @@ export const IconTemplate_Stroke2_Corner0_Rounded = forwardRef(
|
||||
<Svg
|
||||
fill="none"
|
||||
{...rest}
|
||||
// @ts-ignore it's fiiiiine
|
||||
// @ts-expect-error it's fiiiiine
|
||||
ref={ref}
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
|
||||
@@ -65,7 +65,7 @@ interface GalleryProps {
|
||||
}
|
||||
|
||||
const Context = createContext<{
|
||||
bleedRef: React.RefObject<View | null>
|
||||
bleedRef: React.RefObject<React.ComponentRef<typeof View> | null>
|
||||
bleedWidth: number
|
||||
}>({
|
||||
bleedRef: {current: null},
|
||||
@@ -73,7 +73,7 @@ const Context = createContext<{
|
||||
})
|
||||
|
||||
export function GalleryBleed({children}: {children: React.ReactNode}) {
|
||||
const ref = useRef<View>(null)
|
||||
const ref = useRef<React.ComponentRef<typeof View>>(null)
|
||||
const [bleedWidth, setBleedWidth] = useState(0)
|
||||
|
||||
if (!isValidElement(children)) {
|
||||
@@ -145,7 +145,7 @@ export function Gallery({
|
||||
* scroll position, so it works correctly for off-screen FlatList items.
|
||||
*/
|
||||
const {bleedRef, bleedWidth} = useGalleryBleed()
|
||||
const contentRef = useRef<View>(null)
|
||||
const contentRef = useRef<React.ComponentRef<typeof View>>(null)
|
||||
const [contentDims, setContentDims] = useState<{x: number; width: number}>()
|
||||
const measure = () => {
|
||||
if (contentRef.current && bleedRef.current) {
|
||||
@@ -168,7 +168,9 @@ export function Gallery({
|
||||
|
||||
const flatListRef = useRef<FlatList>(null)
|
||||
const itemWidthsRef = useRef<Map<number, number>>(new Map())
|
||||
const itemRefsRef = useRef<Map<number, View>>(new Map())
|
||||
const itemRefsRef = useRef<Map<number, React.ComponentRef<typeof View>>>(
|
||||
new Map(),
|
||||
)
|
||||
const containerRefsRef = useRef<Map<number, AnimatedRef<any>>>(new Map())
|
||||
const thumbDimsRef = useRef<Map<number, Dimensions>>(new Map())
|
||||
const currentIndexRef = useRef(0)
|
||||
@@ -408,7 +410,7 @@ function GalleryImage({
|
||||
index: number
|
||||
imageCount: number
|
||||
onWidthChange: (index: number, width: number) => void
|
||||
itemRef: (node: View | null) => void
|
||||
itemRef: (node: React.ComponentRef<typeof View> | null) => void
|
||||
largeAltBadge?: boolean
|
||||
onContainerRef: (index: number, ref: AnimatedRef<any>) => void
|
||||
onThumbDims: (index: number, dims: Dimensions) => void
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import {useCallback} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {
|
||||
useTrendingSettings,
|
||||
useTrendingSettingsApi,
|
||||
} from '#/state/preferences/trending'
|
||||
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
|
||||
import {useTrendingConfig} from '#/state/service-config'
|
||||
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
import {atoms as a, useGutters, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {TrendingTopicLink} from '#/components/TrendingTopics'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
const TRENDING_LIMIT = 14
|
||||
|
||||
export function TrendingInterstitial() {
|
||||
const {enabled} = useTrendingConfig()
|
||||
const {trendingDisabled} = useTrendingSettings()
|
||||
return enabled && !trendingDisabled ? <Inner /> : null
|
||||
}
|
||||
|
||||
export function Inner() {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const gutters = useGutters([0, 'base', 0, 'base'])
|
||||
const trendingPrompt = Prompt.usePromptControl()
|
||||
const {setTrendingDisabled} = useTrendingSettingsApi()
|
||||
const {
|
||||
data: trending,
|
||||
error,
|
||||
isLoading,
|
||||
} = useGetTrendsQuery({
|
||||
limit: TRENDING_LIMIT,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
const noTopics = !isLoading && !error && !trending?.trends?.length
|
||||
|
||||
const onConfirmHide = useCallback(() => {
|
||||
ax.metric('trendingTopics:hide', {context: 'interstitial'})
|
||||
setTrendingDisabled(true)
|
||||
}, [ax, setTrendingDisabled])
|
||||
|
||||
return error || noTopics ? null : (
|
||||
<View style={[t.atoms.border_contrast_low, a.border_t, a.border_b]}>
|
||||
<BlockDrawerGesture>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
decelerationRate="fast">
|
||||
<View style={[gutters, a.flex_row, a.align_center, a.gap_lg]}>
|
||||
<View style={{paddingLeft: 4, paddingRight: 2}}>
|
||||
<Graph size="sm" />
|
||||
</View>
|
||||
{isLoading ? (
|
||||
<View style={[a.py_lg, a.flex_row, a.gap_lg, a.align_center]}>
|
||||
<LoadingPlaceholder
|
||||
width={80}
|
||||
height={undefined}
|
||||
style={{alignSelf: 'stretch'}}
|
||||
/>
|
||||
<LoadingPlaceholder
|
||||
width={50}
|
||||
height={undefined}
|
||||
style={{alignSelf: 'stretch'}}
|
||||
/>
|
||||
<LoadingPlaceholder
|
||||
width={120}
|
||||
height={undefined}
|
||||
style={{alignSelf: 'stretch'}}
|
||||
/>
|
||||
<LoadingPlaceholder
|
||||
width={30}
|
||||
height={undefined}
|
||||
style={{alignSelf: 'stretch'}}
|
||||
/>
|
||||
<LoadingPlaceholder
|
||||
width={180}
|
||||
height={undefined}
|
||||
style={{alignSelf: 'stretch'}}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_sm,
|
||||
a.font_semi_bold,
|
||||
]}>
|
||||
{' '}
|
||||
</Text>
|
||||
</View>
|
||||
) : !trending?.trends ? null : (
|
||||
<>
|
||||
{trending.trends.map((topic, index) => {
|
||||
const rank = index + 1
|
||||
return (
|
||||
<TrendingTopicLink
|
||||
key={topic.link}
|
||||
topic={topic}
|
||||
metricContext="interstitial"
|
||||
rank={rank}
|
||||
recId={trending.recId}
|
||||
onPress={() => {
|
||||
ax.metric('trendingTopic:click', {
|
||||
context: 'interstitial',
|
||||
rank,
|
||||
recId: trending.recId,
|
||||
})
|
||||
}}>
|
||||
<View style={[a.py_lg]}>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_sm,
|
||||
a.font_semi_bold,
|
||||
]}>
|
||||
{topic.topic}
|
||||
</Text>
|
||||
</View>
|
||||
</TrendingTopicLink>
|
||||
)
|
||||
})}
|
||||
<Button
|
||||
label={l`Hide trending topics`}
|
||||
size="tiny"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={() => trendingPrompt.open()}>
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</BlockDrawerGesture>
|
||||
|
||||
<Prompt.Basic
|
||||
control={trendingPrompt}
|
||||
title={l`Hide trending topics?`}
|
||||
description={l`You can update this later from your settings.`}
|
||||
confirmButtonCta={l`Hide`}
|
||||
onConfirm={onConfirmHide}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -226,7 +226,7 @@ function BlockDialogInner({
|
||||
<View style={[a.py_lg, a.align_center, a.justify_center]}>
|
||||
<Loader size="lg" />
|
||||
</View>
|
||||
) : null
|
||||
) : undefined
|
||||
}
|
||||
footer={
|
||||
<Dialog.FlatListFooter
|
||||
|
||||
@@ -159,7 +159,7 @@ function Inner(
|
||||
const logger = ax.logger.useChild(ax.logger.Context.ReportDialog)
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const ref = useRef<ScrollView>(null)
|
||||
const ref = useRef<React.ComponentRef<typeof ScrollView>>(null)
|
||||
const {
|
||||
data: allLabelers,
|
||||
isLoading: isLabelerLoading,
|
||||
|
||||
@@ -60,7 +60,7 @@ function GifPickerBody({
|
||||
control: Dialog.DialogControlProps
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const textInputRef = useRef<TextInput>(null)
|
||||
const textInputRef = useRef<React.ComponentRef<typeof TextInput>>(null)
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const [rawSearch, setRawSearch] = useState('')
|
||||
const [activeCategory, setActiveCategory] = useState<string>('trending')
|
||||
|
||||
@@ -89,7 +89,7 @@ export const GifPickerGrid = forwardRef<ListMethods, Props>(
|
||||
onRetry={fetchNextPage}
|
||||
style={{borderTopWidth: 0}}
|
||||
/>
|
||||
) : null
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ export function GifPickerHeader({
|
||||
onEscape,
|
||||
canClear,
|
||||
}: {
|
||||
inputRef: Ref<TextInput>
|
||||
inputRef: Ref<React.ComponentRef<typeof TextInput>>
|
||||
onChangeText: (text: string) => void
|
||||
onClear: () => void
|
||||
onEscape: () => void
|
||||
|
||||
@@ -43,7 +43,7 @@ function useWebOnlyDebugLiveEventPreferences() {
|
||||
|
||||
useEffect(() => {
|
||||
if (env.IS_DEV && IS_WEB && typeof window !== 'undefined') {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
window.__updateLiveEventPreferences = async (
|
||||
action: LiveEventPreferencesAction,
|
||||
) => {
|
||||
|
||||
@@ -4,7 +4,54 @@ import {isPostInLanguage} from '../../locale/helpers'
|
||||
import {FALLBACK_MARKER_POST} from './feed/home'
|
||||
import {type ReasonFeedSource} from './feed/types'
|
||||
|
||||
type FeedViewPost = app.bsky.feed.defs.FeedViewPost
|
||||
export type FeedPostNumbering = Pick<
|
||||
app.bsky.unspecced.defs.ThreadItemPost,
|
||||
'opThreadPostIndex' | 'opThreadPostCount'
|
||||
>
|
||||
|
||||
type ValidFeedPostNumbering = Required<FeedPostNumbering>
|
||||
|
||||
// AppView adds these fields to feed responses ahead of their feed lexicon.
|
||||
type FeedViewPost = app.bsky.feed.defs.FeedViewPost & FeedPostNumbering
|
||||
|
||||
function getPostNumbering(
|
||||
value: FeedPostNumbering,
|
||||
): ValidFeedPostNumbering | undefined {
|
||||
const {opThreadPostIndex: index, opThreadPostCount: count} = value
|
||||
|
||||
if (
|
||||
index === undefined ||
|
||||
count === undefined ||
|
||||
index < 1 ||
|
||||
count < 1 ||
|
||||
index > count
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
opThreadPostIndex: index,
|
||||
opThreadPostCount: count,
|
||||
}
|
||||
}
|
||||
|
||||
function inferPostNumbering(
|
||||
feedPost: FeedViewPost,
|
||||
position: 'parent' | 'root',
|
||||
): ValidFeedPostNumbering | undefined {
|
||||
const postNumbering = getPostNumbering(feedPost)
|
||||
if (!postNumbering) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Feed responses number only the selected post, so derive the hydrated
|
||||
// context that the feed renders alongside it.
|
||||
return getPostNumbering({
|
||||
opThreadPostIndex:
|
||||
position === 'root' ? 1 : postNumbering.opThreadPostIndex - 1,
|
||||
opThreadPostCount: postNumbering.opThreadPostCount,
|
||||
})
|
||||
}
|
||||
|
||||
export type FeedTunerFn = (
|
||||
tuner: FeedTuner,
|
||||
@@ -15,6 +62,7 @@ export type FeedTunerFn = (
|
||||
type FeedSliceItem = {
|
||||
post: app.bsky.feed.defs.PostView
|
||||
record: app.bsky.feed.post.Main
|
||||
postNumbering: FeedPostNumbering | undefined
|
||||
parentAuthor: app.bsky.actor.defs.ProfileViewBasic | undefined
|
||||
isParentBlocked: boolean
|
||||
isParentNotFound: boolean
|
||||
@@ -38,7 +86,10 @@ export class FeedViewPostsSlice {
|
||||
rootUri: string
|
||||
feedPostUri: string
|
||||
|
||||
constructor(feedPost: FeedViewPost) {
|
||||
constructor(
|
||||
feedPost: FeedViewPost,
|
||||
postNumberingByUri: Map<string, FeedPostNumbering>,
|
||||
) {
|
||||
const {post, reply, reason} = feedPost
|
||||
this.items = []
|
||||
this.isIncompleteThread = false
|
||||
@@ -80,6 +131,7 @@ export class FeedViewPostsSlice {
|
||||
this.items.push({
|
||||
post,
|
||||
record: post.record,
|
||||
postNumbering: postNumberingByUri.get(post.uri),
|
||||
parentAuthor,
|
||||
isParentBlocked,
|
||||
isParentNotFound,
|
||||
@@ -128,6 +180,9 @@ export class FeedViewPostsSlice {
|
||||
this.items.unshift({
|
||||
post: parent,
|
||||
record: parent.record,
|
||||
postNumbering:
|
||||
postNumberingByUri.get(parent.uri) ??
|
||||
inferPostNumbering(feedPost, 'parent'),
|
||||
parentAuthor: grandparentAuthor,
|
||||
isParentBlocked: isGrandparentBlocked,
|
||||
isParentNotFound: isGrandparentNotFound,
|
||||
@@ -151,6 +206,9 @@ export class FeedViewPostsSlice {
|
||||
this.items.unshift({
|
||||
post: root,
|
||||
record: root.record,
|
||||
postNumbering:
|
||||
postNumberingByUri.get(root.uri) ??
|
||||
inferPostNumbering(feedPost, 'root'),
|
||||
isParentBlocked: false,
|
||||
isParentNotFound: false,
|
||||
parentAuthor: undefined,
|
||||
@@ -241,8 +299,16 @@ export class FeedTuner {
|
||||
dryRun: false,
|
||||
},
|
||||
): FeedViewPostsSlice[] {
|
||||
const postNumberingByUri = new Map<string, FeedPostNumbering>()
|
||||
for (const item of feed) {
|
||||
const postNumbering = getPostNumbering(item)
|
||||
if (postNumbering) {
|
||||
postNumberingByUri.set(item.post.uri, postNumbering)
|
||||
}
|
||||
}
|
||||
|
||||
let slices: FeedViewPostsSlice[] = feed
|
||||
.map(item => new FeedViewPostsSlice(item))
|
||||
.map(item => new FeedViewPostsSlice(item, postNumberingByUri))
|
||||
.filter(s => s.items.length > 0 || s.isFallbackMarker)
|
||||
|
||||
// run the custom tuners
|
||||
|
||||
@@ -334,7 +334,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
|
||||
feed = feed.filter(post => new Date(post.post.indexedAt) > this.minDate)
|
||||
// attach source info
|
||||
for (const post of feed) {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
post.__source = this.sourceInfo
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export {unstable_batchedUpdates as batchedUpdates} from 'react-native'
|
||||
import {unstable_batchedUpdates} from 'react-native'
|
||||
|
||||
export function batchedUpdates(fn: () => void): void {
|
||||
unstable_batchedUpdates(fn, undefined)
|
||||
}
|
||||
|
||||
@@ -194,8 +194,7 @@ export const MAX_LABELERS = 20
|
||||
export const VIDEO_SERVICE = 'https://video.bsky.app'
|
||||
export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app'
|
||||
|
||||
export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds
|
||||
export const VIDEO_10_MINUTE_MAX_DURATION_MS = 10 * 60 * 1000
|
||||
export const VIDEO_MAX_DURATION_MS = 10 * 60 * 1000 // 10 minutes in milliseconds
|
||||
/**
|
||||
* Maximum size of a video in megabytes, _not_ mebibytes. Backend uses
|
||||
* ISO megabytes.
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import {type ForwardedRef, useEffect, useMemo, useRef} from 'react'
|
||||
import {type ScrollView} from 'react-native'
|
||||
import {Platform} from 'react-native'
|
||||
import {Platform, type ScrollView} from 'react-native'
|
||||
|
||||
import {mergeRefs} from '#/lib/merge-refs'
|
||||
|
||||
type Props<Scrollable extends ScrollView = ScrollView> = {
|
||||
type ScrollViewInstance = React.ComponentRef<typeof ScrollView>
|
||||
|
||||
type Props<Scrollable extends ScrollViewInstance = ScrollViewInstance> = {
|
||||
cursor?: string
|
||||
outerRef?: ForwardedRef<Scrollable>
|
||||
}
|
||||
|
||||
export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({
|
||||
outerRef,
|
||||
cursor = 'grab',
|
||||
}: Props<Scrollable> = {}) {
|
||||
export function useDraggableScroll<
|
||||
Scrollable extends ScrollViewInstance = ScrollViewInstance,
|
||||
>({outerRef, cursor = 'grab'}: Props<Scrollable> = {}) {
|
||||
const ref = useRef<Scrollable>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -221,7 +221,7 @@ describe('useApplyPullRequestOTAUpdate', () => {
|
||||
)
|
||||
|
||||
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
|
||||
act(() => buttons?.[1].onPress?.())
|
||||
void act(() => buttons?.[1].onPress?.())
|
||||
|
||||
await waitFor(() => expect(reloadAsync).toHaveBeenCalled())
|
||||
expect(device.set).toHaveBeenCalledWith(['pendingOTAUpdate'], {
|
||||
@@ -256,7 +256,7 @@ describe('useApplyPullRequestOTAUpdate', () => {
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
|
||||
|
||||
act(() => buttons?.[1].onPress?.())
|
||||
void act(() => buttons?.[1].onPress?.())
|
||||
|
||||
await waitFor(() =>
|
||||
expect(Alert.alert).toHaveBeenLastCalledWith(
|
||||
@@ -283,7 +283,7 @@ describe('useApplyPullRequestOTAUpdate', () => {
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
|
||||
|
||||
act(() => buttons?.[1].onPress?.())
|
||||
void act(() => buttons?.[1].onPress?.())
|
||||
|
||||
await waitFor(() =>
|
||||
expect(Alert.alert).toHaveBeenLastCalledWith(
|
||||
|
||||
@@ -452,7 +452,7 @@ export const splash = (scheme: 'light' | 'dark') => {
|
||||
: require('../../../assets/splash/splash-dark.png')
|
||||
|
||||
return {
|
||||
image: RNImage.resolveAssetSource(source).uri,
|
||||
image: RNImage.resolveAssetSource(source)!.uri,
|
||||
imageFullScreen: true,
|
||||
imageResizeMode: 'cover',
|
||||
backgroundColor: scheme === 'light' ? '#006AFF' : '#002861',
|
||||
|
||||
+1
-1
@@ -24,6 +24,6 @@ export function isJwtExpired(token: string) {
|
||||
|
||||
export function isAppPassword(token: string) {
|
||||
const payload = jwtDecode(token)
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
return payload.scope === 'com.atproto.appPass'
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function openPicker(opts?: ImagePickerOptions) {
|
||||
quality: 1,
|
||||
selectionLimit: 1,
|
||||
...opts,
|
||||
shouldDownloadFromNetwork: true,
|
||||
legacy: true,
|
||||
preferredAssetRepresentationMode:
|
||||
UIImagePickerPreferredAssetRepresentationMode.Automatic,
|
||||
@@ -63,6 +64,7 @@ export async function openUnifiedPicker({
|
||||
// the `file` returned on each asset after validation instead.
|
||||
base64: false,
|
||||
selectionLimit: IS_IOS ? selectionCountRemaining : undefined,
|
||||
shouldDownloadFromNetwork: true,
|
||||
preferredAssetRepresentationMode:
|
||||
UIImagePickerPreferredAssetRepresentationMode.Automatic,
|
||||
videoExportPreset: VideoExportPreset.Passthrough,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {type Client} from '@atproto/lex'
|
||||
|
||||
import {com} from '#/lexicons'
|
||||
import {
|
||||
getServiceAuthToken,
|
||||
SERVICE_AUTH_TTL_SEC,
|
||||
serviceAuthExp,
|
||||
} from '../upload.shared'
|
||||
|
||||
function createClient() {
|
||||
const call = jest.fn().mockResolvedValue({token: 'token'})
|
||||
return {client: {call} as unknown as Client, call}
|
||||
}
|
||||
|
||||
describe('serviceAuthExp', () => {
|
||||
it('returns an integer even when the clock has sub-second precision', () => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_500)
|
||||
expect(serviceAuthExp()).toBe(1_700_000_000 + SERVICE_AUTH_TTL_SEC)
|
||||
expect(Number.isInteger(serviceAuthExp())).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a custom ttl and keeps the result integral', () => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_999)
|
||||
expect(serviceAuthExp(90.7)).toBe(1_700_000_090)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getServiceAuthToken', () => {
|
||||
it('floors a fractional exp before sending it', async () => {
|
||||
const {client, call} = createClient()
|
||||
await getServiceAuthToken({
|
||||
client,
|
||||
aud: 'did:web:video.bsky.app',
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
exp: 1_700_001_800.5,
|
||||
})
|
||||
expect(call).toHaveBeenCalledWith(com.atproto.server.getServiceAuth, {
|
||||
aud: 'did:web:video.bsky.app',
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
exp: 1_700_001_800,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves exp undefined when the caller omits it', async () => {
|
||||
const {client, call} = createClient()
|
||||
await getServiceAuthToken({
|
||||
client,
|
||||
aud: 'did:web:video.bsky.app',
|
||||
lxm: 'app.bsky.video.getUploadLimits',
|
||||
})
|
||||
expect(call).toHaveBeenCalledWith(com.atproto.server.getServiceAuth, {
|
||||
aud: 'did:web:video.bsky.app',
|
||||
lxm: 'app.bsky.video.getUploadLimits',
|
||||
exp: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a non-finite exp rather than sending NaN', async () => {
|
||||
const {client, call} = createClient()
|
||||
await expect(
|
||||
getServiceAuthToken({
|
||||
client,
|
||||
aud: 'did:web:video.bsky.app',
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
exp: NaN,
|
||||
}),
|
||||
).rejects.toThrow('Invalid service auth exp')
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -5,13 +5,6 @@ export class VideoTooLargeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ServerError'
|
||||
}
|
||||
}
|
||||
|
||||
export class UploadLimitError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
|
||||
@@ -5,7 +5,7 @@ import {AbortError} from '#/lib/async/cancelable'
|
||||
import {type CompressedVideo} from '#/lib/media/video/types'
|
||||
import {shouldRetryError} from '#/lib/strings/errors'
|
||||
import {type app} from '#/lexicons'
|
||||
import {getServiceAuthToken} from '../upload.shared'
|
||||
import {getServiceAuthToken, serviceAuthExp} from '../upload.shared'
|
||||
import {mimeToExt} from '../util'
|
||||
import {
|
||||
abortUpload,
|
||||
@@ -26,15 +26,12 @@ import {createUploadPart} from './uploadPart'
|
||||
import {uploadParts} from './uploadParts'
|
||||
import {delay, isRetryableMultipartError, retryDelayMs} from './utils'
|
||||
|
||||
export class MultipartFallbackError extends Error {}
|
||||
|
||||
export async function uploadVideoMultipart({
|
||||
video,
|
||||
client,
|
||||
dispatchUrl,
|
||||
setProgress,
|
||||
signal,
|
||||
onStarted,
|
||||
}: {
|
||||
video: CompressedVideo
|
||||
client: Client
|
||||
@@ -42,25 +39,12 @@ export async function uploadVideoMultipart({
|
||||
dispatchUrl: string | URL
|
||||
setProgress: (progress: number) => void
|
||||
signal: AbortSignal
|
||||
onStarted?: () => void
|
||||
}): Promise<app.bsky.video.defs.JobStatus> {
|
||||
throwIfAborted(signal)
|
||||
const tokenProvider = createTokenProvider(client, dispatchUrl, signal)
|
||||
const token = await tokenProvider.get()
|
||||
const name = `${nanoid(12)}.${mimeToExt(video.mimeType)}`
|
||||
let session
|
||||
try {
|
||||
session = await startUpload({token, video, name, signal})
|
||||
} catch (err) {
|
||||
if (signal.aborted) throw new AbortError()
|
||||
// A server without multipart support, or one with the kill switch active,
|
||||
// leaves no reservation behind. The legacy path remains authoritative.
|
||||
throw new MultipartFallbackError(
|
||||
err instanceof Error ? err.message : 'Multipart upload unavailable',
|
||||
)
|
||||
}
|
||||
onStarted?.()
|
||||
|
||||
const session = await startUpload({token, video, name, signal})
|
||||
const {jobId} = session
|
||||
const abortOnCancel = () => {
|
||||
void tokenProvider
|
||||
@@ -89,7 +73,7 @@ export async function uploadVideoMultipart({
|
||||
})
|
||||
} catch (err) {
|
||||
if (signal.aborted) throw new AbortError()
|
||||
return await abortThenFallbackOrResolve(
|
||||
return await abortThenRethrowOrResolve(
|
||||
jobId,
|
||||
await tokenProvider.get(),
|
||||
err,
|
||||
@@ -166,14 +150,14 @@ async function finishAndRecover({
|
||||
}
|
||||
} catch (err) {
|
||||
throwIfAborted(signal)
|
||||
return await abortThenFallbackOrResolve(jobId, token, err)
|
||||
return await abortThenRethrowOrResolve(jobId, token, err)
|
||||
}
|
||||
createdFailures++
|
||||
if (createdFailures < MULTIPART_FINISH_ATTEMPTS) {
|
||||
await delay(500 * 2 ** (createdFailures - 1), signal)
|
||||
continue
|
||||
}
|
||||
return await abortThenFallbackOrResolve(jobId, token, finishError)
|
||||
return await abortThenRethrowOrResolve(jobId, token, finishError)
|
||||
case 'finishing':
|
||||
// The service may have assembled the upload even though the finish
|
||||
// request failed. Poll and retry instead of starting a second upload.
|
||||
@@ -224,16 +208,21 @@ async function getUploadStatusWithRetry(
|
||||
throw lastError
|
||||
}
|
||||
|
||||
async function abortThenFallbackOrResolve(
|
||||
/**
|
||||
* Releases the reservation for an upload we can no longer finish, then surfaces
|
||||
* the failure that got us here. The abort can race a service-side completion,
|
||||
* so a `completed` result is resolved as a success instead.
|
||||
*/
|
||||
async function abortThenRethrowOrResolve(
|
||||
jobId: string,
|
||||
token: string,
|
||||
cause: unknown,
|
||||
): Promise<app.bsky.video.defs.JobStatus> {
|
||||
const result = await abortUploadWithRetry(jobId, token)
|
||||
if (result.state === 'aborted') {
|
||||
throw new MultipartFallbackError(
|
||||
cause instanceof Error ? cause.message : 'Multipart upload failed',
|
||||
)
|
||||
throw cause instanceof Error
|
||||
? cause
|
||||
: new MultipartUploadError('Multipart upload failed')
|
||||
}
|
||||
if (result.state === 'completed' && result.completedJobId) {
|
||||
const status = await getUploadStatus(jobId, token)
|
||||
@@ -280,7 +269,7 @@ function createTokenProvider(
|
||||
async function get(forceRefresh = false) {
|
||||
if (!forceRefresh && token && Date.now() < expiresAt - 60_000) return token
|
||||
if (!refresh) {
|
||||
const exp = Math.floor(Date.now() / 1000) + 60 * 30
|
||||
const exp = serviceAuthExp()
|
||||
refresh = getServiceAuthTokenWithRetry(client, dispatchUrl, exp, signal)
|
||||
.then(nextToken => {
|
||||
token = nextToken
|
||||
|
||||
@@ -5,7 +5,6 @@ import {nanoid} from 'nanoid/non-secure'
|
||||
import {
|
||||
type ProbedMetadata,
|
||||
type VideoCompressSkipReason,
|
||||
type VideoUploadTransport,
|
||||
} from '#/lib/media/video/types'
|
||||
import {Sentry} from '#/logger/sentry/lib'
|
||||
import {type Metrics} from '#/analytics/metrics'
|
||||
@@ -46,7 +45,6 @@ export type VideoTelemetry = {
|
||||
compressCompleted: (video: {size: number; mimeType: string}) => void
|
||||
compressFailed: (e: unknown) => void
|
||||
uploadStarted: (bytes: number) => void
|
||||
uploadTransport: (transport: VideoUploadTransport) => void
|
||||
uploadCompleted: (jobId: string) => void
|
||||
uploadFailed: (e: unknown) => void
|
||||
processingStarted: (jobId: string) => void
|
||||
@@ -72,7 +70,6 @@ export function createVideoTelemetry({
|
||||
let phaseStartedAt = startedAt
|
||||
let jobId: string | undefined
|
||||
let uploadBytes: number | undefined
|
||||
let uploadTransport: VideoUploadTransport = 'legacy'
|
||||
let txnEnded = false
|
||||
let abortBound = true
|
||||
|
||||
@@ -229,11 +226,6 @@ export function createVideoTelemetry({
|
||||
metric('video:upload:uploadStarted', {uploadId, engine, bytes})
|
||||
},
|
||||
|
||||
uploadTransport(transport) {
|
||||
uploadTransport = transport
|
||||
phaseSpan?.setAttribute('video.upload.transport', transport)
|
||||
},
|
||||
|
||||
uploadCompleted(id) {
|
||||
jobId = id
|
||||
const elapsedMs = Date.now() - phaseStartedAt
|
||||
@@ -246,7 +238,6 @@ export function createVideoTelemetry({
|
||||
elapsedMs,
|
||||
throughputBytesPerSec:
|
||||
elapsedMs > 0 ? Math.round((bytes * 1000) / elapsedMs) : 0,
|
||||
transport: uploadTransport,
|
||||
})
|
||||
endPhaseSpan()
|
||||
phase = undefined
|
||||
@@ -259,7 +250,6 @@ export function createVideoTelemetry({
|
||||
bytes: uploadBytes ?? 0,
|
||||
errorClass: errorClass(e),
|
||||
elapsedMs: Date.now() - phaseStartedAt,
|
||||
transport: uploadTransport,
|
||||
})
|
||||
endTxn('error')
|
||||
detachAbort()
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
export type VideoCompressSkipReason =
|
||||
'gif' | 'below-byte-threshold' | 'no-webcodecs' | 'compress-error-fallback'
|
||||
|
||||
export type VideoUploadTransport = 'multipart' | 'legacy' | 'legacy-fallback'
|
||||
|
||||
export type CompressedVideo = {
|
||||
uri: string
|
||||
mimeType: string
|
||||
|
||||
@@ -27,6 +27,11 @@ export async function getServiceAuthToken({
|
||||
dispatchUrl?: string | URL
|
||||
aud?: string
|
||||
lxm: NsidString
|
||||
/**
|
||||
* Unix timestamp in *seconds* at which the token expires. Fractional values
|
||||
* are floored - see {@link toIntegerExp}. Defaults to the server's own
|
||||
* short expiry when omitted.
|
||||
*/
|
||||
exp?: number
|
||||
}) {
|
||||
let resolvedAud = aud
|
||||
@@ -43,11 +48,42 @@ export async function getServiceAuthToken({
|
||||
const {token} = await client.call(com.atproto.server.getServiceAuth, {
|
||||
aud: resolvedAud as DidString,
|
||||
lxm,
|
||||
exp,
|
||||
exp: exp === undefined ? undefined : toIntegerExp(exp),
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Default lifetime for the video upload service auth token. Long enough to
|
||||
* cover a slow upload of a large file, short enough to limit the damage if the
|
||||
* token leaks.
|
||||
*/
|
||||
export const SERVICE_AUTH_TTL_SEC = 60 * 30
|
||||
|
||||
/**
|
||||
* Build a service auth `exp` claim `ttlSec` seconds from now.
|
||||
*
|
||||
* Always use this instead of hand-rolling the arithmetic: `Date.now()` is in
|
||||
* milliseconds, and dividing by 1000 without flooring yields a fractional
|
||||
* timestamp that the endpoint rejects.
|
||||
*/
|
||||
export function serviceAuthExp(ttlSec: number = SERVICE_AUTH_TTL_SEC) {
|
||||
return Math.floor(Date.now() / 1000) + Math.floor(ttlSec)
|
||||
}
|
||||
|
||||
/**
|
||||
* The lexicon types `exp` as an integer and it is serialized straight into the
|
||||
* query string, so a fractional value fails validation and the upload dies
|
||||
* before it starts. Floor here, at the single chokepoint every caller goes
|
||||
* through, so a call site that forgets to cannot reintroduce the bug.
|
||||
*/
|
||||
function toIntegerExp(exp: number) {
|
||||
if (!Number.isFinite(exp)) {
|
||||
throw new Error(`Invalid service auth exp: ${exp}`)
|
||||
}
|
||||
return Math.floor(exp)
|
||||
}
|
||||
|
||||
export async function getVideoUploadLimits(client: Client, i18n: I18n) {
|
||||
const token = await getServiceAuthToken({
|
||||
client,
|
||||
|
||||
@@ -1,112 +1,37 @@
|
||||
import {createUploadTask, FileSystemUploadType} from 'expo-file-system/legacy'
|
||||
import {type Client} from '@atproto/lex'
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {AbortError} from '#/lib/async/cancelable'
|
||||
import {ServerError} from '#/lib/media/video/errors'
|
||||
import {
|
||||
type CompressedVideo,
|
||||
type VideoUploadTransport,
|
||||
} from '#/lib/media/video/types'
|
||||
import {Features, features} from '#/analytics/features'
|
||||
import {type app} from '#/lexicons'
|
||||
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
|
||||
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
|
||||
import {createVideoEndpointUrl, mimeToExt} from './util'
|
||||
import {type CompressedVideo} from '#/lib/media/video/types'
|
||||
import {uploadVideoMultipart} from './multipart/upload'
|
||||
import {getVideoUploadLimits} from './upload.shared'
|
||||
|
||||
export async function uploadVideo({
|
||||
video,
|
||||
client,
|
||||
dispatchUrl,
|
||||
did,
|
||||
setProgress,
|
||||
signal,
|
||||
i18n,
|
||||
onTransport,
|
||||
}: {
|
||||
video: CompressedVideo
|
||||
client: Client
|
||||
/** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */
|
||||
dispatchUrl: string | URL
|
||||
did: string
|
||||
setProgress: (progress: number) => void
|
||||
signal: AbortSignal
|
||||
i18n: I18n
|
||||
onTransport?: (transport: VideoUploadTransport) => void
|
||||
}) {
|
||||
if (signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
await getVideoUploadLimits(client, i18n)
|
||||
|
||||
if (features.isOn(Features.VideoMultipartUploadEnable)) {
|
||||
try {
|
||||
return await uploadVideoMultipart({
|
||||
video,
|
||||
client,
|
||||
dispatchUrl,
|
||||
setProgress,
|
||||
signal,
|
||||
onStarted: () => onTransport?.('multipart'),
|
||||
})
|
||||
} catch (err) {
|
||||
if (!(err instanceof MultipartFallbackError)) throw err
|
||||
onTransport?.('legacy-fallback')
|
||||
setProgress(0)
|
||||
}
|
||||
} else {
|
||||
onTransport?.('legacy')
|
||||
}
|
||||
|
||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||
did,
|
||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||
})
|
||||
|
||||
if (signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
const token = await getServiceAuthToken({
|
||||
return await uploadVideoMultipart({
|
||||
video,
|
||||
client,
|
||||
dispatchUrl,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||
setProgress,
|
||||
signal,
|
||||
})
|
||||
const uploadTask = createUploadTask(
|
||||
uri,
|
||||
video.uri,
|
||||
{
|
||||
headers: {
|
||||
'content-type': video.mimeType,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
httpMethod: 'POST',
|
||||
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
||||
},
|
||||
p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
|
||||
)
|
||||
|
||||
if (signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
const res = await uploadTask.uploadAsync()
|
||||
|
||||
if (!res?.body) {
|
||||
throw new Error('No response')
|
||||
}
|
||||
|
||||
const responseBody = JSON.parse(res.body) as app.bsky.video.defs.JobStatus
|
||||
|
||||
if (!responseBody.jobId) {
|
||||
throw new ServerError(
|
||||
responseBody.error || i18n._(msg`Failed to upload video`),
|
||||
)
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
return responseBody
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import {type Client} from '@atproto/lex'
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {AbortError} from '#/lib/async/cancelable'
|
||||
import {ServerError} from '#/lib/media/video/errors'
|
||||
import {
|
||||
type CompressedVideo,
|
||||
type VideoUploadTransport,
|
||||
} from '#/lib/media/video/types'
|
||||
import {Features, features} from '#/analytics/features'
|
||||
import {type app} from '#/lexicons'
|
||||
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
|
||||
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
|
||||
import {createVideoEndpointUrl, mimeToExt} from './util'
|
||||
|
||||
export async function uploadVideo({
|
||||
video,
|
||||
client,
|
||||
dispatchUrl,
|
||||
did,
|
||||
setProgress,
|
||||
signal,
|
||||
i18n,
|
||||
onTransport,
|
||||
}: {
|
||||
video: CompressedVideo
|
||||
client: Client
|
||||
/** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */
|
||||
dispatchUrl: string | URL
|
||||
did: string
|
||||
setProgress: (progress: number) => void
|
||||
signal: AbortSignal
|
||||
i18n: I18n
|
||||
onTransport?: (transport: VideoUploadTransport) => void
|
||||
}) {
|
||||
if (signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
await getVideoUploadLimits(client, i18n)
|
||||
|
||||
if (features.isOn(Features.VideoMultipartUploadEnable)) {
|
||||
try {
|
||||
return await uploadVideoMultipart({
|
||||
video,
|
||||
client,
|
||||
dispatchUrl,
|
||||
setProgress,
|
||||
signal,
|
||||
onStarted: () => onTransport?.('multipart'),
|
||||
})
|
||||
} catch (err) {
|
||||
if (!(err instanceof MultipartFallbackError)) throw err
|
||||
onTransport?.('legacy-fallback')
|
||||
setProgress(0)
|
||||
}
|
||||
} else {
|
||||
onTransport?.('legacy')
|
||||
}
|
||||
|
||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||
did,
|
||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||
})
|
||||
|
||||
let bytes = video.bytes
|
||||
if (!bytes) {
|
||||
if (signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
const token = await getServiceAuthToken({
|
||||
client,
|
||||
dispatchUrl,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||
})
|
||||
|
||||
if (signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
const xhr = new XMLHttpRequest()
|
||||
const res = await new Promise<app.bsky.video.defs.JobStatus>(
|
||||
(resolve, reject) => {
|
||||
xhr.upload.addEventListener('progress', e => {
|
||||
const progress = e.loaded / e.total
|
||||
setProgress(progress)
|
||||
})
|
||||
xhr.onloadend = () => {
|
||||
if (signal.aborted) {
|
||||
reject(new AbortError())
|
||||
} else if (xhr.readyState === 4) {
|
||||
const uploadRes = JSON.parse(
|
||||
xhr.responseText,
|
||||
) as app.bsky.video.defs.JobStatus
|
||||
resolve(uploadRes)
|
||||
} else {
|
||||
reject(new ServerError(i18n._(msg`Failed to upload video`)))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
reject(new ServerError(i18n._(msg`Failed to upload video`)))
|
||||
}
|
||||
xhr.open('POST', uri)
|
||||
xhr.setRequestHeader('Content-Type', video.mimeType)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.send(bytes)
|
||||
},
|
||||
)
|
||||
|
||||
if (!res.jobId) {
|
||||
throw new ServerError(res.error || i18n._(msg`Failed to upload video`))
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export type CommonNavigatorParams = {
|
||||
NotFound: undefined
|
||||
Lists: undefined
|
||||
Moderation: undefined
|
||||
ModerationInbox: undefined
|
||||
ModerationModlists: undefined
|
||||
ModerationMutedAccounts: undefined
|
||||
ModerationBlockedAccounts: undefined
|
||||
|
||||
@@ -5,8 +5,7 @@ import {IS_WEB} from '#/env'
|
||||
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
|
||||
|
||||
const IFRAME_HOST = IS_WEB
|
||||
? // @ts-ignore only for web
|
||||
window.location.host === 'localhost:8100'
|
||||
? window.location.host === 'localhost:8100'
|
||||
? 'http://localhost:8100'
|
||||
: 'https://bsky.app'
|
||||
: __DEV__ && !process.env.JEST_WORKER_ID
|
||||
@@ -154,10 +153,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
urlp.hostname === 'www.twitch.tv' ||
|
||||
urlp.hostname === 'm.twitch.tv'
|
||||
) {
|
||||
const parent = IS_WEB
|
||||
? // @ts-ignore only for web
|
||||
window.location.hostname
|
||||
: 'localhost'
|
||||
const parent = IS_WEB ? window.location.hostname : 'localhost'
|
||||
|
||||
const [__, channelOrVideo, clipOrId, id] = urlp.pathname.split('/')
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export function getLocales() {
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore checked above
|
||||
// @ts-expect-error checked above
|
||||
output.push(locale)
|
||||
}
|
||||
|
||||
|
||||
+415
-432
File diff suppressed because it is too large
Load Diff
@@ -366,7 +366,7 @@ describe('debug contexts', () => {
|
||||
const timestamp = Date.now()
|
||||
const message = nanoid()
|
||||
const logger = new Logger({
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
context: 'specific',
|
||||
level: LogLevel.Debug,
|
||||
})
|
||||
@@ -388,7 +388,7 @@ describe('debug contexts', () => {
|
||||
const timestamp = Date.now()
|
||||
const message = nanoid()
|
||||
const logger = new Logger({
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
context: 'namespace:foo',
|
||||
contextFilter: 'namespace:*',
|
||||
level: LogLevel.Debug,
|
||||
@@ -411,7 +411,7 @@ describe('debug contexts', () => {
|
||||
const timestamp = Date.now()
|
||||
const message = nanoid()
|
||||
const logger = new Logger({
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
context: 'namespace:bar:baz',
|
||||
contextFilter: 'namespace:foo:*',
|
||||
})
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// @ts-ignore Web-only. On RN, this is set by Metro.
|
||||
// @ts-expect-error Web-only. On RN, this is set by Metro.
|
||||
window.__BUNDLE_START_TIME__ = performance.now()
|
||||
|
||||
@@ -17,6 +17,7 @@ export const router = new Router<AllNavigatableRoutes>({
|
||||
Lists: '/lists',
|
||||
// moderation
|
||||
Moderation: '/moderation',
|
||||
ModerationInbox: '/moderation/inbox',
|
||||
ModerationModlists: '/moderation/modlists',
|
||||
ModerationMutedAccounts: '/moderation/muted-accounts',
|
||||
ModerationBlockedAccounts: '/moderation/blocked-accounts',
|
||||
|
||||
@@ -271,7 +271,6 @@ function HashtagScreenTab({
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
onItemSeen={trackPostView}
|
||||
// @ts-ignore web only -prf
|
||||
desktopFixedHeight
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
|
||||
@@ -78,8 +78,8 @@ export const LoginForm = ({
|
||||
const [identifier, setIdentifier] = useState(initialHandle || '')
|
||||
const [identifierFocused, setIdentifierFocused] = useState(false)
|
||||
const [authFactorToken, setAuthFactorToken] = useState('')
|
||||
const identifierRef = useRef<TextInput>(null)
|
||||
const passwordRef = useRef<TextInput>(null)
|
||||
const identifierRef = useRef<React.ComponentRef<typeof TextInput>>(null)
|
||||
const passwordRef = useRef<React.ComponentRef<typeof TextInput>>(null)
|
||||
const hasFocusedOnce = useRef(false)
|
||||
const [hasPassword, setHasPassword] = useState(false)
|
||||
const [revealPassword, setRevealPassword] = useState(false)
|
||||
|
||||
@@ -379,7 +379,7 @@ function JoinRequestsList({
|
||||
style={[a.flex_1, a.align_center, a.justify_center, a.py_4xl]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : null
|
||||
) : undefined
|
||||
}
|
||||
contentContainerStyle={
|
||||
showFooter ? {paddingBottom: footerHeight} : undefined
|
||||
|
||||
@@ -189,7 +189,7 @@ export function MessageComposer({
|
||||
<View
|
||||
collapsable={false}
|
||||
ref={native(
|
||||
(node: View) =>
|
||||
(node: React.ComponentRef<typeof View>) =>
|
||||
void composerInternalApiRef.current?.setAutocompleteAnchor(node),
|
||||
)}>
|
||||
<GlassContainer
|
||||
@@ -209,7 +209,9 @@ export function MessageComposer({
|
||||
composerInternalApiRef.current?.insert(emoji.native)
|
||||
}
|
||||
nextFocusRef={() =>
|
||||
composerInternalApiRef.current?.input?.element
|
||||
composerInternalApiRef.current?.input
|
||||
?.element as unknown as
|
||||
{focus: () => void} | null | undefined
|
||||
}>
|
||||
<EmojiPicker.Trigger label={l`Open emoji picker`}>
|
||||
{({props, state, control}) => (
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {Fragment, useCallback} from 'react'
|
||||
import {Linking, View} from 'react-native'
|
||||
import {LABELS} from '@bsky/sdk/moderation'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {getLabelingServiceTitle, isAppLabeler} from '#/lib/moderation'
|
||||
import {
|
||||
@@ -35,6 +33,7 @@ import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {EditBig_Stroke2_Corner2_Rounded as EditBig} from '#/components/icons/EditBig'
|
||||
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
|
||||
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
||||
import {Inbox_Stroke2_Corner2_Rounded as Inbox} from '#/components/icons/Inbox'
|
||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import * as LabelingService from '#/components/LabelingServiceCard'
|
||||
import * as Layout from '#/components/Layout'
|
||||
@@ -45,8 +44,11 @@ import {GlobalLabelPreference} from '#/components/moderation/LabelPreference'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_IOS} from '#/env'
|
||||
|
||||
const UNREAD_NOTIFICATION_CAP = 100
|
||||
|
||||
function ErrorState({error}: {error: string}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
@@ -81,7 +83,7 @@ function ErrorState({error}: {error: string}) {
|
||||
export function ModerationScreen(
|
||||
_props: NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>,
|
||||
) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {
|
||||
isLoading: isPreferencesLoading,
|
||||
error: preferencesError,
|
||||
@@ -109,7 +111,7 @@ export function ModerationScreen(
|
||||
<ErrorState
|
||||
error={
|
||||
preferencesError?.toString() ||
|
||||
_(msg`Something went wrong, please try again.`)
|
||||
l`Something went wrong, please try again.`
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@@ -123,12 +125,15 @@ export function ModerationScreen(
|
||||
function SubItem({
|
||||
title,
|
||||
icon: Icon,
|
||||
badge,
|
||||
style,
|
||||
}: ViewStyleProp & {
|
||||
title: string
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
badge?: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l, i18n} = useLingui()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -144,10 +149,38 @@ function SubItem({
|
||||
<Icon size="md" style={[t.atoms.text_contrast_medium]} />
|
||||
<Text style={[a.text_sm, a.font_semi_bold]}>{title}</Text>
|
||||
</View>
|
||||
<ChevronRight
|
||||
size="sm"
|
||||
style={[t.atoms.text_contrast_low, a.self_end, {paddingBottom: 2}]}
|
||||
/>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||
{badge ? (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_lg,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.px_sm,
|
||||
{
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
backgroundColor: t.palette.primary_500,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.font_semi_bold,
|
||||
{color: t.palette.white, fontVariant: ['tabular-nums']},
|
||||
]}>
|
||||
{badge >= UNREAD_NOTIFICATION_CAP
|
||||
? l({
|
||||
message: `${i18n.number(UNREAD_NOTIFICATION_CAP - 1)}+`,
|
||||
comment:
|
||||
'Displayed when the number of notifications exceeds the cap – for example, 99+ notifications',
|
||||
})
|
||||
: i18n.number(badge)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<ChevronRight size="sm" style={[t.atoms.text_contrast_low]} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -157,10 +190,14 @@ export function ModerationScreenInner({
|
||||
}: {
|
||||
preferences: UsePreferencesQueryResponse
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||
const ax = useAnalytics()
|
||||
const isModerationInboxEnabled = ax.features.enabled(
|
||||
ax.features.ModerationInboxEnable,
|
||||
)
|
||||
const {
|
||||
isLoading: isLabelersLoading,
|
||||
data: labelers,
|
||||
@@ -184,10 +221,11 @@ export function ModerationScreenInner({
|
||||
const handleCleanup = async () => {
|
||||
try {
|
||||
await removeLabelers({dids: unavailableDids})
|
||||
Toast.show(_(msg`Removed unavailable services`), {
|
||||
Toast.show(l`Removed unavailable services`, {
|
||||
type: 'success',
|
||||
})
|
||||
} catch (e: any) {
|
||||
} catch (error) {
|
||||
const e = error as Error
|
||||
logger.error('Failed to remove unavailable labelers', {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
@@ -214,7 +252,8 @@ export function ModerationScreenInner({
|
||||
await setAdultContentPref({
|
||||
enabled: selected,
|
||||
})
|
||||
} catch (e: any) {
|
||||
} catch (error) {
|
||||
const e = error as Error
|
||||
logger.error(`Failed to set adult content pref`, {
|
||||
message: e.message,
|
||||
})
|
||||
@@ -234,7 +273,7 @@ export function ModerationScreenInner({
|
||||
your{' '}
|
||||
<InlineLinkText
|
||||
to="/settings/account"
|
||||
label={_(msg`Go to account settings`)}>
|
||||
label={l`Go to account settings`}>
|
||||
account settings
|
||||
</InlineLinkText>
|
||||
.
|
||||
@@ -242,7 +281,25 @@ export function ModerationScreenInner({
|
||||
</Admonition.Admonition>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isModerationInboxEnabled && (
|
||||
<Link
|
||||
label={l`View your moderation inbox`}
|
||||
testID="moderationInboxBtn"
|
||||
to="/moderation/inbox"
|
||||
style={[a.mb_2xl, a.rounded_md, a.overflow_hidden]}>
|
||||
{state => (
|
||||
<SubItem
|
||||
title={l`Moderation inbox`}
|
||||
icon={Inbox}
|
||||
badge={3} // TODO This is a placeholder value. -dsb
|
||||
style={[
|
||||
t.atoms.bg_contrast_25,
|
||||
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
@@ -252,7 +309,6 @@ export function ModerationScreenInner({
|
||||
]}>
|
||||
<Trans>Moderation tools</Trans>
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
@@ -261,12 +317,12 @@ export function ModerationScreenInner({
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Link
|
||||
label={_(msg`View your default post interaction settings`)}
|
||||
label={l`View your default post interaction settings`}
|
||||
testID="interactionSettingsBtn"
|
||||
to="/moderation/interaction-settings">
|
||||
{state => (
|
||||
<SubItem
|
||||
title={_(msg`Interaction settings`)}
|
||||
title={l`Interaction settings`}
|
||||
icon={EditBig}
|
||||
style={[
|
||||
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||
@@ -277,11 +333,11 @@ export function ModerationScreenInner({
|
||||
<Divider />
|
||||
<Button
|
||||
testID="mutedWordsBtn"
|
||||
label={_(msg`Open muted words and tags settings`)}
|
||||
label={l`Open muted words and tags settings`}
|
||||
onPress={() => mutedWordsDialogControl.open()}>
|
||||
{state => (
|
||||
<SubItem
|
||||
title={_(msg`Muted words & tags`)}
|
||||
title={l`Muted words & tags`}
|
||||
icon={Filter}
|
||||
style={[
|
||||
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||
@@ -291,12 +347,12 @@ export function ModerationScreenInner({
|
||||
</Button>
|
||||
<Divider />
|
||||
<Link
|
||||
label={_(msg`View your moderation lists`)}
|
||||
label={l`View your moderation lists`}
|
||||
testID="moderationlistsBtn"
|
||||
to="/moderation/modlists">
|
||||
{state => (
|
||||
<SubItem
|
||||
title={_(msg`Moderation lists`)}
|
||||
title={l`Moderation lists`}
|
||||
icon={Group}
|
||||
style={[
|
||||
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||
@@ -306,12 +362,12 @@ export function ModerationScreenInner({
|
||||
</Link>
|
||||
<Divider />
|
||||
<Link
|
||||
label={_(msg`View your muted accounts`)}
|
||||
label={l`View your muted accounts`}
|
||||
testID="mutedAccountsBtn"
|
||||
to="/moderation/muted-accounts">
|
||||
{state => (
|
||||
<SubItem
|
||||
title={_(msg`Muted accounts`)}
|
||||
title={l`Muted accounts`}
|
||||
icon={Person}
|
||||
style={[
|
||||
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||
@@ -321,12 +377,12 @@ export function ModerationScreenInner({
|
||||
</Link>
|
||||
<Divider />
|
||||
<Link
|
||||
label={_(msg`View your blocked accounts`)}
|
||||
label={l`View your blocked accounts`}
|
||||
testID="blockedAccountsBtn"
|
||||
to="/moderation/blocked-accounts">
|
||||
{state => (
|
||||
<SubItem
|
||||
title={_(msg`Blocked accounts`)}
|
||||
title={l`Blocked accounts`}
|
||||
icon={CircleBanSign}
|
||||
style={[
|
||||
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||
@@ -336,12 +392,12 @@ export function ModerationScreenInner({
|
||||
</Link>
|
||||
<Divider />
|
||||
<Link
|
||||
label={_(msg`Manage verification settings`)}
|
||||
label={l`Manage verification settings`}
|
||||
testID="verificationSettingsBtn"
|
||||
to="/moderation/verification-settings">
|
||||
{state => (
|
||||
<SubItem
|
||||
title={_(msg`Verification settings`)}
|
||||
title={l`Verification settings`}
|
||||
icon={CircleCheck}
|
||||
style={[
|
||||
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||
@@ -350,7 +406,6 @@ export function ModerationScreenInner({
|
||||
)}
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.pt_2xl,
|
||||
@@ -361,11 +416,9 @@ export function ModerationScreenInner({
|
||||
]}>
|
||||
<Trans>Content filters</Trans>
|
||||
</Text>
|
||||
|
||||
<AgeAssuranceAdmonition style={[a.pb_md]}>
|
||||
{aaCopy.notice}
|
||||
</AgeAssuranceAdmonition>
|
||||
|
||||
<View style={[a.gap_md]}>
|
||||
<View
|
||||
style={[
|
||||
@@ -389,11 +442,13 @@ export function ModerationScreenInner({
|
||||
<Trans>Enable adult content</Trans>
|
||||
</Text>
|
||||
<Toggle.Item
|
||||
label={_(msg`Toggle to enable or disable adult content`)}
|
||||
label={l`Toggle to enable or disable adult content`}
|
||||
disabled={adultContentUIDisabled}
|
||||
name="adultContent"
|
||||
value={adultContentEnabled}
|
||||
onChange={onToggleAdultContentEnabled}>
|
||||
onChange={(selected: boolean) =>
|
||||
void onToggleAdultContentEnabled(selected)
|
||||
}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
{adultContentEnabled ? (
|
||||
@@ -412,11 +467,11 @@ export function ModerationScreenInner({
|
||||
<Trans>
|
||||
Adult content can only be enabled via the Web at{' '}
|
||||
<InlineLinkText
|
||||
label={_(msg`The Bluesky web application`)}
|
||||
label={l`The Bluesky web application`}
|
||||
to=""
|
||||
onPress={evt => {
|
||||
evt.preventDefault()
|
||||
Linking.openURL('https://bsky.app/')
|
||||
void Linking.openURL('https://bsky.app/')
|
||||
return false
|
||||
}}>
|
||||
bsky.app
|
||||
@@ -445,7 +500,6 @@ export function ModerationScreenInner({
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
@@ -456,7 +510,6 @@ export function ModerationScreenInner({
|
||||
]}>
|
||||
<Trans>Advanced</Trans>
|
||||
</Text>
|
||||
|
||||
{unavailableDids.length > 0 && (
|
||||
<Admonition.Outer type="tip" style={[a.mb_md]}>
|
||||
<Admonition.Row>
|
||||
@@ -470,7 +523,7 @@ export function ModerationScreenInner({
|
||||
</Admonition.Content>
|
||||
<Admonition.Button
|
||||
color="primary_subtle"
|
||||
label={_(msg`Remove unavailable moderation services`)}
|
||||
label={l`Remove unavailable moderation services`}
|
||||
onPress={handleCleanup}
|
||||
disabled={isRemovingLabelers}>
|
||||
<ButtonText>
|
||||
@@ -481,7 +534,6 @@ export function ModerationScreenInner({
|
||||
</Admonition.Row>
|
||||
</Admonition.Outer>
|
||||
)}
|
||||
|
||||
{isLabelersLoading ? (
|
||||
<View style={[a.w_full, a.align_center, a.p_lg]}>
|
||||
<Loader size="xl" />
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {NotFoundScreen} from '#/view/screens/NotFound'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
export function ModerationInboxScreen() {
|
||||
const ax = useAnalytics()
|
||||
const isEnabled = ax.features.enabled(ax.features.ModerationInboxEnable)
|
||||
|
||||
if (!isEnabled) {
|
||||
return <NotFoundScreen />
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout.Screen testID="moderationInboxScreen">
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Moderation inbox</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const onboardDispatch = useOnboardingDispatch()
|
||||
const {state, dispatch} = useOnboardingInternalState()
|
||||
const scrollview = useRef<ScrollView>(null)
|
||||
const scrollview = useRef<React.ComponentRef<typeof ScrollView>>(null)
|
||||
const prevActiveStep = useRef<string>(state.activeStep)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user