Merge remote-tracking branch 'origin/main' into rn-0.75

This commit is contained in:
Hailey
2024-10-06 12:02:08 -07:00
181 changed files with 5366 additions and 4294 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
import 'react-native-url-polyfill/auto'
import 'lib/sentry' // must be near top
import 'view/icons'
import '#/lib/sentry' // must be near top
import '#/view/icons'
import React, {useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
+1 -1
View File
@@ -78,8 +78,8 @@ import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import HashtagScreen from '#/screens/Hashtag'
import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesScreen} from '#/screens/Messages/List'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
+31 -3
View File
@@ -276,13 +276,13 @@ export const atoms = {
letterSpacing: tokens.TRACKING,
},
font_normal: {
fontWeight: tokens.fontWeight.regular,
fontWeight: tokens.fontWeight.normal,
},
font_bold: {
fontWeight: tokens.fontWeight.semibold,
fontWeight: tokens.fontWeight.bold,
},
font_heavy: {
fontWeight: tokens.fontWeight.extrabold,
fontWeight: tokens.fontWeight.heavy,
},
italic: {
fontStyle: 'italic',
@@ -901,4 +901,32 @@ export const atoms = {
hidden: {
display: 'none',
},
/*
* Transition
*/
transition_none: web({
transitionProperty: 'none',
}),
transition_all: web({
transitionProperty: 'all',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
transition_color: web({
transitionProperty:
'color, background-color, border-color, text-decoration-color, fill, stroke',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
transition_opacity: web({
transitionProperty: 'opacity',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
transition_transform: web({
transitionProperty: 'transform',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
} as const
+18 -23
View File
@@ -1,3 +1,5 @@
import {useFonts} from 'expo-font'
import {isWeb} from '#/platform/detection'
import {Device, device} from '#/storage'
@@ -40,31 +42,10 @@ export function applyFonts(
fontFamily: 'system' | 'theme',
) {
if (fontFamily === 'theme') {
style.fontFamily =
{
// '100': 'Inter-Thin',
// '200': 'Inter-ExtraLight',
// '300': 'Inter-Light',
// '500': 'Inter-Medium',
// '700': 'Inter-Bold',
// '900': 'Inter-Black',
'100': 'Inter-Regular',
'200': 'Inter-Regular',
'300': 'Inter-Regular',
'400': 'Inter-Regular',
'500': 'Inter-SemiBold',
'600': 'Inter-SemiBold',
'700': 'Inter-SemiBold',
'800': 'Inter-ExtraBold',
'900': 'Inter-ExtraBold',
}[style.fontWeight as string] || 'Inter-Regular'
style.fontFamily = 'InterVariable'
if (style.fontStyle === 'italic') {
if (style.fontFamily === 'Inter-Regular') {
style.fontFamily = 'Inter-Italic'
} else {
style.fontFamily += 'Italic'
}
style.fontFamily += 'Italic'
}
// fallback families only supported on web
@@ -84,3 +65,17 @@ export function applyFonts(
*/
style.fontVariant = ['no-contextual']
}
/*
* IMPORTANT: This is unused. Expo statically extracts these fonts.
*
* All used fonts MUST be configured here. Unused fonts can be commented out.
*
* This is used for both web fonts and native fonts.
*/
export function DO_NOT_USE() {
return useFonts({
InterVariable: require('../../assets/fonts/inter/InterVariable.ttf'),
'InterVariable-Italic': require('../../assets/fonts/inter/InterVariable-Italic.ttf'),
})
}
+5 -8
View File
@@ -1,6 +1,6 @@
import {Platform} from 'react-native'
import {isAndroid} from '#/platform/detection'
export const TRACKING = Platform.OS === 'android' ? 0.1 : 0
export const TRACKING = isAndroid ? 0.1 : 0
export const color = {
temp_purple: 'rgb(105 0 255)',
@@ -51,12 +51,9 @@ export const borderRadius = {
* These correspond to Inter font files we actually load.
*/
export const fontWeight = {
regular: '400',
// medium: '500',
semibold: '600',
// bold: '700',
extrabold: '800',
// black: '900',
normal: '400',
bold: isAndroid ? '700' : '600',
heavy: isAndroid ? '900' : '800',
} as const
export const gradients = {
+2 -4
View File
@@ -1,9 +1,8 @@
import React from 'react'
import {ColorSchemeName, useColorScheme} from 'react-native'
import * as SystemUI from 'expo-system-ui'
import {isWeb} from 'platform/detection'
import {useThemePrefs} from 'state/shell'
import {isWeb} from '#/platform/detection'
import {useThemePrefs} from '#/state/shell'
import {dark, dim, light} from '#/alf/themes'
import {ThemeName} from '#/alf/types'
@@ -12,7 +11,6 @@ export function useColorModeTheme(): ThemeName {
React.useLayoutEffect(() => {
updateDocument(theme)
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
}, [theme])
return theme
+10 -7
View File
@@ -14,7 +14,7 @@ import {
} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {atoms as a, flatten, select, tokens, useTheme, web} from '#/alf'
import {atoms as a, flatten, select, tokens, useTheme} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common'
import {Text} from '#/components/Typography'
@@ -87,6 +87,7 @@ export type ButtonProps = Pick<
style?: StyleProp<ViewStyle>
hoverStyle?: StyleProp<ViewStyle>
children: NonTextElements | ((context: ButtonContext) => NonTextElements)
PressableComponent?: React.ComponentType<PressableProps>
}
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
@@ -114,6 +115,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
disabled = false,
style,
hoverStyle: hoverStyleProp,
PressableComponent = Pressable,
...rest
},
ref,
@@ -352,7 +354,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
})
} else if (size === 'small') {
baseStyles.push({
paddingVertical: 8,
paddingVertical: 9,
paddingHorizontal: 12,
borderRadius: 6,
gap: 6,
@@ -374,7 +376,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
}
} else if (size === 'small') {
if (shape === 'round') {
baseStyles.push({height: 36, width: 36})
baseStyles.push({height: 34, width: 34})
} else {
baseStyles.push({height: 34, width: 34})
}
@@ -449,10 +451,11 @@ export const Button = React.forwardRef<View, ButtonProps>(
const flattenedBaseStyles = flatten([baseStyles, style])
return (
<Pressable
<PressableComponent
role="button"
accessibilityHint={undefined} // optional
{...rest}
// @ts-ignore - this will always be a pressable
ref={ref}
aria-label={label}
aria-pressed={state.pressed}
@@ -500,7 +503,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
<Context.Provider value={context}>
{typeof children === 'function' ? children(context) : children}
</Context.Provider>
</Pressable>
</PressableComponent>
)
},
)
@@ -627,9 +630,9 @@ export function useSharedButtonTextStyles() {
}
if (size === 'large') {
baseStyles.push(a.text_md, a.leading_tight, web({top: -0.4}))
baseStyles.push(a.text_md, a.leading_tight)
} else if (size === 'small') {
baseStyles.push(a.text_sm, a.leading_tight, web({top: -0.4}))
baseStyles.push(a.text_sm, a.leading_tight)
} else if (size === 'tiny') {
baseStyles.push(a.text_xs, a.leading_tight)
}
+5
View File
@@ -6,9 +6,14 @@ import {
DialogControlRefProps,
DialogOuterProps,
} from '#/components/Dialog/types'
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
export const Context = React.createContext<DialogContextProps>({
close: () => {},
isNativeDialog: false,
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
disableDrag: false,
setDisableDrag: () => {},
})
export function useDialogContext() {
+172 -206
View File
@@ -1,84 +1,48 @@
import React, {useImperativeHandle} from 'react'
import {
Dimensions,
Keyboard,
NativeScrollEvent,
NativeSyntheticEvent,
Pressable,
ScrollView,
StyleProp,
TextInput,
View,
ViewStyle,
} from 'react-native'
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {
KeyboardAwareScrollView,
useKeyboardHandler,
} from 'react-native-keyboard-controller'
import {runOnJS} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import BottomSheet, {
BottomSheetBackdropProps,
BottomSheetFlatList,
BottomSheetFlatListMethods,
BottomSheetTextInput,
BottomSheetView,
useBottomSheet,
WINDOW_HEIGHT,
} from '@discord/bottom-sheet/src'
import {BottomSheetFlatListProps} from '@discord/bottom-sheet/src/components/bottomSheetScrollable/types'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs'
import {atoms as a, flatten, useTheme} from '#/alf'
import {Context} from '#/components/Dialog/context'
import {List, ListMethods, ListProps} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {Context, useDialogContext} from '#/components/Dialog/context'
import {
DialogControlProps,
DialogInnerProps,
DialogOuterProps,
} from '#/components/Dialog/types'
import {createInput} from '#/components/forms/TextField'
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
import {Portal} from '#/components/Portal'
import {Portal as DefaultPortal} from '#/components/Portal'
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
import {
BottomSheetSnapPointChangeEvent,
BottomSheetStateChangeEvent,
} from '../../../modules/bottom-sheet/src/BottomSheet.types'
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
export * from '#/components/Dialog/utils'
// @ts-ignore
export const Input = createInput(BottomSheetTextInput)
function Backdrop(props: BottomSheetBackdropProps) {
const t = useTheme()
const bottomSheet = useBottomSheet()
const animatedStyle = useAnimatedStyle(() => {
const opacity =
(Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000
return {
opacity: Math.min(Math.max(opacity, 0), 0.55),
}
})
const onPress = React.useCallback(() => {
bottomSheet.close()
}, [bottomSheet])
return (
<Animated.View
style={[
t.atoms.bg_contrast_300,
{
top: 0,
left: 0,
right: 0,
bottom: 0,
position: 'absolute',
},
animatedStyle,
]}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Dialog backdrop"
accessibilityHint="Press the backdrop to close the dialog"
style={{flex: 1}}
onPress={onPress}
/>
</Animated.View>
)
}
export const Input = createInput(TextInput)
export function Outer({
children,
@@ -86,24 +50,22 @@ export function Outer({
onClose,
nativeOptions,
testID,
Portal = DefaultPortal,
}: React.PropsWithChildren<DialogOuterProps>) {
const t = useTheme()
const sheet = React.useRef<BottomSheet>(null)
const sheetOptions = nativeOptions?.sheet || {}
const hasSnapPoints = !!sheetOptions.snapPoints
const insets = useSafeAreaInsets()
const ref = React.useRef<BottomSheet>(null)
const closeCallbacks = React.useRef<(() => void)[]>([])
const {setDialogIsOpen} = useDialogStateControlContext()
const {setDialogIsOpen, setFullyExpandedCount} =
useDialogStateControlContext()
/*
* Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
*/
const [openIndex, setOpenIndex] = React.useState(-1)
const prevSnapPoint = React.useRef<BottomSheetSnapPoint>(
BottomSheetSnapPoint.Hidden,
)
/*
* `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open.
*/
const isOpen = openIndex > -1
const [disableDrag, setDisableDrag] = React.useState(false)
const [snapPoint, setSnapPoint] = React.useState<BottomSheetSnapPoint>(
BottomSheetSnapPoint.Partial,
)
const callQueuedCallbacks = React.useCallback(() => {
for (const cb of closeCallbacks.current) {
@@ -117,25 +79,19 @@ export function Outer({
closeCallbacks.current = []
}, [])
const open = React.useCallback<DialogControlProps['open']>(
({index} = {}) => {
// Run any leftover callbacks that might have been queued up before calling `.open()`
callQueuedCallbacks()
setDialogIsOpen(control.id, true)
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
setOpenIndex(index || 0)
sheet.current?.snapToIndex(index || 0)
},
[setDialogIsOpen, control.id, callQueuedCallbacks],
)
const open = React.useCallback<DialogControlProps['open']>(() => {
// Run any leftover callbacks that might have been queued up before calling `.open()`
callQueuedCallbacks()
setDialogIsOpen(control.id, true)
ref.current?.present()
}, [setDialogIsOpen, control.id, callQueuedCallbacks])
// This is the function that we call when we want to dismiss the dialog.
const close = React.useCallback<DialogControlProps['close']>(cb => {
if (typeof cb === 'function') {
closeCallbacks.current.push(cb)
}
sheet.current?.close()
ref.current?.dismiss()
}, [])
// This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to
@@ -144,12 +100,39 @@ export function Outer({
// This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this
// tells us that we need to toggle the accessibility overlay setting
setDialogIsOpen(control.id, false)
setOpenIndex(-1)
callQueuedCallbacks()
onClose?.()
}, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen])
const onSnapPointChange = (e: BottomSheetSnapPointChangeEvent) => {
const {snapPoint} = e.nativeEvent
setSnapPoint(snapPoint)
if (
snapPoint === BottomSheetSnapPoint.Full &&
prevSnapPoint.current !== BottomSheetSnapPoint.Full
) {
setFullyExpandedCount(c => c + 1)
} else if (
snapPoint !== BottomSheetSnapPoint.Full &&
prevSnapPoint.current === BottomSheetSnapPoint.Full
) {
setFullyExpandedCount(c => c - 1)
}
prevSnapPoint.current = snapPoint
}
const onStateChange = (e: BottomSheetStateChangeEvent) => {
if (e.nativeEvent.state === 'closed') {
onCloseAnimationComplete()
if (prevSnapPoint.current === BottomSheetSnapPoint.Full) {
setFullyExpandedCount(c => c - 1)
}
prevSnapPoint.current = BottomSheetSnapPoint.Hidden
}
}
useImperativeHandle(
control.ref,
() => ({
@@ -159,161 +142,144 @@ export function Outer({
[open, close],
)
React.useEffect(() => {
return () => {
setDialogIsOpen(control.id, false)
}
}, [control.id, setDialogIsOpen])
const context = React.useMemo(() => ({close}), [close])
const context = React.useMemo(
() => ({
close,
isNativeDialog: true,
nativeSnapPoint: snapPoint,
disableDrag,
setDisableDrag,
}),
[close, snapPoint, disableDrag, setDisableDrag],
)
return (
isOpen && (
<Portal>
<FullWindowOverlay>
<View
// iOS
accessibilityViewIsModal
// Android
importantForAccessibility="yes"
style={[a.absolute, a.inset_0]}
testID={testID}
onTouchMove={() => Keyboard.dismiss()}>
<BottomSheet
enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose
keyboardBehavior="interactive"
android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore"
topInset={insets.top}
{...sheetOptions}
snapPoints={sheetOptions.snapPoints || ['100%']}
ref={sheet}
index={openIndex}
backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={Backdrop}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onClose={onCloseAnimationComplete}>
<Context.Provider value={context}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
},
]}
/>
{children}
</Context.Provider>
</BottomSheet>
</View>
</FullWindowOverlay>
</Portal>
)
<Portal>
<Context.Provider value={context}>
<BottomSheet
ref={ref}
cornerRadius={20}
backgroundColor={t.atoms.bg.backgroundColor}
{...nativeOptions}
onSnapPointChange={onSnapPointChange}
onStateChange={onStateChange}
disableDrag={disableDrag}>
<View testID={testID}>{children}</View>
</BottomSheet>
</Context.Provider>
</Portal>
)
}
export function Inner({children, style}: DialogInnerProps) {
const insets = useSafeAreaInsets()
return (
<BottomSheetView
<View
style={[
a.py_xl,
a.pt_2xl,
a.px_xl,
{
paddingTop: 40,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
paddingBottom: insets.bottom + insets.top,
},
flatten(style),
style,
]}>
{children}
</BottomSheetView>
</View>
)
}
export const ScrollableInner = Inner
export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
function ScrollableInner({children, style, ...props}, ref) {
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const insets = useSafeAreaInsets()
const [keyboardHeight, setKeyboardHeight] = React.useState(0)
useKeyboardHandler({
onEnd: e => {
'worklet'
runOnJS(setKeyboardHeight)(e.height)
},
})
// export const ScrollableInner = React.forwardRef<
// BottomSheetScrollViewMethods,
// DialogInnerProps
// >(function ScrollableInner({children, style}, ref) {
// const insets = useSafeAreaInsets()
// return (
// <BottomSheetScrollView
// keyboardShouldPersistTaps="handled"
// style={[
// a.flex_1, // main diff is this
// a.p_xl,
// a.h_full,
// {
// paddingTop: 40,
// borderTopLeftRadius: 40,
// borderTopRightRadius: 40,
// },
// style,
// ]}
// contentContainerStyle={a.pb_4xl}
// ref={ref}>
// {children}
// <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
// </BottomSheetScrollView>
// )
// })
const basePading =
(isIOS ? 30 : 50) + (isIOS ? keyboardHeight / 4 : keyboardHeight)
const fullPaddingBase = insets.bottom + insets.top + basePading
const fullPadding = isIOS ? fullPaddingBase : fullPaddingBase + 50
const paddingBottom =
nativeSnapPoint === BottomSheetSnapPoint.Full ? fullPadding : basePading
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const {contentOffset} = e.nativeEvent
if (contentOffset.y > 0 && !disableDrag) {
setDisableDrag(true)
} else if (contentOffset.y <= 1 && disableDrag) {
setDisableDrag(false)
}
}
return (
<KeyboardAwareScrollView
style={[style]}
contentContainerStyle={[a.pt_2xl, a.px_xl, {paddingBottom}]}
ref={ref}
{...props}
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
bottomOffset={30}
scrollEventThrottle={50}
onScroll={isAndroid ? onScroll : undefined}>
{children}
</KeyboardAwareScrollView>
)
},
)
export const InnerFlatList = React.forwardRef<
BottomSheetFlatListMethods,
BottomSheetFlatListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
>(function InnerFlatList({style, contentContainerStyle, ...props}, ref) {
ListMethods,
ListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
>(function InnerFlatList({style, ...props}, ref) {
const insets = useSafeAreaInsets()
const {nativeSnapPoint} = useDialogContext()
return (
<BottomSheetFlatList
<List
keyboardShouldPersistTaps="handled"
contentContainerStyle={[a.pb_4xl, flatten(contentContainerStyle)]}
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
ListFooterComponent={
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
}
ref={ref}
{...props}
style={[
a.flex_1,
a.p_xl,
a.pt_0,
a.h_full,
{
marginTop: 40,
},
flatten(style),
]}
style={[style]}
/>
)
})
export function Handle() {
const t = useTheme()
const {_} = useLingui()
const {screenReaderEnabled} = useA11y()
const {close} = useDialogContext()
return (
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
<View
style={[
a.rounded_sm,
{
top: a.pt_lg.paddingTop,
width: 35,
height: 4,
alignSelf: 'center',
backgroundColor: t.palette.contrast_900,
opacity: 0.5,
},
]}
/>
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 20}]}>
<Pressable
accessible={screenReaderEnabled}
onPress={() => close()}
accessibilityLabel={_(msg`Dismiss`)}
accessibilityHint={_(msg`Double tap to close the dialog`)}>
<View
style={[
a.rounded_sm,
{
top: 10,
width: 35,
height: 5,
alignSelf: 'center',
backgroundColor: t.palette.contrast_975,
opacity: 0.5,
},
]}
/>
</Pressable>
</View>
)
}
+8 -4
View File
@@ -103,6 +103,10 @@ export function Outer({
const context = React.useMemo(
() => ({
close,
isNativeDialog: false,
nativeSnapPoint: 0,
disableDrag: false,
setDisableDrag: () => {},
}),
[close],
)
@@ -229,10 +233,6 @@ export const InnerFlatList = React.forwardRef<
)
})
export function Handle() {
return null
}
export function Close() {
const {_} = useLingui()
const {close} = React.useContext(Context)
@@ -258,3 +258,7 @@ export function Close() {
</View>
)
}
export function Handle() {
return null
}
+20
View File
@@ -0,0 +1,20 @@
import {useCallback} from 'react'
import {useDialogStateControlContext} from '#/state/dialogs'
/**
* If we're calling a system API like the image picker that opens a sheet
* wrap it in this function to make sure the status bar is the correct color.
*/
export function useSheetWrapper() {
const {setFullyExpandedCount} = useDialogStateControlContext()
return useCallback(
async <T>(promise: Promise<T>): Promise<T> => {
setFullyExpandedCount(c => c + 1)
const res = await promise
setFullyExpandedCount(c => c - 1)
return res
},
[setFullyExpandedCount],
)
}
+9 -4
View File
@@ -4,9 +4,11 @@ import type {
GestureResponderEvent,
ScrollViewProps,
} from 'react-native'
import {BottomSheetProps} from '@discord/bottom-sheet/src'
import {ViewStyleProp} from '#/alf'
import {PortalComponent} from '#/components/Portal'
import {BottomSheetViewProps} from '../../../modules/bottom-sheet'
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
type A11yProps = Required<AccessibilityProps>
@@ -37,6 +39,10 @@ export type DialogControlProps = DialogControlRefProps & {
export type DialogContextProps = {
close: DialogControlProps['close']
isNativeDialog: boolean
nativeSnapPoint: BottomSheetSnapPoint
disableDrag: boolean
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
}
export type DialogControlOpenOptions = {
@@ -52,11 +58,10 @@ export type DialogControlOpenOptions = {
export type DialogOuterProps = {
control: DialogControlProps
onClose?: () => void
nativeOptions?: {
sheet?: Omit<BottomSheetProps, 'children'>
}
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
webOptions?: {}
testID?: string
Portal?: PortalComponent
}
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
@@ -1,31 +0,0 @@
import React from 'react'
import {useKeyboardHandler} from 'react-native-keyboard-controller'
import Animated, {
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
export function KeyboardControllerPadding({maxHeight}: {maxHeight?: number}) {
const keyboardHeight = useSharedValue(0)
useKeyboardHandler(
{
onMove: e => {
'worklet'
if (maxHeight && e.height > maxHeight) {
keyboardHeight.value = maxHeight
} else {
keyboardHeight.value = e.height
}
},
},
[maxHeight],
)
const animatedStyle = useAnimatedStyle(() => ({
height: keyboardHeight.value,
}))
return <Animated.View style={animatedStyle} />
}
@@ -1,7 +0,0 @@
export function KeyboardControllerPadding({
maxHeight: _,
}: {
maxHeight?: number
}) {
return null
}
+8 -10
View File
@@ -1,20 +1,19 @@
import React, {useMemo, useCallback} from 'react'
import React, {useCallback, useMemo} from 'react'
import {ActivityIndicator, FlatList, View} from 'react-native'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import * as Dialog from '#/components/Dialog'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
interface LikesDialogProps {
control: Dialog.DialogOuterProps['control']
@@ -25,7 +24,6 @@ export function LikesDialog(props: LikesDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<LikesDialogInner {...props} />
</Dialog.Outer>
)
+3 -3
View File
@@ -103,17 +103,17 @@ export function useLink({
linkRequiresWarning(href, displayText),
)
if (requiresWarning) {
if (isWeb) {
e.preventDefault()
}
if (requiresWarning) {
openModal({
name: 'link-warning',
text: displayText,
href: href,
})
} else {
e.preventDefault()
if (isExternal) {
openLink(href)
} else {
+11 -10
View File
@@ -4,7 +4,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import flattenReactChildren from 'react-keyed-flatten-children'
import {isNative} from 'platform/detection'
import {isNative} from '#/platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -82,19 +82,21 @@ export function Outer({
style?: StyleProp<ViewStyle>
}>) {
const context = React.useContext(Context)
const {_} = useLingui()
return (
<Dialog.Outer control={context.control}>
<Dialog.Outer
control={context.control}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
{/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}>
<Dialog.ScrollableInner label="Menu TODO">
<Dialog.ScrollableInner label={_(msg`Menu`)} style={[a.pt_sm]}>
<View style={[a.gap_lg]}>
{children}
{isNative && showCancel && <Cancel />}
<View style={[{height: a.pb_lg.paddingBottom}]} />
</View>
<View style={{height: a.gap_lg.gap}} />
</Dialog.ScrollableInner>
</Context.Provider>
</Dialog.Outer>
@@ -116,15 +118,14 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
{...rest}
accessibilityHint=""
accessibilityLabel={label}
onPress={e => {
onPress(e)
onFocus={onFocus}
onBlur={onBlur}
onPress={async e => {
await onPress(e)
if (!e.defaultPrevented) {
control?.close()
}
}}
onFocus={onFocus}
onBlur={onBlur}
onPressIn={e => {
onPressIn()
rest.onPressIn?.(e)
+3 -3
View File
@@ -5,12 +5,12 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns'
import {HITSLOP_10} from '#/lib/constants'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isNative} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {HITSLOP_10} from 'lib/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {useSession} from 'state/session'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
+2
View File
@@ -12,6 +12,8 @@ type ComponentMap = {
[id: string]: Component
}
export type PortalComponent = ({children}: {children?: React.ReactNode}) => null
export function createPortalGroup() {
const Context = React.createContext<ContextType>({
outlet: null,
+11 -7
View File
@@ -4,8 +4,9 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
import {Button, ButtonColor, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography'
export {
@@ -25,9 +26,11 @@ export function Outer({
children,
control,
testID,
Portal,
}: React.PropsWithChildren<{
control: Dialog.DialogControlProps
testID?: string
Portal?: PortalComponent
}>) {
const {gtMobile} = useBreakpoints()
const titleId = React.useId()
@@ -39,10 +42,9 @@ export function Outer({
)
return (
<Dialog.Outer control={control} testID={testID}>
<Dialog.Outer control={control} testID={testID} Portal={Portal}>
<Dialog.Handle />
<Context.Provider value={context}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId}
@@ -141,7 +143,7 @@ export function Action({
* Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method.
*/
onPress: ButtonProps['onPress']
onPress: (e: GestureResponderEvent) => void
color?: ButtonColor
/**
* Optional i18n string. If undefined, it will default to "Confirm".
@@ -181,6 +183,7 @@ export function Basic({
onConfirm,
confirmButtonColor,
showCancel = true,
Portal,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
title: string
@@ -194,12 +197,13 @@ export function Basic({
* Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method.
*/
onConfirm: ButtonProps['onPress']
onConfirm: (e: GestureResponderEvent) => void
confirmButtonColor?: ButtonColor
showCancel?: boolean
Portal?: PortalComponent
}>) {
return (
<Outer control={control} testID="confirmModal">
<Outer control={control} testID="confirmModal" Portal={Portal}>
<TitleText>{title}</TitleText>
<DescriptionText>{description}</DescriptionText>
<Actions>
@@ -4,7 +4,6 @@ import {AppBskyLabelerDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, useButtonContext} from '#/components/Button'
@@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {ReportOption} from '#/lib/moderation/useReportOptions'
import {isAndroid} from '#/platform/detection'
import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast'
@@ -225,6 +226,8 @@ export function SubmitView({
{submitting && <ButtonIcon icon={Loader} />}
</Button>
</View>
{/* Maybe fix this later -h */}
{isAndroid ? <View style={{height: 300}} /> : null}
</View>
)
}
+2 -7
View File
@@ -1,5 +1,6 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -8,12 +9,10 @@ import {useMyLabelersQuery} from '#/state/queries/preferences'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {AppBskyLabelerDefs} from '@atproto/api'
import {BottomSheetScrollViewMethods} from '@discord/bottom-sheet/src'
import {atoms as a} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
import {useOnKeyboardDidShow} from '#/components/hooks/useOnKeyboard'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {SelectLabelerView} from './SelectLabelerView'
@@ -25,7 +24,6 @@ export function ReportDialog(props: ReportDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<ReportDialogInner {...props} />
</Dialog.Outer>
)
@@ -40,10 +38,7 @@ function ReportDialogInner(props: ReportDialogProps) {
} = useMyLabelersQuery()
const isLoading = useDelayedLoading(500, isLabelerLoading)
const ref = React.useRef<BottomSheetScrollViewMethods>(null)
useOnKeyboardDidShow(() => {
ref.current?.scrollToEnd({animated: true})
})
const ref = React.useRef<ScrollView>(null)
return (
<Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}>
@@ -149,7 +149,6 @@ export function QrCodeDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Create a QR code for a starter pack`)}>
<View style={[a.flex_1, a.align_center, a.gap_5xl]}>
+8 -8
View File
@@ -6,14 +6,14 @@ import {AppBskyGraphDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {saveImageToMediaLibrary} from '#/lib/media/manip'
import {shareUrl} from '#/lib/sharing'
import {logEvent} from '#/lib/statsig/statsig'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {saveImageToMediaLibrary} from 'lib/media/manip'
import {shareUrl} from 'lib/sharing'
import {logEvent} from 'lib/statsig/statsig'
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
import {isNative, isWeb} from 'platform/detection'
import * as Toast from 'view/com/util/Toast'
import {isNative, isWeb} from '#/platform/detection'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {DialogControlProps} from '#/components/Dialog'
@@ -32,6 +32,7 @@ interface Props {
export function ShareDialog(props: Props) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<ShareDialogInner {...props} />
</Dialog.Outer>
)
@@ -84,7 +85,6 @@ function ShareDialogInner({
return (
<>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Share link dialog`)}>
{!imageLoaded || !link ? (
<View style={[a.p_xl, a.align_center]}>
@@ -3,13 +3,13 @@ import type {ListRenderItemInfo} from 'react-native'
import {View} from 'react-native'
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {isWeb} from '#/platform/detection'
import {useSession} from '#/state/session'
import {ListMethods} from '#/view/com/util/List'
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
@@ -45,7 +45,7 @@ export function WizardEditListDialog({
const {currentAccount} = useSession()
const initialNumToRender = useInitialNumToRender()
const listRef = useRef<BottomSheetFlatListMethods>(null)
const listRef = useRef<ListMethods>(null)
const getData = () => {
if (state.currentStep === 'Feeds') return state.feeds
@@ -76,10 +76,7 @@ export function WizardEditListDialog({
)
return (
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['95%']}}}>
<Dialog.Outer control={control} testID="newChatDialog">
<Dialog.Handle />
<Dialog.InnerFlatList
ref={listRef}
@@ -89,6 +86,7 @@ export function WizardEditListDialog({
ListHeaderComponent={
<View
style={[
native(a.pt_4xl),
a.flex_row,
a.justify_between,
a.border_b,
@@ -103,13 +101,7 @@ export function WizardEditListDialog({
height: 48,
},
]
: [
a.pb_sm,
a.align_end,
{
height: 68,
},
],
: [a.pb_sm, a.align_end],
]}>
<View style={{width: 60}} />
<Text style={[a.font_bold, a.text_xl]}>
@@ -143,8 +135,6 @@ export function WizardEditListDialog({
paddingHorizontal: 0,
marginTop: 0,
paddingTop: 0,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
}),
]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
-1
View File
@@ -85,7 +85,6 @@ export function TagMenu({
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
{isPreferencesLoading ? (
<View style={[a.w_full, a.align_center]}>
+19 -12
View File
@@ -53,11 +53,14 @@ export function childIsString(
)
}
export function renderChildrenWithEmoji(children: StringChild) {
export function renderChildrenWithEmoji(
children: StringChild,
props: Omit<TextProps, 'children'> = {},
) {
const normalized = Array.isArray(children) ? children : [children]
return (
<UITextView>
<UITextView {...props}>
{normalized.map(child => {
if (typeof child !== 'string') return child
@@ -68,10 +71,12 @@ export function renderChildrenWithEmoji(children: StringChild) {
}
return child.split(EMOJI).map((stringPart, index) => (
<UITextView key={index}>
<UITextView key={index} {...props}>
{stringPart}
{emojis[index] ? (
<UITextView style={{color: 'black', fontFamily: 'System'}}>
<UITextView
{...props}
style={[props?.style, {color: 'black', fontFamily: 'System'}]}>
{emojis[index]}
</UITextView>
) : null}
@@ -163,15 +168,17 @@ export function Text({
}
}
const shared = {
uiTextView: true,
selectable,
style: s,
dataSet: Object.assign({tooltip: title}, dataSet || {}),
...rest,
}
return (
<UITextView
selectable={selectable}
uiTextView
style={s}
{...rest}
// @ts-ignore
dataSet={Object.assign({tooltip: title}, dataSet || {})}>
{isIOS && emoji ? renderChildrenWithEmoji(children) : children}
<UITextView {...shared}>
{isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children}
</UITextView>
)
}
@@ -31,7 +31,6 @@ export function BirthDateSettingsDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`My Birthday`)}>
<View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_2xl, a.font_bold]}>
-1
View File
@@ -50,7 +50,6 @@ export function EmbedConsentDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`External Media`)}
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
-255
View File
@@ -1,255 +0,0 @@
import React, {
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {Modal, ScrollView, TextInput, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {
Gif,
useFeaturedGifsQuery,
useGifSearchQuery,
} from '#/state/queries/tenor'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {FlatList_INTERNAL} from '#/view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Button, ButtonText} from '../Button'
import {Handle} from '../Dialog'
import {useThrottledValue} from '../hooks/useThrottledValue'
import {ListFooter, ListMaybePlaceholder} from '../Lists'
import {GifPreview} from './GifSelect.shared'
export function GifSelectDialog({
controlRef,
onClose,
onSelectGif: onSelectGifProp,
}: {
controlRef: React.RefObject<{open: () => void}>
onClose: () => void
onSelectGif: (gif: Gif) => void
}) {
const t = useTheme()
const [open, setOpen] = useState(false)
useImperativeHandle(controlRef, () => ({
open: () => setOpen(true),
}))
const close = useCallback(() => {
setOpen(false)
onClose()
}, [onClose])
const onSelectGif = useCallback(
(gif: Gif) => {
onSelectGifProp(gif)
close()
},
[onSelectGifProp, close],
)
const renderErrorBoundary = useCallback(
(error: any) => <ModalError details={String(error)} close={close} />,
[close],
)
return (
<Modal
visible={open}
animationType="slide"
presentationStyle="formSheet"
onRequestClose={close}
aria-modal
accessibilityViewIsModal>
<View style={[a.flex_1, t.atoms.bg]}>
<Handle />
<ErrorBoundary renderError={renderErrorBoundary}>
<GifList onSelectGif={onSelectGif} close={close} />
</ErrorBoundary>
</View>
</Modal>
)
}
function GifList({
onSelectGif,
}: {
close: () => void
onSelectGif: (gif: Gif) => void
}) {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const textInputRef = useRef<TextInput>(null)
const listRef = useRef<FlatList_INTERNAL>(null)
const [undeferredSearch, setSearch] = useState('')
const search = useThrottledValue(undeferredSearch, 500)
const isSearching = search.length > 0
const trendingQuery = useFeaturedGifsQuery()
const searchQuery = useGifSearchQuery(search)
const {
data,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
error,
isLoading,
isError,
refetch,
} = isSearching ? searchQuery : trendingQuery
const flattenedData = useMemo(() => {
return data?.pages.flatMap(page => page.results) || []
}, [data])
const renderItem = useCallback(
({item}: {item: Gif}) => {
return <GifPreview gif={item} onSelectGif={onSelectGif} />
},
[onSelectGif],
)
const onEndReached = React.useCallback(() => {
if (isFetchingNextPage || !hasNextPage || error) return
fetchNextPage()
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
const hasData = flattenedData.length > 0
const onGoBack = useCallback(() => {
if (isSearching) {
// clear the input and reset the state
textInputRef.current?.clear()
setSearch('')
} else {
close()
}
}, [isSearching])
const listHeader = useMemo(() => {
return (
<View style={[a.relative, a.mb_lg, a.pt_4xl, a.flex_row, a.align_center]}>
{/* cover top corners */}
<View
style={[
a.absolute,
a.inset_0,
{
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
},
t.atoms.bg,
]}
/>
<TextField.Root>
<TextField.Icon icon={Search} />
<TextField.Input
label={_(msg`Search GIFs`)}
placeholder={_(msg`Search Tenor`)}
onChangeText={text => {
setSearch(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
returnKeyType="search"
clearButtonMode="while-editing"
inputRef={textInputRef}
maxLength={50}
/>
</TextField.Root>
</View>
)
}, [t.atoms.bg, _])
return (
<FlatList_INTERNAL
ref={listRef}
key={gtMobile ? '3 cols' : '2 cols'}
data={flattenedData}
renderItem={renderItem}
numColumns={gtMobile ? 3 : 2}
columnWrapperStyle={a.gap_sm}
contentContainerStyle={a.px_lg}
ListHeaderComponent={
<>
{listHeader}
{!hasData && (
<ListMaybePlaceholder
isLoading={isLoading}
isError={isError}
onRetry={refetch}
onGoBack={onGoBack}
emptyType="results"
sideBorders={false}
topBorder={false}
errorTitle={_(msg`Failed to load GIFs`)}
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
emptyMessage={
isSearching
? _(msg`No search results found for "${search}".`)
: _(
msg`No featured GIFs found. There may be an issue with Tenor.`,
)
}
/>
)}
</>
}
stickyHeaderIndices={[0]}
onEndReached={onEndReached}
onEndReachedThreshold={4}
keyExtractor={(item: Gif) => item.id}
keyboardDismissMode="on-drag"
ListFooterComponent={
hasData ? (
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
style={{borderTopWidth: 0}}
/>
) : null
}
/>
)
}
function ModalError({details, close}: {details?: string; close: () => void}) {
const {_} = useLingui()
return (
<ScrollView
style={[a.flex_1, a.gap_md]}
centerContent
contentContainerStyle={a.px_lg}>
<ErrorScreen
title={_(msg`Oh no!`)}
message={_(
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
)}
details={details}
/>
<Button
label={_(msg`Close dialog`)}
onPress={close}
color="primary"
size="large"
variant="solid">
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</ScrollView>
)
}
@@ -1,53 +0,0 @@
import React, {useCallback} from 'react'
import {Image} from 'expo-image'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {Gif} from '#/state/queries/tenor'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button} from '../Button'
export function GifPreview({
gif,
onSelectGif,
}: {
gif: Gif
onSelectGif: (gif: Gif) => void
}) {
const {gtTablet} = useBreakpoints()
const {_} = useLingui()
const t = useTheme()
const onPress = useCallback(() => {
logEvent('composer:gif:select', {})
onSelectGif(gif)
}, [onSelectGif, gif])
return (
<Button
label={_(msg`Select GIF "${gif.title}"`)}
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
onPress={onPress}>
{({pressed}) => (
<Image
style={[
a.flex_1,
a.mb_sm,
a.rounded_sm,
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
t.atoms.bg_contrast_25,
]}
source={{
uri: gif.media_formats.tinygif.url,
}}
contentFit="cover"
accessibilityLabel={gif.title}
accessibilityHint=""
cachePolicy="none"
accessibilityIgnoresInvertColors
/>
)}
</Button>
)
}
+66 -9
View File
@@ -6,10 +6,12 @@ import React, {
useState,
} from 'react'
import {TextInput, View} from 'react-native'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {useWindowDimensions} from 'react-native'
import {Image} from 'expo-image'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {isWeb} from '#/platform/detection'
import {
@@ -19,7 +21,8 @@ import {
} from '#/state/queries/tenor'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {ListMethods} from '#/view/com/util/List'
import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
@@ -27,16 +30,18 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arr
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Button, ButtonIcon, ButtonText} from '../Button'
import {ListFooter, ListMaybePlaceholder} from '../Lists'
import {GifPreview} from './GifSelect.shared'
import {PortalComponent} from '../Portal'
export function GifSelectDialog({
controlRef,
onClose,
onSelectGif: onSelectGifProp,
Portal,
}: {
controlRef: React.RefObject<{open: () => void}>
onClose: () => void
onSelectGif: (gif: Gif) => void
Portal?: PortalComponent
}) {
const control = Dialog.useDialogControl()
@@ -59,8 +64,13 @@ export function GifSelectDialog({
return (
<Dialog.Outer
control={control}
nativeOptions={{sheet: {snapPoints: ['100%']}}}
onClose={onClose}>
onClose={onClose}
Portal={Portal}
nativeOptions={{
bottomInset: 0,
// use system corner radius on iOS
...ios({cornerRadius: undefined}),
}}>
<Dialog.Handle />
<ErrorBoundary renderError={renderErrorBoundary}>
<GifList control={control} onSelectGif={onSelectGif} />
@@ -80,9 +90,10 @@ function GifList({
const t = useTheme()
const {gtMobile} = useBreakpoints()
const textInputRef = useRef<TextInput>(null)
const listRef = useRef<BottomSheetFlatListMethods>(null)
const listRef = useRef<ListMethods>(null)
const [undeferredSearch, setSearch] = useState('')
const search = useThrottledValue(undeferredSearch, 500)
const {height} = useWindowDimensions()
const isSearching = search.length > 0
@@ -95,7 +106,7 @@ function GifList({
isFetchingNextPage,
hasNextPage,
error,
isLoading,
isPending,
isError,
refetch,
} = isSearching ? searchQuery : trendingQuery
@@ -132,6 +143,7 @@ function GifList({
return (
<View
style={[
native(a.pt_4xl),
a.relative,
a.mb_lg,
a.flex_row,
@@ -196,13 +208,14 @@ function GifList({
data={flattenedData}
renderItem={renderItem}
numColumns={gtMobile ? 3 : 2}
columnWrapperStyle={a.gap_sm}
columnWrapperStyle={[a.gap_sm]}
contentContainerStyle={[native([a.px_xl, {minHeight: height}])]}
ListHeaderComponent={
<>
{listHeader}
{!hasData && (
<ListMaybePlaceholder
isLoading={isLoading}
isLoading={isPending}
isError={isError}
onRetry={refetch}
onGoBack={onGoBack}
@@ -273,3 +286,47 @@ function DialogError({details}: {details?: string}) {
</Dialog.ScrollableInner>
)
}
export function GifPreview({
gif,
onSelectGif,
}: {
gif: Gif
onSelectGif: (gif: Gif) => void
}) {
const {gtTablet} = useBreakpoints()
const {_} = useLingui()
const t = useTheme()
const onPress = useCallback(() => {
logEvent('composer:gif:select', {})
onSelectGif(gif)
}, [onSelectGif, gif])
return (
<Button
label={_(msg`Select GIF "${gif.title}"`)}
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
onPress={onPress}>
{({pressed}) => (
<Image
style={[
a.flex_1,
a.mb_sm,
a.rounded_sm,
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
t.atoms.bg_contrast_25,
]}
source={{
uri: gif.media_formats.tinygif.url,
}}
contentFit="cover"
accessibilityLabel={gif.title}
accessibilityHint=""
cachePolicy="none"
accessibilityIgnoresInvertColors
/>
)}
</Button>
)
}
+333 -287
View File
@@ -30,11 +30,14 @@ import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/P
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import {createPortalGroup} from '#/components/Portal'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
const ONE_DAY = 24 * 60 * 60 * 1000
const Portal = createPortalGroup()
export function MutedWordsDialog() {
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
return (
@@ -105,307 +108,349 @@ function MutedWordsInner() {
}, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
return (
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
<View>
<Text
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
<Trans>Add muted words and tags</Trans>
</Text>
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Posts can be muted based on their text, their tags, or both. We
recommend avoiding common words that appear in many posts, since it
can result in no posts being shown.
</Trans>
</Text>
<View style={[a.pb_sm]}>
<Dialog.Input
autoCorrect={false}
autoCapitalize="none"
autoComplete="off"
label={_(msg`Enter a word or tag`)}
placeholder={_(msg`Enter a word or tag`)}
value={field}
onChangeText={value => {
if (error) {
setError('')
}
setField(value)
}}
onSubmitEditing={submit}
/>
</View>
<View style={[a.pb_xl, a.gap_sm]}>
<Toggle.Group
label={_(msg`Select how long to mute this word for.`)}
type="radio"
values={durations}
onChange={setDurations}>
<Text
style={[
a.pb_xs,
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Duration:</Trans>
</Text>
<View
style={[
gtMobile && [a.flex_row, a.align_center, a.justify_start],
a.gap_sm,
]}>
<View
style={[
a.flex_1,
a.flex_row,
a.justify_start,
a.align_center,
a.gap_sm,
]}>
<Toggle.Item
label={_(msg`Mute this word until you unmute it`)}
name="forever"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Forever</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word for 24 hours`)}
name="24_hours"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>24 hours</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
</View>
<View
style={[
a.flex_1,
a.flex_row,
a.justify_start,
a.align_center,
a.gap_sm,
]}>
<Toggle.Item
label={_(msg`Mute this word for 7 days`)}
name="7_days"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>7 days</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word for 30 days`)}
name="30_days"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>30 days</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
</View>
</View>
</Toggle.Group>
<Toggle.Group
label={_(msg`Select what content this mute word should apply to.`)}
type="radio"
values={targets}
onChange={setTargets}>
<Text
style={[
a.pb_xs,
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Mute in:</Trans>
</Text>
<View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
<Toggle.Item
label={_(msg`Mute this word in post text and tags`)}
name="content"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Text & tags</Trans>
</Toggle.LabelText>
</View>
<PageText size="sm" />
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word in tags only`)}
name="tag"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Tags only</Trans>
</Toggle.LabelText>
</View>
<Hashtag size="sm" />
</TargetToggle>
</Toggle.Item>
</View>
</Toggle.Group>
<View>
<Text
style={[
a.pb_xs,
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Options:</Trans>
</Text>
<Toggle.Item
label={_(msg`Do not apply this mute word to users you follow`)}
name="exclude_following"
style={[a.flex_row, a.justify_between]}
value={excludeFollowing}
onChange={setExcludeFollowing}>
<TargetToggle>
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Checkbox />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Exclude users you follow</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
</View>
<View style={[a.pt_xs]}>
<Button
disabled={isPending || !field}
label={_(msg`Add mute word for configured settings`)}
size="large"
color="primary"
variant="solid"
style={[]}
onPress={submit}>
<ButtonText>
<Trans>Add</Trans>
</ButtonText>
<ButtonIcon icon={isPending ? Loader : Plus} position="right" />
</Button>
</View>
{error && (
<View
style={[
a.mb_lg,
a.flex_row,
a.rounded_sm,
a.p_md,
a.mb_xs,
t.atoms.bg_contrast_25,
{
backgroundColor: t.palette.negative_400,
},
]}>
<Text
style={[
a.italic,
{color: t.palette.white},
native({marginTop: 2}),
]}>
{error}
</Text>
</View>
)}
</View>
<Divider />
<View style={[a.pt_2xl]}>
<Portal.Provider>
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
<View>
<Text
style={[
a.text_md,
a.font_bold,
a.pb_md,
a.pb_sm,
t.atoms.text_contrast_high,
]}>
<Trans>Your muted words</Trans>
<Trans>Add muted words and tags</Trans>
</Text>
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Posts can be muted based on their text, their tags, or both. We
recommend avoiding common words that appear in many posts, since
it can result in no posts being shown.
</Trans>
</Text>
{isPreferencesLoading ? (
<Loader />
) : preferencesError || !preferences ? (
<View
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>
We're sorry, but we weren't able to load your muted words at
this time. Please try again.
</Trans>
<View style={[a.pb_sm]}>
<Dialog.Input
autoCorrect={false}
autoCapitalize="none"
autoComplete="off"
label={_(msg`Enter a word or tag`)}
placeholder={_(msg`Enter a word or tag`)}
value={field}
onChangeText={value => {
if (error) {
setError('')
}
setField(value)
}}
onSubmitEditing={submit}
/>
</View>
<View style={[a.pb_xl, a.gap_sm]}>
<Toggle.Group
label={_(msg`Select how long to mute this word for.`)}
type="radio"
values={durations}
onChange={setDurations}>
<Text
style={[
a.pb_xs,
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Duration:</Trans>
</Text>
</View>
) : preferences.moderationPrefs.mutedWords.length ? (
[...preferences.moderationPrefs.mutedWords]
.reverse()
.map((word, i) => (
<MutedWordRow
key={word.value + i}
word={word}
style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
/>
))
) : (
<View
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>You haven't muted any words or tags yet</Trans>
<View
style={[
gtMobile && [a.flex_row, a.align_center, a.justify_start],
a.gap_sm,
]}>
<View
style={[
a.flex_1,
a.flex_row,
a.justify_start,
a.align_center,
a.gap_sm,
]}>
<Toggle.Item
label={_(msg`Mute this word until you unmute it`)}
name="forever"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_sm,
]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Forever</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word for 24 hours`)}
name="24_hours"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_sm,
]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>24 hours</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
</View>
<View
style={[
a.flex_1,
a.flex_row,
a.justify_start,
a.align_center,
a.gap_sm,
]}>
<Toggle.Item
label={_(msg`Mute this word for 7 days`)}
name="7_days"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_sm,
]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>7 days</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word for 30 days`)}
name="30_days"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_sm,
]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>30 days</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
</View>
</View>
</Toggle.Group>
<Toggle.Group
label={_(
msg`Select what content this mute word should apply to.`,
)}
type="radio"
values={targets}
onChange={setTargets}>
<Text
style={[
a.pb_xs,
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Mute in:</Trans>
</Text>
<View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
<Toggle.Item
label={_(msg`Mute this word in post text and tags`)}
name="content"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Text & tags</Trans>
</Toggle.LabelText>
</View>
<PageText size="sm" />
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word in tags only`)}
name="tag"
style={[a.flex_1]}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Tags only</Trans>
</Toggle.LabelText>
</View>
<Hashtag size="sm" />
</TargetToggle>
</Toggle.Item>
</View>
</Toggle.Group>
<View>
<Text
style={[
a.pb_xs,
a.text_sm,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Options:</Trans>
</Text>
<Toggle.Item
label={_(msg`Do not apply this mute word to users you follow`)}
name="exclude_following"
style={[a.flex_row, a.justify_between]}
value={excludeFollowing}
onChange={setExcludeFollowing}>
<TargetToggle>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Checkbox />
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
<Trans>Exclude users you follow</Trans>
</Toggle.LabelText>
</View>
</TargetToggle>
</Toggle.Item>
</View>
)}
<View style={[a.pt_xs]}>
<Button
disabled={isPending || !field}
label={_(msg`Add mute word for configured settings`)}
size="large"
color="primary"
variant="solid"
style={[]}
onPress={submit}>
<ButtonText>
<Trans>Add</Trans>
</ButtonText>
<ButtonIcon icon={isPending ? Loader : Plus} position="right" />
</Button>
</View>
{error && (
<View
style={[
a.mb_lg,
a.flex_row,
a.rounded_sm,
a.p_md,
a.mb_xs,
t.atoms.bg_contrast_25,
{
backgroundColor: t.palette.negative_400,
},
]}>
<Text
style={[
a.italic,
{color: t.palette.white},
native({marginTop: 2}),
]}>
{error}
</Text>
</View>
)}
</View>
<Divider />
<View style={[a.pt_2xl]}>
<Text
style={[
a.text_md,
a.font_bold,
a.pb_md,
t.atoms.text_contrast_high,
]}>
<Trans>Your muted words</Trans>
</Text>
{isPreferencesLoading ? (
<Loader />
) : preferencesError || !preferences ? (
<View
style={[
a.py_md,
a.px_lg,
a.rounded_md,
t.atoms.bg_contrast_25,
]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>
We're sorry, but we weren't able to load your muted words at
this time. Please try again.
</Trans>
</Text>
</View>
) : preferences.moderationPrefs.mutedWords.length ? (
[...preferences.moderationPrefs.mutedWords]
.reverse()
.map((word, i) => (
<MutedWordRow
key={word.value + i}
word={word}
style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
/>
))
) : (
<View
style={[
a.py_md,
a.px_lg,
a.rounded_md,
t.atoms.bg_contrast_25,
]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>You haven't muted any words or tags yet</Trans>
</Text>
</View>
)}
</View>
{isNative && <View style={{height: 20}} />}
</View>
{isNative && <View style={{height: 20}} />}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
<Dialog.Close />
</Dialog.ScrollableInner>
<Portal.Outlet />
</Portal.Provider>
)
}
@@ -437,6 +482,7 @@ function MutedWordRow({
onConfirm={remove}
confirmButtonCta={_(msg`Remove`)}
confirmButtonColor="negative"
Portal={Portal.Portal}
/>
<View
@@ -37,6 +37,7 @@ import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography'
export type PostInteractionSettingsFormProps = {
@@ -54,13 +55,15 @@ export type PostInteractionSettingsFormProps = {
export function PostInteractionSettingsControlledDialog({
control,
Portal,
...rest
}: PostInteractionSettingsFormProps & {
control: Dialog.DialogControlProps
Portal?: PortalComponent
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Outer control={control} Portal={Portal}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
@@ -231,7 +234,6 @@ export function PostInteractionSettingsForm({
}: PostInteractionSettingsFormProps) {
const t = useTheme()
const {_} = useLingui()
const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate')
const [quotesEnabled, setQuotesEnabled] = React.useState(
!(
@@ -437,7 +439,6 @@ export function PostInteractionSettingsForm({
<Button
label={_(msg`Save`)}
onPress={onSave}
onAccessibilityEscape={control.close}
color="primary"
size="large"
variant="solid"
-1
View File
@@ -43,7 +43,6 @@ export function SwitchAccountDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Switch Account`)}>
<View style={[a.gap_lg]}>
<Text style={[a.text_2xl, a.font_bold]}>
@@ -44,7 +44,6 @@ export function NeueTypography() {
return (
<Dialog.Outer control={control} onClose={onClose}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Introducing new font settings`)}>
<View style={[a.gap_xl]}>
<View style={[a.gap_md]}>
+3 -3
View File
@@ -136,7 +136,7 @@ let ConvoMenu = ({
<Menu.Outer>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={leaveConvoControl.open}>
onPress={() => leaveConvoControl.open()}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
@@ -195,7 +195,7 @@ let ConvoMenu = ({
</Menu.Item>
<Menu.Item
label={_(msg`Report conversation`)}
onPress={reportControl.open}>
onPress={() => reportControl.open()}>
<Menu.ItemText>
<Trans>Report conversation</Trans>
</Menu.ItemText>
@@ -206,7 +206,7 @@ let ConvoMenu = ({
<Menu.Group>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={leaveConvoControl.open}>
onPress={() => leaveConvoControl.open()}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
+80
View File
@@ -0,0 +1,80 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {subDays} from 'date-fns'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '../Typography'
import {localDateString} from './util'
const timeFormatter = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
})
const weekdayFormatter = new Intl.DateTimeFormat(undefined, {
weekday: 'long',
})
const longDateFormatter = new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'long',
day: 'numeric',
})
const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'long',
day: 'numeric',
year: 'numeric',
})
let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
let date: string
const time = timeFormatter.format(new Date(dateStr))
const timestamp = new Date(dateStr)
const today = new Date()
const yesterday = subDays(today, 1)
const oneWeekAgo = subDays(today, 7)
if (localDateString(today) === localDateString(timestamp)) {
date = _(msg`Today`)
} else if (localDateString(yesterday) === localDateString(timestamp)) {
date = _(msg`Yesterday`)
} else {
if (timestamp < oneWeekAgo) {
if (timestamp.getFullYear() === today.getFullYear()) {
date = longDateFormatter.format(timestamp)
} else {
date = longDateFormatterWithYear.format(timestamp)
}
} else {
date = weekdayFormatter.format(timestamp)
}
}
return (
<View style={[a.w_full, a.my_lg]}>
<Text
style={[
a.text_xs,
a.text_center,
t.atoms.bg,
t.atoms.text_contrast_medium,
a.px_md,
]}>
<Trans>
<Text style={[a.text_xs, t.atoms.text_contrast_medium, a.font_bold]}>
{date}
</Text>{' '}
at {time}
</Trans>
</Text>
</View>
)
}
DateDivider = React.memo(DateDivider)
export {DateDivider}
+88 -90
View File
@@ -17,13 +17,15 @@ import {useLingui} from '@lingui/react'
import {ConvoItem} from '#/state/messages/convo/types'
import {useSession} from '#/state/session'
import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {atoms as a, useTheme} from '#/alf'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import {isOnlyEmoji, RichText} from '../RichText'
import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed'
import {localDateString} from './util'
let MessageItem = ({
item,
@@ -33,14 +35,37 @@ let MessageItem = ({
const t = useTheme()
const {currentAccount} = useSession()
const {message, nextMessage} = item
const {message, nextMessage, prevMessage} = item
const isPending = item.type === 'pending-message'
const isFromSelf = message.sender?.did === currentAccount?.did
const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage)
const isNextFromSelf =
ChatBskyConvoDefs.isMessageView(nextMessage) &&
nextMessage.sender?.did === currentAccount?.did
nextIsMessage && nextMessage.sender?.did === currentAccount?.did
const isNextFromSameSender = isNextFromSelf === isFromSelf
const isNewDay = useMemo(() => {
if (!prevMessage) return true
const thisDate = new Date(message.sentAt)
const prevDate = new Date(prevMessage.sentAt)
return localDateString(thisDate) !== localDateString(prevDate)
}, [message, prevMessage])
const isLastMessageOfDay = useMemo(() => {
if (!nextMessage || !nextIsMessage) return true
const thisDate = new Date(message.sentAt)
const prevDate = new Date(nextMessage.sentAt)
return localDateString(thisDate) !== localDateString(prevDate)
}, [message.sentAt, nextIsMessage, nextMessage])
const needsTail = isLastMessageOfDay || !isNextFromSameSender
const isLastInGroup = useMemo(() => {
// if this message is pending, it means the next message is pending too
@@ -48,24 +73,19 @@ let MessageItem = ({
return false
}
// if the next message is from a different sender, then it's the last in the group
if (isFromSelf ? !isNextFromSelf : isNextFromSelf) {
return true
}
// or, if there's a 3 minute gap between this message and the next
// or, if there's a 5 minute gap between this message and the next
if (ChatBskyConvoDefs.isMessageView(nextMessage)) {
const thisDate = new Date(message.sentAt)
const nextDate = new Date(nextMessage.sentAt)
const diff = nextDate.getTime() - thisDate.getTime()
// 3 minutes
return diff > 3 * 60 * 1000
// 5 minutes
return diff > 5 * 60 * 1000
}
return true
}, [message, nextMessage, isFromSelf, isNextFromSelf, isPending])
}, [message, nextMessage, isPending])
const lastInGroupRef = useRef(isLastInGroup)
if (lastInGroupRef.current !== isLastInGroup) {
@@ -80,52 +100,59 @@ let MessageItem = ({
}, [message.text, message.facets])
return (
<View style={[isFromSelf ? a.mr_md : a.ml_md]}>
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed embed={message.embed} />
)}
{rt.text.length > 0 && (
<View
style={
!isOnlyEmoji(message.text) && [
a.py_sm,
a.my_2xs,
a.rounded_md,
{
paddingLeft: 14,
paddingRight: 14,
backgroundColor: isFromSelf
? isPending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf ? a.self_end : a.self_start,
isFromSelf
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
]
}>
<RichText
value={rt}
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={3}
/>
</View>
)}
</ActionsWrapper>
<>
{isNewDay && <DateDivider date={message.sentAt} />}
<View
style={[
isFromSelf ? a.mr_md : a.ml_md,
nextIsMessage && !isNextFromSameSender && a.mb_md,
]}>
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed embed={message.embed} />
)}
{rt.text.length > 0 && (
<View
style={
!isOnlyEmoji(message.text) && [
a.py_sm,
a.my_2xs,
a.rounded_md,
{
paddingLeft: 14,
paddingRight: 14,
backgroundColor: isFromSelf
? isPending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf ? a.self_end : a.self_start,
isFromSelf
? {borderBottomRightRadius: needsTail ? 2 : 17}
: {borderBottomLeftRadius: needsTail ? 2 : 17},
]
}>
<RichText
value={rt}
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={3}
/>
</View>
)}
</ActionsWrapper>
{isLastInGroup && (
<MessageItemMetadata
item={item}
style={isFromSelf ? a.text_right : a.text_left}
/>
)}
</View>
{isLastInGroup && (
<MessageItemMetadata
item={item}
style={isFromSelf ? a.text_right : a.text_left}
/>
)}
</View>
</>
)
}
MessageItem = React.memo(MessageItem)
@@ -165,31 +192,12 @@ let MessageItemMetadata = ({
const diff = now.getTime() - date.getTime()
// if under 1 minute
if (diff < 1000 * 60) {
// if under 30 seconds
if (diff < 1000 * 30) {
return _(msg`Now`)
}
// if in the last day
if (localDateString(now) === localDateString(date)) {
return time
}
// if yesterday
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (localDateString(yesterday) === localDateString(date)) {
return _(msg`Yesterday, ${time}`)
}
return i18n.date(date, {
hour: 'numeric',
minute: 'numeric',
day: 'numeric',
month: 'numeric',
year: 'numeric',
})
return time
},
[_],
)
@@ -242,15 +250,5 @@ let MessageItemMetadata = ({
</Text>
)
}
MessageItemMetadata = React.memo(MessageItemMetadata)
export {MessageItemMetadata}
function localDateString(date: Date) {
// can't use toISOString because it should be in local time
const mm = date.getMonth()
const dd = date.getDate()
const yyyy = date.getFullYear()
// not padding with 0s because it's not necessary, it's just used for comparison
return `${yyyy}-${mm}-${dd}`
}
+5 -5
View File
@@ -7,11 +7,11 @@ import {useLingui} from '@lingui/react'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {isWeb} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
import {useOpenLink} from '#/state/preferences/in-app-browser'
import {isWeb} from 'platform/detection'
import {useConvoActive} from 'state/messages/convo'
import {useSession} from 'state/session'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {ReportDialog} from '#/components/dms/ReportDialog'
@@ -120,7 +120,7 @@ export let MessageMenu = ({
<Menu.Item
testID="messageDropdownDeleteBtn"
label={_(msg`Delete message for me`)}
onPress={deleteControl.open}>
onPress={() => deleteControl.open()}>
<Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
@@ -128,7 +128,7 @@ export let MessageMenu = ({
<Menu.Item
testID="messageDropdownReportBtn"
label={_(msg`Report message`)}
onPress={reportControl.open}>
onPress={() => reportControl.open()}>
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item>
+9 -17
View File
@@ -4,12 +4,13 @@ import {AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import {atoms as a, useTheme} from '#/alf'
import {Message_Stroke2_Corner0_Rounded as Message} from '../icons/Message'
import {Link} from '../Link'
import {canBeMessaged} from './util'
import {ButtonIcon} from '#/components/Button'
import {canBeMessaged} from '#/components/dms/util'
import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message'
import {Link} from '#/components/Link'
export function MessageProfileButton({
profile,
@@ -40,15 +41,9 @@ export function MessageProfileButton({
a.align_center,
t.atoms.bg_contrast_25,
a.rounded_full,
{width: 36, height: 36},
{width: 34, height: 34},
]}>
<Message
style={[
t.atoms.text,
{marginLeft: 1, marginBottom: 1, opacity: 0.3},
]}
size="md"
/>
<Message style={[t.atoms.text, {opacity: 0.3}]} size="md" />
</View>
)
} else {
@@ -66,12 +61,9 @@ export function MessageProfileButton({
shape="round"
label={_(msg`Message ${profile.handle}`)}
to={`/messages/${convo.id}`}
style={[a.justify_center, {width: 36, height: 36}]}
style={[a.justify_center]}
onPress={onPress}>
<Message
style={[t.atoms.text, {marginLeft: 1, marginBottom: 1}]}
size="md"
/>
<ButtonIcon icon={Message} size="md" />
</Link>
)
} else {
-175
View File
@@ -1,175 +0,0 @@
import React, {useCallback, useEffect} from 'react'
import {View} from 'react-native'
import {ChatBskyActorDeclaration} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {Message_Stroke2_Corner0_Rounded} from '#/components/icons/Message'
import {Text} from '#/components/Typography'
export function MessagesNUX() {
const control = Dialog.useDialogControl()
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({
did: currentAccount!.did,
})
useEffect(() => {
if (profile && typeof profile.associated?.chat === 'undefined') {
const timeout = setTimeout(() => {
control.open()
}, 1000)
return () => {
clearTimeout(timeout)
}
}
}, [profile, control])
if (!profile) return null
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<DialogInner chatDeclation={profile.associated?.chat} />
</Dialog.Outer>
)
}
function DialogInner({
chatDeclation,
}: {
chatDeclation?: ChatBskyActorDeclaration.Record
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const t = useTheme()
const [initialized, setInitialzed] = React.useState(false)
const {mutate: updateDeclaration} = useUpdateActorDeclaration({
onError: () => {
Toast.show(_(msg`Failed to update settings`), 'xmark')
},
})
const onSelectItem = useCallback(
(keys: string[]) => {
const key = keys[0]
if (!key) return
updateDeclaration(key as 'all' | 'none' | 'following')
},
[updateDeclaration],
)
useEffect(() => {
if (!chatDeclation && !initialized) {
updateDeclaration('following')
setInitialzed(true)
}
}, [chatDeclation, updateDeclaration, initialized])
return (
<Dialog.ScrollableInner
label={_(msg`Introducing Direct Messages`)}
style={web({maxWidth: 440})}>
<View style={a.gap_xl}>
<View style={[a.align_center, a.pt_sm, a.pb_xs]}>
<Message_Stroke2_Corner0_Rounded width={64} />
<Text style={[a.text_2xl, a.font_bold, a.text_center, a.mt_md]}>
<Trans>Direct messages are here!</Trans>
</Text>
<Text style={[a.text_md, a.text_center, a.mt_sm]}>
<Trans>Privately chat with other users.</Trans>
</Text>
</View>
<View
style={[
a.gap_xs,
a.border,
a.overflow_hidden,
a.rounded_sm,
t.atoms.border_contrast_low,
]}>
<View
style={[
a.p_md,
a.border_b,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
]}>
<Text style={[a.text_sm, a.font_bold]}>
<Trans>Who can message you?</Trans>
</Text>
<Text
style={[
a.mt_xs,
a.text_sm,
a.italic,
t.atoms.text_contrast_medium,
]}>
<Trans>You can change this at any time.</Trans>
</Text>
</View>
<View style={[a.px_md, a.py_xs]}>
<Toggle.Group
label={_(msg`Who can message you?`)}
type="radio"
values={[chatDeclation?.allowIncoming ?? 'following']}
onChange={onSelectItem}>
<View>
<Toggle.Item
name="all"
label={_(msg`Everyone`)}
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
<Toggle.LabelText>
<Trans>Everyone</Trans>
</Toggle.LabelText>
<Toggle.Radio />
</Toggle.Item>
<Toggle.Item
name="following"
label={_(msg`Users I follow`)}
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
<Toggle.LabelText>
<Trans>Users I follow</Trans>
</Toggle.LabelText>
<Toggle.Radio />
</Toggle.Item>
<Toggle.Item
name="none"
label={_(msg`No one`)}
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
<Toggle.LabelText>
<Trans>No one</Trans>
</Toggle.LabelText>
<Toggle.Radio />
</Toggle.Item>
</View>
</Toggle.Group>
</View>
</View>
<Button
label={_(msg`Start chatting`)}
accessibilityHint={_(msg`Close modal`)}
size="large"
color="primary"
variant="solid"
onPress={() => control.close()}>
<ButtonText>
<Trans>Get started</Trans>
</ButtonText>
</Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
+2 -6
View File
@@ -10,13 +10,11 @@ import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {ReportOption} from '#/lib/moderation/useReportOptions'
import {isAndroid} from '#/platform/detection'
import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {KeyboardControllerPadding} from '#/components/KeyboardControllerPadding'
import {Button, ButtonIcon, ButtonText} from '../Button'
import {Divider} from '../Divider'
import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '../icons/Chevron'
@@ -41,14 +39,11 @@ let ReportDialog = ({
}): React.ReactNode => {
const {_} = useLingui()
return (
<Dialog.Outer
control={control}
nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Report this message`)}>
<DialogInner params={params} />
<Dialog.Close />
<KeyboardControllerPadding />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
@@ -277,6 +272,7 @@ function PreviewMessage({message}: {message: ChatBskyConvoDefs.MessageView}) {
message,
key: '',
nextMessage: null,
prevMessage: null,
}}
style={[a.text_left, a.mb_0]}
/>
+3 -5
View File
@@ -2,9 +2,9 @@ import React, {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import {FAB} from '#/view/com/util/fab/FAB'
import * as Toast from '#/view/com/util/Toast'
import {useTheme} from '#/alf'
@@ -55,10 +55,8 @@ export function NewChat({
accessibilityHint=""
/>
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<Dialog.Outer control={control} testID="newChatDialog">
<Dialog.Handle />
<SearchablePeopleList
title={_(msg`Start a new chat`)}
onSelectChat={onCreateChat}
@@ -5,10 +5,8 @@ import React, {
useRef,
useState,
} from 'react'
import type {TextInput as TextInputType} from 'react-native'
import {View} from 'react-native'
import {TextInput, View} from 'react-native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -16,18 +14,17 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useListConvosQuery} from '#/state/queries/messages/list-converations'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session'
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
import {ListMethods} from '#/view/com/util/List'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button} from '#/components/Button'
import {Button, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {TextInput} from '#/components/dms/dialogs/TextInput'
import {canBeMessaged} from '#/components/dms/util'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
@@ -66,9 +63,9 @@ export function SearchablePeopleList({
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const control = Dialog.useDialogContext()
const listRef = useRef<BottomSheetFlatListMethods>(null)
const listRef = useRef<ListMethods>(null)
const {currentAccount} = useSession()
const inputRef = useRef<TextInputType>(null)
const inputRef = useRef<TextInput>(null)
const [searchText, setSearchText] = useState('')
@@ -101,15 +98,15 @@ export function SearchablePeopleList({
})
}
_items = _items.sort(a => {
_items = _items.sort(item => {
// @ts-ignore
return a.enabled ? -1 : 1
return item.enabled ? -1 : 1
})
}
} else {
const placeholders: Item[] = Array(10)
.fill(0)
.map((_, i) => ({
.map((__, i) => ({
type: 'placeholder',
key: i + '',
}))
@@ -155,9 +152,9 @@ export function SearchablePeopleList({
}
// only sort follows
followsItems = followsItems.sort(a => {
followsItems = followsItems.sort(item => {
// @ts-ignore
return a.enabled ? -1 : 1
return item.enabled ? -1 : 1
})
// then append
@@ -177,9 +174,9 @@ export function SearchablePeopleList({
}
}
_items = _items.sort(a => {
_items = _items.sort(item => {
// @ts-ignore
return a.enabled ? -1 : 1
return item.enabled ? -1 : 1
})
} else {
_items.push(...placeholders)
@@ -242,57 +239,46 @@ export function SearchablePeopleList({
<View
style={[
a.relative,
a.pt_md,
web(a.pt_lg),
native(a.pt_4xl),
a.pb_xs,
a.px_lg,
a.border_b,
t.atoms.border_contrast_low,
t.atoms.bg,
native([a.pt_lg]),
]}>
<View
style={[
a.relative,
native(a.align_center),
a.justify_center,
{height: 32},
]}>
<Button
label={_(msg`Close`)}
size="small"
shape="round"
variant="ghost"
color="secondary"
style={[
a.absolute,
a.z_20,
native({
left: -7,
}),
web({
right: -4,
}),
]}
onPress={() => control.close()}>
{isWeb ? (
<X size="md" fill={t.palette.contrast_500} />
) : (
<ChevronLeft size="md" fill={t.palette.contrast_500} />
)}
</Button>
<View style={[a.relative, native(a.align_center), a.justify_center]}>
<Text
style={[
a.z_10,
a.text_lg,
a.font_bold,
a.font_heavy,
a.leading_tight,
t.atoms.text_contrast_high,
]}>
{title}
</Text>
{isWeb ? (
<Button
label={_(msg`Close`)}
size="small"
shape="round"
variant={isWeb ? 'ghost' : 'solid'}
color="secondary"
style={[
a.absolute,
a.z_20,
web({right: -4}),
native({right: 0}),
native({height: 32, width: 32, borderRadius: 16}),
]}
onPress={() => control.close()}>
<ButtonIcon icon={X} size="md" />
</Button>
) : null}
</View>
<View style={[native([a.pt_sm]), web([a.pt_xs])]}>
<View style={[, web([a.pt_xs])]}>
<SearchInput
inputRef={inputRef}
value={searchText}
@@ -309,7 +295,6 @@ export function SearchablePeopleList({
t.atoms.border_contrast_low,
t.atoms.bg,
t.atoms.text_contrast_high,
t.palette.contrast_500,
_,
title,
searchText,
@@ -326,14 +311,7 @@ export function SearchablePeopleList({
keyExtractor={(item: Item) => item.key}
style={[
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
native({
height: '100%',
paddingHorizontal: 0,
marginTop: 0,
paddingTop: 0,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
}),
native({height: '100%'}),
]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
keyboardDismissMode="on-drag"
@@ -396,7 +374,8 @@ function ProfileCard({
<View style={[a.flex_1, a.gap_2xs]}>
<Text
style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]}
numberOfLines={1}>
numberOfLines={1}
emoji>
{displayName}
</Text>
<Text
@@ -474,7 +453,7 @@ function SearchInput({
value: string
onChangeText: (text: string) => void
onEscape: () => void
inputRef: React.RefObject<TextInputType>
inputRef: React.RefObject<TextInput>
}) {
const t = useTheme()
const {_} = useLingui()
@@ -2,9 +2,9 @@ import React, {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import * as Toast from '#/view/com/util/Toast'
import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from './SearchablePeopleList'
@@ -17,10 +17,8 @@ export function SendViaChatDialog({
onSelectChat: (chatId: string) => void
}) {
return (
<Dialog.Outer
control={control}
testID="sendViaChatChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<Dialog.Outer control={control} testID="sendViaChatChatDialog">
<Dialog.Handle />
<SendViaChatDialogInner control={control} onSelectChat={onSelectChat} />
</Dialog.Outer>
)
+9
View File
@@ -16,3 +16,12 @@ export function canBeMessaged(profile: AppBskyActorDefs.ProfileView) {
return false
}
}
export function localDateString(date: Date) {
// can't use toISOString because it should be in local time
const mm = date.getMonth()
const dd = date.getDate()
const yyyy = date.getFullYear()
// not padding with 0s because it's not necessary, it's just used for comparison
return `${yyyy}-${mm}-${dd}`
}
+75
View File
@@ -0,0 +1,75 @@
import React from 'react'
import {TextInput, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'
import {isNative} from '#/platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/components/icons/MagnifyingGlass2'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
type SearchInputProps = Omit<TextField.InputProps, 'label'> & {
label?: TextField.InputProps['label']
/**
* Called when the user presses the (X) button
*/
onClearText?: () => void
}
export const SearchInput = React.forwardRef<TextInput, SearchInputProps>(
function SearchInput({value, label, onClearText, ...rest}, ref) {
const t = useTheme()
const {_} = useLingui()
return (
<View style={[a.w_full, a.relative]}>
<TextField.Root>
<TextField.Icon icon={MagnifyingGlassIcon} />
<TextField.Input
inputRef={ref}
label={label || _(msg`Search`)}
value={value}
placeholder={_(msg`Search`)}
returnKeyType="search"
keyboardAppearance={t.scheme}
selectTextOnFocus={isNative}
autoFocus={false}
accessibilityRole="search"
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
{...rest}
/>
</TextField.Root>
{value && value.length > 0 && (
<View
style={[
a.absolute,
a.z_10,
a.my_auto,
a.inset_0,
a.justify_center,
a.pr_sm,
{left: 'auto'},
]}>
<Button
testID="searchTextInputClearBtn"
onPress={onClearText}
label={_(msg`Clear search query`)}
hitSlop={HITSLOP_10}
size="tiny"
shape="round"
variant="ghost"
color="secondary">
<ButtonIcon icon={X} size="xs" />
</Button>
</View>
)}
</View>
)
},
)
+1 -1
View File
@@ -126,7 +126,7 @@ export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
value?: string
onChangeText?: (value: string) => void
isInvalid?: boolean
inputRef?: React.RefObject<TextInput>
inputRef?: React.RefObject<TextInput> | React.ForwardedRef<TextInput>
}
export function createInput(Component: typeof TextInput) {
+1 -1
View File
@@ -2,8 +2,8 @@ import React from 'react'
import {Pressable, View, ViewStyle} from 'react-native'
import Animated, {LinearTransition} from 'react-native-reanimated'
import {HITSLOP_10} from '#/lib/constants'
import {isNative} from '#/platform/detection'
import {HITSLOP_10} from 'lib/constants'
import {
atoms as a,
flatten,
@@ -3,11 +3,14 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgent, useSession} from 'state/session'
import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {isNative} from '#/platform/detection'
import {useAgent, useSession} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {DialogControlProps} from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Resend} from '#/components/icons/ArrowRotateCounterClockwise'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
@@ -23,7 +26,9 @@ export function VerifyEmailIntentDialog() {
)
}
function Inner({control}: {control: DialogControlProps}) {
function Inner({}: {control: DialogControlProps}) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {_} = useLingui()
const {verifyEmailState: state} = useIntentDialogs()
const [status, setStatus] = React.useState<
@@ -58,43 +63,47 @@ function Inner({control}: {control: DialogControlProps}) {
}
return (
<Dialog.ScrollableInner label={_(msg`Verify email dialog`)}>
<Dialog.Close />
<Dialog.ScrollableInner
label={_(msg`Verify email dialog`)}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
]}>
<View style={[a.gap_xl]}>
{status === 'loading' ? (
<View style={[a.py_2xl, a.align_center, a.justify_center]}>
<Loader size="xl" />
<Loader size="xl" fill={t.atoms.text_contrast_low.color} />
</View>
) : status === 'success' ? (
<>
<Text style={[a.font_bold, a.text_2xl]}>
<View style={[a.gap_sm, isNative && a.pb_xl]}>
<Text style={[a.font_heavy, a.text_2xl]}>
<Trans>Email Verified</Trans>
</Text>
<Text style={[a.text_md, a.leading_tight]}>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Thanks, you have successfully verified your email address.
Thanks, you have successfully verified your email address. You
can close this dialog.
</Trans>
</Text>
</>
</View>
) : status === 'failure' ? (
<>
<Text style={[a.font_bold, a.text_2xl]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_heavy, a.text_2xl]}>
<Trans>Invalid Verification Code</Trans>
</Text>
<Text style={[a.text_md, a.leading_tight]}>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
The verification code you have provided is invalid. Please make
sure that you have used the correct verification link or request
a new one.
</Trans>
</Text>
</>
</View>
) : (
<>
<Text style={[a.font_bold, a.text_2xl]}>
<View style={[a.gap_sm, isNative && a.pb_xl]}>
<Text style={[a.font_heavy, a.text_2xl]}>
<Trans>Email Resent</Trans>
</Text>
<Text style={[a.text_md, a.leading_tight]}>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
We have sent another verification email to{' '}
<Text style={[a.text_md, a.font_bold]}>
@@ -103,38 +112,29 @@ function Inner({control}: {control: DialogControlProps}) {
.
</Trans>
</Text>
</>
</View>
)}
{status !== 'loading' ? (
<View style={[a.w_full, a.flex_row, a.gap_sm, {marginLeft: 'auto'}]}>
{status === 'failure' && (
<>
<Divider />
<Button
label={_(msg`Close`)}
onPress={() => control.close()}
label={_(msg`Resend Verification Email`)}
onPress={onPressResendEmail}
variant="solid"
color={status === 'failure' ? 'secondary' : 'primary'}
color="secondary_inverted"
size="large"
style={{marginLeft: 'auto'}}>
disabled={sending}>
<ButtonIcon icon={sending ? Loader : Resend} position="left" />
<ButtonText>
<Trans>Close</Trans>
<Trans>Resend Email</Trans>
</ButtonText>
</Button>
{status === 'failure' ? (
<Button
label={_(msg`Resend Verification Email`)}
onPress={onPressResendEmail}
variant="solid"
color="primary"
size="large"
disabled={sending}>
<ButtonText>
<Trans>Resend Email</Trans>
</ButtonText>
{sending ? <Loader size="sm" style={{color: 'white'}} /> : null}
</Button>
) : null}
</View>
) : null}
</>
)}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
+38 -35
View File
@@ -32,7 +32,6 @@ export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<LabelsOnMeDialogInner {...props} />
</Dialog.Outer>
)
@@ -158,23 +157,25 @@ function Label({
<Divider />
<View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}>
<Text style={[t.atoms.text_contrast_medium]}>
{isSelfLabel ? (
{isSelfLabel ? (
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>This label was applied by you.</Trans>
) : (
<Trans>
Source:{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}>
{sourceName}
</InlineLinkText>
</Trans>
)}
</Text>
</Text>
) : (
<View style={{flexDirection: 'row'}}>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>Source: </Trans>{' '}
</Text>
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}>
{sourceName}
</InlineLinkText>
</View>
)}
</View>
</View>
)
@@ -236,24 +237,26 @@ function AppealForm({
return (
<>
<Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
<Trans>Appeal "{strings.name}" label</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
This appeal will be sent to{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}
style={[a.text_md, a.leading_snug]}>
{sourceName}
</InlineLinkText>
.
</Trans>
</Text>
<View style={{flexWrap: 'wrap', flexDirection: 'row'}}>
<Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
<Trans>Appeal "{strings.name}" label</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
This appeal will be sent to{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}
style={[a.text_md, a.leading_snug]}>
{sourceName}
</InlineLinkText>
.
</Trans>
</Text>
</View>
<View style={[a.my_md]}>
<Dialog.Input
label={_(msg`Text input field`)}
@@ -141,23 +141,26 @@ function ModerationDetailsDialogInner({
{modcause?.type === 'label' && (
<View style={[a.pt_lg]}>
<Divider />
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
{modcause.source.type === 'user' ? (
{modcause.source.type === 'user' ? (
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
<Trans>This label was applied by the author.</Trans>
) : (
<Trans>
This label was applied by{' '}
<InlineLinkText
label={desc.source || _(msg`an unknown labeler`)}
to={makeProfileLink({did: modcause.label.src, handle: ''})}
onPress={() => control.close()}
style={a.text_md}>
{desc.source || _(msg`an unknown labeler`)}
</InlineLinkText>
.
</Trans>
)}
</Text>
</Text>
) : (
<>
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
<Trans>
This label was applied by{' '}
<InlineLinkText
label={desc.source || _(msg`an unknown labeler`)}
to={makeProfileLink({did: modcause.label.src, handle: ''})}
onPress={() => control.close()}
style={a.text_md}>
{desc.source || _(msg`an unknown labeler`)}
</InlineLinkText>
</Trans>
</Text>
</>
)}
</View>
)}
+126 -146
View File
@@ -1,5 +1,4 @@
import {
AppBskyEmbedDefs,
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecord,
@@ -7,12 +6,13 @@ import {
AppBskyEmbedVideo,
AppBskyFeedPostgate,
AtUri,
BlobRef,
BskyAgent,
ComAtprotoLabelDefs,
RichText,
} from '@atproto/api'
import {isNetworkError} from '#/lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {logger} from '#/logger'
import {ComposerImage, compressImage} from '#/state/gallery'
import {writePostgateRecord} from '#/state/queries/postgate'
@@ -22,8 +22,7 @@ import {
threadgateAllowUISettingToAllowRecordValue,
writeThreadgateRecord,
} from '#/state/queries/threadgate'
import {isNetworkError} from 'lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
import {ComposerState} from '#/view/com/composer/state/composer'
import {LinkMeta} from '../link-meta/link-meta'
import {uploadBlob} from './upload-blob'
@@ -38,20 +37,14 @@ export interface ExternalEmbedDraft {
}
interface PostOpts {
composerState: ComposerState // TODO: Not used yet.
rawText: string
replyTo?: string
quote?: {
uri: string
cid: string
}
video?: {
blobRef: BlobRef
altText: string
captions: {lang: string; file: File}[]
aspectRatio?: AppBskyEmbedDefs.AspectRatio
}
extLink?: ExternalEmbedDraft
images?: ComposerImage[]
labels?: string[]
threadgate: ThreadgateAllowUISetting[]
postgate: AppBskyFeedPostgate.Record
@@ -60,13 +53,6 @@ interface PostOpts {
}
export async function post(agent: BskyAgent, opts: PostOpts) {
let embed:
| AppBskyEmbedImages.Main
| AppBskyEmbedExternal.Main
| AppBskyEmbedRecord.Main
| AppBskyEmbedVideo.Main
| AppBskyEmbedRecordWithMedia.Main
| undefined
let reply
let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true})
@@ -77,134 +63,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
rt = shortenLinks(rt)
rt = stripInvalidMentions(rt)
// add quote embed if present
if (opts.quote) {
embed = {
$type: 'app.bsky.embed.record',
record: {
uri: opts.quote.uri,
cid: opts.quote.cid,
},
} as AppBskyEmbedRecord.Main
}
// add image embed if present
if (opts.images?.length) {
logger.debug(`Uploading images`, {
count: opts.images.length,
})
const images: AppBskyEmbedImages.Image[] = []
for (const image of opts.images) {
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
logger.debug(`Compressing image`)
const {path, width, height, mime} = await compressImage(image)
logger.debug(`Uploading image`)
const res = await uploadBlob(agent, path, mime)
images.push({
image: res.data.blob,
alt: image.alt,
aspectRatio: {width, height},
})
}
if (opts.quote) {
embed = {
$type: 'app.bsky.embed.recordWithMedia',
record: embed,
media: {
$type: 'app.bsky.embed.images',
images,
},
} as AppBskyEmbedRecordWithMedia.Main
} else {
embed = {
$type: 'app.bsky.embed.images',
images,
} as AppBskyEmbedImages.Main
}
}
// add video embed if present
if (opts.video) {
const captions = await Promise.all(
opts.video.captions
.filter(caption => caption.lang !== '')
.map(async caption => {
const {data} = await agent.uploadBlob(caption.file, {
encoding: 'text/vtt',
})
return {lang: caption.lang, file: data.blob}
}),
)
if (opts.quote) {
embed = {
$type: 'app.bsky.embed.recordWithMedia',
record: embed,
media: {
$type: 'app.bsky.embed.video',
video: opts.video.blobRef,
alt: opts.video.altText || undefined,
captions: captions.length === 0 ? undefined : captions,
aspectRatio: opts.video.aspectRatio,
} as AppBskyEmbedVideo.Main,
} as AppBskyEmbedRecordWithMedia.Main
} else {
embed = {
$type: 'app.bsky.embed.video',
video: opts.video.blobRef,
alt: opts.video.altText || undefined,
captions: captions.length === 0 ? undefined : captions,
aspectRatio: opts.video.aspectRatio,
} as AppBskyEmbedVideo.Main
}
}
// add external embed if present
if (opts.extLink && !opts.images?.length) {
if (opts.extLink.embed) {
embed = opts.extLink.embed
} else {
let thumb
if (opts.extLink.localThumb) {
opts.onStateChange?.('Uploading link thumbnail...')
const {path, mime} = opts.extLink.localThumb.source
const res = await uploadBlob(agent, path, mime)
thumb = res.data.blob
}
if (opts.quote) {
embed = {
$type: 'app.bsky.embed.recordWithMedia',
record: embed,
media: {
$type: 'app.bsky.embed.external',
external: {
uri: opts.extLink.uri,
title: opts.extLink.meta?.title || '',
description: opts.extLink.meta?.description || '',
thumb,
},
} as AppBskyEmbedExternal.Main,
} as AppBskyEmbedRecordWithMedia.Main
} else {
embed = {
$type: 'app.bsky.embed.external',
external: {
uri: opts.extLink.uri,
title: opts.extLink.meta?.title || '',
description: opts.extLink.meta?.description || '',
thumb,
},
} as AppBskyEmbedExternal.Main
}
}
}
const embed = await resolveEmbed(agent, opts)
// add replyTo if post is a reply to another post
if (opts.replyTo) {
@@ -313,3 +172,124 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
return res
}
async function resolveEmbed(
agent: BskyAgent,
opts: PostOpts,
): Promise<
| AppBskyEmbedImages.Main
| AppBskyEmbedVideo.Main
| AppBskyEmbedExternal.Main
| AppBskyEmbedRecord.Main
| AppBskyEmbedRecordWithMedia.Main
| undefined
> {
const media = await resolveMedia(agent, opts)
if (opts.quote) {
const quoteRecord = {
$type: 'app.bsky.embed.record',
record: {
uri: opts.quote.uri,
cid: opts.quote.cid,
},
}
if (media) {
return {
$type: 'app.bsky.embed.recordWithMedia',
record: quoteRecord,
media,
}
} else {
return quoteRecord
}
}
if (media) {
return media
}
if (opts.extLink?.embed) {
return opts.extLink.embed
}
return undefined
}
async function resolveMedia(
agent: BskyAgent,
opts: PostOpts,
): Promise<
| AppBskyEmbedExternal.Main
| AppBskyEmbedImages.Main
| AppBskyEmbedVideo.Main
| undefined
> {
const state = opts.composerState
const media = state.embed.media
if (media?.type === 'images') {
logger.debug(`Uploading images`, {
count: media.images.length,
})
opts.onStateChange?.(`Uploading images...`)
const images: AppBskyEmbedImages.Image[] = await Promise.all(
media.images.map(async (image, i) => {
logger.debug(`Compressing image #${i}`)
const {path, width, height, mime} = await compressImage(image)
logger.debug(`Uploading image #${i}`)
const res = await uploadBlob(agent, path, mime)
return {
image: res.data.blob,
alt: image.alt,
aspectRatio: {width, height},
}
}),
)
return {
$type: 'app.bsky.embed.images',
images,
}
}
if (media?.type === 'video' && media.video.status === 'done') {
const video = media.video
const captions = await Promise.all(
video.captions
.filter(caption => caption.lang !== '')
.map(async caption => {
const {data} = await agent.uploadBlob(caption.file, {
encoding: 'text/vtt',
})
return {lang: caption.lang, file: data.blob}
}),
)
return {
$type: 'app.bsky.embed.video',
video: video.pendingPublish.blobRef,
alt: video.altText || undefined,
captions: captions.length === 0 ? undefined : captions,
aspectRatio: {
width: video.asset.width,
height: video.asset.height,
},
}
}
if (opts.extLink) {
// TODO: Read this from composer state as well.
if (opts.extLink.embed) {
return undefined
}
let thumb
if (opts.extLink.localThumb) {
opts.onStateChange?.('Uploading link thumbnail...')
const {path, mime} = opts.extLink.localThumb.source
const res = await uploadBlob(agent, path, mime)
thumb = res.data.blob
}
return {
$type: 'app.bsky.embed.external',
external: {
uri: opts.extLink.uri,
title: opts.extLink.meta?.title || '',
description: opts.extLink.meta?.description || '',
thumb,
},
}
}
return undefined
}
+1 -1
View File
@@ -50,7 +50,7 @@ export const MAX_DM_GRAPHEME_LENGTH = 1000
// Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html
// but increasing limit per user feedback
export const MAX_ALT_TEXT = 1000
export const MAX_ALT_TEXT = 2000
export function IS_TEST_USER(handle?: string) {
return handle && handle?.endsWith('.test')
+1 -1
View File
@@ -2,8 +2,8 @@ import {getVideoMetaData, Video} from 'react-native-compressor'
import {ImagePickerAsset} from 'expo-image-picker'
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
import {extToMime} from '#/state/queries/video/util'
import {CompressedVideo} from './types'
import {extToMime} from './util'
const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
+61
View File
@@ -0,0 +1,61 @@
import {BskyAgent} from '@atproto/api'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro'
import {VIDEO_SERVICE_DID} from '#/lib/constants'
import {UploadLimitError} from '#/lib/media/video/errors'
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
import {createVideoAgent} from './util'
export async function getServiceAuthToken({
agent,
aud,
lxm,
exp,
}: {
agent: BskyAgent
aud?: string
lxm: string
exp?: number
}) {
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
if (!pdsAud) {
throw new Error('Agent does not have a PDS URL')
}
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
aud: aud ?? pdsAud,
lxm,
exp,
})
return serviceAuth.token
}
export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) {
const token = await getServiceAuthToken({
agent,
lxm: 'app.bsky.video.getUploadLimits',
aud: VIDEO_SERVICE_DID,
})
const videoAgent = createVideoAgent()
const {data: limits} = await videoAgent.app.bsky.video
.getUploadLimits({}, {headers: {Authorization: `Bearer ${token}`}})
.catch(err => {
if (err instanceof Error) {
throw new UploadLimitError(err.message)
} else {
throw err
}
})
if (!limits.canUpload) {
if (limits.message) {
throw new UploadLimitError(limits.message)
} else {
throw new UploadLimitError(
_(
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
),
)
}
}
}
+79
View File
@@ -0,0 +1,79 @@
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
import {AppBskyVideoDefs, BskyAgent} from '@atproto/api'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro'
import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
export async function uploadVideo({
video,
agent,
did,
setProgress,
signal,
_,
}: {
video: CompressedVideo
agent: BskyAgent
did: string
setProgress: (progress: number) => void
signal: AbortSignal
_: I18n['_']
}) {
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(agent, _)
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({
agent,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
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 AppBskyVideoDefs.JobStatus
if (!responseBody.jobId) {
throw new ServerError(responseBody.error || _(msg`Failed to upload video`))
}
if (signal.aborted) {
throw new AbortError()
}
return responseBody
}
+95
View File
@@ -0,0 +1,95 @@
import {AppBskyVideoDefs} from '@atproto/api'
import {BskyAgent} from '@atproto/api'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro'
import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
export async function uploadVideo({
video,
agent,
did,
setProgress,
signal,
_,
}: {
video: CompressedVideo
agent: BskyAgent
did: string
setProgress: (progress: number) => void
signal: AbortSignal
_: I18n['_']
}) {
if (signal.aborted) {
throw new AbortError()
}
await getVideoUploadLimits(agent, _)
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({
agent,
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<AppBskyVideoDefs.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 AppBskyVideoDefs.JobStatus
resolve(uploadRes)
} else {
reject(new ServerError(_(msg`Failed to upload video`)))
}
}
xhr.onerror = () => {
reject(new ServerError(_(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 || _(msg`Failed to upload video`))
}
if (signal.aborted) {
throw new AbortError()
}
return res
}
@@ -1,4 +1,3 @@
import {useMemo} from 'react'
import {AtpAgent} from '@atproto/api'
import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
@@ -17,12 +16,10 @@ export const createVideoEndpointUrl = (
return url.href
}
export function useVideoAgent() {
return useMemo(() => {
return new AtpAgent({
service: VIDEO_SERVICE,
})
}, [])
export function createVideoAgent() {
return new AtpAgent({
service: VIDEO_SERVICE,
})
}
export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
+2
View File
@@ -145,6 +145,8 @@ export type LogEvents = {
}
'post:mute': {}
'post:unmute': {}
'post:pin': {}
'post:unpin': {}
'profile:follow:sampled': {
didBecomeMutual: boolean | undefined
followeeClout: number | undefined
-82
View File
@@ -1,82 +0,0 @@
import {describe, expect, it} from '@jest/globals'
import tldts from 'tldts'
import {isEmailMaybeInvalid} from '#/lib/strings/email'
describe('emailTypoChecker', () => {
const invalidCases = [
'gnail.com',
'gnail.co',
'gmaill.com',
'gmaill.co',
'gmai.com',
'gmai.co',
'gmal.com',
'gmal.co',
'gmail.co',
'iclod.com',
'iclod.co',
'outllok.com',
'outllok.co',
'outlook.co',
'yaoo.com',
'yaoo.co',
'yaho.com',
'yaho.co',
'yahooo.com',
'yahooo.co',
'yahoo.co',
'hithere.jul',
'agpowj.notshop',
'thisisnot.avalid.tld.nope',
// old tld for czechoslovakia
'czechoslovakia.cs',
// tlds that cbs was registering in 2024 but cancelled
'liveon.cbs',
'its.showtime',
]
const validCases = [
'gmail.com',
// subdomains (tests end of string)
'gnail.com.test.com',
'outlook.com',
'yahoo.com',
'icloud.com',
'firefox.com',
'firefox.co',
'hello.world.com',
'buy.me.a.coffee.shop',
'mayotte.yt',
'aland.ax',
'bouvet.bv',
'uk.gb',
'chad.td',
'somalia.so',
'plane.aero',
'cute.cat',
'together.coop',
'findme.jobs',
'nightatthe.museum',
'industrial.mil',
'czechrepublic.cz',
'lovakia.sk',
// new gtlds in 2024
'whatsinyour.locker',
'letsmakea.deal',
'skeet.now',
'everyone.みんな',
'bourgeois.lifestyle',
'california.living',
'skeet.ing',
'listeningto.music',
'createa.meme',
]
it.each(invalidCases)(`should be invalid: abcde@%s`, domain => {
expect(isEmailMaybeInvalid(`abcde@${domain}`, tldts)).toEqual(true)
})
it.each(validCases)(`should be valid: abcde@%s`, domain => {
expect(isEmailMaybeInvalid(`abcde@${domain}`, tldts)).toEqual(false)
})
})
+40 -39
View File
@@ -2,6 +2,7 @@ import {Platform} from 'react-native'
import {tokens} from '#/alf'
import {darkPalette, dimPalette, lightPalette} from '#/alf/themes'
import {fontWeight} from '#/alf/tokens'
import {colors} from './styles'
import type {Theme} from './ThemeContext'
@@ -90,195 +91,195 @@ export const defaultTheme: Theme = {
'2xl-thin': {
fontSize: 18,
letterSpacing: tokens.TRACKING,
fontWeight: '400',
fontWeight: fontWeight.normal,
},
'2xl': {
fontSize: 18,
letterSpacing: tokens.TRACKING,
fontWeight: '400',
fontWeight: fontWeight.normal,
},
'2xl-medium': {
fontSize: 18,
letterSpacing: tokens.TRACKING,
fontWeight: '600',
fontWeight: fontWeight.bold,
},
'2xl-bold': {
fontSize: 18,
letterSpacing: tokens.TRACKING,
fontWeight: '600',
fontWeight: fontWeight.bold,
},
'2xl-heavy': {
fontSize: 18,
letterSpacing: tokens.TRACKING,
fontWeight: '800',
fontWeight: fontWeight.heavy,
},
'xl-thin': {
fontSize: 17,
letterSpacing: tokens.TRACKING,
fontWeight: '400',
fontWeight: fontWeight.normal,
},
xl: {
fontSize: 17,
letterSpacing: tokens.TRACKING,
fontWeight: '400',
fontWeight: fontWeight.normal,
},
'xl-medium': {
fontSize: 17,
letterSpacing: tokens.TRACKING,
fontWeight: '600',
fontWeight: fontWeight.bold,
},
'xl-bold': {
fontSize: 17,
letterSpacing: tokens.TRACKING,
fontWeight: '600',
fontWeight: fontWeight.bold,
},
'xl-heavy': {
fontSize: 17,
letterSpacing: tokens.TRACKING,
fontWeight: '800',
fontWeight: fontWeight.heavy,
},
'lg-thin': {
fontSize: 16,
letterSpacing: tokens.TRACKING,
fontWeight: '400',
fontWeight: fontWeight.normal,
},
lg: {
fontSize: 16,
letterSpacing: tokens.TRACKING,
fontWeight: '400',
fontWeight: fontWeight.normal,
},
'lg-medium': {
fontSize: 16,
letterSpacing: tokens.TRACKING,
fontWeight: '600',
fontWeight: fontWeight.bold,
},
'lg-bold': {
fontSize: 16,
letterSpacing: tokens.TRACKING,
fontWeight: '600',
fontWeight: fontWeight.bold,
},
'lg-heavy': {
fontSize: 16,
letterSpacing: tokens.TRACKING,
fontWeight: '800',
fontWeight: fontWeight.heavy,
},
'md-thin': {
fontSize: 15,
letterSpacing: tokens.TRACKING,
fontWeight: '400',
fontWeight: fontWeight.normal,
},
md: {
fontSize: 15,
letterSpacing: tokens.TRACKING,
fontWeight: '400',
fontWeight: fontWeight.normal,
},
'md-medium': {
fontSize: 15,
letterSpacing: tokens.TRACKING,
fontWeight: '600',
fontWeight: fontWeight.bold,
},
'md-bold': {
fontSize: 15,
letterSpacing: tokens.TRACKING,
fontWeight: '600',
fontWeight: fontWeight.bold,
},
'md-heavy': {
fontSize: 15,
letterSpacing: tokens.TRACKING,
fontWeight: '800',
fontWeight: fontWeight.heavy,
},
'sm-thin': {
fontSize: 14,
letterSpacing: tokens.TRACKING,
fontWeight: '400',
fontWeight: fontWeight.normal,
},
sm: {
fontSize: 14,
letterSpacing: tokens.TRACKING,
fontWeight: '400',
fontWeight: fontWeight.normal,
},
'sm-medium': {
fontSize: 14,
letterSpacing: tokens.TRACKING,
fontWeight: '600',
fontWeight: fontWeight.bold,
},
'sm-bold': {
fontSize: 14,
letterSpacing: tokens.TRACKING,
fontWeight: '600',
fontWeight: fontWeight.bold,
},
'sm-heavy': {
fontSize: 14,
letterSpacing: tokens.TRACKING,
fontWeight: '800',
fontWeight: fontWeight.heavy,
},
'xs-thin': {
fontSize: 13,
letterSpacing: tokens.TRACKING,
fontWeight: '400',
fontWeight: fontWeight.normal,
},
xs: {
fontSize: 13,
letterSpacing: tokens.TRACKING,
fontWeight: '400',
fontWeight: fontWeight.normal,
},
'xs-medium': {
fontSize: 13,
letterSpacing: tokens.TRACKING,
fontWeight: '600',
fontWeight: fontWeight.bold,
},
'xs-bold': {
fontSize: 13,
letterSpacing: tokens.TRACKING,
fontWeight: '600',
fontWeight: fontWeight.bold,
},
'xs-heavy': {
fontSize: 13,
letterSpacing: tokens.TRACKING,
fontWeight: '800',
fontWeight: fontWeight.heavy,
},
'title-2xl': {
fontSize: 34,
letterSpacing: tokens.TRACKING,
fontWeight: '600',
fontWeight: fontWeight.bold,
},
'title-xl': {
fontSize: 28,
letterSpacing: tokens.TRACKING,
fontWeight: '600',
fontWeight: fontWeight.bold,
},
'title-lg': {
fontSize: 22,
fontWeight: '600',
fontWeight: fontWeight.bold,
},
title: {
fontWeight: '600',
fontWeight: fontWeight.bold,
fontSize: 20,
letterSpacing: tokens.TRACKING,
},
'title-sm': {
fontWeight: '600',
fontWeight: fontWeight.bold,
fontSize: 17,
letterSpacing: tokens.TRACKING,
},
'post-text': {
fontSize: 16,
letterSpacing: tokens.TRACKING,
fontWeight: '400',
fontWeight: fontWeight.normal,
},
'post-text-lg': {
fontSize: 20,
letterSpacing: tokens.TRACKING,
fontWeight: '400',
fontWeight: fontWeight.normal,
},
'button-lg': {
fontWeight: '600',
fontWeight: fontWeight.bold,
fontSize: 18,
letterSpacing: tokens.TRACKING,
},
button: {
fontWeight: '600',
fontWeight: fontWeight.bold,
fontSize: 14,
letterSpacing: tokens.TRACKING,
},
@@ -22,7 +22,6 @@ import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {DialogControlProps, useDialogControl} from '#/components/Dialog'
import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
import {MessagesNUX} from '#/components/dms/MessagesNUX'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
@@ -33,7 +32,7 @@ import {Link} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {ChatListItem} from './ChatListItem'
import {ChatListItem} from './components/ChatListItem'
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
@@ -151,8 +150,6 @@ export function MessagesScreen({navigation, route}: Props) {
if (conversations.length < 1) {
return (
<View style={a.flex_1}>
<MessagesNUX />
<CenteredView sideBorders={gtMobile} style={[a.h_full_vh]}>
{gtMobile ? (
<DesktopHeader
@@ -240,7 +237,6 @@ export function MessagesScreen({navigation, route}: Props) {
return (
<View style={a.flex_1}>
<MessagesNUX />
{!gtMobile && (
<ViewHeader
title={_(msg`Messages`)}
@@ -8,16 +8,16 @@ import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {isWeb} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo'
import {ConvoStatus} from '#/state/messages/convo/types'
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileQuery} from '#/state/queries/profile'
import {isWeb} from 'platform/detection'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {ConvoProvider, isConvoActive, useConvo} from 'state/messages/convo'
import {ConvoStatus} from 'state/messages/convo/types'
import {useSetMinimalShellMode} from 'state/shell'
import {CenteredView} from 'view/com/util/Views'
import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
import {useSetMinimalShellMode} from '#/state/shell'
import {CenteredView} from '#/view/com/util/Views'
import {MessagesList} from '#/screens/Messages/components/MessagesList'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter'
import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
@@ -18,11 +18,11 @@ import Graphemer from 'graphemer'
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {isIOS} from '#/platform/detection'
import {
useMessageDraft,
useSaveMessageDraft,
} from '#/state/messages/message-drafts'
import {isIOS} from 'platform/detection'
import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
@@ -5,13 +5,13 @@ import {useLingui} from '@lingui/react'
import Graphemer from 'graphemer'
import TextareaAutosize from 'react-textarea-autosize'
import {isSafari, isTouchDevice} from '#/lib/browser'
import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {
useMessageDraft,
useSaveMessageDraft,
} from '#/state/messages/message-drafts'
import {isSafari, isTouchDevice} from 'lib/browser'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {
Emoji,
@@ -15,6 +15,8 @@ import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/rean
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api'
import {clamp} from '#/lib/numbers'
import {ScrollProvider} from '#/lib/ScrollContext'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {
convertBskyAppUrlIfNeeded,
@@ -22,21 +24,19 @@ import {
} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {isWeb} from '#/platform/detection'
import {isConvoActive, useConvoActive} from '#/state/messages/convo'
import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types'
import {useGetPost} from '#/state/queries/post'
import {useAgent} from '#/state/session'
import {clamp} from 'lib/numbers'
import {ScrollProvider} from 'lib/ScrollContext'
import {isWeb} from 'platform/detection'
import {
EmojiPicker,
EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import {List} from 'view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
import {List} from '#/view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
import {MessageInput} from '#/screens/Messages/components/MessageInput'
import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
@@ -46,29 +46,31 @@ export const PlaceholderCanvas = React.forwardRef<PlaceholderCanvasRef, {}>(
return (
<View style={styles.container}>
<LazyViewShot
// @ts-ignore this library doesn't have types
ref={viewshotRef}
options={{
fileName: 'placeholderAvatar',
format: 'jpg',
quality: 0.8,
height: 150 * SIZE_MULTIPLIER,
width: 150 * SIZE_MULTIPLIER,
}}>
<View
style={[
styles.imageContainer,
{backgroundColor: avatar.backgroundColor},
]}
collapsable={false}>
<Icon
height={85 * SIZE_MULTIPLIER}
width={85 * SIZE_MULTIPLIER}
style={{color: 'white'}}
/>
</View>
</LazyViewShot>
<React.Suspense fallback={null}>
<LazyViewShot
// @ts-ignore this library doesn't have types
ref={viewshotRef}
options={{
fileName: 'placeholderAvatar',
format: 'jpg',
quality: 0.8,
height: 150 * SIZE_MULTIPLIER,
width: 150 * SIZE_MULTIPLIER,
}}>
<View
style={[
styles.imageContainer,
{backgroundColor: avatar.backgroundColor},
]}
collapsable={false}>
<Icon
height={85 * SIZE_MULTIPLIER}
width={85 * SIZE_MULTIPLIER}
style={{color: 'white'}}
/>
</View>
</LazyViewShot>
</React.Suspense>
</View>
)
},
+24 -13
View File
@@ -32,6 +32,7 @@ import {
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo'
@@ -89,15 +90,18 @@ export function StepProfile() {
requestNotificationsPermission('StartOnboarding')
}, [gate, requestNotificationsPermission])
const sheetWrapper = useSheetWrapper()
const openPicker = React.useCallback(
async (opts?: ImagePickerOptions) => {
const response = await launchImageLibraryAsync({
exif: false,
mediaTypes: MediaTypeOptions.Images,
quality: 1,
...opts,
legacy: true,
})
const response = await sheetWrapper(
launchImageLibraryAsync({
exif: false,
mediaTypes: MediaTypeOptions.Images,
quality: 1,
...opts,
legacy: true,
}),
)
return (response.assets ?? [])
.slice(0, 1)
@@ -121,7 +125,7 @@ export function StepProfile() {
size: getDataUriSize(image.uri),
}))
},
[_, setError],
[_, setError, sheetWrapper],
)
const onContinue = React.useCallback(async () => {
@@ -168,9 +172,11 @@ export function StepProfile() {
setError('')
const items = await openPicker({
aspect: [1, 1],
})
const items = await sheetWrapper(
openPicker({
aspect: [1, 1],
}),
)
let image = items[0]
if (!image) return
@@ -196,7 +202,13 @@ export function StepProfile() {
image,
useCreatedAvatar: false,
}))
}, [requestPhotoAccessIfNeeded, setAvatar, openPicker, setError])
}, [
requestPhotoAccessIfNeeded,
setAvatar,
openPicker,
setError,
sheetWrapper,
])
const onSecondaryPress = React.useCallback(() => {
if (avatar.useCreatedAvatar) {
@@ -286,7 +298,6 @@ export function StepProfile() {
</View>
<Dialog.Outer control={creatorControl}>
<Dialog.Handle />
<Dialog.Inner
label="Avatar creator"
style={[
+1 -1
View File
@@ -27,7 +27,7 @@ export function ProfileHeaderDisplayName({
t.atoms.text,
gtMobile ? a.text_4xl : a.text_3xl,
a.self_start,
{fontWeight: '600'},
a.font_heavy,
]}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
@@ -25,7 +25,7 @@ import {usePreferencesQuery} from '#/state/queries/preferences'
import {useRequireAuth, useSession} from '#/state/session'
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {DialogOuterProps} from '#/components/Dialog'
import {
@@ -61,7 +61,6 @@ let ProfileHeaderLabeler = ({
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
useProfileShadow(profileUnshadowed)
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {_} = useLingui()
const {currentAccount, hasSession} = useSession()
const {openModal} = useModalControls()
@@ -167,7 +166,7 @@ let ProfileHeaderLabeler = ({
style={[a.px_lg, a.pt_md, a.pb_sm]}
pointerEvents={isIOS ? 'auto' : 'box-none'}>
<View
style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_lg]}
style={[a.flex_row, a.justify_end, a.align_center, a.gap_xs, a.pb_lg]}
pointerEvents={isIOS ? 'auto' : 'box-none'}>
{isMe ? (
<Button
@@ -196,7 +195,10 @@ let ProfileHeaderLabeler = ({
<View
style={[
{
paddingVertical: gtMobile ? 12 : 10,
paddingVertical: 9,
paddingHorizontal: 12,
borderRadius: 6,
gap: 6,
backgroundColor: isSubscribed
? state.hovered || state.pressed
? t.palette.contrast_50
@@ -205,9 +207,6 @@ let ProfileHeaderLabeler = ({
? tokens.color.temp_purple_dark
: tokens.color.temp_purple,
},
a.px_lg,
a.rounded_sm,
a.gap_sm,
]}>
<Text
style={[
@@ -218,6 +217,7 @@ let ProfileHeaderLabeler = ({
},
a.font_bold,
a.text_center,
a.leading_tight,
]}>
{isSubscribed ? (
<Trans>Unsubscribe</Trans>
@@ -153,8 +153,9 @@ let ProfileHeaderStandard = ({
style={[
{paddingLeft: 90},
a.flex_row,
a.align_center,
a.justify_end,
a.gap_sm,
a.gap_xs,
a.pb_sm,
a.flex_wrap,
]}
@@ -167,7 +168,7 @@ let ProfileHeaderStandard = ({
variant="solid"
onPress={onPressEditProfile}
label={_(msg`Edit profile`)}
style={[a.rounded_full, a.py_sm]}>
style={[a.rounded_full]}>
<ButtonText>
<Trans>Edit Profile</Trans>
</ButtonText>
@@ -182,7 +183,7 @@ let ProfileHeaderStandard = ({
label={_(msg`Unblock`)}
disabled={!hasSession}
onPress={() => unblockPromptControl.open()}
style={[a.rounded_full, a.py_sm]}>
style={[a.rounded_full]}>
<ButtonText>
<Trans context="action">Unblock</Trans>
</ButtonText>
@@ -205,7 +206,7 @@ let ProfileHeaderStandard = ({
onPress={
profile.viewer?.following ? onPressUnfollow : onPressFollow
}
style={[a.rounded_full, a.gap_xs, a.py_sm]}>
style={[a.rounded_full]}>
<ButtonIcon
position="left"
icon={profile.viewer?.following ? Check : Plus}
+3 -4
View File
@@ -27,7 +27,7 @@ let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
</View>
<View style={styles.content}>
<View style={[styles.buttonsLine]}>
<LoadingPlaceholder width={167} height={36} style={styles.br50} />
<LoadingPlaceholder width={140} height={34} style={styles.br50} />
</View>
</View>
</View>
@@ -69,13 +69,12 @@ const styles = StyleSheet.create({
},
content: {
paddingTop: 12,
paddingHorizontal: 14,
paddingBottom: 4,
paddingHorizontal: 16,
paddingBottom: 8,
},
buttonsLine: {
flexDirection: 'row',
marginLeft: 'auto',
marginBottom: 12,
},
br45: {borderRadius: 45},
br50: {borderRadius: 50},
+1
View File
@@ -35,6 +35,7 @@ export function makeSearchQuery(query: string, params: Params) {
return [
query,
Object.entries(params)
.filter(([_, value]) => value)
.map(([name, value]) => `${name}:${value}`)
.join(' '),
]
+53 -32
View File
@@ -1,4 +1,4 @@
import React from 'react'
import React, {ReactElement} from 'react'
import {View} from 'react-native'
import {ComAtprotoServerDescribeServer} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
@@ -42,43 +42,64 @@ export const Policies = ({
)
}
const els = []
if (tos) {
els.push(
<InlineLinkText
label={_(msg`Read the Bluesky Terms of Service`)}
key="tos"
to={tos}>
{_(msg`Terms of Service`)}
</InlineLinkText>,
)
}
if (pp) {
els.push(
<InlineLinkText
label={_(msg`Read the Bluesky Privacy Policy`)}
key="pp"
to={pp}>
{_(msg`Privacy Policy`)}
</InlineLinkText>,
)
}
if (els.length === 2) {
els.splice(
1,
0,
<Text key="and" style={[t.atoms.text_contrast_medium]}>
{' '}
let els: ReactElement
if (tos && pp) {
els = (
<Trans>
By creating an account you agree to the{' '}
<InlineLinkText
label={_(msg`Read the Bluesky Terms of Service`)}
key="tos"
to={tos}>
Terms of Service
</InlineLinkText>{' '}
and{' '}
</Text>,
<InlineLinkText
label={_(msg`Read the Bluesky Privacy Policy`)}
key="pp"
to={pp}>
Privacy Policy
</InlineLinkText>
.
</Trans>
)
} else if (tos) {
els = (
<Trans>
By creating an account you agree to the{' '}
<InlineLinkText
label={_(msg`Read the Bluesky Terms of Service`)}
key="tos"
to={tos}>
Terms of Service
</InlineLinkText>
.
</Trans>
)
} else if (pp) {
els = (
<Trans>
By creating an account you agree to the{' '}
<InlineLinkText
label={_(msg`Read the Bluesky Privacy Policy`)}
key="pp"
to={pp}>
Privacy Policy
</InlineLinkText>
.
</Trans>
)
} else {
return null
}
return (
<View style={[a.gap_sm]}>
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>By creating an account you agree to the {els}.</Trans>
</Text>
{els ? (
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
{els}
</Text>
) : null}
{under13 ? (
<Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
+2 -1
View File
@@ -168,7 +168,8 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
<View
style={[a.w_full, a.py_lg, a.flex_row, a.gap_lg, a.align_center]}>
<AppLanguageDropdown />
<Text style={[t.atoms.text, !gtMobile && a.text_md]}>
<Text
style={[t.atoms.text_contrast_medium, !gtMobile && a.text_md]}>
<Trans>Having trouble?</Trans>{' '}
<InlineLinkText
label={_(msg`Contact support`)}
+22 -22
View File
@@ -15,35 +15,35 @@ import {useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {HITSLOP_20} from '#/lib/constants'
import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
import {makeProfileLink, makeStarterPackLink} from '#/lib/routes/links'
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {getAllListMembers} from '#/state/queries/list-members'
import {useResolvedStarterPackShortLink} from '#/state/queries/resolve-short-link'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useShortenLink} from '#/state/queries/shorten-link'
import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs'
import {useStarterPackQuery} from '#/state/queries/starter-packs'
import {useAgent, useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {batchedUpdates} from 'lib/batchedUpdates'
import {HITSLOP_20} from 'lib/constants'
import {isBlockedOrBlocking, isMuted} from 'lib/moderation/blocked-and-muted'
import {makeProfileLink, makeStarterPackLink} from 'lib/routes/links'
import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types'
import {logEvent} from 'lib/statsig/statsig'
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
import {isWeb} from 'platform/detection'
import {updateProfileShadow} from 'state/cache/profile-shadow'
import {useModerationOpts} from 'state/preferences/moderation-opts'
import {getAllListMembers} from 'state/queries/list-members'
import {useResolvedStarterPackShortLink} from 'state/queries/resolve-short-link'
import {useResolveDidQuery} from 'state/queries/resolve-uri'
import {useShortenLink} from 'state/queries/shorten-link'
import {useStarterPackQuery} from 'state/queries/starter-packs'
import {useAgent, useSession} from 'state/session'
import {useLoggedOutViewControls} from 'state/shell/logged-out'
import {useSetActiveStarterPack} from 'state/shell/starter-pack'
import {useSetActiveStarterPack} from '#/state/shell/starter-pack'
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader'
import * as Toast from '#/view/com/util/Toast'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
import {CenteredView} from 'view/com/util/Views'
import {CenteredView} from '#/view/com/util/Views'
import {bulkWriteFollows} from '#/screens/Onboarding/util'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -591,7 +591,7 @@ function OverflowMenu({
<Menu.Item
label={_(msg`Report starter pack`)}
onPress={reportDialogControl.open}>
onPress={() => reportDialogControl.open()}>
<Menu.ItemText>
<Trans>Report starter pack</Trans>
</Menu.ItemText>
+8 -10
View File
@@ -4,17 +4,17 @@ import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
import {AppBskyFeedDefs, ModerationOpts} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {DISCOVER_FEED_URI} from '#/lib/constants'
import {useA11y} from '#/state/a11y'
import {DISCOVER_FEED_URI} from 'lib/constants'
import {
useGetPopularFeedsQuery,
usePopularFeedsSearch,
useSavedFeeds,
} from 'state/queries/feed'
import {SearchInput} from 'view/com/util/forms/SearchInput'
import {List} from 'view/com/util/List'
} from '#/state/queries/feed'
import {List} from '#/view/com/util/List'
import {useWizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, useTheme} from '#/alf'
import {SearchInput} from '#/components/forms/SearchInput'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {Loader} from '#/components/Loader'
import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition'
@@ -81,12 +81,11 @@ export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) {
return (
<ScreenTransition style={[a.flex_1]} direction={state.transitionDirection}>
<View style={[a.border_b, t.atoms.border_contrast_medium]}>
<View style={[a.my_sm, a.px_md, {height: 40}]}>
<View style={[a.py_sm, a.px_md, {height: 60}]}>
<SearchInput
query={query}
onChangeQuery={t => setQuery(t)}
onPressCancelSearch={() => setQuery('')}
onSubmitQuery={() => {}}
value={query}
onChangeText={t => setQuery(t)}
onClearText={() => setQuery('')}
/>
</View>
</View>
@@ -94,7 +93,6 @@ export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) {
data={query ? searchedFeeds : suggestedFeeds}
renderItem={renderItem}
keyExtractor={keyExtractor}
contentContainerStyle={{paddingTop: 6}}
onEndReached={
!query && !screenReaderEnabled ? () => fetchNextPage() : undefined
}
@@ -4,14 +4,14 @@ import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {isNative} from '#/platform/detection'
import {useA11y} from '#/state/a11y'
import {isNative} from 'platform/detection'
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
import {useActorSearchPaginated} from 'state/queries/actor-search'
import {SearchInput} from 'view/com/util/forms/SearchInput'
import {List} from 'view/com/util/List'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useActorSearchPaginated} from '#/state/queries/actor-search'
import {List} from '#/view/com/util/List'
import {useWizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, useTheme} from '#/alf'
import {SearchInput} from '#/components/forms/SearchInput'
import {Loader} from '#/components/Loader'
import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition'
import {WizardProfileCard} from '#/components/StarterPack/Wizard/WizardListCard'
@@ -65,12 +65,11 @@ export function StepProfiles({
return (
<ScreenTransition style={[a.flex_1]} direction={state.transitionDirection}>
<View style={[a.border_b, t.atoms.border_contrast_medium]}>
<View style={[a.my_sm, a.px_md, {height: 40}]}>
<View style={[a.py_sm, a.px_md, {height: 60}]}>
<SearchInput
query={query}
onChangeQuery={setQuery}
onPressCancelSearch={() => setQuery('')}
onSubmitQuery={() => {}}
value={query}
onChangeText={setQuery}
onClearText={() => setQuery('')}
/>
</View>
</View>
+47 -40
View File
@@ -1,8 +1,9 @@
import React from 'react'
import {SharedValue, useSharedValue} from 'react-native-reanimated'
import {isWeb} from '#/platform/detection'
import {DialogControlRefProps} from '#/components/Dialog'
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
import {BottomSheet} from '../../../modules/bottom-sheet'
interface IDialogContext {
/**
@@ -16,25 +17,24 @@ interface IDialogContext {
* `useId`.
*/
openDialogs: React.MutableRefObject<Set<string>>
}
interface IDialogControlContext {
closeAllDialogs(): boolean
setDialogIsOpen(id: string, isOpen: boolean): void
/**
* The counterpart to `accessibilityViewIsModal` for Android. This property
* applies to the parent of all non-modal views, and prevents TalkBack from
* navigating within content beneath an open dialog.
*
* @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android
* The number of dialogs that are fully expanded. This is used to determine the backgground color of the status bar
* on iOS.
*/
importantForAccessibility: SharedValue<'auto' | 'no-hide-descendants'>
fullyExpandedCount: number
setFullyExpandedCount: React.Dispatch<React.SetStateAction<number>>
}
const DialogContext = React.createContext<IDialogContext>({} as IDialogContext)
const DialogControlContext = React.createContext<{
closeAllDialogs(): boolean
setDialogIsOpen(id: string, isOpen: boolean): void
}>({
closeAllDialogs: () => false,
setDialogIsOpen: () => {},
})
const DialogControlContext = React.createContext<IDialogControlContext>(
{} as IDialogControlContext,
)
export function useDialogStateContext() {
return React.useContext(DialogContext)
@@ -45,48 +45,55 @@ export function useDialogStateControlContext() {
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const [fullyExpandedCount, setFullyExpandedCount] = React.useState(0)
const activeDialogs = React.useRef<
Map<string, React.MutableRefObject<DialogControlRefProps>>
>(new Map())
const openDialogs = React.useRef<Set<string>>(new Set())
const importantForAccessibility = useSharedValue<
'auto' | 'no-hide-descendants'
>('auto')
const closeAllDialogs = React.useCallback(() => {
openDialogs.current.forEach(id => {
const dialog = activeDialogs.current.get(id)
if (dialog) dialog.current.close()
})
return openDialogs.current.size > 0
if (isWeb) {
openDialogs.current.forEach(id => {
const dialog = activeDialogs.current.get(id)
if (dialog) dialog.current.close()
})
return openDialogs.current.size > 0
} else {
BottomSheet.dismissAll()
return false
}
}, [])
const setDialogIsOpen = React.useCallback(
(id: string, isOpen: boolean) => {
if (isOpen) {
openDialogs.current.add(id)
importantForAccessibility.value = 'no-hide-descendants'
} else {
openDialogs.current.delete(id)
if (openDialogs.current.size < 1) {
importantForAccessibility.value = 'auto'
}
}
},
[importantForAccessibility],
)
const setDialogIsOpen = React.useCallback((id: string, isOpen: boolean) => {
if (isOpen) {
openDialogs.current.add(id)
} else {
openDialogs.current.delete(id)
}
}, [])
const context = React.useMemo<IDialogContext>(
() => ({
activeDialogs,
openDialogs,
importantForAccessibility,
}),
[importantForAccessibility, activeDialogs, openDialogs],
[activeDialogs, openDialogs],
)
const controls = React.useMemo(
() => ({closeAllDialogs, setDialogIsOpen}),
[closeAllDialogs, setDialogIsOpen],
() => ({
closeAllDialogs,
setDialogIsOpen,
fullyExpandedCount,
setFullyExpandedCount,
}),
[
closeAllDialogs,
setDialogIsOpen,
fullyExpandedCount,
setFullyExpandedCount,
],
)
return (
+19 -4
View File
@@ -972,6 +972,7 @@ export class Convo {
key: m.id,
message: m,
nextMessage: null,
prevMessage: null,
})
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
items.unshift({
@@ -979,6 +980,7 @@ export class Convo {
key: m.id,
message: m,
nextMessage: null,
prevMessage: null,
})
}
})
@@ -1001,6 +1003,7 @@ export class Convo {
key: m.id,
message: m,
nextMessage: null,
prevMessage: null,
})
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
items.push({
@@ -1008,6 +1011,7 @@ export class Convo {
key: m.id,
message: m,
nextMessage: null,
prevMessage: null,
})
}
})
@@ -1030,6 +1034,7 @@ export class Convo {
sender: this.sender!,
},
nextMessage: null,
prevMessage: null,
failed: this.pendingMessageFailure !== null,
retry:
this.pendingMessageFailure === 'recoverable'
@@ -1060,29 +1065,39 @@ export class Convo {
})
.map((item, i, arr) => {
let nextMessage = null
let prevMessage = null
const isMessage = isConvoItemMessage(item)
if (isMessage) {
if (
isMessage &&
(ChatBskyConvoDefs.isMessageView(item.message) ||
ChatBskyConvoDefs.isDeletedMessageView(item.message))
ChatBskyConvoDefs.isMessageView(item.message) ||
ChatBskyConvoDefs.isDeletedMessageView(item.message)
) {
const next = arr[i + 1]
if (
isConvoItemMessage(next) &&
next &&
(ChatBskyConvoDefs.isMessageView(next.message) ||
ChatBskyConvoDefs.isDeletedMessageView(next.message))
) {
nextMessage = next.message
}
const prev = arr[i - 1]
if (
isConvoItemMessage(prev) &&
(ChatBskyConvoDefs.isMessageView(prev.message) ||
ChatBskyConvoDefs.isDeletedMessageView(prev.message))
) {
prevMessage = prev.message
}
}
return {
...item,
nextMessage,
prevMessage,
}
}
+12
View File
@@ -87,6 +87,10 @@ export type ConvoItem =
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
prevMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
}
| {
type: 'pending-message'
@@ -96,6 +100,10 @@ export type ConvoItem =
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
prevMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
failed: boolean
/**
* Retry sending the message. If present, the message is in a failed state.
@@ -110,6 +118,10 @@ export type ConvoItem =
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
prevMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
}
| {
type: 'error'
+6 -6
View File
@@ -2,14 +2,14 @@ import React from 'react'
import {Linking} from 'react-native'
import * as WebBrowser from 'expo-web-browser'
import {isNative} from '#/platform/detection'
import * as persisted from '#/state/persisted'
import {usePalette} from 'lib/hooks/usePalette'
import {usePalette} from '#/lib/hooks/usePalette'
import {
createBskyAppAbsoluteUrl,
isBskyRSSUrl,
isRelativeUrl,
} from 'lib/strings/url-helpers'
} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import * as persisted from '#/state/persisted'
import {useModalControls} from '../modals'
type StateContext = persisted.Schema['useInAppBrowser']
@@ -62,7 +62,7 @@ export function useOpenLink() {
const pal = usePalette('default')
const openLink = React.useCallback(
(url: string, override?: boolean) => {
async (url: string, override?: boolean) => {
if (isBskyRSSUrl(url) && isRelativeUrl(url)) {
url = createBskyAppAbsoluteUrl(url)
}
@@ -75,7 +75,7 @@ export function useOpenLink() {
})
return
} else if (override ?? enabled) {
WebBrowser.openBrowserAsync(url, {
await WebBrowser.openBrowserAsync(url, {
presentationStyle:
WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN,
toolbarColor: pal.colors.backgroundLight,
-39
View File
@@ -1,39 +0,0 @@
import {ImagePickerAsset} from 'expo-image-picker'
import {useMutation} from '@tanstack/react-query'
import {cancelable} from '#/lib/async/cancelable'
import {CompressedVideo} from '#/lib/media/video/types'
import {compressVideo} from 'lib/media/video/compress'
export function useCompressVideoMutation({
onProgress,
onSuccess,
onError,
signal,
}: {
onProgress: (progress: number) => void
onError: (e: any) => void
onSuccess: (video: CompressedVideo) => void
signal: AbortSignal
}) {
return useMutation({
mutationKey: ['video', 'compress'],
mutationFn: cancelable(
(asset: ImagePickerAsset) =>
compressVideo(asset, {
onProgress: num => onProgress(trunc2dp(num)),
signal,
}),
signal,
),
onError,
onSuccess,
onMutate: () => {
onProgress(0)
},
})
}
function trunc2dp(num: number) {
return Math.trunc(num * 100) / 100
}
@@ -1,73 +0,0 @@
import {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {VIDEO_SERVICE_DID} from '#/lib/constants'
import {UploadLimitError} from '#/lib/media/video/errors'
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
import {useAgent} from '#/state/session'
import {useVideoAgent} from './util'
export function useServiceAuthToken({
aud,
lxm,
exp,
}: {
aud?: string
lxm: string
exp?: number
}) {
const agent = useAgent()
return useCallback(async () => {
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
if (!pdsAud) {
throw new Error('Agent does not have a PDS URL')
}
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
aud: aud ?? pdsAud,
lxm,
exp,
})
return serviceAuth.token
}, [agent, aud, lxm, exp])
}
export function useVideoUploadLimits() {
const agent = useVideoAgent()
const getToken = useServiceAuthToken({
lxm: 'app.bsky.video.getUploadLimits',
aud: VIDEO_SERVICE_DID,
})
const {_} = useLingui()
return useCallback(async () => {
const {data: limits} = await agent.app.bsky.video
.getUploadLimits(
{},
{headers: {Authorization: `Bearer ${await getToken()}`}},
)
.catch(err => {
if (err instanceof Error) {
throw new UploadLimitError(err.message)
} else {
throw err
}
})
if (!limits.canUpload) {
if (limits.message) {
throw new UploadLimitError(limits.message)
} else {
throw new UploadLimitError(
_(
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
),
)
}
}
}, [agent, _, getToken])
}
-76
View File
@@ -1,76 +0,0 @@
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
import {AppBskyVideoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure'
import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useSession} from '#/state/session'
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({
onSuccess,
onError,
setProgress,
signal,
}: {
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
onError: (e: any) => void
setProgress: (progress: number) => void
signal: AbortSignal
}) => {
const {currentAccount} = useSession()
const getToken = useServiceAuthToken({
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
const checkLimits = useVideoUploadLimits()
const {_} = useLingui()
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
await checkLimits()
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
const uploadTask = createUploadTask(
uri,
video.uri,
{
headers: {
'content-type': video.mimeType,
Authorization: `Bearer ${await getToken()}`,
},
httpMethod: 'POST',
uploadType: FileSystemUploadType.BINARY_CONTENT,
},
p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
)
const res = await uploadTask.uploadAsync()
if (!res?.body) {
throw new Error('No response')
}
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
if (!responseBody.jobId) {
throw new ServerError(
responseBody.error || _(msg`Failed to upload video`),
)
}
return responseBody
}, signal),
onError,
onSuccess,
})
}
@@ -1,86 +0,0 @@
import {AppBskyVideoDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure'
import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useSession} from '#/state/session'
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({
onSuccess,
onError,
setProgress,
signal,
}: {
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
onError: (e: any) => void
setProgress: (progress: number) => void
signal: AbortSignal
}) => {
const {currentAccount} = useSession()
const getToken = useServiceAuthToken({
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
const checkLimits = useVideoUploadLimits()
const {_} = useLingui()
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
await checkLimits()
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
let bytes = video.bytes
if (!bytes) {
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
}
const token = await getToken()
const xhr = new XMLHttpRequest()
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
(resolve, reject) => {
xhr.upload.addEventListener('progress', e => {
const progress = e.loaded / e.total
setProgress(progress)
})
xhr.onloadend = () => {
if (xhr.readyState === 4) {
const uploadRes = JSON.parse(
xhr.responseText,
) as AppBskyVideoDefs.JobStatus
resolve(uploadRes)
} else {
reject(new ServerError(_(msg`Failed to upload video`)))
}
}
xhr.onerror = () => {
reject(new ServerError(_(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 || _(msg`Failed to upload video`))
}
return res
}, signal),
onError,
onSuccess,
})
}
-351
View File
@@ -1,351 +0,0 @@
import React, {useCallback, useEffect} from 'react'
import {ImagePickerAsset} from 'expo-image-picker'
import {AppBskyVideoDefs, BlobRef} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import {AbortError} from '#/lib/async/cancelable'
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
import {
ServerError,
UploadLimitError,
VideoTooLargeError,
} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useCompressVideoMutation} from '#/state/queries/video/compress-video'
import {useVideoAgent} from '#/state/queries/video/util'
import {useUploadVideoMutation} from '#/state/queries/video/video-upload'
type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done'
type Action =
| {type: 'SetStatus'; status: Status}
| {type: 'SetProgress'; progress: number}
| {type: 'SetError'; error: string | undefined}
| {type: 'Reset'}
| {type: 'SetAsset'; asset: ImagePickerAsset}
| {type: 'SetDimensions'; width: number; height: number}
| {type: 'SetVideo'; video: CompressedVideo}
| {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus}
| {type: 'SetComplete'; blobRef: BlobRef}
export interface State {
status: Status
progress: number
asset?: ImagePickerAsset
video: CompressedVideo | null
jobStatus?: AppBskyVideoDefs.JobStatus
blobRef?: BlobRef
error?: string
abortController: AbortController
pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean}
}
export type VideoUploadDispatch = (action: Action) => void
function reducer(queryClient: QueryClient) {
return (state: State, action: Action): State => {
let updatedState = state
if (action.type === 'SetStatus') {
updatedState = {...state, status: action.status}
} else if (action.type === 'SetProgress') {
updatedState = {...state, progress: action.progress}
} else if (action.type === 'SetError') {
updatedState = {...state, error: action.error}
} else if (action.type === 'Reset') {
state.abortController.abort()
queryClient.cancelQueries({
queryKey: ['video'],
})
updatedState = {
status: 'idle',
progress: 0,
video: null,
blobRef: undefined,
abortController: new AbortController(),
}
} else if (action.type === 'SetAsset') {
updatedState = {
...state,
asset: action.asset,
status: 'compressing',
error: undefined,
}
} else if (action.type === 'SetDimensions') {
updatedState = {
...state,
asset: state.asset
? {...state.asset, width: action.width, height: action.height}
: undefined,
}
} else if (action.type === 'SetVideo') {
updatedState = {...state, video: action.video, status: 'uploading'}
} else if (action.type === 'SetJobStatus') {
updatedState = {...state, jobStatus: action.jobStatus}
} else if (action.type === 'SetComplete') {
updatedState = {
...state,
pendingPublish: {
blobRef: action.blobRef,
mutableProcessed: false,
},
status: 'done',
}
}
return updatedState
}
}
export function useUploadVideo({
setStatus,
initialVideoUri,
}: {
setStatus: (status: string) => void
onSuccess: () => void
initialVideoUri?: string
}) {
const {_} = useLingui()
const queryClient = useQueryClient()
const [state, dispatch] = React.useReducer(reducer(queryClient), {
status: 'idle',
progress: 0,
video: null,
abortController: new AbortController(),
})
const {setJobId} = useUploadStatusQuery({
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => {
// This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user
// Leaving it for now though
dispatch({
type: 'SetJobStatus',
jobStatus: status,
})
setStatus(status.state.toString())
},
onSuccess: blobRef => {
dispatch({
type: 'SetComplete',
blobRef,
})
},
onError: useCallback(
error => {
logger.error('Error processing video', {safeMessage: error})
dispatch({
type: 'SetError',
error: _(msg`Video failed to process`),
})
},
[_],
),
})
const {mutate: onVideoCompressed} = useUploadVideoMutation({
onSuccess: response => {
dispatch({
type: 'SetStatus',
status: 'processing',
})
setJobId(response.jobId)
},
onError: e => {
if (e instanceof AbortError) {
return
} else if (e instanceof ServerError || e instanceof UploadLimitError) {
let message
// https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
switch (e.message) {
case 'User is not allowed to upload videos':
message = _(msg`You are not allowed to upload videos.`)
break
case 'Uploading is disabled at the moment':
message = _(
msg`Hold up! Were gradually giving access to video, and youre still waiting in line. Check back soon!`,
)
break
case "Failed to get user's upload stats":
message = _(
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
)
break
case 'User has exceeded daily upload bytes limit':
message = _(
msg`You've reached your daily limit for video uploads (too many bytes)`,
)
break
case 'User has exceeded daily upload videos limit':
message = _(
msg`You've reached your daily limit for video uploads (too many videos)`,
)
break
case 'Account is not old enough to upload videos':
message = _(
msg`Your account is not yet old enough to upload videos. Please try again later.`,
)
break
default:
message = e.message
break
}
dispatch({
type: 'SetError',
error: message,
})
} else {
dispatch({
type: 'SetError',
error: _(msg`An error occurred while uploading the video.`),
})
}
logger.error('Error uploading video', {safeMessage: e})
},
setProgress: p => {
dispatch({type: 'SetProgress', progress: p})
},
signal: state.abortController.signal,
})
const {mutate: onSelectVideo} = useCompressVideoMutation({
onProgress: p => {
dispatch({type: 'SetProgress', progress: p})
},
onSuccess: (video: CompressedVideo) => {
dispatch({
type: 'SetVideo',
video,
})
onVideoCompressed(video)
},
onError: e => {
if (e instanceof AbortError) {
return
} else if (e instanceof VideoTooLargeError) {
dispatch({
type: 'SetError',
error: _(msg`The selected video is larger than 50MB.`),
})
} else {
dispatch({
type: 'SetError',
error: _(msg`An error occurred while compressing the video.`),
})
logger.error('Error compressing video', {safeMessage: e})
}
},
signal: state.abortController.signal,
})
const selectVideo = React.useCallback(
(asset: ImagePickerAsset) => {
// compression step on native converts to mp4, so no need to check there
if (isWeb) {
const mimeType = getMimeType(asset)
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
throw new Error(_(msg`Unsupported video type: ${mimeType}`))
}
}
dispatch({
type: 'SetAsset',
asset,
})
onSelectVideo(asset)
},
[_, onSelectVideo],
)
const clearVideo = () => {
dispatch({type: 'Reset'})
}
const updateVideoDimensions = useCallback((width: number, height: number) => {
dispatch({
type: 'SetDimensions',
width,
height,
})
}, [])
// Whenever we receive an initial video uri, we should immediately run compression if necessary
useEffect(() => {
if (initialVideoUri) {
selectVideo({uri: initialVideoUri} as ImagePickerAsset)
}
}, [initialVideoUri, selectVideo])
return {
state,
dispatch,
selectVideo,
clearVideo,
updateVideoDimensions,
}
}
const useUploadStatusQuery = ({
onStatusChange,
onSuccess,
onError,
}: {
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void
onSuccess: (blobRef: BlobRef) => void
onError: (error: Error) => void
}) => {
const videoAgent = useVideoAgent()
const [enabled, setEnabled] = React.useState(true)
const [jobId, setJobId] = React.useState<string>()
const {error} = useQuery({
queryKey: ['video', 'upload status', jobId],
queryFn: async () => {
if (!jobId) return // this won't happen, can ignore
const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId})
const status = data.jobStatus
if (status.state === 'JOB_STATE_COMPLETED') {
setEnabled(false)
if (!status.blob)
throw new Error('Job completed, but did not return a blob')
onSuccess(status.blob)
} else if (status.state === 'JOB_STATE_FAILED') {
throw new Error(status.error ?? 'Job failed to process')
}
onStatusChange(status)
return status
},
enabled: Boolean(jobId && enabled),
refetchInterval: 1500,
})
useEffect(() => {
if (error) {
onError(error)
setEnabled(false)
}
}, [error, onError])
return {
setJobId: (_jobId: string) => {
setJobId(_jobId)
setEnabled(true)
},
}
}
function getMimeType(asset: ImagePickerAsset) {
if (isWeb) {
const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
if (!mimeType) {
throw new Error('Could not determine mime type')
}
return mimeType
}
if (!asset.mimeType) {
throw new Error('Could not determine mime type')
}
return asset.mimeType
}
-97
View File
@@ -6,103 +6,6 @@
* may need to touch all three. Ask Eric if you aren't sure.
*/
@font-face {
font-family: 'Inter-Regular';
src: local('Inter-Regular'),
url(/assets/fonts/inter/Inter-Regular.otf) format('opentype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter-Italic';
src: local('Inter-Italic'),
url(/assets/fonts/inter/Inter-Italic.otf) format('opentype');
font-weight: 400;
font-style: italic;
font-display: swap;
}
/*
@font-face {
font-family: "Inter-Medium";
src: local("Inter-Medium"), url(/assets/fonts/inter/Inter-Medium.otf) format("opentype");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Inter-MediumItalic";
src: local("Inter-MediumItalic"), url(/assets/fonts/inter/Inter-MediumItalic.otf) format("opentype");
font-weight: 500;
font-style: italic;
font-display: swap;
}
*/
@font-face {
font-family: 'Inter-SemiBold';
src: local('Inter-SemiBold'),
url(/assets/fonts/inter/Inter-SemiBold.otf) format('opentype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter-SemiBoldItalic';
src: local('Inter-SemiBoldItalic'),
url(/assets/fonts/inter/Inter-SemiBoldItalic.otf) format('opentype');
font-weight: 600;
font-style: italic;
font-display: swap;
}
/*
@font-face {
font-family: "Inter-Bold";
src: local("Inter-Bold"), url(/assets/fonts/inter/Inter-Bold.otf) format("opentype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Inter-BoldItalic";
src: local("Inter-BoldItalic"), url(/assets/fonts/inter/Inter-BoldItalic.otf) format("opentype");
font-weight: 700;
font-style: italic;
font-display: swap;
}
*/
@font-face {
font-family: 'Inter-ExtraBold';
src: local('Inter-ExtraBold'),
url(/assets/fonts/inter/Inter-ExtraBold.otf) format('opentype');
font-weight: 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter-ExtraBoldItalic';
src: local('Inter-ExtraBoldItalic'),
url(/assets/fonts/inter/Inter-ExtraBoldItalic.otf) format('opentype');
font-weight: 800;
font-style: italic;
font-display: swap;
}
/*
@font-face {
font-family: "Inter-Black";
src: local("Inter-Black"), url(/assets/fonts/inter/Inter-Black.otf) format("opentype");
font-weight: 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Inter-BlackItalic";
src: local("Inter-BlackItalic"), url(/assets/fonts/inter/Inter-BlackItalic.otf) format("opentype");
font-weight: 900;
font-style: italic;
font-display: swap;
}
*/
/**
* BEGIN STYLES
*
+1 -5
View File
@@ -66,12 +66,8 @@ export function ServerInputDialog({
])
return (
<Dialog.Outer
control={control}
nativeOptions={{sheet: {snapPoints: ['100%']}}}
onClose={onClose}>
<Dialog.Outer control={control} onClose={onClose}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title">
@@ -0,0 +1,33 @@
import React from 'react'
import {View} from 'react-native'
import {MAX_ALT_TEXT} from '#/lib/constants'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {atoms as a, useTheme} from '#/alf'
export function AltTextCounterWrapper({
altText,
children,
}: {
altText?: string
children: React.ReactNode
}) {
const t = useTheme()
return (
<View style={[a.flex_row]}>
<CharProgress
style={[
a.flex_col_reverse,
a.align_center,
a.mr_xs,
{minWidth: 50, gap: 1},
]}
textStyle={[{marginRight: 0}, a.text_sm, t.atoms.text_contrast_medium]}
size={26}
count={altText?.length || 0}
max={MAX_ALT_TEXT}
/>
{children}
</View>
)
}
+417 -337
View File
@@ -3,6 +3,7 @@ import React, {
useEffect,
useImperativeHandle,
useMemo,
useReducer,
useRef,
useState,
} from 'react'
@@ -35,6 +36,7 @@ import Animated, {
ZoomOut,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {ImagePickerAsset} from 'expo-image-picker'
import {
AppBskyFeedDefs,
AppBskyFeedGetPostThread,
@@ -66,7 +68,7 @@ import {logger} from '#/logger'
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
import {useDialogStateControlContext} from '#/state/dialogs'
import {emitPostCreated} from '#/state/events'
import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery'
import {ComposerImage, pasteImage} from '#/state/gallery'
import {useModalControls} from '#/state/modals'
import {useModals} from '#/state/modals'
import {useRequireAltTextEnabled} from '#/state/preferences'
@@ -80,11 +82,6 @@ import {useProfileQuery} from '#/state/queries/profile'
import {Gif} from '#/state/queries/tenor'
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
import {
State as VideoUploadState,
useUploadVideo,
VideoUploadDispatch,
} from '#/state/queries/video/video'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {ComposerOpts} from '#/state/shell/composer'
@@ -117,8 +114,13 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {createPortalGroup} from '#/components/Portal'
import * as Prompt from '#/components/Prompt'
import {Text as NewText} from '#/components/Typography'
import {composerReducer, createComposerState} from './state/composer'
import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
const Portal = createPortalGroup()
const MAX_IMAGES = 4
@@ -126,6 +128,8 @@ type CancelRef = {
onPressCancel: () => void
}
const NO_IMAGES: ComposerImage[] = []
type Props = ComposerOpts
export const ComposePost = ({
replyTo,
@@ -143,7 +147,8 @@ export const ComposePost = ({
}) => {
const {currentAccount} = useSession()
const agent = useAgent()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
const currentDid = currentAccount!.did
const {data: currentProfile} = useProfileQuery({did: currentDid})
const {isModalActive} = useModals()
const {closeComposer} = useComposerControls()
const pal = usePalette('default')
@@ -182,25 +187,62 @@ export const ComposePost = ({
initQuote,
)
const [videoAltText, setVideoAltText] = useState('')
const [captions, setCaptions] = useState<{lang: string; file: File}[]>([])
// TODO: Move more state here.
const [composerState, dispatch] = useReducer(
composerReducer,
{initImageUris, initQuoteUri: initQuote?.uri},
createComposerState,
)
const {
selectVideo,
clearVideo,
state: videoUploadState,
updateVideoDimensions,
dispatch: videoUploadDispatch,
} = useUploadVideo({
setStatus: setProcessingState,
onSuccess: () => {
if (publishOnUpload) {
onPressPublish(true)
}
let videoState: VideoState | NoVideoState = NO_VIDEO
if (composerState.embed.media?.type === 'video') {
videoState = composerState.embed.media.video
}
const selectVideo = React.useCallback(
(asset: ImagePickerAsset) => {
const abortController = new AbortController()
dispatch({type: 'embed_add_video', asset, abortController})
processVideo(
asset,
videoAction => dispatch({type: 'embed_update_video', videoAction}),
agent,
currentDid,
abortController.signal,
_,
)
},
initialVideoUri: initVideoUri,
})
const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video)
[_, agent, currentDid],
)
// Whenever we receive an initial video uri, we should immediately run compression if necessary
useEffect(() => {
if (initVideoUri) {
selectVideo({uri: initVideoUri} as ImagePickerAsset)
}
}, [initVideoUri, selectVideo])
const clearVideo = React.useCallback(() => {
videoState.abortController.abort()
dispatch({type: 'embed_remove_video'})
}, [videoState.abortController, dispatch])
const updateVideoDimensions = useCallback(
(width: number, height: number) => {
dispatch({
type: 'embed_update_video',
videoAction: {
type: 'update_dimensions',
width,
height,
signal: videoState.abortController.signal,
},
})
},
[videoState.abortController],
)
const hasVideo = Boolean(videoState.asset || videoState.video)
const [publishOnUpload, setPublishOnUpload] = useState(false)
@@ -213,9 +255,11 @@ export const ComposePost = ({
)
const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
const [images, setImages] = useState<ComposerImage[]>(() =>
createInitialImages(initImageUris),
)
let images = NO_IMAGES
if (composerState.embed.media?.type === 'images') {
images = composerState.embed.media.images
}
const onClose = useCallback(() => {
closeComposer()
}, [closeComposer])
@@ -235,7 +279,7 @@ export const ComposePost = ({
graphemeLength > 0 ||
images.length !== 0 ||
extGif ||
videoUploadState.status !== 'idle'
videoState.status !== 'idle'
) {
closeAllDialogs()
Keyboard.dismiss()
@@ -250,7 +294,7 @@ export const ComposePost = ({
closeAllDialogs,
discardPromptControl,
onClose,
videoUploadState.status,
videoState.status,
])
useImperativeHandle(cancelRef, () => ({onPressCancel}))
@@ -293,6 +337,7 @@ export const ComposePost = ({
const onNewLink = useCallback(
(uri: string) => {
dispatch({type: 'embed_add_uri', uri})
if (extLink != null) return
setExtLink({uri, isLoading: true})
},
@@ -301,9 +346,12 @@ export const ComposePost = ({
const onImageAdd = useCallback(
(next: ComposerImage[]) => {
setImages(prev => prev.concat(next.slice(0, MAX_IMAGES - prev.length)))
dispatch({
type: 'embed_add_images',
images: next,
})
},
[setImages],
[dispatch],
)
const onPhotoPasted = useCallback(
@@ -344,8 +392,8 @@ export const ComposePost = ({
if (
!finishedUploading &&
videoUploadState.asset &&
videoUploadState.status !== 'done'
videoState.asset &&
videoState.status !== 'done'
) {
setPublishOnUpload(true)
return
@@ -358,7 +406,7 @@ export const ComposePost = ({
images.length === 0 &&
!extLink &&
!quote &&
videoUploadState.status === 'idle'
videoState.status === 'idle'
) {
setError(_(msg`Did you want to say anything?`))
return
@@ -374,9 +422,9 @@ export const ComposePost = ({
try {
postUri = (
await apilib.post(agent, {
composerState, // TODO: move more state here.
rawText: richtext.text,
replyTo: replyTo?.uri,
images,
quote,
extLink,
labels,
@@ -384,19 +432,6 @@ export const ComposePost = ({
postgate,
onStateChange: setProcessingState,
langs: toPostLanguages(langPrefs.postLanguage),
video: videoUploadState.pendingPublish?.blobRef
? {
blobRef: videoUploadState.pendingPublish.blobRef,
altText: videoAltText,
captions: captions,
aspectRatio: videoUploadState.asset
? {
width: videoUploadState.asset?.width,
height: videoUploadState.asset?.height,
}
: undefined,
}
: undefined,
})
).uri
try {
@@ -474,7 +509,7 @@ export const ComposePost = ({
[
_,
agent,
captions,
composerState,
extLink,
images,
graphemeLength,
@@ -492,21 +527,19 @@ export const ComposePost = ({
setExtLink,
setLangPrefs,
threadgateAllowUISettings,
videoAltText,
videoUploadState.asset,
videoUploadState.pendingPublish,
videoUploadState.status,
videoState.asset,
videoState.status,
],
)
React.useEffect(() => {
if (videoUploadState.pendingPublish && publishOnUpload) {
if (!videoUploadState.pendingPublish.mutableProcessed) {
videoUploadState.pendingPublish.mutableProcessed = true
if (videoState.pendingPublish && publishOnUpload) {
if (!videoState.pendingPublish.mutableProcessed) {
videoState.pendingPublish.mutableProcessed = true
onPressPublish(true)
}
}
}, [onPressPublish, publishOnUpload, videoUploadState.pendingPublish])
}, [onPressPublish, publishOnUpload, videoState.pendingPublish])
const canPost = useMemo(
() => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing,
@@ -519,10 +552,10 @@ export const ComposePost = ({
const canSelectImages =
images.length < MAX_IMAGES &&
!extLink &&
videoUploadState.status === 'idle' &&
!videoUploadState.video
videoState.status === 'idle' &&
!videoState.video
const hasMedia =
images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
images.length > 0 || Boolean(extLink) || Boolean(videoState.video)
const onEmojiButtonPress = useCallback(() => {
openEmojiPicker?.(textInput.current?.getCursorPosition())
@@ -534,6 +567,7 @@ export const ComposePost = ({
const onSelectGif = useCallback(
(gif: Gif) => {
dispatch({type: 'embed_add_gif', gif})
setExtLink({
uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`,
isLoading: true,
@@ -552,6 +586,7 @@ export const ComposePost = ({
const handleChangeGifAltText = useCallback(
(altText: string) => {
dispatch({type: 'embed_update_gif', alt: altText})
setExtLink(ext =>
ext && ext.meta
? {
@@ -581,271 +616,324 @@ export const ComposePost = ({
const keyboardVerticalOffset = useKeyboardVerticalOffset()
return (
<KeyboardAvoidingView
testID="composePostView"
behavior={isIOS ? 'padding' : 'height'}
keyboardVerticalOffset={keyboardVerticalOffset}
style={a.flex_1}>
<View style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal>
<Animated.View
style={topBarAnimatedStyle}
layout={native(LinearTransition)}>
<View style={styles.topbarInner}>
<Button
label={_(msg`Cancel`)}
variant="ghost"
color="primary"
shape="default"
size="small"
style={[
a.rounded_full,
a.py_sm,
{paddingLeft: 7, paddingRight: 7},
]}
onPress={onPressCancel}
accessibilityHint={_(
msg`Closes post composer and discards post draft`,
)}>
<ButtonText style={[a.text_md]}>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
{isProcessing ? (
<>
<Text style={pal.textLight}>{processingState}</Text>
<View style={styles.postBtn}>
<ActivityIndicator />
</View>
</>
) : (
<View style={[styles.postBtnWrapper]}>
<LabelsBtn
labels={labels}
onChange={setLabels}
hasMedia={hasMedia}
/>
{canPost ? (
<Button
testID="composerPublishBtn"
label={
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
}
variant="solid"
color="primary"
shape="default"
size="small"
style={[a.rounded_full, a.py_sm]}
onPress={() => onPressPublish()}
disabled={
videoUploadState.status !== 'idle' && publishOnUpload
}>
<ButtonText style={[a.text_md]}>
{replyTo ? (
<Trans context="action">Reply</Trans>
) : (
<Trans context="action">Post</Trans>
)}
</ButtonText>
</Button>
) : (
<View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>
<Trans context="action">Post</Trans>
</Text>
<Portal.Provider>
<KeyboardAvoidingView
testID="composePostView"
behavior={isIOS ? 'padding' : 'height'}
keyboardVerticalOffset={keyboardVerticalOffset}
style={a.flex_1}>
<View
style={[a.flex_1, viewStyles]}
aria-modal
accessibilityViewIsModal>
<Animated.View
style={topBarAnimatedStyle}
layout={native(LinearTransition)}>
<View style={styles.topbarInner}>
<Button
label={_(msg`Cancel`)}
variant="ghost"
color="primary"
shape="default"
size="small"
style={[
a.rounded_full,
a.py_sm,
{paddingLeft: 7, paddingRight: 7},
]}
onPress={onPressCancel}
accessibilityHint={_(
msg`Closes post composer and discards post draft`,
)}>
<ButtonText style={[a.text_md]}>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
{isProcessing ? (
<>
<Text style={pal.textLight}>{processingState}</Text>
<View style={styles.postBtn}>
<ActivityIndicator />
</View>
)}
</>
) : (
<View style={[styles.postBtnWrapper]}>
<LabelsBtn
labels={labels}
onChange={setLabels}
hasMedia={hasMedia}
/>
{canPost ? (
<Button
testID="composerPublishBtn"
label={
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
}
variant="solid"
color="primary"
shape="default"
size="small"
style={[a.rounded_full, a.py_sm]}
onPress={() => onPressPublish()}
disabled={
videoState.status !== 'idle' && publishOnUpload
}>
<ButtonText style={[a.text_md]}>
{replyTo ? (
<Trans context="action">Reply</Trans>
) : (
<Trans context="action">Post</Trans>
)}
</ButtonText>
</Button>
) : (
<View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>
<Trans context="action">Post</Trans>
</Text>
</View>
)}
</View>
)}
</View>
{isAltTextRequiredAndMissing && (
<View style={[styles.reminderLine, pal.viewLight]}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
/>
</View>
<Text style={[pal.text, a.flex_1]}>
<Trans>One or more images is missing alt text.</Trans>
</Text>
</View>
)}
</View>
<ErrorBanner
error={error}
videoState={videoState}
clearError={() => setError('')}
clearVideo={clearVideo}
/>
</Animated.View>
<Animated.ScrollView
layout={native(LinearTransition)}
onScroll={scrollHandler}
style={styles.scrollView}
keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
{isAltTextRequiredAndMissing && (
<View style={[styles.reminderLine, pal.viewLight]}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
<View
style={[
styles.textInputLayout,
isNative && styles.textInputLayoutMobile,
]}>
<UserAvatar
avatar={currentProfile?.avatar}
size={50}
type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
/>
<TextInput
ref={textInput}
richtext={richtext}
placeholder={selectTextInputPlaceholder}
autoFocus
setRichText={setRichText}
onPhotoPasted={onPhotoPasted}
onPressPublish={() => onPressPublish()}
onNewLink={onNewLink}
onError={setError}
accessible={true}
accessibilityLabel={_(msg`Write post`)}
accessibilityHint={_(
msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
)}
/>
</View>
<Gallery
images={images}
dispatch={dispatch}
Portal={Portal.Portal}
/>
{images.length === 0 && extLink && (
<View style={a.relative}>
<ExternalEmbed
link={extLink}
gif={extGif}
onRemove={() => {
if (extGif) {
dispatch({type: 'embed_remove_gif'})
} else {
dispatch({type: 'embed_remove_link'})
}
setExtLink(undefined)
setExtGif(undefined)
}}
/>
<GifAltText
link={extLink}
gif={extGif}
onSubmit={handleChangeGifAltText}
Portal={Portal.Portal}
/>
</View>
<Text style={[pal.text, a.flex_1]}>
<Trans>One or more images is missing alt text.</Trans>
</Text>
)}
<LayoutAnimationConfig skipExiting>
{hasVideo && (
<Animated.View
style={[a.w_full, a.mt_lg]}
entering={native(ZoomIn)}
exiting={native(ZoomOut)}>
{videoState.asset &&
(videoState.status === 'compressing' ? (
<VideoTranscodeProgress
asset={videoState.asset}
progress={videoState.progress}
clear={clearVideo}
/>
) : videoState.video ? (
<VideoPreview
asset={videoState.asset}
video={videoState.video}
setDimensions={updateVideoDimensions}
clear={clearVideo}
/>
) : null)}
<SubtitleDialogBtn
defaultAltText={videoState.altText}
saveAltText={altText =>
dispatch({
type: 'embed_update_video',
videoAction: {
type: 'update_alt_text',
altText,
signal: videoState.abortController.signal,
},
})
}
captions={videoState.captions}
setCaptions={updater => {
dispatch({
type: 'embed_update_video',
videoAction: {
type: 'update_captions',
updater,
signal: videoState.abortController.signal,
},
})
}}
Portal={Portal.Portal}
/>
</Animated.View>
)}
</LayoutAnimationConfig>
<View style={!hasVideo ? [a.mt_md] : []}>
{quote ? (
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} />
</View>
{quote.uri !== initQuote?.uri && (
<QuoteX
onRemove={() => {
dispatch({type: 'embed_remove_quote'})
setQuote(undefined)
}}
/>
)}
</View>
) : null}
</View>
)}
<ErrorBanner
error={error}
videoUploadState={videoUploadState}
clearError={() => setError('')}
videoUploadDispatch={videoUploadDispatch}
/>
</Animated.View>
<Animated.ScrollView
layout={native(LinearTransition)}
onScroll={scrollHandler}
style={styles.scrollView}
keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
</Animated.ScrollView>
<SuggestedLanguage text={richtext.text} />
{replyTo ? null : (
<ThreadgateBtn
postgate={postgate}
onChangePostgate={setPostgate}
threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={
onChangeThreadgateAllowUISettings
}
style={bottomBarAnimatedStyle}
Portal={Portal.Portal}
/>
)}
<View
style={[
styles.textInputLayout,
isNative && styles.textInputLayoutMobile,
a.flex_row,
a.py_xs,
{paddingLeft: 7, paddingRight: 16},
a.align_center,
a.border_t,
t.atoms.bg,
t.atoms.border_contrast_medium,
a.justify_between,
]}>
<UserAvatar
avatar={currentProfile?.avatar}
size={50}
type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
/>
<TextInput
ref={textInput}
richtext={richtext}
placeholder={selectTextInputPlaceholder}
autoFocus
setRichText={setRichText}
onPhotoPasted={onPhotoPasted}
onPressPublish={() => onPressPublish()}
onNewLink={onNewLink}
onError={setError}
accessible={true}
accessibilityLabel={_(msg`Write post`)}
accessibilityHint={_(
msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
<View style={[a.flex_row, a.align_center]}>
{videoState.status !== 'idle' && videoState.status !== 'done' ? (
<VideoUploadToolbar state={videoState} />
) : (
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn
size={images.length}
disabled={!canSelectImages}
onAdd={onImageAdd}
/>
<SelectVideoBtn
onSelectVideo={selectVideo}
disabled={!canSelectImages || images?.length > 0}
setError={setError}
/>
<OpenCameraBtn
disabled={!canSelectImages}
onAdd={onImageAdd}
/>
<SelectGifBtn
onClose={focusTextInput}
onSelectGif={onSelectGif}
disabled={hasMedia}
Portal={Portal.Portal}
/>
{!isMobile ? (
<Button
onPress={onEmojiButtonPress}
style={a.p_sm}
label={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Open emoji picker`)}
variant="ghost"
shape="round"
color="primary">
<EmojiSmile size="lg" />
</Button>
) : null}
</ToolbarWrapper>
)}
/>
</View>
<Gallery images={images} onChange={setImages} />
{images.length === 0 && extLink && (
<View style={a.relative}>
<ExternalEmbed
link={extLink}
gif={extGif}
onRemove={() => {
setExtLink(undefined)
setExtGif(undefined)
}}
/>
<GifAltText
link={extLink}
gif={extGif}
onSubmit={handleChangeGifAltText}
/>
</View>
)}
<LayoutAnimationConfig skipExiting>
{hasVideo && (
<Animated.View
style={[a.w_full, a.mt_lg]}
entering={native(ZoomIn)}
exiting={native(ZoomOut)}>
{videoUploadState.asset &&
(videoUploadState.status === 'compressing' ? (
<VideoTranscodeProgress
asset={videoUploadState.asset}
progress={videoUploadState.progress}
clear={clearVideo}
/>
) : videoUploadState.video ? (
<VideoPreview
asset={videoUploadState.asset}
video={videoUploadState.video}
setDimensions={updateVideoDimensions}
clear={clearVideo}
/>
) : null)}
<SubtitleDialogBtn
defaultAltText={videoAltText}
saveAltText={setVideoAltText}
captions={captions}
setCaptions={setCaptions}
/>
</Animated.View>
)}
</LayoutAnimationConfig>
<View style={!hasVideo ? [a.mt_md] : []}>
{quote ? (
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} />
</View>
{quote.uri !== initQuote?.uri && (
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View>
) : null}
<View style={[a.flex_row, a.align_center, a.justify_between]}>
<SelectLangBtn />
<CharProgress count={graphemeLength} style={{width: 65}} />
</View>
</View>
</Animated.ScrollView>
<SuggestedLanguage text={richtext.text} />
{replyTo ? null : (
<ThreadgateBtn
postgate={postgate}
onChangePostgate={setPostgate}
threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={
onChangeThreadgateAllowUISettings
}
style={bottomBarAnimatedStyle}
/>
)}
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_medium,
styles.bottomBar,
]}>
{videoUploadState.status !== 'idle' &&
videoUploadState.status !== 'done' ? (
<VideoUploadToolbar state={videoUploadState} />
) : (
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn
size={images.length}
disabled={!canSelectImages}
onAdd={onImageAdd}
/>
<SelectVideoBtn
onSelectVideo={selectVideo}
disabled={!canSelectImages}
setError={setError}
/>
<OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
<SelectGifBtn
onClose={focusTextInput}
onSelectGif={onSelectGif}
disabled={hasMedia}
/>
{!isMobile ? (
<Button
onPress={onEmojiButtonPress}
style={a.p_sm}
label={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Open emoji picker`)}
variant="ghost"
shape="round"
color="primary">
<EmojiSmile size="lg" />
</Button>
) : null}
</ToolbarWrapper>
)}
<View style={a.flex_1} />
<SelectLangBtn />
<CharProgress count={graphemeLength} />
</View>
</View>
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard draft?`)}
description={_(msg`Are you sure you'd like to discard this draft?`)}
onConfirm={onClose}
confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative"
/>
</KeyboardAvoidingView>
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard draft?`)}
description={_(msg`Are you sure you'd like to discard this draft?`)}
onConfirm={onClose}
confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative"
Portal={Portal.Portal}
/>
</KeyboardAvoidingView>
<Portal.Outlet />
</Portal.Provider>
)
}
@@ -1051,40 +1139,31 @@ const styles = StyleSheet.create({
marginHorizontal: 10,
marginBottom: 4,
},
bottomBar: {
flexDirection: 'row',
paddingVertical: 4,
// should be 8 but due to visual alignment we have to fudge it
paddingLeft: 7,
paddingRight: 16,
alignItems: 'center',
borderTopWidth: StyleSheet.hairlineWidth,
},
})
function ErrorBanner({
error: standardError,
videoUploadState,
videoState,
clearError,
videoUploadDispatch,
clearVideo,
}: {
error: string
videoUploadState: VideoUploadState
videoState: VideoState | NoVideoState
clearError: () => void
videoUploadDispatch: VideoUploadDispatch
clearVideo: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const videoError =
videoUploadState.status !== 'idle' ? videoUploadState.error : undefined
videoState.status === 'error' ? videoState.error : undefined
const error = standardError || videoError
const onClearError = () => {
if (standardError) {
clearError()
} else {
videoUploadDispatch({type: 'Reset'})
clearVideo()
}
}
@@ -1119,7 +1198,7 @@ function ErrorBanner({
<ButtonIcon icon={X} />
</Button>
</View>
{videoError && videoUploadState.jobStatus?.jobId && (
{videoError && videoState.jobId && (
<NewText
style={[
{paddingLeft: 28},
@@ -1128,7 +1207,7 @@ function ErrorBanner({
a.leading_snug,
t.atoms.text_contrast_low,
]}>
<Trans>Job ID: {videoUploadState.jobStatus.jobId}</Trans>
<Trans>Job ID: {videoState.jobId}</Trans>
</NewText>
)}
</View>
@@ -1154,12 +1233,10 @@ function ToolbarWrapper({
)
}
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
function VideoUploadToolbar({state}: {state: VideoState}) {
const t = useTheme()
const {_} = useLingui()
const progress = state.jobStatus?.progress
? state.jobStatus.progress / 100
: state.progress
const progress = state.progress
const shouldRotate =
state.status === 'processing' && (progress === 0 || progress === 1)
let wheelProgress = shouldRotate ? 0.33 : progress
@@ -1195,16 +1272,15 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
case 'processing':
text = _('Processing video...')
break
case 'error':
text = _('Error')
wheelProgress = 100
break
case 'done':
text = _('Video uploaded')
break
}
if (state.error) {
text = _('Error')
wheelProgress = 100
}
return (
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
<Animated.View style={[animatedStyle]}>
@@ -1212,7 +1288,11 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
size={30}
borderWidth={1}
borderColor={t.atoms.border_contrast_low.borderColor}
color={state.error ? t.palette.negative_500 : t.palette.primary_500}
color={
state.status === 'error'
? t.palette.negative_500
: t.palette.primary_500
}
progress={wheelProgress}
/>
</Animated.View>
+83 -57
View File
@@ -1,4 +1,4 @@
import React, {useCallback, useState} from 'react'
import React, {useState} from 'react'
import {TouchableOpacity, View} from 'react-native'
import {AppBskyEmbedExternal} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
@@ -11,15 +11,18 @@ import {
EmbedPlayerParams,
parseEmbedPlayerFromUrl,
} from '#/lib/strings/embed-player'
import {enforceLen} from '#/lib/strings/helpers'
import {isAndroid} from '#/platform/detection'
import {Gif} from '#/state/queries/tenor'
import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper'
import {atoms as a, native, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {DialogControlProps} from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography'
import {GifEmbed} from '../util/post-embeds/GifEmbed'
import {AltTextReminder} from './photos/Gallery'
@@ -28,10 +31,12 @@ export function GifAltText({
link: linkProp,
gif,
onSubmit,
Portal,
}: {
link: ExternalEmbedDraft
gif?: Gif
onSubmit: (alt: string) => void
Portal: PortalComponent
}) {
const control = Dialog.useDialogControl()
const {_} = useLingui()
@@ -49,18 +54,11 @@ export function GifAltText({
}
}, [linkProp])
const onPressSubmit = useCallback(
(alt: string) => {
control.close(() => {
onSubmit(alt)
})
},
[onSubmit, control],
)
const parsedAlt = parseAltFromGIFDescription(link.description)
const [altText, setAltText] = useState(parsedAlt.alt)
if (!gif || !params) return null
const parsedAlt = parseAltFromGIFDescription(link.description)
return (
<>
<TouchableOpacity
@@ -98,13 +96,17 @@ export function GifAltText({
<Dialog.Outer
control={control}
nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
onClose={() => {
onSubmit(altText)
}}
Portal={Portal}>
<Dialog.Handle />
<AltTextInner
onSubmit={onPressSubmit}
altText={altText}
setAltText={setAltText}
control={control}
link={link}
params={params}
initialValue={parsedAlt.isPreferred ? parsedAlt.alt : ''}
key={link.uri}
/>
</Dialog.Outer>
@@ -113,61 +115,83 @@ export function GifAltText({
}
function AltTextInner({
onSubmit,
altText,
setAltText,
control,
link,
params,
initialValue: initalValue,
}: {
onSubmit: (text: string) => void
altText: string
setAltText: (text: string) => void
control: DialogControlProps
link: AppBskyEmbedExternal.ViewExternal
params: EmbedPlayerParams
initialValue: string
}) {
const {_} = useLingui()
const [altText, setAltText] = useState(initalValue)
const control = Dialog.useDialogContext()
const onPressSubmit = useCallback(() => {
onSubmit(altText)
}, [onSubmit, altText])
const t = useTheme()
const {_, i18n} = useLingui()
return (
<Dialog.ScrollableInner label={_(msg`Add alt text`)}>
<View style={a.flex_col_reverse}>
<View style={[a.mt_md, a.gap_md]}>
<View>
<TextField.LabelText>
<Trans>Descriptive alt text</Trans>
</TextField.LabelText>
<TextField.Root>
<Dialog.Input
label={_(msg`Alt text`)}
placeholder={link.title}
onChangeText={text =>
setAltText(enforceLen(text, MAX_ALT_TEXT))
}
value={altText}
multiline
numberOfLines={3}
autoFocus
onKeyPress={({nativeEvent}) => {
if (nativeEvent.key === 'Escape') {
control.close()
}
}}
/>
</TextField.Root>
<View style={[a.gap_sm]}>
<View style={[a.relative]}>
<TextField.LabelText>
<Trans>Descriptive alt text</Trans>
</TextField.LabelText>
<TextField.Root>
<Dialog.Input
label={_(msg`Alt text`)}
placeholder={link.title}
onChangeText={text => {
setAltText(text)
}}
defaultValue={altText}
multiline
numberOfLines={3}
autoFocus
onKeyPress={({nativeEvent}) => {
if (nativeEvent.key === 'Escape') {
control.close()
}
}}
/>
</TextField.Root>
</View>
{altText.length > MAX_ALT_TEXT && (
<View style={[a.pb_sm, a.flex_row, a.gap_xs]}>
<CircleInfo fill={t.palette.negative_500} />
<Text
style={[
a.italic,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
Alt text will be truncated. Limit:{' '}
{i18n.number(MAX_ALT_TEXT)} characters.
</Trans>
</Text>
</View>
)}
</View>
<Button
label={_(msg`Save`)}
size="large"
color="primary"
variant="solid"
onPress={onPressSubmit}>
<ButtonText>
<Trans>Save</Trans>
</ButtonText>
</Button>
<AltTextCounterWrapper altText={altText}>
<Button
label={_(msg`Save`)}
size="large"
color="primary"
variant="solid"
onPress={() => {
control.close()
}}
style={[a.flex_grow]}>
<ButtonText>
<Trans>Save</Trans>
</ButtonText>
</Button>
</AltTextCounterWrapper>
</View>
{/* below the text input to force tab order */}
<View>
@@ -185,6 +209,8 @@ function AltTextInner({
</View>
</View>
<Dialog.Close />
{/* Maybe fix this later -h */}
{isAndroid ? <View style={{height: 300}} /> : null}
</Dialog.ScrollableInner>
)
}
@@ -1,48 +1,55 @@
import React from 'react'
import {View} from 'react-native'
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native'
// @ts-ignore no type definition -prf
import ProgressCircle from 'react-native-progress/Circle'
// @ts-ignore no type definition -prf
import ProgressPie from 'react-native-progress/Pie'
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {atoms as a} from '#/alf'
import {Text} from '../../util/text/Text'
const DANGER_LENGTH = MAX_GRAPHEME_LENGTH
export function CharProgress({count}: {count: number}) {
export function CharProgress({
count,
max,
style,
textStyle,
size,
}: {
count: number
max?: number
style?: StyleProp<ViewStyle>
textStyle?: StyleProp<TextStyle>
size?: number
}) {
const maxLength = max || MAX_GRAPHEME_LENGTH
const pal = usePalette('default')
const textColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.text
const circleColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.link
const textColor = count > maxLength ? '#e60000' : pal.colors.text
const circleColor = count > maxLength ? '#e60000' : pal.colors.link
return (
<>
<Text style={[s.mr10, s.tabularNum, {color: textColor}]}>
{MAX_GRAPHEME_LENGTH - count}
<View
style={[a.flex_row, a.align_center, a.justify_between, a.gap_sm, style]}>
<Text style={[{color: textColor}, a.flex_grow, a.text_right, textStyle]}>
{maxLength - count}
</Text>
<View>
{count > DANGER_LENGTH ? (
<ProgressPie
size={30}
borderWidth={4}
borderColor={circleColor}
color={circleColor}
progress={Math.min(
(count - MAX_GRAPHEME_LENGTH) / MAX_GRAPHEME_LENGTH,
1,
)}
/>
) : (
<ProgressCircle
size={30}
borderWidth={1}
borderColor={pal.colors.border}
color={circleColor}
progress={count / MAX_GRAPHEME_LENGTH}
/>
)}
</View>
</>
{count > maxLength ? (
<ProgressPie
size={size ?? 30}
borderWidth={4}
borderColor={circleColor}
color={circleColor}
progress={Math.min((count - maxLength) / maxLength, 1)}
/>
) : (
<ProgressCircle
size={size ?? 30}
borderWidth={1}
borderColor={pal.colors.border}
color={circleColor}
progress={count / maxLength}
/>
)}
</View>
)
}

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