diff --git a/app/containers/RoomItem/Actions.tsx b/app/containers/RoomItem/Actions.tsx index c215627cfe..a48f72ca43 100644 --- a/app/containers/RoomItem/Actions.tsx +++ b/app/containers/RoomItem/Actions.tsx @@ -1,19 +1,19 @@ import { memo } from 'react'; import { View } from 'react-native'; import Animated, { + type SharedValue, useAnimatedStyle, - interpolate, - withSpring, useAnimatedReaction, - useSharedValue + useDerivedValue, + useSharedValue, + withTiming } from 'react-native-reanimated'; -import * as Haptics from 'expo-haptics'; -import { scheduleOnRN } from 'react-native-worklets'; import { RectButton } from '~/containers/GestureButtons'; import { CustomIcon } from '../CustomIcon'; import { DisplayMode } from '~/lib/constants/constantDisplayMode'; -import styles, { ACTION_WIDTH, LONG_SWIPE } from './styles'; +import styles from './styles'; +import { getActionWidth, getFullSwipeThreshold } from './utils/swipeRelease'; import { type ILeftActionsProps, type IRightActionsProps } from './interfaces'; import { useTheme } from '~/theme'; import I18n from '~/i18n'; @@ -21,14 +21,33 @@ import { useResponsiveLayout } from '~/lib/hooks/useResponsiveLayout/useResponsi const CONDENSED_ICON_SIZE = 24; const EXPANDED_ICON_SIZE = 28; +const EXPAND_DURATION = 300; + +const useHideExpandProgress = (transX: SharedValue, width: number) => { + const fullSwipeThreshold = getFullSwipeThreshold(width); + const expandProgress = useSharedValue(0); + + useAnimatedReaction( + () => -transX.value >= fullSwipeThreshold, + (isCrossed, wasCrossed) => { + if (isCrossed !== wasCrossed) { + expandProgress.value = withTiming(isCrossed ? 1 : 0, { duration: EXPAND_DURATION }); + } + } + ); + + return expandProgress; +}; export const LeftActions = memo(({ transX, isRead, width, onToggleReadPress, displayMode }: ILeftActionsProps) => { const { colors } = useTheme(); const { rowHeight, rowHeightCondensed } = useResponsiveLayout(); - const animatedStyles = useAnimatedStyle(() => ({ - transform: [{ translateX: transX.value }] + const actionWidth = getActionWidth(width); + + const animatedButtonStyles = useAnimatedStyle(() => ({ + width: Math.max(transX.value, 0) })); const isCondensed = displayMode === DisplayMode.Condensed; @@ -43,23 +62,23 @@ export const LeftActions = memo(({ transX, isRead, width, onToggleReadPress, dis - - + + - - + + ); @@ -70,55 +89,23 @@ export const RightActions = memo(({ transX, favorite, width, toggleFav, onHidePr const { rowHeight, rowHeightCondensed } = useResponsiveLayout(); - const animatedFavStyles = useAnimatedStyle(() => ({ transform: [{ translateX: transX.value }] })); - - const translateXHide = useSharedValue(0); + const actionWidth = getActionWidth(width); + const expandProgress = useHideExpandProgress(transX, width); - const triggerHideAnimation = (toValue: number) => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - translateXHide.value = withSpring(toValue, { overshootClamping: true, mass: 0.7 }); - }; + const buttonWidths = useDerivedValue(() => { + const reveal = Math.max(-transX.value, 0); + const favorite = (reveal / 2) * (1 - expandProgress.value); + return { favorite, hide: reveal - favorite }; + }); - useAnimatedReaction( - () => transX.value, - (currentTransX, previousTransX) => { - // Triggers the animation and hapticFeedback if swipe reaches/unreaches the threshold. - if (I18n.isRTL) { - if (previousTransX && currentTransX > LONG_SWIPE && previousTransX <= LONG_SWIPE) { - scheduleOnRN(triggerHideAnimation, ACTION_WIDTH); - } else if (previousTransX && currentTransX <= LONG_SWIPE && previousTransX > LONG_SWIPE) { - scheduleOnRN(triggerHideAnimation, 0); - } - } else if (previousTransX && currentTransX < -LONG_SWIPE && previousTransX >= -LONG_SWIPE) { - scheduleOnRN(triggerHideAnimation, -ACTION_WIDTH); - } else if (previousTransX && currentTransX >= -LONG_SWIPE && previousTransX < -LONG_SWIPE) { - scheduleOnRN(triggerHideAnimation, 0); - } - } - ); + const animatedFavStyles = useAnimatedStyle(() => ({ + width: buttonWidths.value.favorite, + right: buttonWidths.value.hide + })); - const animatedHideStyles = useAnimatedStyle(() => { - if (I18n.isRTL) { - if (transX.value < LONG_SWIPE && transX.value >= 2 * ACTION_WIDTH) { - const parallaxSwipe = interpolate( - transX.value, - [2 * ACTION_WIDTH, LONG_SWIPE], - [ACTION_WIDTH, ACTION_WIDTH + 0.1 * transX.value] - ); - return { transform: [{ translateX: parallaxSwipe + translateXHide.value }] }; - } - return { transform: [{ translateX: transX.value - ACTION_WIDTH + translateXHide.value }] }; - } - if (transX.value > -LONG_SWIPE && transX.value <= -2 * ACTION_WIDTH) { - const parallaxSwipe = interpolate( - transX.value, - [-2 * ACTION_WIDTH, -LONG_SWIPE], - [-ACTION_WIDTH, -ACTION_WIDTH + 0.1 * transX.value] - ); - return { transform: [{ translateX: parallaxSwipe + translateXHide.value }] }; - } - return { transform: [{ translateX: transX.value + ACTION_WIDTH + translateXHide.value }] }; - }); + const animatedHideStyles = useAnimatedStyle(() => ({ + width: buttonWidths.value.hide + })); const isCondensed = displayMode === DisplayMode.Condensed; const viewHeight = { height: isCondensed ? rowHeightCondensed : rowHeight }; @@ -130,49 +117,36 @@ export const RightActions = memo(({ transX, favorite, width, toggleFav, onHidePr accessibilityElementsHidden importantForAccessibility='no'> + style={[styles.actionRightButtonContainer, { backgroundColor: colors.statusFontWarning }, viewHeight, animatedFavStyles]}> - + + + - - + + + + diff --git a/app/containers/RoomItem/Touchable.tsx b/app/containers/RoomItem/Touchable.tsx index bf828e9301..7bbca19a40 100644 --- a/app/containers/RoomItem/Touchable.tsx +++ b/app/containers/RoomItem/Touchable.tsx @@ -1,25 +1,30 @@ -import { useRef, memo, type ReactElement } from 'react'; -import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated'; -import { - GestureDetector, - type PanGestureActiveEvent, - useCompetingGestures, - useLongPressGesture, - usePanGesture -} from 'react-native-gesture-handler'; -import { scheduleOnRN } from 'react-native-worklets'; +import { useEffect, useState, memo, type ReactElement } from 'react'; +import { I18nManager } from 'react-native'; +import Animated, { useSharedValue, useAnimatedStyle, useAnimatedReaction } from 'react-native-reanimated'; +import { GestureDetector, usePanGesture } from 'react-native-gesture-handler'; +import { runOnUISync, scheduleOnRN } from 'react-native-worklets'; +import * as Haptics from 'expo-haptics'; import Touch from '../Touch'; -import { ACTION_WIDTH, LONG_SWIPE, SMALL_SWIPE } from './styles'; import { LeftActions, RightActions } from './Actions'; +import { getFullSwipeThreshold, getSwipeRelease } from './utils/swipeRelease'; +import { unregisterOpenSwipeItem, closeOpenSwipeItem, settleSwipeRow } from './utils/openSwipeItem'; import { type ITouchableProps } from './interfaces'; import { useTheme } from '~/theme'; -import I18n from '~/i18n'; import { toggleFav } from '~/lib/methods/toggleFav'; import { toggleRead } from '~/lib/methods/toggleRead'; import { hideRoom } from '~/lib/methods/hideRoom'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; +const rubberband = (overshoot: number, dimension: number, constant = 0.55) => { + 'worklet'; + return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot)); +}; + +const triggerThresholdHaptic = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); +}; + const Touchable = ({ children, type, @@ -35,187 +40,125 @@ const Touchable = ({ }: ITouchableProps): ReactElement => { const { colors } = useTheme(); const serverVersion = useAppSelector(state => state.server.version); + const direction = I18nManager.isRTL ? -1 : 1; const rowOffSet = useSharedValue(0); const transX = useSharedValue(0); - const rowState = useSharedValue(0); // 0: closed, 1: right opened, -1: left opened - const valueRef = useRef(0); - - const close = () => { - rowState.value = 0; - transX.value = withSpring(0, { overshootClamping: true }); - rowOffSet.value = 0; - valueRef.current = 0; - }; + const crossedFullSwipe = useSharedValue(false); + const touchClosedOtherRow = useSharedValue(false); + const [actionsMounted, setActionsMounted] = useState(false); + const row = { rid, transX, rowOffSet }; + + useAnimatedReaction( + () => transX.value !== 0, + (moved, previouslyMoved) => { + if (previouslyMoved !== null && moved !== previouslyMoved) { + scheduleOnRN(setActionsMounted, moved); + } + } + ); + + const close = () => settleSwipeRow(row, 0); + + useEffect(() => () => unregisterOpenSwipeItem(rid), [rid]); const handleToggleFav = () => { toggleFav(rid, favorite); close(); }; - const handleToggleRead = () => { - toggleRead(rid, isRead, serverVersion); - }; + const toggleReadRoom = () => toggleRead(rid, isRead, serverVersion); - const handleHideChannel = () => { - hideRoom(rid, type); - }; + const hideChannel = () => hideRoom(rid, type); const onToggleReadPress = () => { - handleToggleRead(); + toggleReadRoom(); close(); }; const onHidePress = () => { - handleHideChannel(); + hideChannel(); close(); }; - const handlePress = () => { - if (rowState.value !== 0) { + const guardTouch = (action?: () => void) => () => { + if (rowOffSet.value !== 0) { close(); return; } - if (onPress) { - onPress(); - } - }; - - const handleLongPress = () => { - if (rowState.value !== 0) { - close(); + if (touchClosedOtherRow.value || runOnUISync(closeOpenSwipeItem, rid)) { + touchClosedOtherRow.value = false; return; } - - if (onLongPress) { - onLongPress(); - } + action?.(); }; - const handleRelease = (event: PanGestureActiveEvent) => { - const { translationX } = event; - valueRef.current += translationX; - let toValue = 0; - if (rowState.value === 0) { - // if no option is opened - if (translationX > 0 && translationX < LONG_SWIPE) { - if (I18n.isRTL) { - toValue = 2 * ACTION_WIDTH; - } else { - toValue = ACTION_WIDTH; - } - rowState.value = -1; - } else if (translationX >= LONG_SWIPE) { - toValue = 0; - if (I18n.isRTL) { - handleHideChannel(); - } else { - handleToggleRead(); - } - } else if (translationX < 0 && translationX > -LONG_SWIPE) { - // open trailing option if he swipe left - if (I18n.isRTL) { - toValue = -ACTION_WIDTH; - } else { - toValue = -2 * ACTION_WIDTH; - } - rowState.value = 1; - } else if (translationX <= -LONG_SWIPE) { - toValue = 0; - rowState.value = 1; - if (I18n.isRTL) { - handleToggleRead(); - } else { - handleHideChannel(); - } - } else { - toValue = 0; - } - } else if (rowState.value === -1) { - // if left option is opened - if (valueRef.current < SMALL_SWIPE) { - toValue = 0; - rowState.value = 0; - } else if (valueRef.current > LONG_SWIPE) { - toValue = 0; - rowState.value = 0; - if (I18n.isRTL) { - handleHideChannel(); - } else { - handleToggleRead(); - } - } else if (I18n.isRTL) { - toValue = 2 * ACTION_WIDTH; - } else { - toValue = ACTION_WIDTH; - } - } else if (rowState.value === 1) { - // if right option is opened - if (valueRef.current > -2 * SMALL_SWIPE) { - toValue = 0; - rowState.value = 0; - } else if (valueRef.current < -LONG_SWIPE) { - if (I18n.isRTL) { - handleToggleRead(); - } else { - handleHideChannel(); - } - } else if (I18n.isRTL) { - toValue = -ACTION_WIDTH; - } else { - toValue = -2 * ACTION_WIDTH; - } - } - transX.value = withSpring(toValue, { overshootClamping: true }); - rowOffSet.value = toValue; - valueRef.current = toValue; - }; + const handlePress = guardTouch(onPress); - const longPressGesture = useLongPressGesture({ - minDuration: 500, - onActivate: () => { - scheduleOnRN(handleLongPress); - } - }); + const handleLongPress = guardTouch(onLongPress); const panGesture = usePanGesture({ activeOffsetX: [-10, 10], // More sensitive horizontal detection failOffsetY: [-20, 20], // Fail on vertical movement to distinguish scrolling enabled: swipeEnabled, + onBegin: () => { + crossedFullSwipe.value = false; + touchClosedOtherRow.value = closeOpenSwipeItem(rid); + }, + onActivate: () => { + scheduleOnRN(setActionsMounted, true); + }, onUpdate: event => { - transX.value = event.translationX + rowOffSet.value; - if (transX.value > 2 * width) transX.value = 2 * width; + const next = rowOffSet.value + direction * event.translationX; + const threshold = getFullSwipeThreshold(width); + const overshoot = Math.abs(next) - threshold; + transX.value = overshoot > 0 ? Math.sign(next) * (threshold + rubberband(overshoot, width)) : next; + const crossed = overshoot >= 0; + if (crossed !== crossedFullSwipe.value) { + crossedFullSwipe.value = crossed; + scheduleOnRN(triggerThresholdHaptic); + } }, onDeactivate: event => { - scheduleOnRN(handleRelease, event); + const release = getSwipeRelease({ + restingOffset: rowOffSet.value, + offset: rowOffSet.value + direction * event.translationX, + width + }); + settleSwipeRow(row, release.restingOffset, direction * event.velocityX); + if (release.fullSwipe === 'left') { + scheduleOnRN(toggleReadRoom); + } else if (release.fullSwipe === 'right') { + scheduleOnRN(hideChannel); + } } }); - // Use competing gestures instead of simultaneous to prevent conflicts - // Pan gesture will take priority over long press for horizontal swipes - const composedGesture = useCompetingGestures(panGesture, longPressGesture); - const animatedStyles = useAnimatedStyle(() => ({ - transform: [{ translateX: transX.value }] + transform: [{ translateX: direction * transX.value }] })); return ( - + - - + {actionsMounted ? ( + <> + + + + ) : null} - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - @@ -677,8 +336,7 @@ exports[`Story Snapshots: Alerts should match snapshot 1`] = ` - - - -  - - - - - - - - - -  - - - - - - -  - - - - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - - - - - 10:00 - - - - - - - - - - - -`; - -exports[`Story Snapshots: CondensedRoomItem should match snapshot 1`] = ` -[ - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + "textAlign": "left", + }, + { + "color": "#2F343D", + }, + undefined, + ] + } + > + 10:00 + + + + + - + + + + +`; + +exports[`Story Snapshots: CondensedRoomItem should match snapshot 1`] = ` +[ + + @@ -9199,375 +4766,34 @@ exports[`Story Snapshots: CondensedRoomItem should match snapshot 1`] = ` 1 - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + + + + + - + + + , + + @@ -9900,390 +5126,49 @@ exports[`Story Snapshots: CondensedRoomItem should match snapshot 1`] = ` "fontFamily": "Inter", "fontSize": 13, "fontVariant": [ - "tabular-nums", - ], - "fontWeight": "600", - "textAlign": "left", - }, - undefined, - { - "color": "#FFFFFF", - }, - ] - } - > - +999 - - - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + "tabular-nums", + ], + "fontWeight": "600", + "textAlign": "left", + }, + undefined, + { + "color": "#FFFFFF", + }, + ] + } + > + +999 + + + + + + + - + + + , + + @@ -10611,374 +5496,33 @@ exports[`Story Snapshots: CondensedRoomItem should match snapshot 1`] = ` - - - - , -] -`; - -exports[`Story Snapshots: CondensedRoomItemWithoutAvatar should match snapshot 1`] = ` -[ - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - + + + , +] +`; + +exports[`Story Snapshots: CondensedRoomItemWithoutAvatar should match snapshot 1`] = ` +[ + + @@ -11252,390 +5796,49 @@ exports[`Story Snapshots: CondensedRoomItemWithoutAvatar should match snapshot 1 "fontFamily": "Inter", "fontSize": 13, "fontVariant": [ - "tabular-nums", - ], - "fontWeight": "600", - "textAlign": "left", - }, - undefined, - { - "color": "#FFFFFF", - }, - ] - } - > - 1 - - - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + "tabular-nums", + ], + "fontWeight": "600", + "textAlign": "left", + }, + undefined, + { + "color": "#FFFFFF", + }, + ] + } + > + 1 + + + + + + + - + + + , + + @@ -11859,375 +6062,34 @@ exports[`Story Snapshots: CondensedRoomItemWithoutAvatar should match snapshot 1 > 10:00 - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + + + + + - + + + , + + @@ -12480,390 +6342,49 @@ exports[`Story Snapshots: CondensedRoomItemWithoutAvatar should match snapshot 1 "fontWeight": "400", "marginLeft": 4, "textAlign": "left", - }, - { - "color": "#2F343D", - }, - undefined, - ] - } - > - 10:00 - - - - - - - - - - - , -] -`; - -exports[`Story Snapshots: ExpandedRoomItemWithoutAvatar should match snapshot 1`] = ` -[ - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + }, + { + "color": "#2F343D", + }, + undefined, + ] + } + > + 10:00 + + + + + + - + + + , +] +`; + +exports[`Story Snapshots: ExpandedRoomItemWithoutAvatar should match snapshot 1`] = ` +[ + + @@ -13204,375 +6725,34 @@ exports[`Story Snapshots: ExpandedRoomItemWithoutAvatar should match snapshot 1` > 1 - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + + + + + - + + + , + + @@ -13898,390 +7078,49 @@ exports[`Story Snapshots: ExpandedRoomItemWithoutAvatar should match snapshot 1` "backgroundColor": "transparent", "fontFamily": "Inter", "fontSize": 13, - "fontVariant": [ - "tabular-nums", - ], - "fontWeight": "600", - "textAlign": "left", - }, - undefined, - { - "color": "#FFFFFF", - }, - ] - } - > - 1 - - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "600", + "textAlign": "left", + }, + undefined, + { + "color": "#FFFFFF", + }, + ] + } + > + 1 + + + + + + - + + + , + + @@ -14586,372 +7425,31 @@ exports[`Story Snapshots: ExpandedRoomItemWithoutAvatar should match snapshot 1` - - , -] -`; - -exports[`Story Snapshots: InvitedRoom should match snapshot 1`] = ` -[ - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - + + , +] +`; + +exports[`Story Snapshots: InvitedRoom should match snapshot 1`] = ` +[ + + @@ -15244,393 +7742,52 @@ exports[`Story Snapshots: InvitedRoom should match snapshot 1`] = ` [ { "backgroundColor": "transparent", - "fontFamily": "Inter", - "fontSize": 13, - "fontVariant": [ - "tabular-nums", - ], - "fontWeight": "400", - "marginLeft": 4, - "textAlign": "left", - }, - { - "color": "#2F343D", - }, - undefined, - ] - } - > - 10:00 - - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + "fontFamily": "Inter", + "fontSize": 13, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "400", + "marginLeft": 4, + "textAlign": "left", + }, + { + "color": "#2F343D", + }, + undefined, + ] + } + > + 10:00 + + + + + + - + + + , + + @@ -15951,370 +8108,29 @@ exports[`Story Snapshots: InvitedRoom should match snapshot 1`] = ` , -] -`; - -exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` -[ - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - +] +`; + +exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` +[ + + @@ -16614,395 +8430,54 @@ exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` "color": "#2F343D", "lineHeight": undefined, }, - { - "backgroundColor": "transparent", - "flex": 1, - "fontFamily": "Inter", - "fontSize": 14, - "fontVariant": [ - "tabular-nums", - ], - "fontWeight": "400", - "textAlign": "left", - }, - { - "color": "#6C727A", - }, - {}, - ] - } - testID="markdown-preview-No message" - > - No message - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + { + "backgroundColor": "transparent", + "flex": 1, + "fontFamily": "Inter", + "fontSize": 14, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "400", + "textAlign": "left", + }, + { + "color": "#6C727A", + }, + {}, + ] + } + testID="markdown-preview-No message" + > + No message + + + + + - + + + , + + @@ -17328,369 +8803,28 @@ exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - + + + , + + @@ -17990,395 +9124,54 @@ exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` "color": "#2F343D", "lineHeight": undefined, }, - { - "backgroundColor": "transparent", - "flex": 1, - "fontFamily": "Inter", - "fontSize": 14, - "fontVariant": [ - "tabular-nums", - ], - "fontWeight": "400", - "textAlign": "left", - }, - { - "color": "#6C727A", - }, - {}, - ] - } - testID="markdown-preview-You: 1" - > - You: 1 - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + { + "backgroundColor": "transparent", + "flex": 1, + "fontFamily": "Inter", + "fontSize": 14, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "400", + "textAlign": "left", + }, + { + "color": "#6C727A", + }, + {}, + ] + } + testID="markdown-preview-You: 1" + > + You: 1 + + + + + - + + + , + + @@ -18704,369 +9497,28 @@ exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - + + + , + + @@ -19429,397 +9881,56 @@ exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` testID="unread-badge-1" > - 1 - - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + numberOfLines={1} + style={ + [ + { + "backgroundColor": "transparent", + "fontFamily": "Inter", + "fontSize": 13, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "600", + "textAlign": "left", + }, + undefined, + { + "color": "#FFFFFF", + }, + ] + } + > + 1 + + + + + + - + + + , + + @@ -20210,369 +10321,28 @@ exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - + + + , + + @@ -20940,397 +10710,56 @@ exports[`Story Snapshots: LastMessage should match snapshot 1`] = ` [ { "backgroundColor": "transparent", - "fontFamily": "Inter", - "fontSize": 13, - "fontVariant": [ - "tabular-nums", - ], - "fontWeight": "600", - "textAlign": "left", - }, - undefined, - { - "color": "#FFFFFF", - }, - ] - } - > - 1 - - - - - - - - - - - , -] -`; - -exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` -[ - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + "fontFamily": "Inter", + "fontSize": 13, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "600", + "textAlign": "left", + }, + undefined, + { + "color": "#FFFFFF", + }, + ] + } + > + 1 + + + + + + - + + + , +] +`; + +exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` +[ + + @@ -21619,369 +11048,28 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - + + + , + + @@ -22242,397 +11330,56 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` - 10:00 - - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + style={ + [ + { + "backgroundColor": "transparent", + "fontFamily": "Inter", + "fontSize": 13, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "400", + "marginLeft": 4, + "textAlign": "left", + }, + { + "color": "#2F343D", + }, + undefined, + ] + } + > + 10:00 + + + + + + - + + + , + + @@ -22921,369 +11668,28 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - + + + , + + @@ -23544,397 +11950,56 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` - 10:00 - - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + style={ + [ + { + "backgroundColor": "transparent", + "fontFamily": "Inter", + "fontSize": 13, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "400", + "marginLeft": 4, + "textAlign": "left", + }, + { + "color": "#2F343D", + }, + undefined, + ] + } + > + 10:00 + + + + + + - + + + , + + @@ -24225,367 +12290,26 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - + + , + + @@ -24844,399 +12568,58 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` } > - 10:00 - - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + ellipsizeMode="tail" + numberOfLines={1} + style={ + [ + { + "backgroundColor": "transparent", + "fontFamily": "Inter", + "fontSize": 13, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "400", + "marginLeft": 4, + "textAlign": "left", + }, + { + "color": "#2F343D", + }, + undefined, + ] + } + > + 10:00 + + + + + + - + + + , + + @@ -25529,365 +12912,24 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - + + @@ -26144,401 +13186,60 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` "alignItems": "flex-end", } } - > - - 10:00 - - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + > + + 10:00 + + + + + + - + + + , + + @@ -26834,362 +13535,21 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - + 40, + ] + } + onGestureHandlerEvent={[Function]} + onGestureHandlerStateChange={[Function]} + onGestureHandlerTouchEvent={[Function]} + onStartShouldSetResponder={[Function]} + pointerEvents="box-none" + style={ + { + "display": "contents", + } + } + > + @@ -27447,405 +13807,64 @@ exports[`Story Snapshots: OmnichannelIcon should match snapshot 1`] = ` } } > - - 10:00 - - - - - - - - - - - , -] -`; - -exports[`Story Snapshots: Tag should match snapshot 1`] = ` -[ - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + + 10:00 + + + + + + - + + + , +] +`; + +exports[`Story Snapshots: Tag should match snapshot 1`] = ` +[ + + @@ -28180,362 +14199,21 @@ exports[`Story Snapshots: Tag should match snapshot 1`] = ` - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - + onGestureHandlerTouchEvent={[Function]} + onStartShouldSetResponder={[Function]} + pointerEvents="box-none" + style={ + { + "display": "contents", + } + } + > + @@ -28861,409 +14539,68 @@ exports[`Story Snapshots: Tag should match snapshot 1`] = ` style={ [ { - "backgroundColor": "transparent", - "fontFamily": "Inter", - "fontSize": 16, - "fontVariant": [ - "tabular-nums", - ], - "fontWeight": "400", - "lineHeight": 22, - "textAlign": "left", - }, - { - "color": "#2F343D", - "lineHeight": undefined, - }, - { - "backgroundColor": "transparent", - "flex": 1, - "fontFamily": "Inter", - "fontSize": 14, - "fontVariant": [ - "tabular-nums", - ], - "fontWeight": "400", - "textAlign": "left", - }, - { - "color": "#6C727A", - }, - {}, - ] - } - testID="markdown-preview-No message" - > - No message - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + "backgroundColor": "transparent", + "fontFamily": "Inter", + "fontSize": 16, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "400", + "lineHeight": 22, + "textAlign": "left", + }, + { + "color": "#2F343D", + "lineHeight": undefined, + }, + { + "backgroundColor": "transparent", + "flex": 1, + "fontFamily": "Inter", + "fontSize": 14, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "400", + "textAlign": "left", + }, + { + "color": "#6C727A", + }, + {}, + ] + } + testID="markdown-preview-No message" + > + No message + + + + + - + + + , + + @@ -29598,8 +14935,7 @@ exports[`Story Snapshots: Tag should match snapshot 1`] = ` - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - + { + "display": "contents", + } + } + > + @@ -30291,401 +15287,60 @@ exports[`Story Snapshots: Tag should match snapshot 1`] = ` }, { "color": "#2F343D", - "lineHeight": undefined, - }, - { - "backgroundColor": "transparent", - "flex": 1, - "fontFamily": "Inter", - "fontSize": 14, - "fontVariant": [ - "tabular-nums", - ], - "fontWeight": "400", - "textAlign": "left", - }, - { - "color": "#6C727A", - }, - {}, - ] - } - testID="markdown-preview-No message" - > - No message - - - - - - - - - - , -] -`; - -exports[`Story Snapshots: Touch should match snapshot 1`] = ` - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - + "lineHeight": undefined, + }, + { + "backgroundColor": "transparent", + "flex": 1, + "fontFamily": "Inter", + "fontSize": 14, + "fontVariant": [ + "tabular-nums", + ], + "fontWeight": "400", + "textAlign": "left", + }, + { + "color": "#6C727A", + }, + {}, + ] + } + testID="markdown-preview-No message" + > + No message + + + + + + + + , +] +`; + +exports[`Story Snapshots: Touch should match snapshot 1`] = ` + + @@ -30982,365 +15637,24 @@ exports[`Story Snapshots: Touch should match snapshot 1`] = ` exports[`Story Snapshots: Type should match snapshot 1`] = ` [ - - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - + + @@ -31605,401 +15919,60 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` "alignItems": "flex-end", } } - > - - 10:00 - - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + > + + 10:00 + + + + + + - + + + , + + @@ -32294,363 +16267,22 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` , - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - + [ + 48, + ] + } + onGestureHandlerEvent={[Function]} + onGestureHandlerStateChange={[Function]} + onGestureHandlerTouchEvent={[Function]} + onStartShouldSetResponder={[Function]} + pointerEvents="box-none" + style={ + { + "display": "contents", + } + } + > + @@ -32905,403 +16537,62 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` style={ { "alignItems": "flex-end", - } - } - > - - 10:00 - - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + } + } + > + + 10:00 + + + + + + - + + + , + + @@ -33597,362 +16888,21 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - + onGestureHandlerEvent={[Function]} + onGestureHandlerStateChange={[Function]} + onGestureHandlerTouchEvent={[Function]} + onStartShouldSetResponder={[Function]} + pointerEvents="box-none" + style={ + { + "display": "contents", + } + } + > + @@ -34203,407 +17153,66 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` > rocket.cat - - - 10:00 - - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + + + 10:00 + + + + + + - + + + , + + @@ -34899,362 +17508,21 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - + pointerEvents="box-none" + style={ + { + "display": "contents", + } + } + > + @@ -35501,411 +17769,70 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` "color": "#1F2329", }, ] - } - > - rocket.cat - - - - 10:00 - - - - - - - - - - - , - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + } + > + rocket.cat + + + + 10:00 + + + + + + - + + + , + + @@ -36201,8 +18128,7 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` - - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - + } + } + > + @@ -36804,415 +18390,74 @@ exports[`Story Snapshots: Type should match snapshot 1`] = ` }, ] } - > - rocket.cat - - - - 10:00 - - - - - - - - - - - , -] -`; - -exports[`Story Snapshots: User should match snapshot 1`] = ` -[ - - - - - - - -  - - - - - - - - - -  - - - - - - -  - + > + rocket.cat + + + + 10:00 + + + + + + - + + + , +] +`; + +exports[`Story Snapshots: User should match snapshot 1`] = ` +[ + + @@ -37516,8 +18761,7 @@ exports[`Story Snapshots: User should match snapshot 1`] = ` - - - - - -  - - - - - - - - - -  - - - - - - -  - - - - diff --git a/app/containers/RoomItem/__tests__/RoomItemContainer.test.tsx b/app/containers/RoomItem/__tests__/RoomItemContainer.test.tsx new file mode 100644 index 0000000000..c5043f26f3 --- /dev/null +++ b/app/containers/RoomItem/__tests__/RoomItemContainer.test.tsx @@ -0,0 +1,82 @@ +import { act, render } from '@testing-library/react-native'; +import { BehaviorSubject } from 'rxjs'; + +import RoomItemContainer from '..'; +import { isRead } from '~/lib/methods/helpers'; + +jest.mock('../RoomItem', () => { + const { Text: MockText } = jest.requireActual('react-native'); + return ({ name, isRead: read, unread }: { name: string; isRead: boolean; unread: number }) => ( + {`${name}:${read ? 'read' : 'unread'}:${unread}`} + ); +}); +jest.mock('../../ActionSheet', () => ({ useActionSheet: () => ({ showActionSheet: jest.fn() }) })); +jest.mock('~/lib/hooks/useAppSelector', () => ({ useAppSelector: () => '7.0.0' })); + +const createRecord = () => { + const changes = new BehaviorSubject(undefined); + const record = { + rid: 'rid', + t: 'c', + name: 'general', + fname: 'general', + open: true, + archived: false, + alert: true, + unread: 3, + tunread: [], + observe: () => changes, + asPlain: () => ({ ...record }) + }; + return { record, changes }; +}; + +const renderRow = (record: ReturnType['record']) => + render( + room.fname} + getRoomAvatar={() => ''} + getIsRead={isRead} + swipeEnabled + /> + ); + +describe('RoomItemContainer', () => { + it('reflects in-place record updates after the record emits', () => { + const { record, changes } = createRecord(); + const { getByText } = renderRow(record); + expect(getByText('general:unread:3')).toBeTruthy(); + + record.alert = false; + record.unread = 0; + record.fname = 'renamed'; + act(() => changes.next()); + + expect(getByText('renamed:read:0')).toBeTruthy(); + }); + + it('reflects an unread count change while the row stays unread', () => { + const { record, changes } = createRecord(); + const { getByText } = renderRow(record); + + record.unread = 4; + act(() => changes.next()); + + expect(getByText('general:unread:4')).toBeTruthy(); + }); + + it('unsubscribes from the record on unmount', () => { + const { record, changes } = createRecord(); + const { unmount } = renderRow(record); + unmount(); + expect(changes.observed).toBe(false); + }); +}); diff --git a/app/containers/RoomItem/__tests__/Touchable.test.tsx b/app/containers/RoomItem/__tests__/Touchable.test.tsx new file mode 100644 index 0000000000..82ffb3bac2 --- /dev/null +++ b/app/containers/RoomItem/__tests__/Touchable.test.tsx @@ -0,0 +1,96 @@ +import { act, fireEvent, render } from '@testing-library/react-native'; +import { Text } from 'react-native'; +import { State, usePanGesture } from 'react-native-gesture-handler'; +import { fireGestureHandler } from 'react-native-gesture-handler/jest-utils'; +import { makeMutable } from 'react-native-reanimated'; + +import Touchable from '../Touchable'; +import { settleSwipeRow, unregisterOpenSwipeItem } from '../utils/openSwipeItem'; +import { SubscriptionType } from '~/definitions'; + +jest.mock('~/lib/hooks/useAppSelector', () => ({ useAppSelector: () => '7.0.0' })); +jest.mock('react-native-gesture-handler', () => { + const actual = jest.requireActual('react-native-gesture-handler'); + return { ...actual, usePanGesture: jest.fn(actual.usePanGesture) }; +}); + +const setup = ({ swipeEnabled = true } = {}) => { + const onPress = jest.fn(); + const { getByText } = render( + + row + + ); + const { results } = jest.mocked(usePanGesture).mock; + return { gesture: results[results.length - 1].value, onPress, pressRow: () => fireEvent.press(getByText('row')) }; +}; + +const openOtherRow = () => settleSwipeRow({ rid: 'roomA', transX: makeMutable(0), rowOffSet: makeMutable(0) }, 80); + +const touchWithoutSwipe = [ + { state: State.BEGAN, translationX: 0, velocityX: 0 }, + { state: State.FAILED, translationX: 0, velocityX: 0 } +]; + +afterEach(() => { + unregisterOpenSwipeItem('roomA'); + jest.restoreAllMocks(); +}); + +test('a tap after a touch that closed another row without pressing still opens the room', async () => { + const { gesture, onPress, pressRow } = setup(); + openOtherRow(); + await act(() => fireGestureHandler(gesture, touchWithoutSwipe)); + + await act(() => fireGestureHandler(gesture, touchWithoutSwipe)); + pressRow(); + + expect(onPress).toHaveBeenCalledTimes(1); +}); + +test('a tap that closed another row does not open the room', async () => { + const { gesture, onPress, pressRow } = setup(); + openOtherRow(); + + await act(() => fireGestureHandler(gesture, touchWithoutSwipe)); + pressRow(); + + expect(onPress).not.toHaveBeenCalled(); +}); + +test('a tap on a swiped-open row closes it instead of opening the room', async () => { + const { gesture, onPress, pressRow } = setup(); + await act(() => + fireGestureHandler(gesture, [ + { state: State.BEGAN, translationX: 0, velocityX: 0 }, + { state: State.ACTIVE, translationX: 60, velocityX: 0 }, + { state: State.END, translationX: 60, velocityX: 0 } + ]) + ); + + pressRow(); + expect(onPress).not.toHaveBeenCalled(); + + pressRow(); + expect(onPress).toHaveBeenCalledTimes(1); +}); + +test('a tap on a row that cannot swipe closes the open row instead of opening the room', () => { + const { onPress, pressRow } = setup({ swipeEnabled: false }); + openOtherRow(); + + pressRow(); + expect(onPress).not.toHaveBeenCalled(); + + pressRow(); + expect(onPress).toHaveBeenCalledTimes(1); +}); diff --git a/app/containers/RoomItem/hooks/useRoomSnapshot.ts b/app/containers/RoomItem/hooks/useRoomSnapshot.ts new file mode 100644 index 0000000000..fb06b636a1 --- /dev/null +++ b/app/containers/RoomItem/hooks/useRoomSnapshot.ts @@ -0,0 +1,35 @@ +import { useCallback, useRef, useSyncExternalStore } from 'react'; +import { skip } from 'rxjs'; + +import { type IRoomItemContainerProps } from '../interfaces'; + +type TRoomItem = IRoomItemContainerProps['item']; + +const takeSnapshot = (item: TRoomItem): TRoomItem => item.asPlain?.() ?? item; + +export const useRoomSnapshot = (item: TRoomItem): TRoomItem => { + const cache = useRef<{ source: TRoomItem; room: TRoomItem } | null>(null); + + const getSnapshot = () => { + if (!cache.current || cache.current.source !== item) { + cache.current = { source: item, room: takeSnapshot(item) }; + } + return cache.current.room; + }; + + const subscribe = useCallback( + (onStoreChange: () => void) => { + const subscription = item + .observe?.() + .pipe(skip(1)) + .subscribe(() => { + cache.current = { source: item, room: takeSnapshot(item) }; + onStoreChange(); + }); + return () => subscription?.unsubscribe(); + }, + [item] + ); + + return useSyncExternalStore(subscribe, getSnapshot); +}; diff --git a/app/containers/RoomItem/index.tsx b/app/containers/RoomItem/index.tsx index 3e530fb9c0..a23c402770 100644 --- a/app/containers/RoomItem/index.tsx +++ b/app/containers/RoomItem/index.tsx @@ -1,5 +1,4 @@ -import { useEffect, useReducer, useRef, memo } from 'react'; -import { type Subscription } from 'rxjs'; +import { memo } from 'react'; import { AccessibilityInfo } from 'react-native'; import { useActionSheet } from '../ActionSheet'; @@ -11,8 +10,7 @@ import RoomItem from './RoomItem'; import { getRoomActionsOptions } from './getRoomActionsOptions'; import { isInviteSubscription } from '~/lib/methods/isInviteSubscription'; import { isExternalKeyboardConnected } from '~/lib/methods/helpers/externalInput'; - -const attrs = ['width', 'isFocused', 'showLastMessage', 'autoJoin', 'showAvatar', 'displayMode']; +import { useRoomSnapshot } from './hooks/useRoomSnapshot'; const RoomItemContainer = memo( ({ @@ -33,32 +31,18 @@ const RoomItemContainer = memo( getIsRead = () => false, swipeEnabled = true }: IRoomItemContainerProps) => { + const room = useRoomSnapshot(item); const { showActionSheet } = useActionSheet(); const serverVersion = useAppSelector(state => state.server.version); - const name = getRoomTitle(item); + const name = getRoomTitle(room); const testID = `rooms-list-view-item-${name}`; - const avatar = getRoomAvatar(item); - const isRead = getIsRead(item); - const date = item.roomUpdatedAt && formatDate(item.roomUpdatedAt); - const alert = item.alert || item.tunread?.length; - const [_, forceUpdate] = useReducer(x => x + 1, 1); - const roomSubscription = useRef(null); - const userId = item.t === 'd' && id && !isGroupChat(item) ? id : null; - const accessibilityDate = formatDateAccessibility(item.roomUpdatedAt); - - useEffect(() => { - const init = () => { - if (item?.observe) { - const observable = item.observe(); - roomSubscription.current = observable?.subscribe?.(() => { - if (_) forceUpdate(); - }); - } - }; - init(); - - return () => roomSubscription.current?.unsubscribe(); - }, []); + const avatar = getRoomAvatar(room); + const isRead = getIsRead(room); + const isInvited = isInviteSubscription(room); + const date = room.roomUpdatedAt && formatDate(room.roomUpdatedAt); + const alert = room.alert || room.tunread?.length; + const userId = room.t === 'd' && id && !isGroupChat(room) ? id : null; + const accessibilityDate = formatDateAccessibility(room.roomUpdatedAt); const handleOnPress = () => onPress(item); @@ -75,10 +59,10 @@ const RoomItemContainer = memo( } showActionSheet({ options: getRoomActionsOptions({ - rid: item.rid, - type: item.t, + rid: room.rid, + type: room.t, isRead, - favorite: !!item.f, + favorite: !!room.f, serverVersion }) }); @@ -88,46 +72,45 @@ const RoomItemContainer = memo( ); - }, - (props, nextProps) => attrs.every(key => props[key] === nextProps[key]) + } ); export default RoomItemContainer; diff --git a/app/containers/RoomItem/styles.ts b/app/containers/RoomItem/styles.ts index 26f760c186..cf48a92f84 100644 --- a/app/containers/RoomItem/styles.ts +++ b/app/containers/RoomItem/styles.ts @@ -2,10 +2,6 @@ import { StyleSheet } from 'react-native'; import sharedStyles from '~/views/Styles'; -export const ACTION_WIDTH = 80; -export const SMALL_SWIPE = ACTION_WIDTH / 2; -export const LONG_SWIPE = ACTION_WIDTH * 2.5; - export default StyleSheet.create({ flex: { flex: 1 @@ -80,7 +76,7 @@ export default StyleSheet.create({ position: 'absolute', justifyContent: 'center', top: 0, - right: 0 + left: 0 }, actionRightButtonContainer: { position: 'absolute', @@ -88,7 +84,15 @@ export default StyleSheet.create({ top: 0 }, actionButton: { - width: ACTION_WIDTH, + width: '100%', + height: '100%', + flexDirection: 'row', + overflow: 'hidden' + }, + actionButtonContentEnd: { + justifyContent: 'flex-end' + }, + actionIconSlot: { height: '100%', alignItems: 'center', justifyContent: 'center' diff --git a/app/containers/RoomItem/utils/__tests__/openSwipeItem.test.ts b/app/containers/RoomItem/utils/__tests__/openSwipeItem.test.ts new file mode 100644 index 0000000000..6cd2b43fb6 --- /dev/null +++ b/app/containers/RoomItem/utils/__tests__/openSwipeItem.test.ts @@ -0,0 +1,60 @@ +import { makeMutable } from 'react-native-reanimated'; + +import { settleSwipeRow, unregisterOpenSwipeItem, closeOpenSwipeItem } from '../openSwipeItem'; + +const createRow = (rid: string) => ({ + rid, + transX: makeMutable(0), + rowOffSet: makeMutable(0) +}); + +describe('openSwipeItem', () => { + afterEach(() => { + unregisterOpenSwipeItem('roomA'); + unregisterOpenSwipeItem('roomB'); + }); + + it('closes the open row and reports it was consumed', () => { + const row = createRow('roomA'); + settleSwipeRow(row, 80); + + const consumed = closeOpenSwipeItem('roomB'); + + expect(row.rowOffSet.value).toBe(0); + expect(row.transX.value).toBe(0); + expect(consumed).toBe(true); + }); + + it('reports nothing was consumed when no row is open', () => { + expect(closeOpenSwipeItem('roomB')).toBe(false); + }); + + it('reports nothing was consumed when closing the same room that is open', () => { + const row = createRow('roomA'); + settleSwipeRow(row, 80); + + const consumed = closeOpenSwipeItem('roomA'); + + expect(row.rowOffSet.value).toBe(80); + expect(consumed).toBe(false); + }); + + it('closes the previously open row when another one opens', () => { + const previous = createRow('roomA'); + settleSwipeRow(previous, 80); + + settleSwipeRow(createRow('roomB'), -160); + + expect(previous.rowOffSet.value).toBe(0); + expect(previous.transX.value).toBe(0); + }); + + it('forgets a row once it settles closed', () => { + const row = createRow('roomA'); + settleSwipeRow(row, 80); + + settleSwipeRow(row, 0); + + expect(closeOpenSwipeItem('roomB')).toBe(false); + }); +}); diff --git a/app/containers/RoomItem/utils/__tests__/swipeRelease.test.ts b/app/containers/RoomItem/utils/__tests__/swipeRelease.test.ts new file mode 100644 index 0000000000..3799e21073 --- /dev/null +++ b/app/containers/RoomItem/utils/__tests__/swipeRelease.test.ts @@ -0,0 +1,37 @@ +import { getActionWidth, getFullSwipeThreshold, getOpenWidth, getSwipeRelease } from '../swipeRelease'; + +const width = 400; +const actionWidth = getActionWidth(width); +const openWidth = getOpenWidth(width); +const fullSwipeThreshold = getFullSwipeThreshold(width); + +const release = (restingOffset: number, offset: number) => getSwipeRelease({ restingOffset, offset, width }); + +const closed = { restingOffset: 0, fullSwipe: null }; +const leftOpen = { restingOffset: actionWidth, fullSwipe: null }; +const rightOpen = { restingOffset: -openWidth, fullSwipe: null }; + +describe('getSwipeRelease', () => { + it.each([ + [0, 37, closed], + [0, 38, leftOpen], + [0, -37, closed], + [0, -38, rightOpen], + [0, 0, closed], + [actionWidth, 1, leftOpen], + [actionWidth, 200, leftOpen], + [actionWidth, 0, closed], + [actionWidth, -30, closed], + [-openWidth, -1, rightOpen], + [-openWidth, -200, rightOpen], + [-openWidth, 0, closed], + [-openWidth, 30, closed] + ])('from resting offset %d releasing at %d', (restingOffset, offset, expected) => { + expect(release(restingOffset, offset)).toEqual(expected); + }); + + it.each([0, actionWidth, -openWidth])('commits a full swipe from resting offset %d and closes', restingOffset => { + expect(release(restingOffset, fullSwipeThreshold)).toEqual({ ...closed, fullSwipe: 'left' }); + expect(release(restingOffset, -fullSwipeThreshold)).toEqual({ ...closed, fullSwipe: 'right' }); + }); +}); diff --git a/app/containers/RoomItem/utils/openSwipeItem.ts b/app/containers/RoomItem/utils/openSwipeItem.ts new file mode 100644 index 0000000000..7097e95377 --- /dev/null +++ b/app/containers/RoomItem/utils/openSwipeItem.ts @@ -0,0 +1,46 @@ +import { makeMutable, withSpring, type SharedValue } from 'react-native-reanimated'; + +import { SWIPE_SPRING_CONFIG } from './swipeRelease'; + +export type SwipeRow = { + rid: string; + transX: SharedValue; + rowOffSet: SharedValue; +}; + +export const openSwipeItem = makeMutable(null); + +const springRow = ({ transX, rowOffSet }: SwipeRow, restingOffset: number, velocity: number) => { + 'worklet'; + transX.value = withSpring(restingOffset, { ...SWIPE_SPRING_CONFIG, velocity }); + rowOffSet.value = restingOffset; +}; + +export const unregisterOpenSwipeItem = (rid: string) => { + 'worklet'; + if (openSwipeItem.value?.rid === rid) { + openSwipeItem.value = null; + } +}; + +export const closeOpenSwipeItem = (exceptRid?: string) => { + 'worklet'; + const row = openSwipeItem.value; + if (!row || row.rid === exceptRid) { + return false; + } + springRow(row, 0, 0); + openSwipeItem.value = null; + return true; +}; + +export const settleSwipeRow = (row: SwipeRow, restingOffset: number, velocity = 0) => { + 'worklet'; + springRow(row, restingOffset, velocity); + if (restingOffset === 0) { + unregisterOpenSwipeItem(row.rid); + return; + } + closeOpenSwipeItem(row.rid); + openSwipeItem.value = row; +}; diff --git a/app/containers/RoomItem/utils/swipeRelease.ts b/app/containers/RoomItem/utils/swipeRelease.ts new file mode 100644 index 0000000000..4a942f7863 --- /dev/null +++ b/app/containers/RoomItem/utils/swipeRelease.ts @@ -0,0 +1,56 @@ +import { ReduceMotion } from 'react-native-reanimated'; + +const OPEN_RATIO = 0.375; +const FULL_SWIPE_RATIO = 0.53; + +export const SWIPE_SPRING_CONFIG = { + mass: 1, + stiffness: 150, + damping: 24.5, + reduceMotion: ReduceMotion.System +}; + +export const getOpenWidth = (width: number) => { + 'worklet'; + return width * OPEN_RATIO; +}; + +export const getActionWidth = (width: number) => { + 'worklet'; + return getOpenWidth(width) / 2; +}; + +export const getFullSwipeThreshold = (width: number) => { + 'worklet'; + return width * FULL_SWIPE_RATIO; +}; + +export interface ISwipeRelease { + restingOffset: number; + fullSwipe: 'left' | 'right' | null; +} + +interface ISwipeReleaseInput { + restingOffset: number; + offset: number; + width: number; +} + +const CLOSED: ISwipeRelease = { restingOffset: 0, fullSwipe: null }; + +export const getSwipeRelease = ({ restingOffset, offset, width }: ISwipeReleaseInput): ISwipeRelease => { + 'worklet'; + const fullSwipeThreshold = getFullSwipeThreshold(width); + if (offset >= fullSwipeThreshold) { + return { ...CLOSED, fullSwipe: 'left' }; + } + if (offset <= -fullSwipeThreshold) { + return { ...CLOSED, fullSwipe: 'right' }; + } + const wasOpen = restingOffset !== 0; + const keepsSide = Math.sign(offset) === Math.sign(restingOffset); + if (wasOpen ? !keepsSide : Math.abs(offset) < getActionWidth(width) / 2) { + return CLOSED; + } + return { restingOffset: offset > 0 ? getActionWidth(width) : -getOpenWidth(width), fullSwipe: null }; +}; diff --git a/app/lib/methods/helpers/__tests__/isRead.test.ts b/app/lib/methods/helpers/__tests__/isRead.test.ts new file mode 100644 index 0000000000..5f1a576ac8 --- /dev/null +++ b/app/lib/methods/helpers/__tests__/isRead.test.ts @@ -0,0 +1,21 @@ +import { isRead } from '../helpers'; + +const openRoom = { open: true, archived: false, unread: 0, alert: false, tunread: [] as string[] }; + +describe('isRead', () => { + it('is read when there are no unreads', () => { + expect(isRead(openRoom)).toBe(true); + }); + + it('is unread when there are unread messages', () => { + expect(isRead({ ...openRoom, unread: 2 })).toBe(false); + }); + + it('is unread when only threads have unread messages', () => { + expect(isRead({ ...openRoom, tunread: ['tmid'] })).toBe(false); + }); + + it('is read when the room is archived even with thread unreads', () => { + expect(isRead({ ...openRoom, archived: true, tunread: ['tmid'] })).toBe(true); + }); +}); diff --git a/app/lib/methods/helpers/helpers.ts b/app/lib/methods/helpers/helpers.ts index 1280a9474b..322b95420c 100644 --- a/app/lib/methods/helpers/helpers.ts +++ b/app/lib/methods/helpers/helpers.ts @@ -81,7 +81,7 @@ export function canAutoTranslate() { export function isRead(item) { let isUnread = item.archived !== true && item.open === true; // item is not archived and not opened - isUnread = isUnread && (item.unread > 0 || item.alert === true); // either its unread count > 0 or its alert + isUnread = isUnread && (item.unread > 0 || item.alert === true || item.tunread?.length > 0); return !isUnread; } diff --git a/app/views/RoomsListView/index.tsx b/app/views/RoomsListView/index.tsx index b7229c126c..ebe342e033 100644 --- a/app/views/RoomsListView/index.tsx +++ b/app/views/RoomsListView/index.tsx @@ -98,7 +98,7 @@ const RoomsListView = memo(function RoomsListView() { getRoomTitle={getRoomTitle} getRoomAvatar={getRoomAvatar} getIsRead={isRead} - isFocused={subscribedRoom === item.rid} + isFocused={isMasterDetail && subscribedRoom === item.rid} swipeEnabled={swipeEnabled} showAvatar={showAvatar} displayMode={displayMode} diff --git a/jest.setup.js b/jest.setup.js index ae1cffce13..78d01b00d0 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -31,7 +31,8 @@ jest.mock('react-native-reanimated', () => { const actual = jest.requireActual('react-native-reanimated/mock'); return { ...actual, - useSharedValue: jest.fn(init => ({ value: init })), + useSharedValue: jest.fn(init => jest.requireActual('react').useState(() => ({ value: init }))[0]), + makeMutable: jest.fn(init => ({ value: init })), useAnimatedReaction: jest.fn(), withTiming: jest.fn(value => value), useAnimatedGestureHandler: jest.fn(() => jest.fn()),