From 6f3062830741ba2b2e94b1fffd9b61ad3144ff22 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Fri, 9 Jan 2026 11:34:50 -0300 Subject: [PATCH 01/27] patch package to not invert the a11y order --- android/settings.gradle | 9 ++ patches/react-native+0.79.4.patch | 202 +++++++++++++++++++++++++++++- 2 files changed, 210 insertions(+), 1 deletion(-) diff --git a/android/settings.gradle b/android/settings.gradle index 9bb0709a8db..b52bfa2b6ee 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -8,5 +8,14 @@ include ':app' includeBuild('../node_modules/@react-native/gradle-plugin') +includeBuild('../node_modules/react-native') { + dependencySubstitution { + substitute(module("com.facebook.react:react-android")).using(project(":packages:react-native:ReactAndroid")) + substitute(module("com.facebook.react:react-native")).using(project(":packages:react-native:ReactAndroid")) + substitute(module("com.facebook.react:hermes-android")).using(project(":packages:react-native:ReactAndroid:hermes-engine")) + substitute(module("com.facebook.react:hermes-engine")).using(project(":packages:react-native:ReactAndroid:hermes-engine")) + } +} + apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle"); useExpoModules() \ No newline at end of file diff --git a/patches/react-native+0.79.4.patch b/patches/react-native+0.79.4.patch index 0fd01cf14df..a551885ef89 100644 --- a/patches/react-native+0.79.4.patch +++ b/patches/react-native+0.79.4.patch @@ -22,4 +22,204 @@ index f017cb8..f6aaafb 100644 + if (view.reactAccessibilityElement.roleTraits != roleTraits || view.reactAccessibilityElement.role != roleString) { view.roleTraits = roleTraits; view.reactAccessibilityElement.role = json ? [RCTConvert NSString:json] : nil; - [self updateAccessibilityTraitsForRole:view withDefaultView:defaultView]; \ No newline at end of file + [self updateAccessibilityTraitsForRole:view withDefaultView:defaultView]; +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +index 1234567..abcdefg 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +@@ -1387,6 +1387,11 @@ public class ReactScrollView extends ScrollView + public void setIsInvertedVirtualizedList(boolean isInverted) { + mIsInvertedVirtualizedList = isInverted; + } ++ ++ public boolean isInvertedVirtualizedList() { ++ Log.d("ReactScrollView", "isInvertedVirtualizedList() called, returning: " + mIsInvertedVirtualizedList); ++ return mIsInvertedVirtualizedList; ++ } + + @Override + public void setScrollEventThrottle(int scrollEventThrottle) { +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java +index 1234567..abcdefg 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java +@@ -1014,6 +1014,103 @@ public class ReactViewGroup extends ViewGroup implements ReactClippingViewGroup + + setAlpha(0); + } ++ ++ /** ++ * Check if a view is a message item based on testID, content description, or class name ++ */ ++ private boolean isMessageView(View view) { ++ if (view == null) { ++ return false; ++ } ++ ++ // Check testID for "message-" pattern ++ Object testIdTag = view.getTag(R.id.react_test_id); ++ if (testIdTag instanceof String) { ++ String testId = (String) testIdTag; ++ if (testId != null && testId.startsWith("message-")) { ++ return true; ++ } ++ } ++ ++ // Check content description for message patterns ++ CharSequence contentDesc = view.getContentDescription(); ++ if (contentDesc != null) { ++ String desc = contentDesc.toString().toLowerCase(); ++ if (desc.contains("message") || desc.contains("msg")) { ++ return true; ++ } ++ } ++ ++ // Check class name ++ String className = view.getClass().getSimpleName(); ++ if (className != null && className.toLowerCase().contains("message")) { ++ return true; ++ } ++ ++ // Check accessibility label ++ try { ++ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { ++ android.view.accessibility.AccessibilityNodeInfo info = android.view.accessibility.AccessibilityNodeInfo.obtain(); ++ view.onInitializeAccessibilityNodeInfo(info); ++ CharSequence label = info.getContentDescription(); ++ if (label != null) { ++ String labelStr = label.toString().toLowerCase(); ++ if (labelStr.contains("message") || labelStr.contains("msg")) { ++ info.recycle(); ++ return true; ++ } ++ } ++ info.recycle(); ++ } ++ } catch (Exception e) { ++ // Ignore errors in accessibility check ++ } ++ ++ return false; ++ } ++ ++ /** ++ * Log all visible items in the list for debugging ++ */ ++ private void logVisibleItems(ReactScrollView scrollView) { ++ if (scrollView == null) { ++ return; ++ } ++ ++ int childCount = getChildCount(); ++ Log.d("A11Y_DEBUG", "=== VISIBLE ITEMS DETECTION ==="); ++ Log.d("A11Y_DEBUG", "ScrollView scrollY: " + scrollView.getScrollY() + ", height: " + scrollView.getHeight()); ++ Log.d("A11Y_DEBUG", "Total childCount: " + childCount); ++ ++ int visibleCount = 0; ++ int messageCount = 0; ++ ++ for (int i = 0; i < childCount; i++) { ++ View child = getChildAt(i); ++ if (child != null) { ++ boolean isShown = child.isShown(); ++ boolean isVisible = child.getVisibility() == View.VISIBLE; ++ boolean isMessage = isMessageView(child); ++ ++ String testId = ""; ++ Object testIdTag = child.getTag(R.id.react_test_id); ++ if (testIdTag instanceof String) { ++ testId = (String) testIdTag; ++ } ++ ++ String desc = child.getContentDescription() != null ? child.getContentDescription().toString() : "null"; ++ String className = child.getClass().getSimpleName(); ++ ++ if (isShown && isVisible) { ++ visibleCount++; ++ if (isMessage) { ++ messageCount++; ++ } ++ Log.d("A11Y_DEBUG", String.format( ++ " VISIBLE[%d]: %s - id=%d, testID=%s, className=%s, desc=%s", ++ i, isMessage ? "MESSAGE" : "NOT MESSAGE", child.getId(), testId, className, desc ++ )); ++ } ++ } ++ } ++ ++ Log.d("A11Y_DEBUG", "Summary - Visible items: " + visibleCount + " (messages: " + messageCount + ", non-messages: " + (visibleCount - messageCount) + ")"); ++ Log.d("A11Y_DEBUG", "=== END VISIBLE ITEMS ==="); ++ } ++ ++ @Override ++ public void addChildrenForAccessibility(ArrayList outChildren) { ++ int childCount = getChildCount(); ++ ++ // Check if parent is an inverted ScrollView - only log for relevant cases ++ ViewParent parent = getParent(); ++ if (parent instanceof ReactScrollView) { ++ ReactScrollView scrollView = (ReactScrollView) parent; ++ boolean isInverted = scrollView.isInvertedVirtualizedList(); ++ ++ if (isInverted) { ++ // Log all visible items before processing ++ logVisibleItems(scrollView); ++ ++ Log.d("A11Y_DEBUG", "=== INVERTED LIST - Processing accessibility order ==="); ++ Log.d("A11Y_DEBUG", "Total childCount: " + childCount); ++ ++ // Reverse order for inverted lists ++ int visibleCount = 0; ++ for (int i = childCount - 1; i >= 0; i--) { ++ View child = getChildAt(i); ++ if (child != null && child.isShown() && child.getVisibility() == View.VISIBLE) { ++ outChildren.add(child); ++ visibleCount++; ++ } ++ } ++ ++ // Log complete accessibility order with message identification ++ Log.d("A11Y_DEBUG", "=== COMPLETE ACCESSIBILITY ORDER (INVERTED) ==="); ++ Log.d("A11Y_DEBUG", "Total children in a11y order: " + outChildren.size()); ++ int messageCount = 0; ++ int nonMessageCount = 0; ++ ++ for (int i = 0; i < outChildren.size(); i++) { ++ View child = outChildren.get(i); ++ boolean isMessage = isMessageView(child); ++ String testId = ""; ++ Object testIdTag = child.getTag(R.id.react_test_id); ++ if (testIdTag instanceof String) { ++ testId = (String) testIdTag; ++ } ++ String desc = child.getContentDescription() != null ? child.getContentDescription().toString() : "null"; ++ String className = child.getClass().getSimpleName(); ++ ++ if (isMessage) { ++ messageCount++; ++ Log.d("A11Y_DEBUG", String.format( ++ " A11y[%d]: MESSAGE - id=%d, testID=%s, className=%s, desc=%s", ++ i, child.getId(), testId, className, desc ++ )); ++ } else { ++ nonMessageCount++; ++ Log.d("A11Y_DEBUG", String.format( ++ " A11y[%d]: NOT MESSAGE - id=%d, testID=%s, className=%s, desc=%s", ++ i, child.getId(), testId, className, desc ++ )); ++ } ++ } ++ ++ Log.d("A11Y_DEBUG", String.format( ++ "Summary: Total=%d, Messages=%d, Non-Messages=%d", ++ outChildren.size(), messageCount, nonMessageCount ++ )); ++ Log.d("A11Y_DEBUG", "=== END ACCESSIBILITY ORDER (INVERTED) ==="); ++ return; ++ } ++ } ++ ++ // Default behavior for non-inverted lists - no logging to reduce noise ++ super.addChildrenForAccessibility(outChildren); ++ } + } From 8d73ba91d577731e3a142a037daafaea5ed4c302 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Mon, 19 Jan 2026 18:12:09 -0300 Subject: [PATCH 02/27] feat: a11yInvertedView manager --- .../rocket/reactnative/MainApplication.kt | 2 + .../a11y/AccessibleInvertedScrollView.java | 165 +++++++++++++ .../AccessibleInvertedScrollViewManager.java | 194 +++++++++++++++ .../AccessibleInvertedScrollViewPackage.java | 30 +++ patches/react-native+0.79.4.patch | 225 ------------------ 5 files changed, 391 insertions(+), 225 deletions(-) create mode 100644 android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollView.java create mode 100644 android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewManager.java create mode 100644 android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewPackage.java delete mode 100644 patches/react-native+0.79.4.patch 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 89622d5d0a4..f4508a4fb33 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -23,6 +23,7 @@ import chat.rocket.reactnative.storage.MMKVKeyManager; import chat.rocket.reactnative.storage.SecureStoragePackage; import chat.rocket.reactnative.notification.CustomPushNotification; import chat.rocket.reactnative.notification.VideoConfTurboPackage +import chat.rocket.reactnative.a11y.AccessibleInvertedScrollViewPackage; /** * Main Application class. @@ -46,6 +47,7 @@ open class MainApplication : Application(), ReactApplication { add(WatermelonDBJSIPackage()) add(VideoConfTurboPackage()) add(SecureStoragePackage()) + add(AccessibleInvertedScrollViewPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollView.java b/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollView.java new file mode 100644 index 00000000000..cf14e45ac63 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollView.java @@ -0,0 +1,165 @@ +package chat.rocket.reactnative.a11y; + +import android.content.Context; +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.Nullable; +import com.facebook.react.views.scroll.ReactScrollView; +import com.facebook.react.views.scroll.FpsListener; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; + +/** + * Custom ReactScrollView that fixes accessibility traversal order for inverted lists. + * + * When a FlatList is inverted (inverted={true}), React Native uses scaleY: -1 transform + * which visually inverts the list but also inverts the accessibility traversal order. + * This class overrides addChildrenForAccessibility() to maintain correct accessibility + * order (oldest to newest) despite the visual inversion. + * + * The implementation tracks visible items similar to the useScroll hook in JavaScript, + * filtering children based on viewport visibility and reordering them for correct + * accessibility traversal. + */ +public class AccessibleInvertedScrollView extends ReactScrollView { + + private static final float VIEWABILITY_THRESHOLD = 0.1f; // 10% visibility threshold + + public AccessibleInvertedScrollView(Context context) { + this(context, null); + } + + public AccessibleInvertedScrollView(Context context, @Nullable FpsListener fpsListener) { + super(context, fpsListener); + } + + /** + * Override addChildrenForAccessibility to reorder children for correct accessibility traversal. + * + * For inverted lists, the visual order is reversed (newest at top), but we want + * accessibility to traverse in logical order (oldest to newest). We achieve this by: + * 1. Getting all visible children based on viewport bounds + * 2. Sorting them by their position in the view hierarchy (which reflects logical order) + * 3. Reversing the order so accessibility traverses from bottom to top (oldest to newest) + * + * This method is called by Android's accessibility framework to determine the order + * in which screen readers should traverse child views. + */ + @Override + public void addChildrenForAccessibility(ArrayList outChildren) { + // Get the content view (the container that holds all list items) + ViewGroup contentView = (ViewGroup) getContentView(); + if (contentView == null || contentView.getChildCount() == 0) { + super.addChildrenForAccessibility(outChildren); + return; + } + + // Calculate viewport bounds for visibility checking + int scrollY = getScrollY(); + int paddingTop = getPaddingTop(); + int paddingBottom = getPaddingBottom(); + int viewportTop = scrollY + paddingTop; + int viewportBottom = scrollY + getHeight() - paddingBottom; + + // Collect visible children with their indices + ArrayList visibleChildren = new ArrayList<>(); + + for (int i = 0; i < contentView.getChildCount(); i++) { + View child = contentView.getChildAt(i); + if (child == null || child.getVisibility() != View.VISIBLE) { + continue; + } + + // Check if child is at least partially visible (using same threshold as useScroll hook) + if (isChildVisible(child, viewportTop, viewportBottom)) { + visibleChildren.add(new ViewInfo(child, i, child.getTop())); + } + } + + if (visibleChildren.isEmpty()) { + super.addChildrenForAccessibility(outChildren); + return; + } + + // Sort children by their original index in the content view + // This reflects the logical order (oldest messages have lower indices) + Collections.sort(visibleChildren, new Comparator() { + @Override + public int compare(ViewInfo v1, ViewInfo v2) { + // Sort by original index - lower indices come first (older messages) + return Integer.compare(v1.originalIndex, v2.originalIndex); + } + }); + + // For inverted lists, we want accessibility to read from bottom to top + // (oldest to newest), so we reverse the sorted list + Collections.reverse(visibleChildren); + + // Add sorted and reversed children to output + for (ViewInfo viewInfo : visibleChildren) { + outChildren.add(viewInfo.view); + } + } + + /** + * Helper class to store view information for sorting. + */ + private static class ViewInfo { + final View view; + final int originalIndex; + final int top; + + ViewInfo(View view, int originalIndex, int top) { + this.view = view; + this.originalIndex = originalIndex; + this.top = top; + } + } + + /** + * Check if a child view is at least partially visible in the viewport. + * Uses the same threshold as VIEWABILITY_CONFIG (10%). + * + * This matches the logic from useScroll.ts hook which uses + * itemVisiblePercentThreshold: 10 from VIEWABILITY_CONFIG. + */ + private boolean isChildVisible(View child, int viewportTop, int viewportBottom) { + if (child == null || child.getVisibility() != View.VISIBLE) { + return false; + } + + int childTop = child.getTop(); + int childBottom = child.getBottom(); + int childHeight = childBottom - childTop; + + if (childHeight == 0) { + return false; + } + + // Calculate visible height within viewport + int visibleTop = Math.max(childTop, viewportTop); + int visibleBottom = Math.min(childBottom, viewportBottom); + int visibleHeight = Math.max(0, visibleBottom - visibleTop); + + // Check if at least 10% of the child is visible (matching VIEWABILITY_CONFIG) + float visibleRatio = (float) visibleHeight / childHeight; + return visibleRatio >= VIEWABILITY_THRESHOLD; + } + + /** + * Get the content view (the container holding list items). + * ScrollView typically has one child which is the content container. + */ + @Nullable + private ViewGroup getContentView() { + if (getChildCount() > 0) { + View child = getChildAt(0); + if (child instanceof ViewGroup) { + return (ViewGroup) child; + } + } + return null; + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewManager.java b/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewManager.java new file mode 100644 index 00000000000..8b99c6823e3 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewManager.java @@ -0,0 +1,194 @@ +package chat.rocket.reactnative.a11y; + +import android.graphics.Color; +import android.view.View; +import androidx.annotation.Nullable; +import com.facebook.infer.annotation.Nullsafe; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.RetryableMountingLayerException; +import com.facebook.react.common.MapBuilder; +import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.uimanager.LengthPercentage; +import com.facebook.react.uimanager.LengthPercentageType; +import com.facebook.react.uimanager.PixelUtil; +import com.facebook.react.uimanager.PointerEvents; +import com.facebook.react.uimanager.ReactClippingViewGroupHelper; +import com.facebook.react.uimanager.Spacing; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.uimanager.ViewGroupManager; +import com.facebook.react.uimanager.ViewProps; +import com.facebook.react.uimanager.annotations.ReactProp; +import com.facebook.react.uimanager.annotations.ReactPropGroup; +import com.facebook.react.uimanager.style.BorderRadiusProp; +import com.facebook.react.uimanager.style.BorderStyle; +import com.facebook.react.uimanager.style.LogicalEdge; +import com.facebook.react.views.scroll.ReactScrollViewCommandHelper; +import com.facebook.react.views.scroll.ScrollEventType; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * ViewManager for AccessibleInvertedScrollView. + * + * This manager extends the functionality of ReactScrollViewManager to use + * AccessibleInvertedScrollView instead, which fixes accessibility traversal + * order for inverted lists. + */ +@Nullsafe(Nullsafe.Mode.LOCAL) +@ReactModule(name = AccessibleInvertedScrollViewManager.REACT_CLASS) +public class AccessibleInvertedScrollViewManager extends ViewGroupManager + implements ReactScrollViewCommandHelper.ScrollCommandHandler { + + public static final String REACT_CLASS = "RCTAccessibleInvertedScrollView"; + + private static final int[] SPACING_TYPES = { + Spacing.ALL, Spacing.LEFT, Spacing.RIGHT, Spacing.TOP, Spacing.BOTTOM, + }; + + @Override + public String getName() { + return REACT_CLASS; + } + + @Override + public AccessibleInvertedScrollView createViewInstance(ThemedReactContext context) { + return new AccessibleInvertedScrollView(context, null); + } + + // Delegate all ReactScrollView props to maintain compatibility + // Most props are handled by the base ReactScrollView class + + @ReactProp(name = "scrollEnabled", defaultBoolean = true) + public void setScrollEnabled(AccessibleInvertedScrollView view, boolean value) { + view.setScrollEnabled(value); + view.setFocusable(value); + } + + @ReactProp(name = "showsVerticalScrollIndicator", defaultBoolean = true) + public void setShowsVerticalScrollIndicator(AccessibleInvertedScrollView view, boolean value) { + view.setVerticalScrollBarEnabled(value); + } + + @ReactProp(name = "decelerationRate") + public void setDecelerationRate(AccessibleInvertedScrollView view, float decelerationRate) { + view.setDecelerationRate(decelerationRate); + } + + @ReactProp(name = ReactClippingViewGroupHelper.PROP_REMOVE_CLIPPED_SUBVIEWS) + public void setRemoveClippedSubviews(AccessibleInvertedScrollView view, boolean removeClippedSubviews) { + view.setRemoveClippedSubviews(removeClippedSubviews); + } + + @ReactProp(name = "pagingEnabled") + public void setPagingEnabled(AccessibleInvertedScrollView view, boolean pagingEnabled) { + view.setPagingEnabled(pagingEnabled); + } + + @ReactProp(name = "nestedScrollEnabled") + public void setNestedScrollEnabled(AccessibleInvertedScrollView view, boolean value) { + androidx.core.view.ViewCompat.setNestedScrollingEnabled(view, value); + } + + @ReactProp(name = "overflow") + public void setOverflow(AccessibleInvertedScrollView view, @Nullable String overflow) { + view.setOverflow(overflow); + } + + @ReactProp(name = ViewProps.POINTER_EVENTS) + public void setPointerEvents(AccessibleInvertedScrollView view, @Nullable String pointerEventsStr) { + view.setPointerEvents(PointerEvents.parsePointerEvents(pointerEventsStr)); + } + + @ReactProp(name = "scrollEventThrottle") + public void setScrollEventThrottle(AccessibleInvertedScrollView view, int scrollEventThrottle) { + view.setScrollEventThrottle(scrollEventThrottle); + } + + @ReactProp(name = "horizontal") + public void setHorizontal(AccessibleInvertedScrollView view, boolean horizontal) { + // Do Nothing: Align with static ViewConfigs + } + + @Override + public @Nullable Map getCommandsMap() { + return ReactScrollViewCommandHelper.getCommandsMap(); + } + + @Override + public void receiveCommand( + AccessibleInvertedScrollView scrollView, int commandId, @Nullable ReadableArray args) { + ReactScrollViewCommandHelper.receiveCommand(this, scrollView, commandId, args); + } + + @Override + public void receiveCommand( + AccessibleInvertedScrollView scrollView, String commandId, @Nullable ReadableArray args) { + ReactScrollViewCommandHelper.receiveCommand(this, scrollView, commandId, args); + } + + @Override + public void flashScrollIndicators(AccessibleInvertedScrollView scrollView) { + scrollView.flashScrollIndicators(); + } + + @Override + public void scrollTo( + AccessibleInvertedScrollView scrollView, ReactScrollViewCommandHelper.ScrollToCommandData data) { + scrollView.abortAnimation(); + if (data.mAnimated) { + scrollView.reactSmoothScrollTo(data.mDestX, data.mDestY); + } else { + scrollView.scrollTo(data.mDestX, data.mDestY); + } + } + + @Override + public void scrollToEnd( + AccessibleInvertedScrollView scrollView, ReactScrollViewCommandHelper.ScrollToEndCommandData data) { + View child = scrollView.getChildAt(0); + if (child == null) { + throw new RetryableMountingLayerException("scrollToEnd called on ScrollView without child"); + } + + int bottom = child.getHeight() + scrollView.getPaddingBottom(); + scrollView.abortAnimation(); + if (data.mAnimated) { + scrollView.reactSmoothScrollTo(scrollView.getScrollX(), bottom); + } else { + scrollView.scrollTo(scrollView.getScrollX(), bottom); + } + } + + @Override + public @Nullable Map getExportedCustomDirectEventTypeConstants() { + @Nullable + Map baseEventTypeConstants = super.getExportedCustomDirectEventTypeConstants(); + Map eventTypeConstants = + baseEventTypeConstants == null ? new HashMap() : baseEventTypeConstants; + eventTypeConstants.putAll(createExportedCustomDirectEventTypeConstants()); + return eventTypeConstants; + } + + public static Map createExportedCustomDirectEventTypeConstants() { + return MapBuilder.builder() + .put( + ScrollEventType.getJSEventName(ScrollEventType.SCROLL), + MapBuilder.of("registrationName", "onScroll")) + .put( + ScrollEventType.getJSEventName(ScrollEventType.BEGIN_DRAG), + MapBuilder.of("registrationName", "onScrollBeginDrag")) + .put( + ScrollEventType.getJSEventName(ScrollEventType.END_DRAG), + MapBuilder.of("registrationName", "onScrollEndDrag")) + .put( + ScrollEventType.getJSEventName(ScrollEventType.MOMENTUM_BEGIN), + MapBuilder.of("registrationName", "onMomentumScrollBegin")) + .put( + ScrollEventType.getJSEventName(ScrollEventType.MOMENTUM_END), + MapBuilder.of("registrationName", "onMomentumScrollEnd")) + .build(); + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewPackage.java b/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewPackage.java new file mode 100644 index 00000000000..487c86f7aca --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewPackage.java @@ -0,0 +1,30 @@ +package chat.rocket.reactnative.a11y; + +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.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * ReactPackage for AccessibleInvertedScrollView. + * + * Registers the AccessibleInvertedScrollViewManager to make the custom + * scroll view available to React Native. + */ +public class AccessibleInvertedScrollViewPackage implements ReactPackage { + @Override + public List createNativeModules(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + List viewManagers = new ArrayList<>(); + viewManagers.add(new AccessibleInvertedScrollViewManager()); + return viewManagers; + } +} diff --git a/patches/react-native+0.79.4.patch b/patches/react-native+0.79.4.patch deleted file mode 100644 index a551885ef89..00000000000 --- a/patches/react-native+0.79.4.patch +++ /dev/null @@ -1,225 +0,0 @@ -diff --git a/node_modules/react-native/React/Views/RCTViewManager.m b/node_modules/react-native/React/Views/RCTViewManager.m -index f017cb8..f6aaafb 100644 ---- a/node_modules/react-native/React/Views/RCTViewManager.m -+++ b/node_modules/react-native/React/Views/RCTViewManager.m -@@ -225,7 +225,9 @@ RCT_CUSTOM_VIEW_PROPERTY(accessibilityRole, UIAccessibilityTraits, RCTView) - { - UIAccessibilityTraits accessibilityRoleTraits = - json ? [RCTConvert UIAccessibilityTraits:json] : UIAccessibilityTraitNone; -- if (view.reactAccessibilityElement.accessibilityRoleTraits != accessibilityRoleTraits) { -+ NSString *roleString = json ? [RCTConvert NSString:json] : nil; -+ if (view.reactAccessibilityElement.accessibilityRoleTraits != accessibilityRoleTraits || view.reactAccessibilityElement.accessibilityRole != roleString) { -+ - view.accessibilityRoleTraits = accessibilityRoleTraits; - view.reactAccessibilityElement.accessibilityRole = json ? [RCTConvert NSString:json] : nil; - [self updateAccessibilityTraitsForRole:view withDefaultView:defaultView]; -@@ -235,7 +237,8 @@ RCT_CUSTOM_VIEW_PROPERTY(accessibilityRole, UIAccessibilityTraits, RCTView) - RCT_CUSTOM_VIEW_PROPERTY(role, UIAccessibilityTraits, RCTView) - { - UIAccessibilityTraits roleTraits = json ? [RCTConvert UIAccessibilityTraits:json] : UIAccessibilityTraitNone; -- if (view.reactAccessibilityElement.roleTraits != roleTraits) { -+ NSString *roleString = json ? [RCTConvert NSString:json] : nil; -+ if (view.reactAccessibilityElement.roleTraits != roleTraits || view.reactAccessibilityElement.role != roleString) { - view.roleTraits = roleTraits; - view.reactAccessibilityElement.role = json ? [RCTConvert NSString:json] : nil; - [self updateAccessibilityTraitsForRole:view withDefaultView:defaultView]; -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java -index 1234567..abcdefg 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java -@@ -1387,6 +1387,11 @@ public class ReactScrollView extends ScrollView - public void setIsInvertedVirtualizedList(boolean isInverted) { - mIsInvertedVirtualizedList = isInverted; - } -+ -+ public boolean isInvertedVirtualizedList() { -+ Log.d("ReactScrollView", "isInvertedVirtualizedList() called, returning: " + mIsInvertedVirtualizedList); -+ return mIsInvertedVirtualizedList; -+ } - - @Override - public void setScrollEventThrottle(int scrollEventThrottle) { -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java -index 1234567..abcdefg 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java -@@ -1014,6 +1014,103 @@ public class ReactViewGroup extends ViewGroup implements ReactClippingViewGroup - - setAlpha(0); - } -+ -+ /** -+ * Check if a view is a message item based on testID, content description, or class name -+ */ -+ private boolean isMessageView(View view) { -+ if (view == null) { -+ return false; -+ } -+ -+ // Check testID for "message-" pattern -+ Object testIdTag = view.getTag(R.id.react_test_id); -+ if (testIdTag instanceof String) { -+ String testId = (String) testIdTag; -+ if (testId != null && testId.startsWith("message-")) { -+ return true; -+ } -+ } -+ -+ // Check content description for message patterns -+ CharSequence contentDesc = view.getContentDescription(); -+ if (contentDesc != null) { -+ String desc = contentDesc.toString().toLowerCase(); -+ if (desc.contains("message") || desc.contains("msg")) { -+ return true; -+ } -+ } -+ -+ // Check class name -+ String className = view.getClass().getSimpleName(); -+ if (className != null && className.toLowerCase().contains("message")) { -+ return true; -+ } -+ -+ // Check accessibility label -+ try { -+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { -+ android.view.accessibility.AccessibilityNodeInfo info = android.view.accessibility.AccessibilityNodeInfo.obtain(); -+ view.onInitializeAccessibilityNodeInfo(info); -+ CharSequence label = info.getContentDescription(); -+ if (label != null) { -+ String labelStr = label.toString().toLowerCase(); -+ if (labelStr.contains("message") || labelStr.contains("msg")) { -+ info.recycle(); -+ return true; -+ } -+ } -+ info.recycle(); -+ } -+ } catch (Exception e) { -+ // Ignore errors in accessibility check -+ } -+ -+ return false; -+ } -+ -+ /** -+ * Log all visible items in the list for debugging -+ */ -+ private void logVisibleItems(ReactScrollView scrollView) { -+ if (scrollView == null) { -+ return; -+ } -+ -+ int childCount = getChildCount(); -+ Log.d("A11Y_DEBUG", "=== VISIBLE ITEMS DETECTION ==="); -+ Log.d("A11Y_DEBUG", "ScrollView scrollY: " + scrollView.getScrollY() + ", height: " + scrollView.getHeight()); -+ Log.d("A11Y_DEBUG", "Total childCount: " + childCount); -+ -+ int visibleCount = 0; -+ int messageCount = 0; -+ -+ for (int i = 0; i < childCount; i++) { -+ View child = getChildAt(i); -+ if (child != null) { -+ boolean isShown = child.isShown(); -+ boolean isVisible = child.getVisibility() == View.VISIBLE; -+ boolean isMessage = isMessageView(child); -+ -+ String testId = ""; -+ Object testIdTag = child.getTag(R.id.react_test_id); -+ if (testIdTag instanceof String) { -+ testId = (String) testIdTag; -+ } -+ -+ String desc = child.getContentDescription() != null ? child.getContentDescription().toString() : "null"; -+ String className = child.getClass().getSimpleName(); -+ -+ if (isShown && isVisible) { -+ visibleCount++; -+ if (isMessage) { -+ messageCount++; -+ } -+ Log.d("A11Y_DEBUG", String.format( -+ " VISIBLE[%d]: %s - id=%d, testID=%s, className=%s, desc=%s", -+ i, isMessage ? "MESSAGE" : "NOT MESSAGE", child.getId(), testId, className, desc -+ )); -+ } -+ } -+ } -+ -+ Log.d("A11Y_DEBUG", "Summary - Visible items: " + visibleCount + " (messages: " + messageCount + ", non-messages: " + (visibleCount - messageCount) + ")"); -+ Log.d("A11Y_DEBUG", "=== END VISIBLE ITEMS ==="); -+ } -+ -+ @Override -+ public void addChildrenForAccessibility(ArrayList outChildren) { -+ int childCount = getChildCount(); -+ -+ // Check if parent is an inverted ScrollView - only log for relevant cases -+ ViewParent parent = getParent(); -+ if (parent instanceof ReactScrollView) { -+ ReactScrollView scrollView = (ReactScrollView) parent; -+ boolean isInverted = scrollView.isInvertedVirtualizedList(); -+ -+ if (isInverted) { -+ // Log all visible items before processing -+ logVisibleItems(scrollView); -+ -+ Log.d("A11Y_DEBUG", "=== INVERTED LIST - Processing accessibility order ==="); -+ Log.d("A11Y_DEBUG", "Total childCount: " + childCount); -+ -+ // Reverse order for inverted lists -+ int visibleCount = 0; -+ for (int i = childCount - 1; i >= 0; i--) { -+ View child = getChildAt(i); -+ if (child != null && child.isShown() && child.getVisibility() == View.VISIBLE) { -+ outChildren.add(child); -+ visibleCount++; -+ } -+ } -+ -+ // Log complete accessibility order with message identification -+ Log.d("A11Y_DEBUG", "=== COMPLETE ACCESSIBILITY ORDER (INVERTED) ==="); -+ Log.d("A11Y_DEBUG", "Total children in a11y order: " + outChildren.size()); -+ int messageCount = 0; -+ int nonMessageCount = 0; -+ -+ for (int i = 0; i < outChildren.size(); i++) { -+ View child = outChildren.get(i); -+ boolean isMessage = isMessageView(child); -+ String testId = ""; -+ Object testIdTag = child.getTag(R.id.react_test_id); -+ if (testIdTag instanceof String) { -+ testId = (String) testIdTag; -+ } -+ String desc = child.getContentDescription() != null ? child.getContentDescription().toString() : "null"; -+ String className = child.getClass().getSimpleName(); -+ -+ if (isMessage) { -+ messageCount++; -+ Log.d("A11Y_DEBUG", String.format( -+ " A11y[%d]: MESSAGE - id=%d, testID=%s, className=%s, desc=%s", -+ i, child.getId(), testId, className, desc -+ )); -+ } else { -+ nonMessageCount++; -+ Log.d("A11Y_DEBUG", String.format( -+ " A11y[%d]: NOT MESSAGE - id=%d, testID=%s, className=%s, desc=%s", -+ i, child.getId(), testId, className, desc -+ )); -+ } -+ } -+ -+ Log.d("A11Y_DEBUG", String.format( -+ "Summary: Total=%d, Messages=%d, Non-Messages=%d", -+ outChildren.size(), messageCount, nonMessageCount -+ )); -+ Log.d("A11Y_DEBUG", "=== END ACCESSIBILITY ORDER (INVERTED) ==="); -+ return; -+ } -+ } -+ -+ // Default behavior for non-inverted lists - no logging to reduce noise -+ super.addChildrenForAccessibility(outChildren); -+ } - } From 3db3a52a3d4317e889cf240eab08f757d6569f38 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Mon, 19 Jan 2026 18:40:24 -0300 Subject: [PATCH 03/27] fix(a11y): Fix accessibility traversal order for inverted FlatList on Android --- patches/react-native+0.79.4.patch | 112 ++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 patches/react-native+0.79.4.patch diff --git a/patches/react-native+0.79.4.patch b/patches/react-native+0.79.4.patch new file mode 100644 index 00000000000..0126c509015 --- /dev/null +++ b/patches/react-native+0.79.4.patch @@ -0,0 +1,112 @@ +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +index 1234567..abcdefg 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +@@ -126,6 +126,7 @@ public class ReactScrollView extends ScrollView + private int mScrollEventThrottle = 0; + private @Nullable MaintainVisibleScrollPositionHelper mMaintainVisibleContentPositionHelper = + null; ++ private boolean mIsInvertedVirtualizedList = false; + + public ReactScrollView(Context context) { + this(context, null); +@@ -156,6 +157,79 @@ public class ReactScrollView extends ScrollView + } + } + ++ /** ++ * Set whether this ScrollView is used for an inverted virtualized list. ++ * When true, we override accessibility traversal order to fix the issue where ++ * inverted lists have reversed accessibility order. ++ */ ++ public void setIsInvertedVirtualizedList(boolean isInverted) { ++ mIsInvertedVirtualizedList = isInverted; ++ } ++ ++ /** ++ * Override addChildrenForAccessibility to fix traversal order for inverted lists. ++ * ++ * When a FlatList is inverted (inverted={true}), React Native uses scaleY: -1 transform ++ * which visually inverts the list but also inverts the accessibility traversal order. ++ * ++ * Due to scaleY: -1 transform: ++ * - Higher array indices appear at VISUAL TOP (newest messages) ++ * - Lower array indices appear at VISUAL BOTTOM (oldest messages) ++ * ++ * This method returns VISIBLE children (plus a buffer zone) in VISUAL order (top to bottom): ++ * 1. First item = topmost visible (focus enters here from header) = highest index ++ * 2. Last item = bottommost visible (focus exits to composer from here) = lowest index ++ * 3. Buffer zone allows scroll-to-reveal when navigating beyond visible bounds ++ */ ++ @Override ++ public void addChildrenForAccessibility(ArrayList outChildren) { ++ if (!mIsInvertedVirtualizedList) { ++ super.addChildrenForAccessibility(outChildren); ++ return; ++ } ++ ++ // Get the content view (the container that holds all list items) ++ View contentView = getContentView(); ++ if (contentView == null || !(contentView instanceof ViewGroup)) { ++ super.addChildrenForAccessibility(outChildren); ++ return; ++ } ++ ViewGroup contentViewGroup = (ViewGroup) contentView; ++ int childCount = contentViewGroup.getChildCount(); ++ if (childCount == 0) { ++ super.addChildrenForAccessibility(outChildren); ++ return; ++ } ++ ++ // Calculate viewport bounds with buffer zone for scroll-to-reveal ++ int scrollY = getScrollY(); ++ int viewportTop = scrollY; ++ int viewportBottom = scrollY + getHeight(); ++ ++ // Buffer zone allows focus to move to items just outside viewport, ++ // triggering requestChildFocus() which scrolls them into view ++ int bufferZone = 200; // pixels ++ int extendedViewportTop = viewportTop - bufferZone; ++ int extendedViewportBottom = viewportBottom + bufferZone; ++ ++ // For inverted lists with scaleY: -1, we must iterate in REVERSE order ++ // to match VISUAL top-to-bottom order: ++ // - childCount-1 = visually at TOP (newest message) -> added FIRST ++ // - 0 = visually at BOTTOM (oldest message) -> added LAST ++ // ++ // This ensures: ++ // - First accessible child = top visible item (focus enters here from header) ++ // - Last accessible child = bottom visible item (focus exits to composer from here) ++ for (int i = childCount - 1; i >= 0; i--) { ++ View child = contentViewGroup.getChildAt(i); ++ if (child != null && child.getVisibility() == View.VISIBLE) { ++ // Check if child is within extended viewport (visible + buffer) ++ int childTop = child.getTop(); ++ int childBottom = child.getBottom(); ++ if (childBottom > extendedViewportTop && childTop < extendedViewportBottom) { ++ outChildren.add(child); ++ } ++ } ++ } ++ ++ // If no visible children found, fall back to default behavior ++ if (outChildren.isEmpty()) { ++ super.addChildrenForAccessibility(outChildren); ++ } ++ } ++ + @Nullable + private OverScroller getOverScrollerFromParent() { + OverScroller scroller; +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java +index 1234567..abcdefg 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java +@@ -403,5 +403,7 @@ public class ReactScrollViewManager extends ViewGroupManager + } else { + view.setVerticalScrollbarPosition(View.SCROLLBAR_POSITION_DEFAULT); + } ++ // Set the inverted flag for accessibility fix ++ view.setIsInvertedVirtualizedList(applyFix); + } + } From 8362a92dc61338a455e79a893bb24c4990c0038e Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Tue, 20 Jan 2026 13:33:11 -0300 Subject: [PATCH 04/27] fix: use the default a11y behavior --- .../rocket/reactnative/MainApplication.kt | 2 - .../a11y/AccessibleInvertedScrollView.java | 165 --------------- .../AccessibleInvertedScrollViewManager.java | 194 ------------------ .../AccessibleInvertedScrollViewPackage.java | 30 --- patches/react-native+0.79.4.patch | 90 +++----- 5 files changed, 23 insertions(+), 458 deletions(-) delete mode 100644 android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollView.java delete mode 100644 android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewManager.java delete mode 100644 android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewPackage.java 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 f4508a4fb33..89622d5d0a4 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -23,7 +23,6 @@ import chat.rocket.reactnative.storage.MMKVKeyManager; import chat.rocket.reactnative.storage.SecureStoragePackage; import chat.rocket.reactnative.notification.CustomPushNotification; import chat.rocket.reactnative.notification.VideoConfTurboPackage -import chat.rocket.reactnative.a11y.AccessibleInvertedScrollViewPackage; /** * Main Application class. @@ -47,7 +46,6 @@ open class MainApplication : Application(), ReactApplication { add(WatermelonDBJSIPackage()) add(VideoConfTurboPackage()) add(SecureStoragePackage()) - add(AccessibleInvertedScrollViewPackage()) } override fun getJSMainModuleName(): String = "index" diff --git a/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollView.java b/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollView.java deleted file mode 100644 index cf14e45ac63..00000000000 --- a/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollView.java +++ /dev/null @@ -1,165 +0,0 @@ -package chat.rocket.reactnative.a11y; - -import android.content.Context; -import android.graphics.Rect; -import android.view.View; -import android.view.ViewGroup; -import androidx.annotation.Nullable; -import com.facebook.react.views.scroll.ReactScrollView; -import com.facebook.react.views.scroll.FpsListener; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; - -/** - * Custom ReactScrollView that fixes accessibility traversal order for inverted lists. - * - * When a FlatList is inverted (inverted={true}), React Native uses scaleY: -1 transform - * which visually inverts the list but also inverts the accessibility traversal order. - * This class overrides addChildrenForAccessibility() to maintain correct accessibility - * order (oldest to newest) despite the visual inversion. - * - * The implementation tracks visible items similar to the useScroll hook in JavaScript, - * filtering children based on viewport visibility and reordering them for correct - * accessibility traversal. - */ -public class AccessibleInvertedScrollView extends ReactScrollView { - - private static final float VIEWABILITY_THRESHOLD = 0.1f; // 10% visibility threshold - - public AccessibleInvertedScrollView(Context context) { - this(context, null); - } - - public AccessibleInvertedScrollView(Context context, @Nullable FpsListener fpsListener) { - super(context, fpsListener); - } - - /** - * Override addChildrenForAccessibility to reorder children for correct accessibility traversal. - * - * For inverted lists, the visual order is reversed (newest at top), but we want - * accessibility to traverse in logical order (oldest to newest). We achieve this by: - * 1. Getting all visible children based on viewport bounds - * 2. Sorting them by their position in the view hierarchy (which reflects logical order) - * 3. Reversing the order so accessibility traverses from bottom to top (oldest to newest) - * - * This method is called by Android's accessibility framework to determine the order - * in which screen readers should traverse child views. - */ - @Override - public void addChildrenForAccessibility(ArrayList outChildren) { - // Get the content view (the container that holds all list items) - ViewGroup contentView = (ViewGroup) getContentView(); - if (contentView == null || contentView.getChildCount() == 0) { - super.addChildrenForAccessibility(outChildren); - return; - } - - // Calculate viewport bounds for visibility checking - int scrollY = getScrollY(); - int paddingTop = getPaddingTop(); - int paddingBottom = getPaddingBottom(); - int viewportTop = scrollY + paddingTop; - int viewportBottom = scrollY + getHeight() - paddingBottom; - - // Collect visible children with their indices - ArrayList visibleChildren = new ArrayList<>(); - - for (int i = 0; i < contentView.getChildCount(); i++) { - View child = contentView.getChildAt(i); - if (child == null || child.getVisibility() != View.VISIBLE) { - continue; - } - - // Check if child is at least partially visible (using same threshold as useScroll hook) - if (isChildVisible(child, viewportTop, viewportBottom)) { - visibleChildren.add(new ViewInfo(child, i, child.getTop())); - } - } - - if (visibleChildren.isEmpty()) { - super.addChildrenForAccessibility(outChildren); - return; - } - - // Sort children by their original index in the content view - // This reflects the logical order (oldest messages have lower indices) - Collections.sort(visibleChildren, new Comparator() { - @Override - public int compare(ViewInfo v1, ViewInfo v2) { - // Sort by original index - lower indices come first (older messages) - return Integer.compare(v1.originalIndex, v2.originalIndex); - } - }); - - // For inverted lists, we want accessibility to read from bottom to top - // (oldest to newest), so we reverse the sorted list - Collections.reverse(visibleChildren); - - // Add sorted and reversed children to output - for (ViewInfo viewInfo : visibleChildren) { - outChildren.add(viewInfo.view); - } - } - - /** - * Helper class to store view information for sorting. - */ - private static class ViewInfo { - final View view; - final int originalIndex; - final int top; - - ViewInfo(View view, int originalIndex, int top) { - this.view = view; - this.originalIndex = originalIndex; - this.top = top; - } - } - - /** - * Check if a child view is at least partially visible in the viewport. - * Uses the same threshold as VIEWABILITY_CONFIG (10%). - * - * This matches the logic from useScroll.ts hook which uses - * itemVisiblePercentThreshold: 10 from VIEWABILITY_CONFIG. - */ - private boolean isChildVisible(View child, int viewportTop, int viewportBottom) { - if (child == null || child.getVisibility() != View.VISIBLE) { - return false; - } - - int childTop = child.getTop(); - int childBottom = child.getBottom(); - int childHeight = childBottom - childTop; - - if (childHeight == 0) { - return false; - } - - // Calculate visible height within viewport - int visibleTop = Math.max(childTop, viewportTop); - int visibleBottom = Math.min(childBottom, viewportBottom); - int visibleHeight = Math.max(0, visibleBottom - visibleTop); - - // Check if at least 10% of the child is visible (matching VIEWABILITY_CONFIG) - float visibleRatio = (float) visibleHeight / childHeight; - return visibleRatio >= VIEWABILITY_THRESHOLD; - } - - /** - * Get the content view (the container holding list items). - * ScrollView typically has one child which is the content container. - */ - @Nullable - private ViewGroup getContentView() { - if (getChildCount() > 0) { - View child = getChildAt(0); - if (child instanceof ViewGroup) { - return (ViewGroup) child; - } - } - return null; - } -} diff --git a/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewManager.java b/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewManager.java deleted file mode 100644 index 8b99c6823e3..00000000000 --- a/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewManager.java +++ /dev/null @@ -1,194 +0,0 @@ -package chat.rocket.reactnative.a11y; - -import android.graphics.Color; -import android.view.View; -import androidx.annotation.Nullable; -import com.facebook.infer.annotation.Nullsafe; -import com.facebook.react.bridge.ReadableArray; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.RetryableMountingLayerException; -import com.facebook.react.common.MapBuilder; -import com.facebook.react.module.annotations.ReactModule; -import com.facebook.react.uimanager.LengthPercentage; -import com.facebook.react.uimanager.LengthPercentageType; -import com.facebook.react.uimanager.PixelUtil; -import com.facebook.react.uimanager.PointerEvents; -import com.facebook.react.uimanager.ReactClippingViewGroupHelper; -import com.facebook.react.uimanager.Spacing; -import com.facebook.react.uimanager.ThemedReactContext; -import com.facebook.react.uimanager.ViewGroupManager; -import com.facebook.react.uimanager.ViewProps; -import com.facebook.react.uimanager.annotations.ReactProp; -import com.facebook.react.uimanager.annotations.ReactPropGroup; -import com.facebook.react.uimanager.style.BorderRadiusProp; -import com.facebook.react.uimanager.style.BorderStyle; -import com.facebook.react.uimanager.style.LogicalEdge; -import com.facebook.react.views.scroll.ReactScrollViewCommandHelper; -import com.facebook.react.views.scroll.ScrollEventType; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * ViewManager for AccessibleInvertedScrollView. - * - * This manager extends the functionality of ReactScrollViewManager to use - * AccessibleInvertedScrollView instead, which fixes accessibility traversal - * order for inverted lists. - */ -@Nullsafe(Nullsafe.Mode.LOCAL) -@ReactModule(name = AccessibleInvertedScrollViewManager.REACT_CLASS) -public class AccessibleInvertedScrollViewManager extends ViewGroupManager - implements ReactScrollViewCommandHelper.ScrollCommandHandler { - - public static final String REACT_CLASS = "RCTAccessibleInvertedScrollView"; - - private static final int[] SPACING_TYPES = { - Spacing.ALL, Spacing.LEFT, Spacing.RIGHT, Spacing.TOP, Spacing.BOTTOM, - }; - - @Override - public String getName() { - return REACT_CLASS; - } - - @Override - public AccessibleInvertedScrollView createViewInstance(ThemedReactContext context) { - return new AccessibleInvertedScrollView(context, null); - } - - // Delegate all ReactScrollView props to maintain compatibility - // Most props are handled by the base ReactScrollView class - - @ReactProp(name = "scrollEnabled", defaultBoolean = true) - public void setScrollEnabled(AccessibleInvertedScrollView view, boolean value) { - view.setScrollEnabled(value); - view.setFocusable(value); - } - - @ReactProp(name = "showsVerticalScrollIndicator", defaultBoolean = true) - public void setShowsVerticalScrollIndicator(AccessibleInvertedScrollView view, boolean value) { - view.setVerticalScrollBarEnabled(value); - } - - @ReactProp(name = "decelerationRate") - public void setDecelerationRate(AccessibleInvertedScrollView view, float decelerationRate) { - view.setDecelerationRate(decelerationRate); - } - - @ReactProp(name = ReactClippingViewGroupHelper.PROP_REMOVE_CLIPPED_SUBVIEWS) - public void setRemoveClippedSubviews(AccessibleInvertedScrollView view, boolean removeClippedSubviews) { - view.setRemoveClippedSubviews(removeClippedSubviews); - } - - @ReactProp(name = "pagingEnabled") - public void setPagingEnabled(AccessibleInvertedScrollView view, boolean pagingEnabled) { - view.setPagingEnabled(pagingEnabled); - } - - @ReactProp(name = "nestedScrollEnabled") - public void setNestedScrollEnabled(AccessibleInvertedScrollView view, boolean value) { - androidx.core.view.ViewCompat.setNestedScrollingEnabled(view, value); - } - - @ReactProp(name = "overflow") - public void setOverflow(AccessibleInvertedScrollView view, @Nullable String overflow) { - view.setOverflow(overflow); - } - - @ReactProp(name = ViewProps.POINTER_EVENTS) - public void setPointerEvents(AccessibleInvertedScrollView view, @Nullable String pointerEventsStr) { - view.setPointerEvents(PointerEvents.parsePointerEvents(pointerEventsStr)); - } - - @ReactProp(name = "scrollEventThrottle") - public void setScrollEventThrottle(AccessibleInvertedScrollView view, int scrollEventThrottle) { - view.setScrollEventThrottle(scrollEventThrottle); - } - - @ReactProp(name = "horizontal") - public void setHorizontal(AccessibleInvertedScrollView view, boolean horizontal) { - // Do Nothing: Align with static ViewConfigs - } - - @Override - public @Nullable Map getCommandsMap() { - return ReactScrollViewCommandHelper.getCommandsMap(); - } - - @Override - public void receiveCommand( - AccessibleInvertedScrollView scrollView, int commandId, @Nullable ReadableArray args) { - ReactScrollViewCommandHelper.receiveCommand(this, scrollView, commandId, args); - } - - @Override - public void receiveCommand( - AccessibleInvertedScrollView scrollView, String commandId, @Nullable ReadableArray args) { - ReactScrollViewCommandHelper.receiveCommand(this, scrollView, commandId, args); - } - - @Override - public void flashScrollIndicators(AccessibleInvertedScrollView scrollView) { - scrollView.flashScrollIndicators(); - } - - @Override - public void scrollTo( - AccessibleInvertedScrollView scrollView, ReactScrollViewCommandHelper.ScrollToCommandData data) { - scrollView.abortAnimation(); - if (data.mAnimated) { - scrollView.reactSmoothScrollTo(data.mDestX, data.mDestY); - } else { - scrollView.scrollTo(data.mDestX, data.mDestY); - } - } - - @Override - public void scrollToEnd( - AccessibleInvertedScrollView scrollView, ReactScrollViewCommandHelper.ScrollToEndCommandData data) { - View child = scrollView.getChildAt(0); - if (child == null) { - throw new RetryableMountingLayerException("scrollToEnd called on ScrollView without child"); - } - - int bottom = child.getHeight() + scrollView.getPaddingBottom(); - scrollView.abortAnimation(); - if (data.mAnimated) { - scrollView.reactSmoothScrollTo(scrollView.getScrollX(), bottom); - } else { - scrollView.scrollTo(scrollView.getScrollX(), bottom); - } - } - - @Override - public @Nullable Map getExportedCustomDirectEventTypeConstants() { - @Nullable - Map baseEventTypeConstants = super.getExportedCustomDirectEventTypeConstants(); - Map eventTypeConstants = - baseEventTypeConstants == null ? new HashMap() : baseEventTypeConstants; - eventTypeConstants.putAll(createExportedCustomDirectEventTypeConstants()); - return eventTypeConstants; - } - - public static Map createExportedCustomDirectEventTypeConstants() { - return MapBuilder.builder() - .put( - ScrollEventType.getJSEventName(ScrollEventType.SCROLL), - MapBuilder.of("registrationName", "onScroll")) - .put( - ScrollEventType.getJSEventName(ScrollEventType.BEGIN_DRAG), - MapBuilder.of("registrationName", "onScrollBeginDrag")) - .put( - ScrollEventType.getJSEventName(ScrollEventType.END_DRAG), - MapBuilder.of("registrationName", "onScrollEndDrag")) - .put( - ScrollEventType.getJSEventName(ScrollEventType.MOMENTUM_BEGIN), - MapBuilder.of("registrationName", "onMomentumScrollBegin")) - .put( - ScrollEventType.getJSEventName(ScrollEventType.MOMENTUM_END), - MapBuilder.of("registrationName", "onMomentumScrollEnd")) - .build(); - } -} diff --git a/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewPackage.java b/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewPackage.java deleted file mode 100644 index 487c86f7aca..00000000000 --- a/android/app/src/main/java/chat/rocket/reactnative/a11y/AccessibleInvertedScrollViewPackage.java +++ /dev/null @@ -1,30 +0,0 @@ -package chat.rocket.reactnative.a11y; - -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.ArrayList; -import java.util.Collections; -import java.util.List; - -/** - * ReactPackage for AccessibleInvertedScrollView. - * - * Registers the AccessibleInvertedScrollViewManager to make the custom - * scroll view available to React Native. - */ -public class AccessibleInvertedScrollViewPackage implements ReactPackage { - @Override - public List createNativeModules(ReactApplicationContext reactContext) { - return Collections.emptyList(); - } - - @Override - public List createViewManagers(ReactApplicationContext reactContext) { - List viewManagers = new ArrayList<>(); - viewManagers.add(new AccessibleInvertedScrollViewManager()); - return viewManagers; - } -} diff --git a/patches/react-native+0.79.4.patch b/patches/react-native+0.79.4.patch index 0126c509015..81f6b0fea13 100644 --- a/patches/react-native+0.79.4.patch +++ b/patches/react-native+0.79.4.patch @@ -1,8 +1,17 @@ diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java -index 1234567..abcdefg 100644 +index 71acfa6..812710e 100644 --- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java -@@ -126,6 +126,7 @@ public class ReactScrollView extends ScrollView +@@ -62,6 +62,8 @@ import com.facebook.react.views.scroll.ReactScrollViewHelper.HasStateWrapper; + import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState; + import com.facebook.systrace.Systrace; + import java.lang.reflect.Field; ++import java.util.ArrayList; ++import java.util.Collections; + import java.util.List; + + /** +@@ -127,6 +129,7 @@ public class ReactScrollView extends ScrollView private int mScrollEventThrottle = 0; private @Nullable MaintainVisibleScrollPositionHelper mMaintainVisibleContentPositionHelper = null; @@ -10,14 +19,13 @@ index 1234567..abcdefg 100644 public ReactScrollView(Context context) { this(context, null); -@@ -156,6 +157,79 @@ public class ReactScrollView extends ScrollView +@@ -158,6 +161,34 @@ public class ReactScrollView extends ScrollView } } + /** + * Set whether this ScrollView is used for an inverted virtualized list. -+ * When true, we override accessibility traversal order to fix the issue where -+ * inverted lists have reversed accessibility order. ++ * When true, we reverse the accessibility traversal order to match the visual order. + */ + public void setIsInvertedVirtualizedList(boolean isInverted) { + mIsInvertedVirtualizedList = isInverted; @@ -27,71 +35,19 @@ index 1234567..abcdefg 100644 + * Override addChildrenForAccessibility to fix traversal order for inverted lists. + * + * When a FlatList is inverted (inverted={true}), React Native uses scaleY: -1 transform -+ * which visually inverts the list but also inverts the accessibility traversal order. -+ * -+ * Due to scaleY: -1 transform: -+ * - Higher array indices appear at VISUAL TOP (newest messages) -+ * - Lower array indices appear at VISUAL BOTTOM (oldest messages) ++ * which visually inverts the list but Android still reports children in array order. + * -+ * This method returns VISIBLE children (plus a buffer zone) in VISUAL order (top to bottom): -+ * 1. First item = topmost visible (focus enters here from header) = highest index -+ * 2. Last item = bottommost visible (focus exits to composer from here) = lowest index -+ * 3. Buffer zone allows scroll-to-reveal when navigating beyond visible bounds ++ * This method simply reverses the order returned by the parent to match the visual order. ++ * This mirrors exactly how normal (non-inverted) lists work - Android handles all the ++ * viewport/visibility logic, we just reverse the result. + */ + @Override + public void addChildrenForAccessibility(ArrayList outChildren) { -+ if (!mIsInvertedVirtualizedList) { -+ super.addChildrenForAccessibility(outChildren); -+ return; -+ } -+ -+ // Get the content view (the container that holds all list items) -+ View contentView = getContentView(); -+ if (contentView == null || !(contentView instanceof ViewGroup)) { -+ super.addChildrenForAccessibility(outChildren); -+ return; -+ } -+ ViewGroup contentViewGroup = (ViewGroup) contentView; -+ int childCount = contentViewGroup.getChildCount(); -+ if (childCount == 0) { -+ super.addChildrenForAccessibility(outChildren); -+ return; -+ } -+ -+ // Calculate viewport bounds with buffer zone for scroll-to-reveal -+ int scrollY = getScrollY(); -+ int viewportTop = scrollY; -+ int viewportBottom = scrollY + getHeight(); ++ super.addChildrenForAccessibility(outChildren); + -+ // Buffer zone allows focus to move to items just outside viewport, -+ // triggering requestChildFocus() which scrolls them into view -+ int bufferZone = 200; // pixels -+ int extendedViewportTop = viewportTop - bufferZone; -+ int extendedViewportBottom = viewportBottom + bufferZone; -+ -+ // For inverted lists with scaleY: -1, we must iterate in REVERSE order -+ // to match VISUAL top-to-bottom order: -+ // - childCount-1 = visually at TOP (newest message) -> added FIRST -+ // - 0 = visually at BOTTOM (oldest message) -> added LAST -+ // -+ // This ensures: -+ // - First accessible child = top visible item (focus enters here from header) -+ // - Last accessible child = bottom visible item (focus exits to composer from here) -+ for (int i = childCount - 1; i >= 0; i--) { -+ View child = contentViewGroup.getChildAt(i); -+ if (child != null && child.getVisibility() == View.VISIBLE) { -+ // Check if child is within extended viewport (visible + buffer) -+ int childTop = child.getTop(); -+ int childBottom = child.getBottom(); -+ if (childBottom > extendedViewportTop && childTop < extendedViewportBottom) { -+ outChildren.add(child); -+ } -+ } -+ } -+ -+ // If no visible children found, fall back to default behavior -+ if (outChildren.isEmpty()) { -+ super.addChildrenForAccessibility(outChildren); ++ if (mIsInvertedVirtualizedList) { ++ // Reverse order to match visual order (due to scaleY: -1 transform) ++ Collections.reverse(outChildren); + } + } + @@ -99,10 +55,10 @@ index 1234567..abcdefg 100644 private OverScroller getOverScrollerFromParent() { OverScroller scroller; diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java -index 1234567..abcdefg 100644 +index b9260c5..3d3f2ec 100644 --- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java +++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java -@@ -403,5 +403,7 @@ public class ReactScrollViewManager extends ViewGroupManager +@@ -405,5 +405,7 @@ public class ReactScrollViewManager extends ViewGroupManager } else { view.setVerticalScrollbarPosition(View.SCROLLBAR_POSITION_DEFAULT); } From c3da7dded9210f54f828110d50f2cf83809216fd Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Tue, 20 Jan 2026 14:20:41 -0300 Subject: [PATCH 05/27] fix: build --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 45c7b5e50e1..af43efdb3f4 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "redux": "4.2.0", "redux-immutable-state-invariant": "2.1.0", "redux-saga": "1.1.3", - "remove-markdown": "^0.3.0", + "remove-markdown": "0.3.0", "reselect": "4.0.0", "semver": "7.5.2", "transliteration": "2.3.5", From 37c77013d199c363088d8ecdc9671f7781eaa896 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Tue, 20 Jan 2026 14:29:22 -0300 Subject: [PATCH 06/27] lock --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 4a19e871830..76ccb99c0e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13240,7 +13240,7 @@ regjsparser@^0.9.1: dependencies: jsesc "~0.5.0" -remove-markdown@^0.3.0: +remove-markdown@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/remove-markdown/-/remove-markdown-0.3.0.tgz#5e4b667493a93579728f3d52ecc1db9ca505dc98" integrity sha512-5392eIuy1mhjM74739VunOlsOYKjsH82rQcTBlJ1bkICVC3dQ3ksQzTHh4jGHQFnM+1xzLzcFOMH+BofqXhroQ== From 4d4d5722c47d723d70b5f215dcd2e06b80158c16 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Tue, 20 Jan 2026 15:21:32 -0300 Subject: [PATCH 07/27] timeout --- .github/workflows/build-android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 02f3511de7f..20882320345 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -47,7 +47,7 @@ jobs: - name: Build Android uses: ./.github/actions/build-android - timeout-minutes: 40 + timeout-minutes: 120 with: type: 'experimental' BUGSNAG_KEY: ${{ secrets.BUGSNAG_KEY }} From df97db613bb394deb9ac43629d874a4693599d43 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 28 Jan 2026 13:48:46 -0300 Subject: [PATCH 08/27] feat: created flatlist inverted native module --- .../rocket/reactnative/MainApplication.kt | 2 + .../scroll/InvertedScrollContentView.java | 23 +++++++ .../InvertedScrollContentViewManager.java | 25 +++++++ .../scroll/InvertedScrollPackage.java | 24 +++++++ .../scroll/InvertedScrollView.java | 39 +++++++++++ .../scroll/InvertedScrollViewManager.java | 26 ++++++++ .../List/components/InvertedScrollView.tsx | 66 +++++++++++++++++++ app/views/RoomView/List/components/List.tsx | 6 +- 8 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentView.java create mode 100644 android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollContentViewManager.java create mode 100644 android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollPackage.java create mode 100644 android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java create mode 100644 android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollViewManager.java create mode 100644 app/views/RoomView/List/components/InvertedScrollView.tsx 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..96fce4bb058 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java @@ -0,0 +1,39 @@ +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; + +/** + * ScrollView subclass that fixes accessibility traversal order for inverted FlatLists. + * + *

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..ce7974ed153 --- /dev/null +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { + Platform, + requireNativeComponent, + type LayoutChangeEvent, + type ScrollViewProps, + type ViewProps +} from 'react-native'; + +/** + * Android-only native ScrollView that fixes TalkBack traversal order for inverted FlatLists. + * Used via FlatList's renderScrollComponent. VirtualizedList passes multiple children (cells) + * via cloneElement; Android ScrollView accepts only one direct child, so we wrap them in + * InvertedScrollContentView (native), which (1) satisfies the one-child constraint and + * (2) reports its children in reversed order for accessibility so TalkBack matches visual order. + */ +const NativeInvertedScrollView = + Platform.OS === 'android' + ? requireNativeComponent('InvertedScrollView') + : null; + +const NativeInvertedScrollContentView = + Platform.OS === 'android' + ? requireNativeComponent( + 'InvertedScrollContentView' + ) + : null; + +const InvertedScrollView = (props: ScrollViewProps) => { + if (NativeInvertedScrollView == null || NativeInvertedScrollContentView == null) { + return null; + } + const { + children, + contentContainerStyle, + onContentSizeChange, + removeClippedSubviews, + maintainVisibleContentPosition, + ...rest + } = props; + + const preserveChildren = + maintainVisibleContentPosition != null || + (Platform.OS === 'android' && props.snapToAlignment != null); + + const handleContentLayout = (e: LayoutChangeEvent) => { + const { width, height } = e.nativeEvent.layout; + onContentSizeChange?.(width, height); + }; + + return ( + + + {children} + + + ); +}; + +export default InvertedScrollView; diff --git a/app/views/RoomView/List/components/List.tsx b/app/views/RoomView/List/components/List.tsx index 2d8b0c081e7..adf0102a306 100644 --- a/app/views/RoomView/List/components/List.tsx +++ b/app/views/RoomView/List/components/List.tsx @@ -1,9 +1,10 @@ import React, { useState } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import Animated, { runOnJS, useAnimatedScrollHandler } from 'react-native-reanimated'; 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,9 @@ const List = ({ listRef, jumpToBottom, ...props }: IListProps) => { contentContainerStyle={styles.contentContainer} style={styles.list} inverted + renderScrollComponent={ + Platform.OS === 'android' ? (props) => : undefined + } removeClippedSubviews={isIOS} initialNumToRender={7} onEndReachedThreshold={0.5} From 1467be66650f22964d4ed249e74616019dfcfc03 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 28 Jan 2026 13:48:54 -0300 Subject: [PATCH 09/27] remove previous solution --- android/settings.gradle | 9 ---- patches/react-native+0.79.4.patch | 68 ------------------------------- 2 files changed, 77 deletions(-) delete mode 100644 patches/react-native+0.79.4.patch diff --git a/android/settings.gradle b/android/settings.gradle index b52bfa2b6ee..9bb0709a8db 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -8,14 +8,5 @@ include ':app' includeBuild('../node_modules/@react-native/gradle-plugin') -includeBuild('../node_modules/react-native') { - dependencySubstitution { - substitute(module("com.facebook.react:react-android")).using(project(":packages:react-native:ReactAndroid")) - substitute(module("com.facebook.react:react-native")).using(project(":packages:react-native:ReactAndroid")) - substitute(module("com.facebook.react:hermes-android")).using(project(":packages:react-native:ReactAndroid:hermes-engine")) - substitute(module("com.facebook.react:hermes-engine")).using(project(":packages:react-native:ReactAndroid:hermes-engine")) - } -} - apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle"); useExpoModules() \ No newline at end of file diff --git a/patches/react-native+0.79.4.patch b/patches/react-native+0.79.4.patch deleted file mode 100644 index 81f6b0fea13..00000000000 --- a/patches/react-native+0.79.4.patch +++ /dev/null @@ -1,68 +0,0 @@ -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java -index 71acfa6..812710e 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java -@@ -62,6 +62,8 @@ import com.facebook.react.views.scroll.ReactScrollViewHelper.HasStateWrapper; - import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState; - import com.facebook.systrace.Systrace; - import java.lang.reflect.Field; -+import java.util.ArrayList; -+import java.util.Collections; - import java.util.List; - - /** -@@ -127,6 +129,7 @@ public class ReactScrollView extends ScrollView - private int mScrollEventThrottle = 0; - private @Nullable MaintainVisibleScrollPositionHelper mMaintainVisibleContentPositionHelper = - null; -+ private boolean mIsInvertedVirtualizedList = false; - - public ReactScrollView(Context context) { - this(context, null); -@@ -158,6 +161,34 @@ public class ReactScrollView extends ScrollView - } - } - -+ /** -+ * 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 addChildrenForAccessibility to fix traversal order for inverted lists. -+ * -+ * 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 method simply reverses the order returned by the parent to match the visual order. -+ * This mirrors exactly how normal (non-inverted) lists work - Android handles all the -+ * viewport/visibility logic, we just reverse the result. -+ */ -+ @Override -+ public void addChildrenForAccessibility(ArrayList outChildren) { -+ super.addChildrenForAccessibility(outChildren); -+ -+ if (mIsInvertedVirtualizedList) { -+ // Reverse order to match visual order (due to scaleY: -1 transform) -+ Collections.reverse(outChildren); -+ } -+ } -+ - @Nullable - private OverScroller getOverScrollerFromParent() { - OverScroller scroller; -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java -index b9260c5..3d3f2ec 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java -@@ -405,5 +405,7 @@ public class ReactScrollViewManager extends ViewGroupManager - } else { - view.setVerticalScrollbarPosition(View.SCROLLBAR_POSITION_DEFAULT); - } -+ // Set the inverted flag for accessibility fix -+ view.setIsInvertedVirtualizedList(applyFix); - } - } From e7d809c604d7dbd9540fe9a6a69a3a1c98937cc8 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 28 Jan 2026 17:52:28 -0300 Subject: [PATCH 10/27] fix: revert timeout build android --- .github/workflows/build-android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 20882320345..02f3511de7f 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -47,7 +47,7 @@ jobs: - name: Build Android uses: ./.github/actions/build-android - timeout-minutes: 120 + timeout-minutes: 40 with: type: 'experimental' BUGSNAG_KEY: ${{ secrets.BUGSNAG_KEY }} From b25af00c50455205a819a7666eb5c4c977a9e5e8 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 28 Jan 2026 17:52:38 -0300 Subject: [PATCH 11/27] fix: list components style --- .../List/components/InvertedScrollView.tsx | 58 ++++++++++++++++--- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx index ce7974ed153..04b6eee1836 100644 --- a/app/views/RoomView/List/components/InvertedScrollView.tsx +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Platform, requireNativeComponent, + StyleSheet, type LayoutChangeEvent, type ScrollViewProps, type ViewProps @@ -13,6 +14,8 @@ import { * via cloneElement; Android ScrollView accepts only one direct child, so we wrap them in * InvertedScrollContentView (native), which (1) satisfies the one-child constraint and * (2) reports its children in reversed order for accessibility so TalkBack matches visual order. + * + * Content container props and style match the default ScrollView (ScrollView.js) exactly. */ const NativeInvertedScrollView = Platform.OS === 'android' @@ -36,6 +39,8 @@ const InvertedScrollView = (props: ScrollViewProps) => { onContentSizeChange, removeClippedSubviews, maintainVisibleContentPosition, + snapToAlignment, + stickyHeaderIndices, ...rest } = props; @@ -43,19 +48,41 @@ const InvertedScrollView = (props: ScrollViewProps) => { maintainVisibleContentPosition != null || (Platform.OS === 'android' && props.snapToAlignment != null); - const handleContentLayout = (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout; - onContentSizeChange?.(width, height); - }; + 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; return ( - + {children} @@ -63,4 +90,19 @@ const InvertedScrollView = (props: ScrollViewProps) => { ); }; +const styles = StyleSheet.create({ + baseVertical: { + flexGrow: 1, + flexShrink: 1, + flexDirection: 'column' as const, + overflow: 'scroll' as const + }, + baseHorizontal: { + flexGrow: 1, + flexShrink: 1, + flexDirection: 'row' as const, + overflow: 'scroll' as const + } +}); + export default InvertedScrollView; From d6b0f232b7d67e42b10ac06a7b6ccc68d86cc609 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 28 Jan 2026 17:53:42 -0300 Subject: [PATCH 12/27] patch-package --- patches/react-native+0.79.4.patch | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 patches/react-native+0.79.4.patch diff --git a/patches/react-native+0.79.4.patch b/patches/react-native+0.79.4.patch new file mode 100644 index 00000000000..0fd01cf14df --- /dev/null +++ b/patches/react-native+0.79.4.patch @@ -0,0 +1,25 @@ +diff --git a/node_modules/react-native/React/Views/RCTViewManager.m b/node_modules/react-native/React/Views/RCTViewManager.m +index f017cb8..f6aaafb 100644 +--- a/node_modules/react-native/React/Views/RCTViewManager.m ++++ b/node_modules/react-native/React/Views/RCTViewManager.m +@@ -225,7 +225,9 @@ RCT_CUSTOM_VIEW_PROPERTY(accessibilityRole, UIAccessibilityTraits, RCTView) + { + UIAccessibilityTraits accessibilityRoleTraits = + json ? [RCTConvert UIAccessibilityTraits:json] : UIAccessibilityTraitNone; +- if (view.reactAccessibilityElement.accessibilityRoleTraits != accessibilityRoleTraits) { ++ NSString *roleString = json ? [RCTConvert NSString:json] : nil; ++ if (view.reactAccessibilityElement.accessibilityRoleTraits != accessibilityRoleTraits || view.reactAccessibilityElement.accessibilityRole != roleString) { ++ + view.accessibilityRoleTraits = accessibilityRoleTraits; + view.reactAccessibilityElement.accessibilityRole = json ? [RCTConvert NSString:json] : nil; + [self updateAccessibilityTraitsForRole:view withDefaultView:defaultView]; +@@ -235,7 +237,8 @@ RCT_CUSTOM_VIEW_PROPERTY(accessibilityRole, UIAccessibilityTraits, RCTView) + RCT_CUSTOM_VIEW_PROPERTY(role, UIAccessibilityTraits, RCTView) + { + UIAccessibilityTraits roleTraits = json ? [RCTConvert UIAccessibilityTraits:json] : UIAccessibilityTraitNone; +- if (view.reactAccessibilityElement.roleTraits != roleTraits) { ++ NSString *roleString = json ? [RCTConvert NSString:json] : nil; ++ if (view.reactAccessibilityElement.roleTraits != roleTraits || view.reactAccessibilityElement.role != roleString) { + view.roleTraits = roleTraits; + view.reactAccessibilityElement.role = json ? [RCTConvert NSString:json] : nil; + [self updateAccessibilityTraitsForRole:view withDefaultView:defaultView]; \ No newline at end of file From 62c6a83db510f05d9426f7dce0fcb8f5f6ba7e23 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 28 Jan 2026 17:55:04 -0300 Subject: [PATCH 13/27] revert package.json updates --- package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index af43efdb3f4..d0199a5bfeb 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "redux": "4.2.0", "redux-immutable-state-invariant": "2.1.0", "redux-saga": "1.1.3", - "remove-markdown": "0.3.0", + "remove-markdown": "ˆ0.3.0", "reselect": "4.0.0", "semver": "7.5.2", "transliteration": "2.3.5", diff --git a/yarn.lock b/yarn.lock index 76ccb99c0e7..d981e0a2b9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13240,7 +13240,7 @@ regjsparser@^0.9.1: dependencies: jsesc "~0.5.0" -remove-markdown@0.3.0: +remove-markdown@ˆ0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/remove-markdown/-/remove-markdown-0.3.0.tgz#5e4b667493a93579728f3d52ecc1db9ca505dc98" integrity sha512-5392eIuy1mhjM74739VunOlsOYKjsH82rQcTBlJ1bkICVC3dQ3ksQzTHh4jGHQFnM+1xzLzcFOMH+BofqXhroQ== From bbc5ee56e677bcef4927b2d56550292dbd8651c6 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 28 Jan 2026 17:56:08 -0300 Subject: [PATCH 14/27] revert package.json updates --- package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d0199a5bfeb..45c7b5e50e1 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "redux": "4.2.0", "redux-immutable-state-invariant": "2.1.0", "redux-saga": "1.1.3", - "remove-markdown": "ˆ0.3.0", + "remove-markdown": "^0.3.0", "reselect": "4.0.0", "semver": "7.5.2", "transliteration": "2.3.5", diff --git a/yarn.lock b/yarn.lock index d981e0a2b9c..4a19e871830 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13240,7 +13240,7 @@ regjsparser@^0.9.1: dependencies: jsesc "~0.5.0" -remove-markdown@ˆ0.3.0: +remove-markdown@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/remove-markdown/-/remove-markdown-0.3.0.tgz#5e4b667493a93579728f3d52ecc1db9ca505dc98" integrity sha512-5392eIuy1mhjM74739VunOlsOYKjsH82rQcTBlJ1bkICVC3dQ3ksQzTHh4jGHQFnM+1xzLzcFOMH+BofqXhroQ== From 4eed5d98b27dd72a5f43b4655f9b1e6627248277 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 28 Jan 2026 20:57:57 +0000 Subject: [PATCH 15/27] chore: format code and fix lint issues [skip ci] --- .../List/components/InvertedScrollView.tsx | 35 +++++-------------- app/views/RoomView/List/components/List.tsx | 4 +-- 2 files changed, 10 insertions(+), 29 deletions(-) diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx index 04b6eee1836..4e585b22eeb 100644 --- a/app/views/RoomView/List/components/InvertedScrollView.tsx +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -17,16 +17,11 @@ import { * * Content container props and style match the default ScrollView (ScrollView.js) exactly. */ -const NativeInvertedScrollView = - Platform.OS === 'android' - ? requireNativeComponent('InvertedScrollView') - : null; +const NativeInvertedScrollView = Platform.OS === 'android' ? requireNativeComponent('InvertedScrollView') : null; const NativeInvertedScrollContentView = Platform.OS === 'android' - ? requireNativeComponent( - 'InvertedScrollContentView' - ) + ? requireNativeComponent('InvertedScrollContentView') : null; const InvertedScrollView = (props: ScrollViewProps) => { @@ -44,17 +39,11 @@ const InvertedScrollView = (props: ScrollViewProps) => { ...rest } = props; - const preserveChildren = - maintainVisibleContentPosition != null || - (Platform.OS === 'android' && props.snapToAlignment != null); + const preserveChildren = maintainVisibleContentPosition != null || (Platform.OS === 'android' && props.snapToAlignment != null); - const hasStickyHeaders = - Array.isArray(stickyHeaderIndices) && stickyHeaderIndices.length > 0; + const hasStickyHeaders = Array.isArray(stickyHeaderIndices) && stickyHeaderIndices.length > 0; - const contentContainerStyleArray = [ - props.horizontal ? { flexDirection: 'row' as const } : null, - contentContainerStyle - ]; + const contentContainerStyleArray = [props.horizontal ? { flexDirection: 'row' as const } : null, contentContainerStyle]; const contentSizeChangeProps = onContentSizeChange == null @@ -64,26 +53,20 @@ const InvertedScrollView = (props: ScrollViewProps) => { const { width, height } = e.nativeEvent.layout; onContentSizeChange(width, height); } - }; + }; const horizontal = !!props.horizontal; const baseStyle = horizontal ? styles.baseHorizontal : styles.baseVertical; const { style, ...restWithoutStyle } = rest; return ( - + + collapsableChildren={!preserveChildren}> {children} diff --git a/app/views/RoomView/List/components/List.tsx b/app/views/RoomView/List/components/List.tsx index adf0102a306..0c199cfc8da 100644 --- a/app/views/RoomView/List/components/List.tsx +++ b/app/views/RoomView/List/components/List.tsx @@ -44,9 +44,7 @@ const List = ({ listRef, jumpToBottom, ...props }: IListProps) => { contentContainerStyle={styles.contentContainer} style={styles.list} inverted - renderScrollComponent={ - Platform.OS === 'android' ? (props) => : undefined - } + renderScrollComponent={Platform.OS === 'android' ? props => : undefined} removeClippedSubviews={isIOS} initialNumToRender={7} onEndReachedThreshold={0.5} From e6c7e16a4d0125aa1b85e6ceed27a40332dbef9b Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 28 Jan 2026 18:02:25 -0300 Subject: [PATCH 16/27] code improvements --- .../List/components/InvertedScrollView.tsx | 44 +++++++++++-------- app/views/RoomView/List/components/List.tsx | 6 ++- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx index 4e585b22eeb..13fdb31f289 100644 --- a/app/views/RoomView/List/components/InvertedScrollView.tsx +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -3,21 +3,21 @@ import { Platform, requireNativeComponent, StyleSheet, + type StyleProp, + type ViewStyle, type LayoutChangeEvent, type ScrollViewProps, type ViewProps } from 'react-native'; -/** - * Android-only native ScrollView that fixes TalkBack traversal order for inverted FlatLists. - * Used via FlatList's renderScrollComponent. VirtualizedList passes multiple children (cells) - * via cloneElement; Android ScrollView accepts only one direct child, so we wrap them in - * InvertedScrollContentView (native), which (1) satisfies the one-child constraint and - * (2) reports its children in reversed order for accessibility so TalkBack matches visual order. - * - * Content container props and style match the default ScrollView (ScrollView.js) exactly. - */ -const NativeInvertedScrollView = Platform.OS === 'android' ? requireNativeComponent('InvertedScrollView') : null; + +// Android-only native ScrollView that fixes TalkBack traversal order for inverted FlatLists. +// Used via FlatList's renderScrollComponent. VirtualizedList passes multiple children (cells). + +const NativeInvertedScrollView = + Platform.OS === 'android' + ? requireNativeComponent('InvertedScrollView') + : null; const NativeInvertedScrollContentView = Platform.OS === 'android' @@ -43,7 +43,10 @@ const InvertedScrollView = (props: ScrollViewProps) => { const hasStickyHeaders = Array.isArray(stickyHeaderIndices) && stickyHeaderIndices.length > 0; - const contentContainerStyleArray = [props.horizontal ? { flexDirection: 'row' as const } : null, contentContainerStyle]; + const contentContainerStyleArray = [ + props.horizontal ? { flexDirection: 'row' } : null, + contentContainerStyle + ]; const contentSizeChangeProps = onContentSizeChange == null @@ -62,11 +65,14 @@ const InvertedScrollView = (props: ScrollViewProps) => { return ( + collapsableChildren={!preserveChildren} + style={contentContainerStyleArray as StyleProp} + > {children} @@ -77,14 +83,14 @@ const styles = StyleSheet.create({ baseVertical: { flexGrow: 1, flexShrink: 1, - flexDirection: 'column' as const, - overflow: 'scroll' as const + flexDirection: 'column', + overflow: 'scroll' }, baseHorizontal: { flexGrow: 1, flexShrink: 1, - flexDirection: 'row' as const, - overflow: 'scroll' as const + flexDirection: 'row', + overflow: 'scroll' } }); diff --git a/app/views/RoomView/List/components/List.tsx b/app/views/RoomView/List/components/List.tsx index 0c199cfc8da..7eb11ecee79 100644 --- a/app/views/RoomView/List/components/List.tsx +++ b/app/views/RoomView/List/components/List.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import Animated, { runOnJS, useAnimatedScrollHandler } from 'react-native-reanimated'; import { isIOS } from '../../../../lib/methods/helpers'; @@ -44,7 +44,9 @@ const List = ({ listRef, jumpToBottom, ...props }: IListProps) => { contentContainerStyle={styles.contentContainer} style={styles.list} inverted - renderScrollComponent={Platform.OS === 'android' ? props => : undefined} + renderScrollComponent={ + isIOS ? undefined : (props) => + } removeClippedSubviews={isIOS} initialNumToRender={7} onEndReachedThreshold={0.5} From c23670b93c01e8e4886f50bdaba6a2e5b0b6dd27 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 28 Jan 2026 18:04:20 -0300 Subject: [PATCH 17/27] code improvements --- .../RoomView/List/components/InvertedScrollView.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx index 13fdb31f289..6a2e45b001a 100644 --- a/app/views/RoomView/List/components/InvertedScrollView.tsx +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { - Platform, requireNativeComponent, StyleSheet, type StyleProp, @@ -10,17 +9,19 @@ import { type ViewProps } from 'react-native'; +import { isAndroid } from '../../../../lib/methods/helpers'; + // Android-only native ScrollView that fixes TalkBack traversal order for inverted FlatLists. // Used via FlatList's renderScrollComponent. VirtualizedList passes multiple children (cells). const NativeInvertedScrollView = - Platform.OS === 'android' + isAndroid ? requireNativeComponent('InvertedScrollView') : null; const NativeInvertedScrollContentView = - Platform.OS === 'android' + isAndroid ? requireNativeComponent('InvertedScrollContentView') : null; @@ -39,7 +40,7 @@ const InvertedScrollView = (props: ScrollViewProps) => { ...rest } = props; - const preserveChildren = maintainVisibleContentPosition != null || (Platform.OS === 'android' && props.snapToAlignment != null); + const preserveChildren = maintainVisibleContentPosition != null || (isAndroid && props.snapToAlignment != null); const hasStickyHeaders = Array.isArray(stickyHeaderIndices) && stickyHeaderIndices.length > 0; @@ -67,7 +68,7 @@ const InvertedScrollView = (props: ScrollViewProps) => { Date: Wed, 28 Jan 2026 21:05:54 +0000 Subject: [PATCH 18/27] chore: format code and fix lint issues [skip ci] --- .../List/components/InvertedScrollView.tsx | 27 ++++++------------- app/views/RoomView/List/components/List.tsx | 4 +-- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx index 6a2e45b001a..8f7d91b5d55 100644 --- a/app/views/RoomView/List/components/InvertedScrollView.tsx +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -11,19 +11,14 @@ import { import { isAndroid } from '../../../../lib/methods/helpers'; - // Android-only native ScrollView that fixes TalkBack traversal order for inverted FlatLists. // Used via FlatList's renderScrollComponent. VirtualizedList passes multiple children (cells). -const NativeInvertedScrollView = - isAndroid - ? requireNativeComponent('InvertedScrollView') - : null; +const NativeInvertedScrollView = isAndroid ? requireNativeComponent('InvertedScrollView') : null; -const NativeInvertedScrollContentView = - isAndroid - ? requireNativeComponent('InvertedScrollContentView') - : null; +const NativeInvertedScrollContentView = isAndroid + ? requireNativeComponent('InvertedScrollContentView') + : null; const InvertedScrollView = (props: ScrollViewProps) => { if (NativeInvertedScrollView == null || NativeInvertedScrollContentView == null) { @@ -44,10 +39,7 @@ const InvertedScrollView = (props: ScrollViewProps) => { const hasStickyHeaders = Array.isArray(stickyHeaderIndices) && stickyHeaderIndices.length > 0; - const contentContainerStyleArray = [ - props.horizontal ? { flexDirection: 'row' } : null, - contentContainerStyle - ]; + const contentContainerStyleArray = [props.horizontal ? { flexDirection: 'row' } : null, contentContainerStyle]; const contentSizeChangeProps = onContentSizeChange == null @@ -66,14 +58,11 @@ const InvertedScrollView = (props: ScrollViewProps) => { return ( } - > + style={contentContainerStyleArray as StyleProp}> {children} diff --git a/app/views/RoomView/List/components/List.tsx b/app/views/RoomView/List/components/List.tsx index 7eb11ecee79..7ffe0135587 100644 --- a/app/views/RoomView/List/components/List.tsx +++ b/app/views/RoomView/List/components/List.tsx @@ -44,9 +44,7 @@ const List = ({ listRef, jumpToBottom, ...props }: IListProps) => { contentContainerStyle={styles.contentContainer} style={styles.list} inverted - renderScrollComponent={ - isIOS ? undefined : (props) => - } + renderScrollComponent={isIOS ? undefined : props => } removeClippedSubviews={isIOS} initialNumToRender={7} onEndReachedThreshold={0.5} From 556d4047c33e2b1953b6aa8ce4c4899057e8af2c Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 28 Jan 2026 18:07:18 -0300 Subject: [PATCH 19/27] update comments --- .../scroll/InvertedScrollView.java | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) 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 index 96fce4bb058..def585a7511 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java +++ b/android/app/src/main/java/chat/rocket/reactnative/scroll/InvertedScrollView.java @@ -6,13 +6,10 @@ import java.util.ArrayList; import java.util.Collections; -/** - * ScrollView subclass that fixes accessibility traversal order for inverted FlatLists. - * - *

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. - */ +// 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; @@ -21,10 +18,10 @@ 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. - */ + + // 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; } From ed43d9e1c103b585d3bbd9dc4a7e0efdb47aeb8b Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 28 Jan 2026 18:10:29 -0300 Subject: [PATCH 20/27] use foward ref --- .../RoomView/List/components/InvertedScrollView.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx index 8f7d91b5d55..52081e62ed5 100644 --- a/app/views/RoomView/List/components/InvertedScrollView.tsx +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -20,7 +20,10 @@ const NativeInvertedScrollContentView = isAndroid ? requireNativeComponent('InvertedScrollContentView') : null; -const InvertedScrollView = (props: ScrollViewProps) => { +const InvertedScrollView = React.forwardRef< + React.ComponentRef>, + ScrollViewProps +>((props, ref) => { if (NativeInvertedScrollView == null || NativeInvertedScrollContentView == null) { return null; } @@ -56,7 +59,7 @@ const InvertedScrollView = (props: ScrollViewProps) => { const { style, ...restWithoutStyle } = rest; return ( - + { ); -}; +}); + +InvertedScrollView.displayName = 'InvertedScrollView'; const styles = StyleSheet.create({ baseVertical: { From b1062343c670547434f51683793b34ab5dea7d41 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 28 Jan 2026 18:11:35 -0300 Subject: [PATCH 21/27] use FowardRef --- .../List/components/InvertedScrollView.tsx | 89 +++++++++---------- 1 file changed, 44 insertions(+), 45 deletions(-) diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx index 52081e62ed5..bc57bd5b1d6 100644 --- a/app/views/RoomView/List/components/InvertedScrollView.tsx +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -20,57 +20,56 @@ const NativeInvertedScrollContentView = isAndroid ? requireNativeComponent('InvertedScrollContentView') : null; -const InvertedScrollView = React.forwardRef< - React.ComponentRef>, - ScrollViewProps ->((props, ref) => { - if (NativeInvertedScrollView == null || NativeInvertedScrollContentView == null) { - return null; - } - const { - children, - contentContainerStyle, - onContentSizeChange, - removeClippedSubviews, - maintainVisibleContentPosition, - snapToAlignment, - stickyHeaderIndices, - ...rest - } = props; +const InvertedScrollView = React.forwardRef>, ScrollViewProps>( + (props, ref) => { + if (NativeInvertedScrollView == null || NativeInvertedScrollContentView == null) { + return null; + } + const { + children, + contentContainerStyle, + onContentSizeChange, + removeClippedSubviews, + maintainVisibleContentPosition, + snapToAlignment, + stickyHeaderIndices, + ...rest + } = props; - const preserveChildren = maintainVisibleContentPosition != null || (isAndroid && props.snapToAlignment != null); + const preserveChildren = maintainVisibleContentPosition != null || (isAndroid && props.snapToAlignment != null); - const hasStickyHeaders = Array.isArray(stickyHeaderIndices) && stickyHeaderIndices.length > 0; + const hasStickyHeaders = Array.isArray(stickyHeaderIndices) && stickyHeaderIndices.length > 0; - const contentContainerStyleArray = [props.horizontal ? { flexDirection: 'row' } : null, contentContainerStyle]; + const contentContainerStyleArray = [props.horizontal ? { flexDirection: 'row' } : null, contentContainerStyle]; - const contentSizeChangeProps = - onContentSizeChange == null - ? undefined - : { - onLayout: (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout; - onContentSizeChange(width, height); - } - }; + 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; + const horizontal = !!props.horizontal; + const baseStyle = horizontal ? styles.baseHorizontal : styles.baseVertical; + const { style, ...restWithoutStyle } = rest; - return ( - - }> - {children} - - - ); -}); + return ( + + }> + {children} + + + ); + } +); InvertedScrollView.displayName = 'InvertedScrollView'; From 195df51d5add73733b1b950c54e07a3353504ba1 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 29 Jan 2026 17:24:46 -0300 Subject: [PATCH 22/27] fix: invertedScrollView ref --- .../List/components/InvertedScrollView.tsx | 248 +++++++++++++----- 1 file changed, 178 insertions(+), 70 deletions(-) diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx index bc57bd5b1d6..b402ebca8f1 100644 --- a/app/views/RoomView/List/components/InvertedScrollView.tsx +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -1,91 +1,199 @@ -import React from 'react'; +import React, { + forwardRef, + useRef, + useLayoutEffect +} from 'react'; import { - requireNativeComponent, - StyleSheet, - type StyleProp, - type ViewStyle, - type LayoutChangeEvent, - type ScrollViewProps, - type ViewProps + findNodeHandle, + requireNativeComponent, + StyleSheet, + UIManager, + type StyleProp, + type ViewStyle, + type LayoutChangeEvent, + type ScrollViewProps, + type ViewProps } from 'react-native'; import { isAndroid } from '../../../../lib/methods/helpers'; -// Android-only native ScrollView that fixes TalkBack traversal order for inverted FlatLists. -// Used via FlatList's renderScrollComponent. VirtualizedList passes multiple children (cells). + +const COMMAND_SCROLL_TO = 1; +const COMMAND_SCROLL_TO_END = 2; +const COMMAND_FLASH_SCROLL_INDICATORS = 3; + +// ---------------------------------------------------------------------- +// Type Definitions +// ---------------------------------------------------------------------- + +// The instance type of the native HostComponent (what the ref receives), not the Ref type. +type NativeScrollInstance = React.ComponentRef>; + +// Interface for the methods we are dynamically attaching +interface ScrollableMethods { + scrollTo(options?: { x?: number; y?: number; animated?: boolean }): void; + scrollToEnd(options?: { animated?: boolean }): void; + flashScrollIndicators(): void; + getScrollRef(): NativeScrollInstance | null; + setNativeProps(props: object): void; +} + +// The final Ref type exposed to consumers (Intersection of Native + Custom) +export type InvertedScrollViewRef = NativeScrollInstance & ScrollableMethods; + +// ---------------------------------------------------------------------- +// Native Component Requirements +// ---------------------------------------------------------------------- const NativeInvertedScrollView = isAndroid ? requireNativeComponent('InvertedScrollView') : null; const NativeInvertedScrollContentView = isAndroid - ? requireNativeComponent('InvertedScrollContentView') - : null; - -const InvertedScrollView = React.forwardRef>, ScrollViewProps>( - (props, ref) => { - if (NativeInvertedScrollView == null || NativeInvertedScrollContentView == null) { - return null; - } - const { - children, - contentContainerStyle, - onContentSizeChange, - removeClippedSubviews, - maintainVisibleContentPosition, - snapToAlignment, - stickyHeaderIndices, - ...rest - } = props; - - const preserveChildren = maintainVisibleContentPosition != null || (isAndroid && props.snapToAlignment != null); - - const hasStickyHeaders = Array.isArray(stickyHeaderIndices) && stickyHeaderIndices.length > 0; - - const contentContainerStyleArray = [props.horizontal ? { flexDirection: 'row' } : null, contentContainerStyle]; - - const contentSizeChangeProps = - onContentSizeChange == null - ? undefined - : { - onLayout: (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout; - onContentSizeChange(width, height); - } - }; + ? requireNativeComponent('InvertedScrollContentView') + : null; + + +const InvertedScrollView = forwardRef((props, externalRef) => { + const internalRef = useRef(null); + // internalRef captures the Host Instance for local manipulation + + // useLayoutEffect runs synchronously after DOM mutations. + // This guarantees methods are attached before the parent can access the ref. + useLayoutEffect(() => { + const node = internalRef.current; + + if (node) { + // Cast to 'any' to allow dynamic property assignment + const patchedNode = node as any; + + // 1. Implementation of scrollTo + patchedNode.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 + patchedNode.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 + patchedNode.flashScrollIndicators = () => { + const tag = findNodeHandle(node as any); + if (tag !== null) { + UIManager.dispatchViewManagerCommand(tag, COMMAND_FLASH_SCROLL_INDICATORS, []); + } + }; + + // 4. Implementation of getScrollRef (Legacy support) + patchedNode.getScrollRef = () => node; + + // 5. Ensure setNativeProps exists (it usually does on Host Instances, but explicit is safer) + if (typeof patchedNode.setNativeProps!== 'function') { + patchedNode.setNativeProps = (nativeProps: object) => { + // Check again if the underlying node has the method hidden + if (node && typeof (node as any).setNativeProps === 'function') { + (node as any).setNativeProps(nativeProps); + } + }; + } + } + }); + + // 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; + } + }; + + // Extract specific props that require transformation + const { + children, + contentContainerStyle, + onContentSizeChange, + removeClippedSubviews, + maintainVisibleContentPosition, + snapToAlignment, + stickyHeaderIndices, + ...rest + } = props; + + // Logic preserved from original class component + const preserveChildren = maintainVisibleContentPosition != null || (isAndroid && snapToAlignment != null); + const hasStickyHeaders = Array.isArray(stickyHeaderIndices) && stickyHeaderIndices.length > 0; + + // Construct styles + const contentContainerStyleArray = [props.horizontal ? { flexDirection: 'row' as const } : null, contentContainerStyle]; + + // Wrap onContentSizeChange to normalize the event payload + 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; - return ( - - }> - {children} - - - ); + if (!NativeInvertedScrollView || !NativeInvertedScrollContentView) { + return null; } -); + type ScrollViewPropsWithRef = ScrollViewProps & React.RefAttributes; + const ScrollView = NativeInvertedScrollView as React.ComponentType; + const ContentView = NativeInvertedScrollContentView as React.ComponentType; + + return ( + + } + > + {children} + + + ); +}); InvertedScrollView.displayName = 'InvertedScrollView'; const styles = StyleSheet.create({ - baseVertical: { - flexGrow: 1, - flexShrink: 1, - flexDirection: 'column', - overflow: 'scroll' - }, - baseHorizontal: { - flexGrow: 1, - flexShrink: 1, - flexDirection: 'row', - overflow: 'scroll' - } + baseVertical: { + flexGrow: 1, + flexShrink: 1, + flexDirection: 'column', + overflow: 'scroll' + }, + baseHorizontal: { + flexGrow: 1, + flexShrink: 1, + flexDirection: 'row', + overflow: 'scroll' + } }); -export default InvertedScrollView; +export default InvertedScrollView; \ No newline at end of file From d7a0a5e1bc1bbac9bf1ac6aad1a7a3b48ebc7f2e Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 29 Jan 2026 17:32:39 -0300 Subject: [PATCH 23/27] cleanup --- .../List/components/InvertedScrollView.tsx | 118 +++++++----------- 1 file changed, 42 insertions(+), 76 deletions(-) diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx index b402ebca8f1..4f98b4ed6c0 100644 --- a/app/views/RoomView/List/components/InvertedScrollView.tsx +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -1,36 +1,26 @@ -import React, { - forwardRef, - useRef, - useLayoutEffect -} from 'react'; +import React, { forwardRef, useRef, useLayoutEffect } from 'react'; import { - findNodeHandle, - requireNativeComponent, - StyleSheet, - UIManager, - type StyleProp, - type ViewStyle, - type LayoutChangeEvent, - type ScrollViewProps, - type ViewProps + 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; -// ---------------------------------------------------------------------- -// Type Definitions -// ---------------------------------------------------------------------- -// The instance type of the native HostComponent (what the ref receives), not the Ref type. +type ScrollViewPropsWithRef = ScrollViewProps & React.RefAttributes; type NativeScrollInstance = React.ComponentRef>; - -// Interface for the methods we are dynamically attaching -interface ScrollableMethods { +interface IScrollableMethods { scrollTo(options?: { x?: number; y?: number; animated?: boolean }): void; scrollToEnd(options?: { animated?: boolean }): void; flashScrollIndicators(): void; @@ -38,35 +28,24 @@ interface ScrollableMethods { setNativeProps(props: object): void; } -// The final Ref type exposed to consumers (Intersection of Native + Custom) -export type InvertedScrollViewRef = NativeScrollInstance & ScrollableMethods; - -// ---------------------------------------------------------------------- -// Native Component Requirements -// ---------------------------------------------------------------------- +export type InvertedScrollViewRef = NativeScrollInstance & IScrollableMethods; const NativeInvertedScrollView = isAndroid ? requireNativeComponent('InvertedScrollView') : null; const NativeInvertedScrollContentView = isAndroid - ? requireNativeComponent('InvertedScrollContentView') - : null; - + ? requireNativeComponent('InvertedScrollContentView') + : null; const InvertedScrollView = forwardRef((props, externalRef) => { const internalRef = useRef(null); - // internalRef captures the Host Instance for local manipulation - // useLayoutEffect runs synchronously after DOM mutations. - // This guarantees methods are attached before the parent can access the ref. useLayoutEffect(() => { - const node = internalRef.current; + const node = internalRef.current as any; if (node) { - // Cast to 'any' to allow dynamic property assignment - const patchedNode = node as any; // 1. Implementation of scrollTo - patchedNode.scrollTo = (options?: { x?: number; y?: number; animated?: boolean }) => { + node.scrollTo = (options?: { x?: number; y?: number; animated?: boolean }) => { const tag = findNodeHandle(node); if (tag!= null) { const x = options?.x || 0; @@ -77,7 +56,7 @@ const InvertedScrollView = forwardRef((p }; // 2. Implementation of scrollToEnd - patchedNode.scrollToEnd = (options?: { animated?: boolean }) => { + node.scrollToEnd = (options?: { animated?: boolean }) => { const tag = findNodeHandle(node); if (tag!= null) { const animated = options?.animated !== false; @@ -86,19 +65,17 @@ const InvertedScrollView = forwardRef((p }; // 3. Implementation of flashScrollIndicators - patchedNode.flashScrollIndicators = () => { + node.flashScrollIndicators = () => { const tag = findNodeHandle(node as any); if (tag !== null) { UIManager.dispatchViewManagerCommand(tag, COMMAND_FLASH_SCROLL_INDICATORS, []); } }; - // 4. Implementation of getScrollRef (Legacy support) - patchedNode.getScrollRef = () => node; + node.getScrollRef = () => node; - // 5. Ensure setNativeProps exists (it usually does on Host Instances, but explicit is safer) - if (typeof patchedNode.setNativeProps!== 'function') { - patchedNode.setNativeProps = (nativeProps: object) => { + if (typeof node.setNativeProps!== 'function') { + node.setNativeProps = (nativeProps: object) => { // Check again if the underlying node has the method hidden if (node && typeof (node as any).setNativeProps === 'function') { (node as any).setNativeProps(nativeProps); @@ -118,9 +95,8 @@ const InvertedScrollView = forwardRef((p (externalRef as React.MutableRefObject).current = node; } }; - - // Extract specific props that require transformation - const { + + const { children, contentContainerStyle, onContentSizeChange, @@ -131,14 +107,11 @@ const InvertedScrollView = forwardRef((p ...rest } = props; - // Logic preserved from original class component const preserveChildren = maintainVisibleContentPosition != null || (isAndroid && snapToAlignment != null); const hasStickyHeaders = Array.isArray(stickyHeaderIndices) && stickyHeaderIndices.length > 0; - // Construct styles const contentContainerStyleArray = [props.horizontal ? { flexDirection: 'row' as const } : null, contentContainerStyle]; - // Wrap onContentSizeChange to normalize the event payload const contentSizeChangeProps = onContentSizeChange == null ? undefined : { @@ -148,31 +121,24 @@ const InvertedScrollView = forwardRef((p } }; - const horizontal = !!props.horizontal; - const baseStyle = horizontal ? styles.baseHorizontal : styles.baseVertical; - const { style, ...restWithoutStyle } = rest; + const horizontal = !!props.horizontal; + const baseStyle = horizontal ? styles.baseHorizontal : styles.baseVertical; + const { style, ...restWithoutStyle } = rest; if (!NativeInvertedScrollView || !NativeInvertedScrollContentView) { return null; } - type ScrollViewPropsWithRef = ScrollViewProps & React.RefAttributes; const ScrollView = NativeInvertedScrollView as React.ComponentType; const ContentView = NativeInvertedScrollContentView as React.ComponentType; return ( - + } - > + style={contentContainerStyleArray as StyleProp}> {children} @@ -182,18 +148,18 @@ const InvertedScrollView = forwardRef((p InvertedScrollView.displayName = 'InvertedScrollView'; const styles = StyleSheet.create({ - baseVertical: { - flexGrow: 1, - flexShrink: 1, - flexDirection: 'column', - overflow: 'scroll' - }, - baseHorizontal: { - flexGrow: 1, - flexShrink: 1, - flexDirection: 'row', - overflow: 'scroll' - } + baseVertical: { + flexGrow: 1, + flexShrink: 1, + flexDirection: 'column', + overflow: 'scroll' + }, + baseHorizontal: { + flexGrow: 1, + flexShrink: 1, + flexDirection: 'row', + overflow: 'scroll' + } }); -export default InvertedScrollView; \ No newline at end of file +export default InvertedScrollView; From 4837b1a5201325334be314328ae31dfc968ff856 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 29 Jan 2026 20:37:02 +0000 Subject: [PATCH 24/27] chore: format code and fix lint issues [skip ci] --- .../List/components/InvertedScrollView.tsx | 177 +++++++++--------- 1 file changed, 88 insertions(+), 89 deletions(-) diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx index 4f98b4ed6c0..764283ac868 100644 --- a/app/views/RoomView/List/components/InvertedScrollView.tsx +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -17,15 +17,14 @@ const COMMAND_SCROLL_TO = 1; const COMMAND_SCROLL_TO_END = 2; const COMMAND_FLASH_SCROLL_INDICATORS = 3; - 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; + 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; @@ -37,89 +36,89 @@ const NativeInvertedScrollContentView = isAndroid : 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; - - if (typeof node.setNativeProps!== 'function') { - node.setNativeProps = (nativeProps: object) => { - // Check again if the underlying node has the method hidden - if (node && typeof (node as any).setNativeProps === 'function') { - (node as any).setNativeProps(nativeProps); - } - }; - } - } - }); - - // 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 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; + + if (typeof node.setNativeProps !== 'function') { + node.setNativeProps = (nativeProps: object) => { + // Check again if the underlying node has the method hidden + if (node && typeof (node as any).setNativeProps === 'function') { + (node as any).setNativeProps(nativeProps); + } + }; + } + } + }); + + // 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; From 51d984e6c800adef56a38d554b6715d6fb7d2c78 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 29 Jan 2026 17:33:46 -0300 Subject: [PATCH 25/27] fix: invertedScrollView --- .../List/components/InvertedScrollView.tsx | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx index 764283ac868..1a22a35022d 100644 --- a/app/views/RoomView/List/components/InvertedScrollView.tsx +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -17,6 +17,22 @@ 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 { @@ -146,19 +162,4 @@ const InvertedScrollView = forwardRef((p InvertedScrollView.displayName = 'InvertedScrollView'; -const styles = StyleSheet.create({ - baseVertical: { - flexGrow: 1, - flexShrink: 1, - flexDirection: 'column', - overflow: 'scroll' - }, - baseHorizontal: { - flexGrow: 1, - flexShrink: 1, - flexDirection: 'row', - overflow: 'scroll' - } -}); - export default InvertedScrollView; From 151aebb6794cd37264e6a4423539a5d06aea5ff2 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 29 Jan 2026 17:46:52 -0300 Subject: [PATCH 26/27] code improvements --- .../List/components/InvertedScrollView.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx index 1a22a35022d..3dfbfaf458d 100644 --- a/app/views/RoomView/List/components/InvertedScrollView.tsx +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -87,17 +87,13 @@ const InvertedScrollView = forwardRef((p }; node.getScrollRef = () => node; - - if (typeof node.setNativeProps !== 'function') { - node.setNativeProps = (nativeProps: object) => { - // Check again if the underlying node has the method hidden - if (node && typeof (node as any).setNativeProps === 'function') { - (node as any).setNativeProps(nativeProps); - } - }; - } + 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) => { From ead814bf02a68342cebd5b2a71b28ff5c3740c99 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 29 Jan 2026 20:48:15 +0000 Subject: [PATCH 27/27] chore: format code and fix lint issues [skip ci] --- app/views/RoomView/List/components/InvertedScrollView.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/views/RoomView/List/components/InvertedScrollView.tsx b/app/views/RoomView/List/components/InvertedScrollView.tsx index 3dfbfaf458d..a60fe91eee1 100644 --- a/app/views/RoomView/List/components/InvertedScrollView.tsx +++ b/app/views/RoomView/List/components/InvertedScrollView.tsx @@ -32,7 +32,6 @@ const styles = StyleSheet.create({ } }); - type ScrollViewPropsWithRef = ScrollViewProps & React.RefAttributes; type NativeScrollInstance = React.ComponentRef>; interface IScrollableMethods { @@ -88,10 +87,9 @@ const InvertedScrollView = forwardRef((p node.getScrollRef = () => node; const originalSetNativeProps = (node as any).setNativeProps; - if (typeof originalSetNativeProps !== 'function') { - node.setNativeProps = (_nativeProps: object) => { - }; - } + if (typeof originalSetNativeProps !== 'function') { + node.setNativeProps = (_nativeProps: object) => {}; + } } }, []);