chore(upstream): refresh mobile snapshot

This commit is contained in:
toki 2026-05-30 08:02:06 +09:00
parent 6e72f9e53b
commit b786946e7d
24 changed files with 496 additions and 94 deletions

View file

@ -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/`

View file

@ -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'

View file

@ -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>
`;

View file

@ -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}>

View file

@ -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 && (

View 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();
});
});
});

View file

@ -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;

View file

@ -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),

View file

@ -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(() => {

View file

@ -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);

View file

@ -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,

View file

@ -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);

View file

@ -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;
}

View file

@ -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
/>
);
}
}

View file

@ -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) => {

View file

@ -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(() => {

View file

@ -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',

View file

@ -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

View file

@ -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"

View file

@ -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;

View file

@ -37,7 +37,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>757</string>
<string>760</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>

View file

@ -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>

View file

@ -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>

View file

@ -6,7 +6,7 @@
"packages": {
"": {
"name": "mattermost-mobile",
"version": "2.40.0",
"version": "2.41.0",
"hasInstallScript": true,
"license": "Apache 2.0",
"dependencies": {