Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6f30628
patch package to not invert the a11y order
OtavioStasiak Jan 9, 2026
8d73ba9
feat: a11yInvertedView manager
OtavioStasiak Jan 19, 2026
3db3a52
fix(a11y): Fix accessibility traversal order for inverted FlatList on…
OtavioStasiak Jan 19, 2026
8362a92
fix: use the default a11y behavior
OtavioStasiak Jan 20, 2026
8ac5ccf
Merge branch 'develop' into test.flatlist-inverted-content
OtavioStasiak Jan 20, 2026
c3da7dd
fix: build
OtavioStasiak Jan 20, 2026
37c7701
lock
OtavioStasiak Jan 20, 2026
4d4d572
timeout
OtavioStasiak Jan 20, 2026
98f7dc9
Merge branch 'develop' into test.flatlist-inverted-content
OtavioStasiak Jan 27, 2026
df97db6
feat: created flatlist inverted native module
OtavioStasiak Jan 28, 2026
1467be6
remove previous solution
OtavioStasiak Jan 28, 2026
e7d809c
fix: revert timeout build android
OtavioStasiak Jan 28, 2026
b25af00
fix: list components style
OtavioStasiak Jan 28, 2026
d6b0f23
patch-package
OtavioStasiak Jan 28, 2026
62c6a83
revert package.json updates
OtavioStasiak Jan 28, 2026
bbc5ee5
revert package.json updates
OtavioStasiak Jan 28, 2026
4eed5d9
chore: format code and fix lint issues [skip ci]
OtavioStasiak Jan 28, 2026
e6c7e16
code improvements
OtavioStasiak Jan 28, 2026
c23670b
code improvements
OtavioStasiak Jan 28, 2026
b2b40bb
chore: format code and fix lint issues [skip ci]
OtavioStasiak Jan 28, 2026
556d404
update comments
OtavioStasiak Jan 28, 2026
ed43d9e
use foward ref
OtavioStasiak Jan 28, 2026
b106234
use FowardRef
OtavioStasiak Jan 28, 2026
195df51
fix: invertedScrollView ref
OtavioStasiak Jan 29, 2026
d7a0a5e
cleanup
OtavioStasiak Jan 29, 2026
4837b1a
chore: format code and fix lint issues [skip ci]
OtavioStasiak Jan 29, 2026
51d984e
fix: invertedScrollView
OtavioStasiak Jan 29, 2026
151aebb
code improvements
OtavioStasiak Jan 29, 2026
ead814b
chore: format code and fix lint issues [skip ci]
OtavioStasiak Jan 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -44,6 +45,7 @@ open class MainApplication : Application(), ReactApplication {
add(VideoConfTurboPackage())
add(PushNotificationTurboPackage())
add(SecureStoragePackage())
add(InvertedScrollPackage())
}

override fun getJSMainModuleName(): String = "index"
Expand Down
Original file line number Diff line number Diff line change
@@ -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<View> outChildren) {
super.addChildrenForAccessibility(outChildren);
Collections.reverse(outChildren);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
return Collections.emptyList();
}

@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
List<ViewManager> managers = new java.util.ArrayList<>();
managers.add(new InvertedScrollViewManager());
managers.add(new InvertedScrollContentViewManager());
return managers;
}
}
Original file line number Diff line number Diff line change
@@ -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<View> outChildren) {
super.addChildrenForAccessibility(outChildren);
if (mIsInvertedVirtualizedList) {
Collections.reverse(outChildren);
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
159 changes: 159 additions & 0 deletions app/views/RoomView/List/components/InvertedScrollView.tsx
Original file line number Diff line number Diff line change
@@ -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<NativeScrollInstance | null>;
type NativeScrollInstance = React.ComponentRef<NonNullable<typeof NativeInvertedScrollView>>;
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<ScrollViewProps>('InvertedScrollView') : null;

const NativeInvertedScrollContentView = isAndroid
? requireNativeComponent<ViewProps & { removeClippedSubviews?: boolean }>('InvertedScrollContentView')
: null;

const InvertedScrollView = forwardRef<InvertedScrollViewRef, ScrollViewProps>((props, externalRef) => {
const internalRef = useRef<NativeScrollInstance | null>(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<NativeScrollInstance | null>).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<ScrollViewPropsWithRef>;
const ContentView = NativeInvertedScrollContentView as React.ComponentType<ViewProps & { removeClippedSubviews?: boolean }>;

return (
<ScrollView ref={setRef} {...restWithoutStyle} style={StyleSheet.compose(baseStyle, style)} horizontal={horizontal}>
<ContentView
{...contentSizeChangeProps}
removeClippedSubviews={isAndroid && hasStickyHeaders ? false : removeClippedSubviews}
collapsable={false}
collapsableChildren={!preserveChildren}
style={contentContainerStyleArray as StyleProp<ViewStyle>}>
{children}
</ContentView>
</ScrollView>
);
});

InvertedScrollView.displayName = 'InvertedScrollView';

export default InvertedScrollView;
2 changes: 2 additions & 0 deletions app/views/RoomView/List/components/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -43,6 +44,7 @@ const List = ({ listRef, jumpToBottom, ...props }: IListProps) => {
contentContainerStyle={styles.contentContainer}
style={styles.list}
inverted
renderScrollComponent={isIOS ? undefined : props => <InvertedScrollView {...props} />}
removeClippedSubviews={isIOS}
initialNumToRender={7}
onEndReachedThreshold={0.5}
Expand Down