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": {