diff --git a/apps/mattermost/UPSTREAM.md b/apps/mattermost/UPSTREAM.md
index 2ba80086..bb05a034 100644
--- a/apps/mattermost/UPSTREAM.md
+++ b/apps/mattermost/UPSTREAM.md
@@ -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/`
diff --git a/apps/mattermost/android/app/build.gradle b/apps/mattermost/android/app/build.gradle
index bbd30927..e3330830 100644
--- a/apps/mattermost/android/app/build.gradle
+++ b/apps/mattermost/android/app/build.gradle
@@ -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'
diff --git a/apps/mattermost/app/components/formatted_date/__snapshots__/index.test.tsx.snap b/apps/mattermost/app/components/formatted_date/__snapshots__/index.test.tsx.snap
index 703e3ad0..be074c85 100644
--- a/apps/mattermost/app/components/formatted_date/__snapshots__/index.test.tsx.snap
+++ b/apps/mattermost/app/components/formatted_date/__snapshots__/index.test.tsx.snap
@@ -374,13 +374,13 @@ exports[` should match snapshot for 'ko' locale and '{"dateStyle
exports[` should match snapshot for 'ko' locale and '{"day": "numeric", "hour": "numeric", "minute": "numeric", "month": "short", "year": "numeric"}' format 1`] = `
- 2024년 10월 26일 오전 10:01
+ 2024년 10월 26일 AM 10:01
`;
exports[` should match snapshot for 'ko' locale and '{"day": "numeric", "hour": "numeric", "minute": "numeric", "month": "short"}' format 1`] = `
- 10월 26일 오전 10:01
+ 10월 26일 AM 10:01
`;
diff --git a/apps/mattermost/app/components/keyboard_aware_post_draft_container.tsx b/apps/mattermost/app/components/keyboard_aware_post_draft_container.tsx
index e3349d58..7f90f92c 100644
--- a/apps/mattermost/app/components/keyboard_aware_post_draft_container.tsx
+++ b/apps/mattermost/app/components/keyboard_aware_post_draft_container.tsx
@@ -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 (
diff --git a/apps/mattermost/app/components/post_list/post/header/display_name/index.tsx b/apps/mattermost/app/components/post_list/post/header/display_name/index.tsx
index ae2290fa..f6a8722d 100644
--- a/apps/mattermost/app/components/post_list/post/header/display_name/index.tsx
+++ b/apps/mattermost/app/components/post_list/post/header/display_name/index.tsx
@@ -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)}
{showCustomStatusEmoji && (
diff --git a/apps/mattermost/app/components/remove_markdown/index.test.tsx b/apps/mattermost/app/components/remove_markdown/index.test.tsx
new file mode 100644
index 00000000..643aeaa7
--- /dev/null
+++ b/apps/mattermost/app/components/remove_markdown/index.test.tsx
@@ -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}) => (
+
+ {`@${mentionName}`}
+
+ );
+ MockAtMention.displayName = 'MockAtMention';
+ return MockAtMention;
+});
+
+jest.mock('../markdown/channel_mention', () => {
+ const {Text} = require('react-native');
+ const MockChannelMention = ({channelName, textStyle}: {channelName: string; textStyle: any}) => (
+
+ {`~${channelName}`}
+
+ );
+ MockChannelMention.displayName = 'MockChannelMention';
+ return MockChannelMention;
+});
+
+jest.mock('@components/emoji', () => {
+ const {Text} = require('react-native');
+ const MockEmoji = ({emojiName, textStyle}: {emojiName: string; textStyle: any}) => (
+
+ {`:${emojiName}:`}
+
+ );
+ MockEmoji.displayName = 'MockEmoji';
+ return MockEmoji;
+});
+
+const theme = Preferences.THEMES.denim;
+const headingTextStyles = getMarkdownTextStyles(theme);
+
+function getFlatStyle(element: ReturnType): 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ const json = JSON.stringify(toJSON());
+ expect(json).toContain('Heading');
+ expect(json).toContain('Body');
+ });
+ });
+
+ describe('basic rendering', () => {
+ it('should render plain text', () => {
+ renderWithIntl(
+ ,
+ );
+
+ expect(screen.getByText('Hello world')).toBeTruthy();
+ });
+
+ it('should render @mention', () => {
+ renderWithIntl(
+ ,
+ );
+
+ expect(screen.getByTestId('at_mention.admin')).toBeTruthy();
+ });
+
+ it('should render code span when enabled', () => {
+ renderWithIntl(
+ ,
+ );
+
+ expect(screen.getByTestId('markdown_code_span')).toBeTruthy();
+ });
+ });
+});
diff --git a/apps/mattermost/app/components/remove_markdown/index.tsx b/apps/mattermost/app/components/remove_markdown/index.tsx
index 30c51697..a67581d5 100644
--- a/apps/mattermost/app/components/remove_markdown/index.tsx
+++ b/apps/mattermost/app/components/remove_markdown/index.tsx
@@ -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;
+ 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) => {
+ if (numberOfLines > 0) {
+ onLineCount(Math.min(e.nativeEvent.lines.length, numberOfLines));
+ }
+ }, [numberOfLines, onLineCount]);
+
+ return (
+ 0 ? handleTextLayout : undefined}
+ >
+ {children}
+
+ );
+};
+
+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 {literal};
@@ -56,7 +94,7 @@ const RemoveMarkdown = ({enableEmoji, enableChannelLink, enableHardBreak, enable
const renderAtMention = useCallback(({context, mentionName}: {context: string[]; mentionName: string}) => {
return (
);
@@ -67,7 +105,7 @@ const RemoveMarkdown = ({enableEmoji, enableChannelLink, enableHardBreak, enable
return (
);
@@ -92,6 +130,32 @@ const RemoveMarkdown = ({enableEmoji, enableChannelLink, enableHardBreak, enable
);
}, [enableCodeSpan, textStyle, baseStyle, renderText]);
+ const renderHeading = useCallback(({children}: {children: ReactNode}) => {
+ if (separateHeading) {
+ return (
+
+ {children}
+
+ );
+ }
+ return {children}{'\n'};
+ }, [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 {children};
+ }
+ 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 {rendered};
+ }
+
+ return rendered;
};
export default RemoveMarkdown;
diff --git a/apps/mattermost/app/hooks/screen_transition_animation.test.ts b/apps/mattermost/app/hooks/screen_transition_animation.test.ts
index e7a296a1..95539731 100644
--- a/apps/mattermost/app/hooks/screen_transition_animation.test.ts
+++ b/apps/mattermost/app/hooks/screen_transition_animation.test.ts
@@ -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),
diff --git a/apps/mattermost/app/hooks/screen_transition_animation.ts b/apps/mattermost/app/hooks/screen_transition_animation.ts
index 9b563fe6..c36e5a12 100644
--- a/apps/mattermost/app/hooks/screen_transition_animation.ts
+++ b/apps/mattermost/app/hooks/screen_transition_animation.ts
@@ -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(() => {
diff --git a/apps/mattermost/app/keyboard/state_machine/transitions/keyboard_open.test.ts b/apps/mattermost/app/keyboard/state_machine/transitions/keyboard_open.test.ts
index 6b746aff..fa852d20 100644
--- a/apps/mattermost/app/keyboard/state_machine/transitions/keyboard_open.test.ts
+++ b/apps/mattermost/app/keyboard/state_machine/transitions/keyboard_open.test.ts
@@ -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);
diff --git a/apps/mattermost/app/keyboard/state_machine/transitions/keyboard_open.ts b/apps/mattermost/app/keyboard/state_machine/transitions/keyboard_open.ts
index 8c3fc46b..9d8c04c3 100644
--- a/apps/mattermost/app/keyboard/state_machine/transitions/keyboard_open.ts
+++ b/apps/mattermost/app/keyboard/state_machine/transitions/keyboard_open.ts
@@ -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,
diff --git a/apps/mattermost/app/managers/session_manager.ts b/apps/mattermost/app/managers/session_manager.ts
index 16f1661d..0d3c3ae8 100644
--- a/apps/mattermost/app/managers/session_manager.ts
+++ b/apps/mattermost/app/managers/session_manager.ts
@@ -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);
diff --git a/apps/mattermost/app/routes/+native-intent.ts b/apps/mattermost/app/routes/+native-intent.ts
index e8944c83..f64bc5c5 100644
--- a/apps/mattermost/app/routes/+native-intent.ts
+++ b/apps/mattermost/app/routes/+native-intent.ts
@@ -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;
}
diff --git a/apps/mattermost/app/screens/global_threads/threads_list/thread/thread.tsx b/apps/mattermost/app/screens/global_threads/threads_list/thread/thread.tsx
index 893b32e0..0db835a3 100644
--- a/apps/mattermost/app/screens/global_threads/threads_list/thread/thread.tsx
+++ b/apps/mattermost/app/screens/global_threads/threads_list/thread/thread.tsx
@@ -215,17 +215,17 @@ const Thread = ({author, channel, location, post, teammateNameDisplay, testID, t
);
if (message) {
postBody = (
-
-
-
+
);
}
}
diff --git a/apps/mattermost/app/screens/server/index.tsx b/apps/mattermost/app/screens/server/index.tsx
index 4bdf866d..e95c5141 100644
--- a/apps/mattermost/app/screens/server/index.tsx
+++ b/apps/mattermost/app/screens/server/index.tsx
@@ -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) => {
diff --git a/apps/mattermost/app/screens/sso/index.tsx b/apps/mattermost/app/screens/sso/index.tsx
index fd18494f..fd1441fe 100644
--- a/apps/mattermost/app/screens/sso/index.tsx
+++ b/apps/mattermost/app/screens/sso/index.tsx
@@ -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(() => {
diff --git a/apps/mattermost/app/screens/sso/sso_authentication.tsx b/apps/mattermost/app/screens/sso/sso_authentication.tsx
index cd11043c..31d00731 100644
--- a/apps/mattermost/app/screens/sso/sso_authentication.tsx
+++ b/apps/mattermost/app/screens/sso/sso_authentication.tsx
@@ -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',
diff --git a/apps/mattermost/app/screens/user_profile/title/index.tsx b/apps/mattermost/app/screens/user_profile/title/index.tsx
index 09ae218e..bd3aabc6 100644
--- a/apps/mattermost/app/screens/user_profile/title/index.tsx
+++ b/apps/mattermost/app/screens/user_profile/title/index.tsx
@@ -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}`)}
{!hideUsername &&
CFBundleVersion
- 757
+ 760
ITSAppUsesNonExemptEncryption
LSRequiresIPhoneOS
diff --git a/apps/mattermost/ios/MattermostShare/Info.plist b/apps/mattermost/ios/MattermostShare/Info.plist
index b96eaf73..953f5e5b 100644
--- a/apps/mattermost/ios/MattermostShare/Info.plist
+++ b/apps/mattermost/ios/MattermostShare/Info.plist
@@ -21,7 +21,7 @@
CFBundleShortVersionString
2.41.0
CFBundleVersion
- 757
+ 760
UIAppFonts
OpenSans-Bold.ttf
diff --git a/apps/mattermost/ios/NotificationService/Info.plist b/apps/mattermost/ios/NotificationService/Info.plist
index 878e4e6a..80c63d97 100644
--- a/apps/mattermost/ios/NotificationService/Info.plist
+++ b/apps/mattermost/ios/NotificationService/Info.plist
@@ -21,7 +21,7 @@
CFBundleShortVersionString
2.41.0
CFBundleVersion
- 757
+ 760
NSExtension
NSExtensionPointIdentifier
diff --git a/apps/mattermost/package-lock.json b/apps/mattermost/package-lock.json
index 195f4e52..937931e8 100644
--- a/apps/mattermost/package-lock.json
+++ b/apps/mattermost/package-lock.json
@@ -6,7 +6,7 @@
"packages": {
"": {
"name": "mattermost-mobile",
- "version": "2.40.0",
+ "version": "2.41.0",
"hasInstallScript": true,
"license": "Apache 2.0",
"dependencies": {