Merge pull request 'chore(upstream): refresh Mattermost mobile to 8e5f8b38e833' (#2) from upstream/mobile-8e5f8b38e833 into main
Reviewed-on: #2
This commit is contained in:
commit
02763fad47
24 changed files with 496 additions and 94 deletions
|
|
@ -4,8 +4,8 @@
|
|||
|
||||
- Source: `https://github.com/mattermost/mattermost-mobile.git`
|
||||
- Branch: `main`
|
||||
- Snapshot commit: `052aeac1768b7faa7c447e8e33d790e46c7faef4`
|
||||
- Snapshot date: `2026-05-28`
|
||||
- Snapshot commit: `8e5f8b38e83363a2a1e1a5d72a3c2b9baf99e4e9`
|
||||
- Snapshot date: `2026-05-30`
|
||||
- Snapshot source: `/config/workspace/mattermost/mattermost-mobile/`
|
||||
- Snapshot target: `/config/workspace/nexo/apps/mattermost/`
|
||||
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ android {
|
|||
applicationId "com.mattermost.rnbeta"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 757
|
||||
versionCode 760
|
||||
versionName "2.41.0"
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
|
|
|
|||
|
|
@ -374,13 +374,13 @@ exports[`<FormattedDate/> should match snapshot for 'ko' locale and '{"dateStyle
|
|||
|
||||
exports[`<FormattedDate/> should match snapshot for 'ko' locale and '{"day": "numeric", "hour": "numeric", "minute": "numeric", "month": "short", "year": "numeric"}' format 1`] = `
|
||||
<Text>
|
||||
2024년 10월 26일 오전 10:01
|
||||
2024년 10월 26일 AM 10:01
|
||||
</Text>
|
||||
`;
|
||||
|
||||
exports[`<FormattedDate/> should match snapshot for 'ko' locale and '{"day": "numeric", "hour": "numeric", "minute": "numeric", "month": "short"}' format 1`] = `
|
||||
<Text>
|
||||
10월 26일 오전 10:01
|
||||
10월 26일 AM 10:01
|
||||
</Text>
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {Events} from '@constants';
|
|||
import {isEdgeToEdge} from '@constants/device';
|
||||
import {useKeyboardState} from '@context/keyboard_state';
|
||||
import useDidMount from '@hooks/did_mount';
|
||||
import {DEFAULT_POST_INPUT_HEIGHT} from '@hooks/use_keyboard_state_context';
|
||||
import {dismissKeyboard} from '@utils/keyboard';
|
||||
|
||||
// Use KeyboardGestureArea on iOS and Android 35+ (with edge-to-edge)
|
||||
|
|
@ -182,7 +181,6 @@ export const KeyboardAwarePostDraftContainer = ({
|
|||
return (
|
||||
<Wrapper
|
||||
{...wrapperProps}
|
||||
offset={DEFAULT_POST_INPUT_HEIGHT} // KeyboardGestureArea has a bug on iOS that changing the offset causes the input to translate, see: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1365
|
||||
enableSwipeToDismiss={false} // this applies only to Android
|
||||
>
|
||||
<View style={styles.gestureArea}>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
|
|||
import FormattedText from '@components/formatted_text';
|
||||
import {usePreventDoubleTap} from '@hooks/utils';
|
||||
import {openUserProfile} from '@utils/navigation';
|
||||
import {nonBreakingString} from '@utils/strings';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
|
|
@ -112,7 +113,7 @@ const HeaderDisplayName = ({
|
|||
numberOfLines={1}
|
||||
testID='post_header.display_name'
|
||||
>
|
||||
{displayName}
|
||||
{nonBreakingString(displayName)}
|
||||
</Text>
|
||||
</View>
|
||||
{showCustomStatusEmoji && (
|
||||
|
|
|
|||
196
apps/mattermost/app/components/remove_markdown/index.test.tsx
Normal file
196
apps/mattermost/app/components/remove_markdown/index.test.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {type TextStyle, StyleSheet} from 'react-native';
|
||||
|
||||
import {Preferences} from '@constants';
|
||||
import {renderWithIntl, screen} from '@test/intl-test-helper';
|
||||
import {getMarkdownTextStyles} from '@utils/markdown';
|
||||
|
||||
import RemoveMarkdown from '.';
|
||||
|
||||
jest.mock('@context/theme', () => {
|
||||
const {Preferences: Prefs} = require('@constants');
|
||||
return {
|
||||
useTheme: () => Prefs.THEMES.denim,
|
||||
getDefaultThemeByAppearance: () => Prefs.THEMES.denim,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@context/server', () => ({
|
||||
useServerUrl: () => 'http://localhost:8065',
|
||||
}));
|
||||
|
||||
jest.mock('./at_mention', () => {
|
||||
const {Text} = require('react-native');
|
||||
const MockAtMention = ({mentionName, textStyle}: {mentionName: string; textStyle: any}) => (
|
||||
<Text
|
||||
testID={`at_mention.${mentionName}`}
|
||||
style={textStyle}
|
||||
>
|
||||
{`@${mentionName}`}
|
||||
</Text>
|
||||
);
|
||||
MockAtMention.displayName = 'MockAtMention';
|
||||
return MockAtMention;
|
||||
});
|
||||
|
||||
jest.mock('../markdown/channel_mention', () => {
|
||||
const {Text} = require('react-native');
|
||||
const MockChannelMention = ({channelName, textStyle}: {channelName: string; textStyle: any}) => (
|
||||
<Text
|
||||
testID={`channel_mention.${channelName}`}
|
||||
style={textStyle}
|
||||
>
|
||||
{`~${channelName}`}
|
||||
</Text>
|
||||
);
|
||||
MockChannelMention.displayName = 'MockChannelMention';
|
||||
return MockChannelMention;
|
||||
});
|
||||
|
||||
jest.mock('@components/emoji', () => {
|
||||
const {Text} = require('react-native');
|
||||
const MockEmoji = ({emojiName, textStyle}: {emojiName: string; textStyle: any}) => (
|
||||
<Text
|
||||
testID={`emoji.${emojiName}`}
|
||||
style={textStyle}
|
||||
>
|
||||
{`:${emojiName}:`}
|
||||
</Text>
|
||||
);
|
||||
MockEmoji.displayName = 'MockEmoji';
|
||||
return MockEmoji;
|
||||
});
|
||||
|
||||
const theme = Preferences.THEMES.denim;
|
||||
const headingTextStyles = getMarkdownTextStyles(theme);
|
||||
|
||||
function getFlatStyle(element: ReturnType<typeof screen.getByTestId>): TextStyle {
|
||||
return StyleSheet.flatten(element.props.style) as TextStyle;
|
||||
}
|
||||
|
||||
describe('RemoveMarkdown', () => {
|
||||
const baseStyle: TextStyle = {fontSize: 16, color: '#000'};
|
||||
|
||||
describe('heading context stripping for mentions', () => {
|
||||
it('should render @mention inside a heading without heading font size', () => {
|
||||
renderWithIntl(
|
||||
<RemoveMarkdown
|
||||
baseStyle={baseStyle}
|
||||
value='### Hello @channel'
|
||||
/>,
|
||||
);
|
||||
|
||||
const style = getFlatStyle(screen.getByTestId('at_mention.channel'));
|
||||
const heading3FontSize = StyleSheet.flatten(headingTextStyles.heading3 as TextStyle).fontSize;
|
||||
expect(style.fontSize).not.toBe(heading3FontSize);
|
||||
expect(style.fontSize).toBe(baseStyle.fontSize);
|
||||
});
|
||||
|
||||
it('should render channel link inside a heading without heading font size', () => {
|
||||
renderWithIntl(
|
||||
<RemoveMarkdown
|
||||
enableChannelLink={true}
|
||||
baseStyle={baseStyle}
|
||||
value='### Check ~town-square'
|
||||
/>,
|
||||
);
|
||||
|
||||
const style = getFlatStyle(screen.getByTestId('channel_mention.town-square'));
|
||||
const heading3FontSize = StyleSheet.flatten(headingTextStyles.heading3 as TextStyle).fontSize;
|
||||
expect(style.fontSize).not.toBe(heading3FontSize);
|
||||
expect(style.fontSize).toBe(baseStyle.fontSize);
|
||||
});
|
||||
|
||||
it('should strip heading styles for all heading levels', () => {
|
||||
for (const level of [1, 2, 3, 4, 5, 6]) {
|
||||
const hashes = '#'.repeat(level);
|
||||
|
||||
const {unmount} = renderWithIntl(
|
||||
<RemoveMarkdown
|
||||
baseStyle={baseStyle}
|
||||
value={`${hashes} Mention @testuser`}
|
||||
/>,
|
||||
);
|
||||
|
||||
const style = getFlatStyle(screen.getByTestId('at_mention.testuser'));
|
||||
expect(style.fontFamily).not.toBe('Metropolis-SemiBold');
|
||||
expect(style.fontSize).toBe(baseStyle.fontSize);
|
||||
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('heading block separation', () => {
|
||||
it('should add a newline after heading content', () => {
|
||||
const {toJSON} = renderWithIntl(
|
||||
<RemoveMarkdown
|
||||
baseStyle={baseStyle}
|
||||
value={'### Heading\nBody text'}
|
||||
/>,
|
||||
);
|
||||
|
||||
const json = JSON.stringify(toJSON());
|
||||
const headingIdx = json.indexOf('Heading');
|
||||
const newlineIdx = json.indexOf('\\n', headingIdx);
|
||||
const bodyIdx = json.indexOf('Body');
|
||||
expect(headingIdx).toBeGreaterThan(-1);
|
||||
expect(newlineIdx).toBeGreaterThan(headingIdx);
|
||||
expect(bodyIdx).toBeGreaterThan(newlineIdx);
|
||||
});
|
||||
|
||||
it('should render heading in a View block when separateHeading is set', () => {
|
||||
const {toJSON} = renderWithIntl(
|
||||
<RemoveMarkdown
|
||||
baseStyle={baseStyle}
|
||||
numberOfLines={2}
|
||||
separateHeading={true}
|
||||
value={'### Heading\nBody text'}
|
||||
/>,
|
||||
);
|
||||
|
||||
const json = JSON.stringify(toJSON());
|
||||
expect(json).toContain('Heading');
|
||||
expect(json).toContain('Body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('should render plain text', () => {
|
||||
renderWithIntl(
|
||||
<RemoveMarkdown
|
||||
baseStyle={baseStyle}
|
||||
value='Hello world'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Hello world')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render @mention', () => {
|
||||
renderWithIntl(
|
||||
<RemoveMarkdown
|
||||
baseStyle={baseStyle}
|
||||
value='Hello @admin'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('at_mention.admin')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render code span when enabled', () => {
|
||||
renderWithIntl(
|
||||
<RemoveMarkdown
|
||||
enableCodeSpan={true}
|
||||
baseStyle={baseStyle}
|
||||
value='Use `code` here'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('markdown_code_span')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3,8 +3,8 @@
|
|||
|
||||
import {Parser} from 'commonmark';
|
||||
import Renderer from 'commonmark-react-renderer';
|
||||
import React, {type ReactElement, useCallback, useMemo, useRef} from 'react';
|
||||
import {type StyleProp, Text, type TextStyle} from 'react-native';
|
||||
import React, {type ReactElement, type ReactNode, useCallback, useMemo, useRef, useState} from 'react';
|
||||
import {type NativeSyntheticEvent, type StyleProp, Text, type TextLayoutEventData, type TextStyle, View} from 'react-native';
|
||||
|
||||
import Emoji from '@components/emoji';
|
||||
import {useTheme} from '@context/theme';
|
||||
|
|
@ -23,12 +23,50 @@ type Props = {
|
|||
enableSoftBreak?: boolean;
|
||||
enableChannelLink?: boolean;
|
||||
baseStyle?: StyleProp<TextStyle>;
|
||||
numberOfLines?: number;
|
||||
separateHeading?: boolean;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const RemoveMarkdown = ({enableEmoji, enableChannelLink, enableHardBreak, enableSoftBreak, enableCodeSpan, baseStyle, value}: Props) => {
|
||||
const filterHeadingContext = (context: string[]) => context.filter((c) => !c.startsWith('heading'));
|
||||
|
||||
type HeadingBlockProps = {
|
||||
children: ReactNode;
|
||||
numberOfLines: number;
|
||||
onLineCount: (count: number) => void;
|
||||
};
|
||||
|
||||
const HeadingBlock = ({children, numberOfLines, onLineCount}: HeadingBlockProps) => {
|
||||
const handleTextLayout = useCallback((e: NativeSyntheticEvent<TextLayoutEventData>) => {
|
||||
if (numberOfLines > 0) {
|
||||
onLineCount(Math.min(e.nativeEvent.lines.length, numberOfLines));
|
||||
}
|
||||
}, [numberOfLines, onLineCount]);
|
||||
|
||||
return (
|
||||
<Text
|
||||
numberOfLines={numberOfLines || undefined}
|
||||
onTextLayout={numberOfLines > 0 ? handleTextLayout : undefined}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const RemoveMarkdown = ({
|
||||
enableEmoji,
|
||||
enableChannelLink,
|
||||
enableHardBreak,
|
||||
enableSoftBreak,
|
||||
enableCodeSpan,
|
||||
baseStyle,
|
||||
numberOfLines,
|
||||
separateHeading,
|
||||
value,
|
||||
}: Props) => {
|
||||
const theme = useTheme();
|
||||
const textStyle = getMarkdownTextStyles(theme);
|
||||
const [headingLineCount, setHeadingLineCount] = useState(0);
|
||||
|
||||
const renderText = useCallback(({literal}: {literal: string}) => {
|
||||
return <Text style={baseStyle}>{literal}</Text>;
|
||||
|
|
@ -56,7 +94,7 @@ const RemoveMarkdown = ({enableEmoji, enableChannelLink, enableHardBreak, enable
|
|||
const renderAtMention = useCallback(({context, mentionName}: {context: string[]; mentionName: string}) => {
|
||||
return (
|
||||
<AtMention
|
||||
textStyle={computeTextStyle(textStyle, baseStyle, context)}
|
||||
textStyle={computeTextStyle(textStyle, baseStyle, filterHeadingContext(context))}
|
||||
mentionName={mentionName}
|
||||
/>
|
||||
);
|
||||
|
|
@ -67,7 +105,7 @@ const RemoveMarkdown = ({enableEmoji, enableChannelLink, enableHardBreak, enable
|
|||
return (
|
||||
<ChannelMention
|
||||
linkStyle={textStyle.link}
|
||||
textStyle={computeTextStyle(textStyle, baseStyle, context)}
|
||||
textStyle={computeTextStyle(textStyle, baseStyle, filterHeadingContext(context))}
|
||||
channelName={channelName}
|
||||
/>
|
||||
);
|
||||
|
|
@ -92,6 +130,32 @@ const RemoveMarkdown = ({enableEmoji, enableChannelLink, enableHardBreak, enable
|
|||
);
|
||||
}, [enableCodeSpan, textStyle, baseStyle, renderText]);
|
||||
|
||||
const renderHeading = useCallback(({children}: {children: ReactNode}) => {
|
||||
if (separateHeading) {
|
||||
return (
|
||||
<HeadingBlock
|
||||
numberOfLines={numberOfLines ?? 0}
|
||||
onLineCount={setHeadingLineCount}
|
||||
>
|
||||
{children}
|
||||
</HeadingBlock>
|
||||
);
|
||||
}
|
||||
return <Text>{children}{'\n'}</Text>;
|
||||
}, [numberOfLines, separateHeading]);
|
||||
|
||||
const paragraphLines = numberOfLines ? Math.max(0, numberOfLines - headingLineCount) : 0;
|
||||
|
||||
const renderParagraph = useCallback(({children, first}: {children: ReactNode; first: boolean}) => {
|
||||
if (separateHeading) {
|
||||
if (!first || (numberOfLines && paragraphLines === 0)) {
|
||||
return null;
|
||||
}
|
||||
return <Text numberOfLines={paragraphLines || undefined}>{children}</Text>;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}, [numberOfLines, paragraphLines, separateHeading]);
|
||||
|
||||
const renderNull = () => {
|
||||
return null;
|
||||
};
|
||||
|
|
@ -113,8 +177,8 @@ const RemoveMarkdown = ({enableEmoji, enableChannelLink, enableHardBreak, enable
|
|||
hashtag: Renderer.forwardChildren,
|
||||
latexinline: Renderer.forwardChildren,
|
||||
|
||||
paragraph: Renderer.forwardChildren,
|
||||
heading: Renderer.forwardChildren,
|
||||
paragraph: renderParagraph,
|
||||
heading: renderHeading,
|
||||
codeBlock: renderNull,
|
||||
blockQuote: renderNull,
|
||||
|
||||
|
|
@ -151,13 +215,21 @@ const RemoveMarkdown = ({enableEmoji, enableChannelLink, enableHardBreak, enable
|
|||
renderAtMention,
|
||||
renderChannelLink,
|
||||
renderEmoji,
|
||||
renderHeading,
|
||||
renderParagraph,
|
||||
enableHardBreak,
|
||||
renderBreak,
|
||||
enableSoftBreak,
|
||||
]);
|
||||
const ast = parser.parse(value);
|
||||
|
||||
return renderer.render(ast) as ReactElement;
|
||||
const rendered = renderer.render(ast) as ReactElement;
|
||||
|
||||
if (separateHeading) {
|
||||
return <View>{rendered}</View>;
|
||||
}
|
||||
|
||||
return rendered;
|
||||
};
|
||||
|
||||
export default RemoveMarkdown;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import {renderHook} from '@testing-library/react-native';
|
||||
import {Platform} from 'react-native';
|
||||
import {AppState, Platform} from 'react-native';
|
||||
|
||||
import {useScreenTransitionAnimation} from './screen_transition_animation';
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ const mockUseReducedMotion = jest.fn();
|
|||
const mockUseSharedValue = jest.fn();
|
||||
const mockUseAnimatedStyle = jest.fn();
|
||||
const mockWithTiming = jest.fn();
|
||||
const mockCancelAnimation = jest.fn();
|
||||
const mockUseWindowDimensions = jest.fn();
|
||||
|
||||
jest.mock('expo-router', () => ({
|
||||
|
|
@ -18,6 +19,7 @@ jest.mock('expo-router', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('react-native-reanimated', () => ({
|
||||
cancelAnimation: (sv: {value: number}) => mockCancelAnimation(sv),
|
||||
useReducedMotion: () => mockUseReducedMotion(),
|
||||
useSharedValue: (value: number) => mockUseSharedValue(value),
|
||||
useAnimatedStyle: (callback: () => any) => mockUseAnimatedStyle(callback),
|
||||
|
|
@ -30,12 +32,17 @@ jest.mock('./device', () => ({
|
|||
|
||||
describe('useScreenTransitionAnimation', () => {
|
||||
let sharedValue: {value: number};
|
||||
let mockSubRemove: jest.Mock;
|
||||
let capturedAppStateCallback: ((state: string) => void) | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup default mock implementations
|
||||
sharedValue = {value: 0};
|
||||
mockSubRemove = jest.fn();
|
||||
capturedAppStateCallback = undefined;
|
||||
|
||||
mockUseSharedValue.mockReturnValue(sharedValue);
|
||||
mockUseAnimatedStyle.mockImplementation((callback) => {
|
||||
const style = callback();
|
||||
|
|
@ -44,6 +51,15 @@ describe('useScreenTransitionAnimation', () => {
|
|||
mockWithTiming.mockImplementation((value) => value);
|
||||
mockUseReducedMotion.mockReturnValue(false);
|
||||
mockUseWindowDimensions.mockReturnValue({width: 375, height: 812});
|
||||
|
||||
jest.spyOn(AppState, 'addEventListener').mockImplementation((_event, callback) => {
|
||||
capturedAppStateCallback = callback as (state: string) => void;
|
||||
return {remove: mockSubRemove} as any;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
|
|
@ -87,8 +103,10 @@ describe('useScreenTransitionAnimation', () => {
|
|||
// Get the callback that was passed to useAnimatedStyle
|
||||
const animatedStyleCallback = mockUseAnimatedStyle.mock.calls[0][0];
|
||||
const style = animatedStyleCallback();
|
||||
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
||||
focusEffectCallback();
|
||||
|
||||
expect(mockWithTiming).toHaveBeenCalledWith(sharedValue.value, {duration: 250});
|
||||
expect(mockWithTiming).toHaveBeenCalledWith(0, {duration: 250});
|
||||
expect(style).toHaveProperty('transform');
|
||||
});
|
||||
|
||||
|
|
@ -100,8 +118,10 @@ describe('useScreenTransitionAnimation', () => {
|
|||
// Get the callback that was passed to useAnimatedStyle
|
||||
const animatedStyleCallback = mockUseAnimatedStyle.mock.calls[0][0];
|
||||
const style = animatedStyleCallback();
|
||||
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
||||
focusEffectCallback();
|
||||
|
||||
expect(mockWithTiming).toHaveBeenCalledWith(sharedValue.value, {duration: 350});
|
||||
expect(mockWithTiming).toHaveBeenCalledWith(0, {duration: 350});
|
||||
expect(style).toHaveProperty('transform');
|
||||
});
|
||||
|
||||
|
|
@ -170,6 +190,60 @@ describe('useScreenTransitionAnimation', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('AppState resume', () => {
|
||||
it('cancels the in-flight animation when going to background so it does not race to completion', () => {
|
||||
renderHook(() => useScreenTransitionAnimation(true));
|
||||
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
||||
focusEffectCallback();
|
||||
|
||||
sharedValue.value = 150;
|
||||
|
||||
expect(capturedAppStateCallback).toBeDefined();
|
||||
capturedAppStateCallback?.('background');
|
||||
|
||||
expect(mockCancelAnimation).toHaveBeenCalledWith(sharedValue);
|
||||
});
|
||||
|
||||
it('resumes the slide-in animation on active when the value is still mid-flight', () => {
|
||||
renderHook(() => useScreenTransitionAnimation(true));
|
||||
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
||||
focusEffectCallback();
|
||||
|
||||
sharedValue.value = 150;
|
||||
expect(capturedAppStateCallback).toBeDefined();
|
||||
capturedAppStateCallback?.('background');
|
||||
mockWithTiming.mockClear();
|
||||
|
||||
capturedAppStateCallback?.('active');
|
||||
|
||||
expect(mockWithTiming).toHaveBeenCalledWith(0, {duration: expect.any(Number)});
|
||||
});
|
||||
|
||||
it('does restart animation even when app returns to active with the screen already at rest', () => {
|
||||
renderHook(() => useScreenTransitionAnimation(true));
|
||||
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
||||
focusEffectCallback();
|
||||
|
||||
sharedValue.value = 0;
|
||||
mockWithTiming.mockClear();
|
||||
|
||||
expect(capturedAppStateCallback).toBeDefined();
|
||||
capturedAppStateCallback?.('active');
|
||||
|
||||
expect(mockWithTiming).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes AppState listener on blur', () => {
|
||||
renderHook(() => useScreenTransitionAnimation(true));
|
||||
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
||||
const cleanup = focusEffectCallback();
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(mockSubRemove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useEffect for shouldAnimate changes', () => {
|
||||
it('should set translateX to 0 when shouldAnimate becomes false', () => {
|
||||
const {rerender} = renderHook(
|
||||
|
|
@ -282,8 +356,8 @@ describe('useScreenTransitionAnimation', () => {
|
|||
|
||||
renderHook(() => useScreenTransitionAnimation(true));
|
||||
|
||||
const animatedStyleCallback = mockUseAnimatedStyle.mock.calls[0][0];
|
||||
animatedStyleCallback();
|
||||
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
||||
focusEffectCallback();
|
||||
|
||||
expect(mockWithTiming).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
|
|
@ -296,8 +370,8 @@ describe('useScreenTransitionAnimation', () => {
|
|||
|
||||
renderHook(() => useScreenTransitionAnimation(true));
|
||||
|
||||
const animatedStyleCallback = mockUseAnimatedStyle.mock.calls[0][0];
|
||||
animatedStyleCallback();
|
||||
const focusEffectCallback = mockUseFocusEffect.mock.calls[0][0];
|
||||
focusEffectCallback();
|
||||
|
||||
expect(mockWithTiming).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
|
||||
import {useFocusEffect} from 'expo-router';
|
||||
import {useCallback, useEffect, useRef} from 'react';
|
||||
import {Platform} from 'react-native';
|
||||
import {useReducedMotion, useSharedValue, useAnimatedStyle, withTiming} from 'react-native-reanimated';
|
||||
import {AppState, Platform} from 'react-native';
|
||||
import {cancelAnimation, useReducedMotion, useSharedValue, useAnimatedStyle, withTiming} from 'react-native-reanimated';
|
||||
|
||||
import {useWindowDimensions} from './device';
|
||||
|
||||
|
|
@ -17,6 +17,7 @@ export const useScreenTransitionAnimation = (animated: boolean = true) => {
|
|||
const reducedMotion = useReducedMotion();
|
||||
const shouldAnimate = animated && !reducedMotion;
|
||||
const translateX = useSharedValue(shouldAnimate ? width : 0);
|
||||
const animationDuration = Platform.OS === 'android' ? 250 : 350;
|
||||
|
||||
// Keep a ref so the useFocusEffect cleanup always reads the latest values
|
||||
// without adding them as dependencies (which would re-trigger cleanup on rotation/motion changes)
|
||||
|
|
@ -25,26 +26,33 @@ export const useScreenTransitionAnimation = (animated: boolean = true) => {
|
|||
latestRef.current = {width, shouldAnimate};
|
||||
}, [width, shouldAnimate]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const duration = Platform.OS === 'android' ? 250 : 350;
|
||||
return {
|
||||
transform: [{translateX: withTiming(translateX.value, {duration})}],
|
||||
};
|
||||
}, []);
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{translateX: translateX.value}],
|
||||
}), []);
|
||||
|
||||
// Use focus effect to handle screen appearing (similar to componentDidAppear)
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// Screen is focused (appeared)
|
||||
translateX.value = 0;
|
||||
const {shouldAnimate: sa} = latestRef.current;
|
||||
translateX.value = sa ? withTiming(0, {duration: animationDuration}) : 0;
|
||||
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'inactive' || state === 'background') {
|
||||
// Freeze the animation at its current value so the UI thread doesn't
|
||||
// race to completion while the app is backgrounded.
|
||||
cancelAnimation(translateX);
|
||||
} else if (state === 'active') {
|
||||
cancelAnimation(translateX);
|
||||
translateX.value = latestRef.current.width;
|
||||
translateX.value = latestRef.current.shouldAnimate ? withTiming(0, {duration: animationDuration}) : 0;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Screen is blurred (disappeared)
|
||||
const {width: w, shouldAnimate: sa} = latestRef.current;
|
||||
translateX.value = sa ? -w : 0;
|
||||
sub.remove();
|
||||
const {width: w, shouldAnimate: saBlur} = latestRef.current;
|
||||
translateX.value = saBlur ? withTiming(-w, {duration: animationDuration}) : 0;
|
||||
};
|
||||
|
||||
}, [translateX]),
|
||||
}, [translateX, animationDuration]),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -112,9 +112,38 @@ describe('keyboardOpenTransitions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('[3] KEYBOARD_OPEN → KEYBOARD_EVENT_END → KEYBOARD_OPEN (isRotating guard)', () => {
|
||||
describe('[3] KEYBOARD_OPEN → KEYBOARD_EVENT_START → KEYBOARD_OPEN', () => {
|
||||
const t = keyboardOpenTransitions[3];
|
||||
|
||||
it('should have correct from/event/to', () => {
|
||||
expect(t.from).toBe(InputContainerStateType.KEYBOARD_OPEN);
|
||||
expect(t.event).toBe(StateMachineEventType.KEYBOARD_EVENT_START);
|
||||
expect(t.to).toBe(InputContainerStateType.KEYBOARD_OPEN);
|
||||
});
|
||||
|
||||
it('action calls calculateKeyboardUpdates with event.height when rawHeight is defined', () => {
|
||||
const snapshot = makeSnapshot();
|
||||
const event = makeEvent({type: StateMachineEventType.KEYBOARD_EVENT_START, height: 280, rawHeight: 330});
|
||||
t.action!(snapshot, event);
|
||||
expect(calculateKeyboardUpdates).toHaveBeenCalledWith(snapshot, 280, 330);
|
||||
});
|
||||
|
||||
it('action falls back to snapshot.keyboardEventHeight when event.height is undefined', () => {
|
||||
const snapshot = makeSnapshot({keyboardEventHeight: 300});
|
||||
const event = makeEvent({type: StateMachineEventType.KEYBOARD_EVENT_START, height: undefined, rawHeight: 330});
|
||||
t.action!(snapshot, event);
|
||||
expect(calculateKeyboardUpdates).toHaveBeenCalledWith(snapshot, 300, 330);
|
||||
});
|
||||
|
||||
it('action returns undefined when rawHeight is undefined', () => {
|
||||
const result = t.action!(makeSnapshot(), makeEvent({type: StateMachineEventType.KEYBOARD_EVENT_START, height: 280, rawHeight: undefined}));
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('[4] KEYBOARD_OPEN → KEYBOARD_EVENT_END → KEYBOARD_OPEN (isRotating guard)', () => {
|
||||
const t = keyboardOpenTransitions[4];
|
||||
|
||||
it('should have correct from/event/to', () => {
|
||||
expect(t.from).toBe(InputContainerStateType.KEYBOARD_OPEN);
|
||||
expect(t.event).toBe(StateMachineEventType.KEYBOARD_EVENT_END);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,18 @@ import {
|
|||
type StateEvent,
|
||||
} from '@keyboard/state_machine/types';
|
||||
|
||||
const moveStartAction = (snapshot: StateSnapshot, event: StateEvent): ActionUpdates | undefined => {
|
||||
'worklet';
|
||||
|
||||
const height = event.height ?? snapshot.keyboardEventHeight;
|
||||
|
||||
if (event.rawHeight !== undefined) {
|
||||
return calculateKeyboardUpdates(snapshot, height, event.rawHeight);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const keyboardOpenTransitions: StateTransition[] = [
|
||||
{
|
||||
from: InputContainerStateType.KEYBOARD_OPEN,
|
||||
|
|
@ -44,17 +56,13 @@ export const keyboardOpenTransitions: StateTransition[] = [
|
|||
from: InputContainerStateType.KEYBOARD_OPEN,
|
||||
event: StateMachineEventType.KEYBOARD_EVENT_MOVE,
|
||||
to: InputContainerStateType.KEYBOARD_OPEN,
|
||||
action: (snapshot: StateSnapshot, event: StateEvent): ActionUpdates | void => {
|
||||
'worklet';
|
||||
|
||||
const height = event.height ?? snapshot.keyboardEventHeight;
|
||||
|
||||
if (event.rawHeight !== undefined) {
|
||||
return calculateKeyboardUpdates(snapshot, height, event.rawHeight);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
action: moveStartAction,
|
||||
},
|
||||
{
|
||||
from: InputContainerStateType.KEYBOARD_OPEN,
|
||||
event: StateMachineEventType.KEYBOARD_EVENT_START,
|
||||
to: InputContainerStateType.KEYBOARD_OPEN,
|
||||
action: moveStartAction,
|
||||
},
|
||||
{
|
||||
from: InputContainerStateType.KEYBOARD_OPEN,
|
||||
|
|
|
|||
|
|
@ -153,7 +153,9 @@ export class SessionManagerSingleton {
|
|||
}
|
||||
const launchRoute = await determineRouteFromLaunchProps({launchType, serverUrl, displayName});
|
||||
|
||||
router.replace({pathname: launchRoute.route, params: launchRoute.params});
|
||||
requestAnimationFrame(() => {
|
||||
router.replace({pathname: launchRoute.route, params: launchRoute.params});
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.terminatingSessionUrl.delete(serverUrl);
|
||||
|
|
|
|||
|
|
@ -55,7 +55,15 @@ export const addEventListener = () => {
|
|||
/**
|
||||
* Optional: Redirect system paths if needed
|
||||
* We don't need custom redirection, so just return the path as-is
|
||||
*
|
||||
* Exception: SSO callback URLs (mmauth://, mmauthbeta://) are consumed by the
|
||||
* SSO screen's own Linking listener. If they reach expo-router they resolve to
|
||||
* an unregistered route. Returning null keeps the app on its current path.
|
||||
*/
|
||||
export function redirectSystemPath(options: {path: string; initial: boolean}) {
|
||||
if (options.path?.startsWith(Sso.REDIRECT_URL_SCHEME) ||
|
||||
options.path?.startsWith(Sso.REDIRECT_URL_SCHEME_DEV)) {
|
||||
return null;
|
||||
}
|
||||
return options.path;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -215,17 +215,17 @@ const Thread = ({author, channel, location, post, teammateNameDisplay, testID, t
|
|||
);
|
||||
if (message) {
|
||||
postBody = (
|
||||
<Text numberOfLines={2}>
|
||||
<RemoveMarkdown
|
||||
enableCodeSpan={true}
|
||||
enableEmoji={true}
|
||||
enableChannelLink={true}
|
||||
enableHardBreak={true}
|
||||
enableSoftBreak={true}
|
||||
baseStyle={styles.message}
|
||||
value={message.substring(0, 100)} // This substring helps to avoid ANR's
|
||||
/>
|
||||
</Text>
|
||||
<RemoveMarkdown
|
||||
enableCodeSpan={true}
|
||||
enableEmoji={true}
|
||||
enableChannelLink={true}
|
||||
enableHardBreak={true}
|
||||
enableSoftBreak={true}
|
||||
baseStyle={styles.message}
|
||||
numberOfLines={2}
|
||||
separateHeading={true}
|
||||
value={message.substring(0, 150)} // This substring helps to avoid ANR's
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,9 +172,14 @@ const Server = ({
|
|||
return false;
|
||||
});
|
||||
|
||||
PushNotifications.registerIfNeeded();
|
||||
const registerTimeout = setTimeout(() => {
|
||||
PushNotifications.registerIfNeeded();
|
||||
}, 500);
|
||||
|
||||
return () => backHandler.remove();
|
||||
return () => {
|
||||
backHandler.remove();
|
||||
clearTimeout(registerTimeout);
|
||||
};
|
||||
});
|
||||
|
||||
const displayLogin = (serverUrl: string, config: ClientConfig, license: ClientLicense) => {
|
||||
|
|
|
|||
|
|
@ -112,24 +112,6 @@ const SSO = ({
|
|||
setLoginError(errorMessage);
|
||||
}, [intl]);
|
||||
|
||||
const doSSOLogin = async (bearerToken: string, csrfToken: string) => {
|
||||
const result: LoginActionResponse = await ssoLogin(serverUrl, serverDisplayName, config.DiagnosticId!, bearerToken, csrfToken, serverPreauthSecret);
|
||||
if (result?.error && result.failed) {
|
||||
onLoadEndError(result.error);
|
||||
return;
|
||||
}
|
||||
goToHome(result.error);
|
||||
};
|
||||
|
||||
const doSSOCodeExchange = async (loginCode: string, samlChallenge: {codeVerifier: string; state: string}) => {
|
||||
const result: LoginActionResponse = await ssoLoginWithCodeExchange(serverUrl, serverDisplayName, config.DiagnosticId!, loginCode, samlChallenge, serverPreauthSecret);
|
||||
if (result?.error && result.failed) {
|
||||
onLoadEndError(result.error);
|
||||
return;
|
||||
}
|
||||
goToHome(result.error);
|
||||
};
|
||||
|
||||
const goToHome = useCallback((error?: unknown) => {
|
||||
const hasError = launchError || Boolean(error);
|
||||
navigateToScreen(Screens.HOME, {extra, launchError: hasError, launchType, serverUrl}, true);
|
||||
|
|
@ -151,6 +133,24 @@ const SSO = ({
|
|||
return true;
|
||||
}, [serverUrl, serverDisplayName, config.DiagnosticId, config.IntuneScope, goToHome, onLoadEndError]);
|
||||
|
||||
const doSSOLogin = useCallback(async (bearerToken: string, csrfToken: string) => {
|
||||
const result: LoginActionResponse = await ssoLogin(serverUrl, serverDisplayName, config.DiagnosticId!, bearerToken, csrfToken, serverPreauthSecret);
|
||||
if (result?.error && result.failed) {
|
||||
onLoadEndError(result.error);
|
||||
return;
|
||||
}
|
||||
goToHome(result.error);
|
||||
}, [config.DiagnosticId, goToHome, onLoadEndError, serverDisplayName, serverPreauthSecret, serverUrl]);
|
||||
|
||||
const doSSOCodeExchange = useCallback(async (loginCode: string, samlChallenge: {codeVerifier: string; state: string}) => {
|
||||
const result: LoginActionResponse = await ssoLoginWithCodeExchange(serverUrl, serverDisplayName, config.DiagnosticId!, loginCode, samlChallenge, serverPreauthSecret);
|
||||
if (result?.error && result.failed) {
|
||||
onLoadEndError(result.error);
|
||||
return;
|
||||
}
|
||||
goToHome(result.error);
|
||||
}, [config.DiagnosticId, goToHome, onLoadEndError, serverDisplayName, serverPreauthSecret, serverUrl]);
|
||||
|
||||
const animatedStyles = useScreenTransitionAnimation(!isModal);
|
||||
|
||||
const onBackPressed = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ const SSOAuthentication = ({doSSOLogin, doSSOCodeExchange, loginError, loginUrl,
|
|||
setLoginSuccess(true);
|
||||
doSSOLogin(bearerToken, csrfToken);
|
||||
}
|
||||
} else if (Platform.OS === 'ios' || result.type === 'dismiss') {
|
||||
} else if (Platform.OS === 'ios') {
|
||||
setError(
|
||||
intl.formatMessage({
|
||||
id: 'mobile.oauth.failed_to_login',
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {useIsTablet} from '@hooks/device';
|
|||
import {useGalleryItem} from '@hooks/gallery';
|
||||
import {openGalleryAtIndex} from '@utils/gallery';
|
||||
import {urlSafeBase64Encode} from '@utils/security';
|
||||
import {nonBreakingString} from '@utils/strings';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
import {displayUsername} from '@utils/user';
|
||||
|
|
@ -172,7 +173,7 @@ const UserProfileTitle = ({
|
|||
style={styles.displayName}
|
||||
testID='user_profile.display_name'
|
||||
>
|
||||
{`${prefix}${displayName}`}
|
||||
{nonBreakingString(`${prefix}${displayName}`)}
|
||||
</Text>
|
||||
{!hideUsername &&
|
||||
<Text
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
"android.emulator": {
|
||||
"type": "android.emulator",
|
||||
"device": {
|
||||
"avdName": "Pixel_6_Pro_API_34"
|
||||
"avdName": "detox_pixel_4_xl_api_34"
|
||||
},
|
||||
"environment": {
|
||||
"MM_FEATUREFLAGS_InteractiveDialogAppsForm": "true"
|
||||
|
|
|
|||
|
|
@ -2040,7 +2040,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 757;
|
||||
CURRENT_PROJECT_VERSION = 760;
|
||||
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
|
||||
ENABLE_BITCODE = NO;
|
||||
HEADER_SEARCH_PATHS = "$(inherited)";
|
||||
|
|
@ -2082,7 +2082,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 757;
|
||||
CURRENT_PROJECT_VERSION = 760;
|
||||
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
|
||||
ENABLE_BITCODE = NO;
|
||||
HEADER_SEARCH_PATHS = "$(inherited)";
|
||||
|
|
@ -2234,7 +2234,7 @@
|
|||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 757;
|
||||
CURRENT_PROJECT_VERSION = 760;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
|
|
@ -2287,7 +2287,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 757;
|
||||
CURRENT_PROJECT_VERSION = 760;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>757</string>
|
||||
<string>760</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.41.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>757</string>
|
||||
<string>760</string>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>OpenSans-Bold.ttf</string>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.41.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>757</string>
|
||||
<string>760</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
|
|
|||
2
apps/mattermost/package-lock.json
generated
2
apps/mattermost/package-lock.json
generated
|
|
@ -6,7 +6,7 @@
|
|||
"packages": {
|
||||
"": {
|
||||
"name": "mattermost-mobile",
|
||||
"version": "2.40.0",
|
||||
"version": "2.41.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache 2.0",
|
||||
"dependencies": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue