diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 5ac2e25f483..811e72f97ce 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -20,6 +20,7 @@ import chat.rocket.reactnative.storage.MMKVKeyManager; import chat.rocket.reactnative.storage.SecureStoragePackage; import chat.rocket.reactnative.notification.VideoConfTurboPackage import chat.rocket.reactnative.notification.PushNotificationTurboPackage +import chat.rocket.reactnative.scroll.InvertedScrollPackage /** * Main Application class. @@ -44,6 +45,7 @@ open class MainApplication : Application(), ReactApplication { add(VideoConfTurboPackage()) add(PushNotificationTurboPackage()) add(SecureStoragePackage()) + add(InvertedScrollPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentView.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentView.java new file mode 100644 index 00000000000..a4acb0c1e13 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentView.java @@ -0,0 +1,23 @@ +package chat.rocket.reactnative.scroll; + +import android.view.View; +import com.facebook.react.views.view.ReactViewGroup; +import java.util.ArrayList; +import java.util.Collections; + +/** + * Content view for inverted FlatLists. Reports its children to accessibility in reversed order so + * TalkBack traversal matches the visual order (newest-first) when used inside InvertedScrollView. + */ +public class InvertedScrollContentView extends ReactViewGroup { + + public InvertedScrollContentView(android.content.Context context) { + super(context); + } + + @Override + public void addChildrenForAccessibility(ArrayList outChildren) { + super.addChildrenForAccessibility(outChildren); + Collections.reverse(outChildren); + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentViewManager.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentViewManager.java new file mode 100644 index 00000000000..d30f9fc84c2 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentViewManager.java @@ -0,0 +1,25 @@ +package chat.rocket.reactnative.scroll; + +import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.views.view.ReactViewManager; + +/** + * View manager for InvertedScrollContentView. Behaves like a View but reports children in reversed + * order for accessibility so TalkBack matches the visual order in inverted lists. + */ +@ReactModule(name = InvertedScrollContentViewManager.REACT_CLASS) +public class InvertedScrollContentViewManager extends ReactViewManager { + + public static final String REACT_CLASS = "InvertedScrollContentView"; + + @Override + public String getName() { + return REACT_CLASS; + } + + @Override + public InvertedScrollContentView createViewInstance(ThemedReactContext context) { + return new InvertedScrollContentView(context); + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollPackage.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollPackage.java new file mode 100644 index 00000000000..05e6a7be0d5 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollPackage.java @@ -0,0 +1,24 @@ +package chat.rocket.reactnative.scroll; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; +import java.util.Collections; +import java.util.List; + +public class InvertedScrollPackage implements ReactPackage { + + @Override + public List createNativeModules(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + List managers = new java.util.ArrayList<>(); + managers.add(new InvertedScrollViewManager()); + managers.add(new InvertedScrollContentViewManager()); + return managers; + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java new file mode 100644 index 00000000000..def585a7511 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java @@ -0,0 +1,36 @@ +package chat.rocket.reactnative.scroll; + +import android.view.View; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.views.scroll.ReactScrollView; +import java.util.ArrayList; +import java.util.Collections; + +// When a FlatList is inverted (inverted={true}), React Native uses scaleY: -1 transform which +// visually inverts the list but Android still reports children in array order. This view overrides +// addChildrenForAccessibility to reverse the order so TalkBack matches the visual order. + +public class InvertedScrollView extends ReactScrollView { + + private boolean mIsInvertedVirtualizedList = false; + + public InvertedScrollView(ReactContext context) { + super(context); + } + + + // Set whether this ScrollView is used for an inverted virtualized list. When true, we reverse the + // accessibility traversal order to match the visual order. + + public void setIsInvertedVirtualizedList(boolean isInverted) { + mIsInvertedVirtualizedList = isInverted; + } + + @Override + public void addChildrenForAccessibility(ArrayList outChildren) { + super.addChildrenForAccessibility(outChildren); + if (mIsInvertedVirtualizedList) { + Collections.reverse(outChildren); + } + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollViewManager.java b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollViewManager.java new file mode 100644 index 00000000000..453dd009ec0 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollViewManager.java @@ -0,0 +1,26 @@ +package chat.rocket.reactnative.scroll; + +import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.views.scroll.ReactScrollViewManager; + +/** + * View manager for {@link InvertedScrollView}. Registers as "InvertedScrollView" to avoid + * collision with core RCTScrollView. Inherits all ScrollView props from ReactScrollViewManager; + * FlatList passes isInvertedVirtualizedList when inverted, which is applied by the parent setter. + */ +@ReactModule(name = InvertedScrollViewManager.REACT_CLASS) +public class InvertedScrollViewManager extends ReactScrollViewManager { + + public static final String REACT_CLASS = "InvertedScrollView"; + + @Override + public String getName() { + return REACT_CLASS; + } + + @Override + public InvertedScrollView createViewInstance(ThemedReactContext context) { + return new InvertedScrollView(context); + } +} diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx new file mode 100644 index 00000000000..a60fe91eee1 --- /dev/null +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -0,0 +1,159 @@ +import React, { forwardRef, useRef, useLayoutEffect } from 'react'; +import { + findNodeHandle, + requireNativeComponent, + StyleSheet, + UIManager, + type StyleProp, + type ViewStyle, + type LayoutChangeEvent, + type ScrollViewProps, + type ViewProps +} from 'react-native'; + +import { isAndroid } from '../../../../lib/methods/helpers'; + +const COMMAND_SCROLL_TO = 1; +const COMMAND_SCROLL_TO_END = 2; +const COMMAND_FLASH_SCROLL_INDICATORS = 3; + +const styles = StyleSheet.create({ + baseVertical: { + flexGrow: 1, + flexShrink: 1, + flexDirection: 'column', + overflow: 'scroll' + }, + baseHorizontal: { + flexGrow: 1, + flexShrink: 1, + flexDirection: 'row', + overflow: 'scroll' + } +}); + +type ScrollViewPropsWithRef = ScrollViewProps & React.RefAttributes; +type NativeScrollInstance = React.ComponentRef>; +interface IScrollableMethods { + scrollTo(options?: { x?: number; y?: number; animated?: boolean }): void; + scrollToEnd(options?: { animated?: boolean }): void; + flashScrollIndicators(): void; + getScrollRef(): NativeScrollInstance | null; + setNativeProps(props: object): void; +} + +export type InvertedScrollViewRef = NativeScrollInstance & IScrollableMethods; + +const NativeInvertedScrollView = isAndroid ? requireNativeComponent('InvertedScrollView') : null; + +const NativeInvertedScrollContentView = isAndroid + ? requireNativeComponent('InvertedScrollContentView') + : null; + +const InvertedScrollView = forwardRef((props, externalRef) => { + const internalRef = useRef(null); + + useLayoutEffect(() => { + const node = internalRef.current as any; + + if (node) { + // 1. Implementation of scrollTo + node.scrollTo = (options?: { x?: number; y?: number; animated?: boolean }) => { + const tag = findNodeHandle(node); + if (tag != null) { + const x = options?.x || 0; + const y = options?.y || 0; + const animated = options?.animated !== false; + UIManager.dispatchViewManagerCommand(tag, COMMAND_SCROLL_TO, [x, y, animated]); + } + }; + + // 2. Implementation of scrollToEnd + node.scrollToEnd = (options?: { animated?: boolean }) => { + const tag = findNodeHandle(node); + if (tag != null) { + const animated = options?.animated !== false; + UIManager.dispatchViewManagerCommand(tag, COMMAND_SCROLL_TO_END, [animated]); + } + }; + + // 3. Implementation of flashScrollIndicators + node.flashScrollIndicators = () => { + const tag = findNodeHandle(node as any); + if (tag !== null) { + UIManager.dispatchViewManagerCommand(tag, COMMAND_FLASH_SCROLL_INDICATORS, []); + } + }; + + node.getScrollRef = () => node; + const originalSetNativeProps = (node as any).setNativeProps; + if (typeof originalSetNativeProps !== 'function') { + node.setNativeProps = (_nativeProps: object) => {}; + } + } + }, []); + + // Callback Ref to handle merging internal and external refs + const setRef = (node: NativeScrollInstance | null) => { + internalRef.current = node; + + if (typeof externalRef === 'function') { + externalRef(node as InvertedScrollViewRef); + } else if (externalRef) { + (externalRef as React.MutableRefObject).current = node; + } + }; + + const { + children, + contentContainerStyle, + onContentSizeChange, + removeClippedSubviews, + maintainVisibleContentPosition, + snapToAlignment, + stickyHeaderIndices, + ...rest + } = props; + + const preserveChildren = maintainVisibleContentPosition != null || (isAndroid && snapToAlignment != null); + const hasStickyHeaders = Array.isArray(stickyHeaderIndices) && stickyHeaderIndices.length > 0; + + const contentContainerStyleArray = [props.horizontal ? { flexDirection: 'row' as const } : null, contentContainerStyle]; + + const contentSizeChangeProps = + onContentSizeChange == null + ? undefined + : { + onLayout: (e: LayoutChangeEvent) => { + const { width, height } = e.nativeEvent.layout; + onContentSizeChange(width, height); + } + }; + + const horizontal = !!props.horizontal; + const baseStyle = horizontal ? styles.baseHorizontal : styles.baseVertical; + const { style, ...restWithoutStyle } = rest; + + if (!NativeInvertedScrollView || !NativeInvertedScrollContentView) { + return null; + } + const ScrollView = NativeInvertedScrollView as React.ComponentType; + const ContentView = NativeInvertedScrollContentView as React.ComponentType; + + return ( + + }> + {children} + + + ); +}); + +InvertedScrollView.displayName = 'InvertedScrollView'; + +export default InvertedScrollView; diff --git a/app/views/RoomView/List/components/List.tsx b/app/views/RoomView/List/components/List.tsx index 2d8b0c081e7..7ffe0135587 100644 --- a/app/views/RoomView/List/components/List.tsx +++ b/app/views/RoomView/List/components/List.tsx @@ -4,6 +4,7 @@ import Animated, { runOnJS, useAnimatedScrollHandler } from 'react-native-reanim import { isIOS } from '../../../../lib/methods/helpers'; import scrollPersistTaps from '../../../../lib/methods/helpers/scrollPersistTaps'; +import InvertedScrollView from './InvertedScrollView'; import NavBottomFAB from './NavBottomFAB'; import { type IListProps } from '../definitions'; import { SCROLL_LIMIT } from '../constants'; @@ -43,6 +44,7 @@ const List = ({ listRef, jumpToBottom, ...props }: IListProps) => { contentContainerStyle={styles.contentContainer} style={styles.list} inverted + renderScrollComponent={isIOS ? undefined : props => } removeClippedSubviews={isIOS} initialNumToRender={7} onEndReachedThreshold={0.5}