Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
32d151f
Mobile: single-click leave for non-hosts + remove end confirmation (#…
bgardner8008 Aug 5, 2026
3005544
[MM-69767] Quick call button in the channel header (#9995)
M-ZubairAhmed Aug 7, 2026
bcefc25
Mobile: single-click leave for non-hosts + remove end confirmation (#…
bgardner8008 Aug 5, 2026
a85c965
[MM-69767] Quick call button in the channel header (#9995)
M-ZubairAhmed Aug 7, 2026
6136c99
[MM-69761][MM-70133][MM-70179] Auto-open full-screen call view on ini…
M-ZubairAhmed Aug 12, 2026
7edcb8c
[MM-70043][MM-69758] Call's "Calling" state in widget and full screen…
M-ZubairAhmed Aug 17, 2026
9a5ebe0
[MM-70134] Implement call post card states (Calling / No answer / Can…
M-ZubairAhmed Aug 17, 2026
e67fe33
Merge branch 'main' of github.com:mattermost/mattermost-mobile into d…
M-ZubairAhmed Aug 17, 2026
a1a77e4
merge main
M-ZubairAhmed Aug 17, 2026
63a4e39
[MM-69760] Play outbound ringback tone while calling on DM calls (#9986)
bgardner8008 Aug 17, 2026
ea7b3e5
Refactor tests and improve utility functions for call handling
M-ZubairAhmed Aug 17, 2026
c1809a7
Merge branch 'dm-calls' of github.com:mattermost/mattermost-mobile in…
M-ZubairAhmed Aug 17, 2026
84bf61a
Refactor call-related tests and improve utility function descriptions
M-ZubairAhmed Aug 17, 2026
482b641
Fix ringback timeout handling in call actions to prevent orphaned tim…
M-ZubairAhmed Aug 17, 2026
fde9237
t1 for fullscreen bug
M-ZubairAhmed Aug 18, 2026
1382c93
test(e2e): quarantine 4 iOS Detox flakes from the hit-test/visibility…
M-ZubairAhmed Aug 18, 2026
706e84e
test(e2e): add Detox coverage for the DM quick call button
M-ZubairAhmed Aug 18, 2026
e57125e
Revert "test(e2e): quarantine 4 iOS Detox flakes from the hit-test/vi…
M-ZubairAhmed Aug 19, 2026
978a4f8
Revert "t1 for fullscreen bug"
M-ZubairAhmed Aug 19, 2026
345fe0d
Refactor imports in navigation header and agent chat components to us…
M-ZubairAhmed Aug 24, 2026
51ba192
test(e2e): update DM call button tests with specific identifiers
M-ZubairAhmed Aug 24, 2026
f491538
Refactor CallStatusText component to use theme-based styling
M-ZubairAhmed Aug 24, 2026
14925e2
Refactor call state handling and improve comments
M-ZubairAhmed Aug 24, 2026
40d390e
Merge branch 'main' into dm-calls
mattermost-build Aug 24, 2026
4989cff
fix(calls): allow ringback to continue in background on iOS
bgardner8008 Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions app/components/channel_actions/channel_actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
import React, {useCallback} from 'react';
import {StyleSheet, View} from 'react-native';

import ChannelInfoStartButton from '@calls/components/channel_info_start';
import ChannelInfoStartCallButton from '@calls/components/channel_info_start_call';
import AddMembersBox from '@components/channel_actions/add_members_box';
import CopyChannelLinkBox from '@components/channel_actions/copy_channel_link_box';
import FavoriteBox from '@components/channel_actions/favorite_box';
import MutedBox from '@components/channel_actions/mute_box';
import SetHeaderBox from '@components/channel_actions/set_header_box';
import {useServerUrl} from '@context/server';
import {dismissBottomSheet} from '@screens/navigation';
import {isTypeDMorGM} from '@utils/channel';
import {isDMChannel, isTypeDMorGM} from '@utils/channel';

type Props = {
channelId: string;
Expand Down Expand Up @@ -55,7 +55,8 @@ const ChannelActions = ({
}
}, [inModal]);

const isDM = isTypeDMorGM(channelType);
const isDMOrGM = isTypeDMorGM(channelType);
const isDM = isDMChannel(channelType);

return (
<View style={styles.wrapper}>
Expand All @@ -71,7 +72,7 @@ const ChannelActions = ({
testID={testID}
/>
<View style={styles.separator}/>
{isDM &&
{isDMOrGM &&
<SetHeaderBox
channelId={channelId}
inModal={inModal}
Expand All @@ -85,7 +86,7 @@ const ChannelActions = ({
testID={`${testID}.add_members.action`}
/>
}
{!isDM && !callsEnabled &&
{!isDMOrGM && !callsEnabled &&
<>
<View style={styles.separator}/>
<CopyChannelLinkBox
Expand All @@ -95,10 +96,10 @@ const ChannelActions = ({
/>
</>
}
{callsEnabled &&
{(!isDM && callsEnabled) &&
<>
<View style={styles.separator}/>
<ChannelInfoStartButton
<ChannelInfoStartCallButton
serverUrl={serverUrl}
channelId={channelId}
dismissChannelInfo={dismissChannelInfo}
Expand Down
25 changes: 23 additions & 2 deletions app/components/navigation_button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@ import {useMemo} from 'react';
import {Pressable, Text} from 'react-native';

import CompassIcon, {type CompassIconName} from '@components/compass_icon';
import Loading from '@components/loading';
import {useTheme} from '@context/theme';
import usePressableOpacityStyle from '@hooks/use_pressable_opacity';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';

export type NavigationButtonProps = {
id?: string;
accessibilityLabel?: string;
borderless?: boolean;
color?: string;
disabled?: boolean;
iconName?: CompassIconName;
iconSize?: number;
isLoading?: boolean;
text?: string;
count?: number | string;
onPress: () => void;
Expand All @@ -30,17 +34,21 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
enabled: {color: theme.sidebarHeaderTextColor},
disabled: {color: theme.sidebarHeaderTextColor, opacity: 0.32},
icon: {padding: 5},
loading: {padding: 5, alignItems: 'center', justifyContent: 'center'},
title: typography('Body', 200),
count: typography('Body', 200),
}));

function NavigationButton({
id,
accessibilityLabel,
borderless = true,
color,
count,
disabled,
iconName,
iconSize = 20,
isLoading = false,
onPress,
rippleRadius = 20,
testID,
Expand All @@ -50,21 +58,34 @@ function NavigationButton({
const styles = getStyleSheet(theme);

const colorStyle = useMemo(() => ({color: disabled ? changeOpacity(color ?? theme.sidebarHeaderTextColor, 0.32) : (color ?? theme.sidebarHeaderTextColor)}), [color, disabled, theme.sidebarHeaderTextColor]);

// Reserve the icon's footprint so the header doesn't shift when swapping to the spinner.
const loadingStyle = useMemo(() => [styles.loading, {width: iconSize + 10, height: iconSize + 10}], [styles.loading, iconSize]);
const ripple = useMemo(() => ({borderless, radius: rippleRadius}), [borderless, rippleRadius]);
const pressableStyle = usePressableOpacityStyle(styles.container);

return (
<Pressable
id={id}
accessibilityLabel={accessibilityLabel}
onPress={onPress}
disabled={disabled}
disabled={disabled || isLoading}
hitSlop={hitSlop}
testID={testID}
android_ripple={ripple}
style={pressableStyle}
>

{Boolean(text) && <Text style={[styles.title, colorStyle]}>{text}</Text>}
{Boolean(iconName) &&
{isLoading &&
<Loading
containerStyle={loadingStyle}
color={colorStyle.color}
size='small'
testID={testID ? `${testID}.loading` : undefined}
/>
}
{!isLoading && Boolean(iconName) &&
<CompassIcon
name={iconName!}
size={iconSize}
Expand Down
87 changes: 85 additions & 2 deletions app/components/navigation_header/header.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {render, within} from '@testing-library/react-native';
import {fireEvent, render, within} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';

import {Preferences} from '@constants';

import Header from './header';

import type {NavigationButtonProps} from '@components/navigation_button';

describe('Header', () => {
const getBaseProps = (): ComponentProps<typeof Header> => ({
defaultHeight: 0,
Expand All @@ -17,10 +19,11 @@ describe('Header', () => {
theme: Preferences.THEMES.denim,
});

it('right buttons are rendered with count', () => {
it('should render right buttons with count', () => {
const props = getBaseProps();
props.rightButtons = [
{
id: 'playbooks',
iconName: 'playlist-check',
count: 123,
onPress: jest.fn(),
Expand All @@ -34,6 +37,7 @@ describe('Header', () => {

props.rightButtons = [
{
id: 'playbooks',
iconName: 'playlist-check',
count: undefined,
onPress: jest.fn(),
Expand All @@ -47,5 +51,84 @@ describe('Header', () => {
expect(button).not.toHaveTextContent('0');
expect(button).not.toHaveTextContent('undefined');
});

it('should show a spinner and ignore presses on right buttons while loading', () => {
const props = getBaseProps();
const onPress = jest.fn();
const callButton: NavigationButtonProps = {
id: 'calls',
accessibilityLabel: 'Start call',
iconName: 'phone',
isLoading: true,
onPress,
testID: 'test-button',
};
props.rightButtons = [callButton];
const {getByTestId, queryByTestId, rerender} = render(<Header {...props}/>);

const button = getByTestId('test-button');
expect(button.props.accessibilityLabel).toBe('Start call');
expect(getByTestId('test-button.loading')).toBeOnTheScreen();

fireEvent.press(button);
expect(onPress).not.toHaveBeenCalled();

props.rightButtons = [{...callButton, isLoading: false}];
rerender(<Header {...props}/>);

expect(queryByTestId('test-button.loading')).toBeNull();
fireEvent.press(getByTestId('test-button'));
expect(onPress).toHaveBeenCalledTimes(1);
});

it('should not fire onPress on right buttons when disabled', () => {
const props = getBaseProps();
const onPress = jest.fn();
props.rightButtons = [
{
id: 'calls',
disabled: true,
iconName: 'phone',
onPress,
testID: 'test-button',
},
];
const {getByTestId} = render(<Header {...props}/>);

fireEvent.press(getByTestId('test-button'));
expect(onPress).not.toHaveBeenCalled();
});

it('should keep the right button pressable when its icon changes', () => {
const props = getBaseProps();
const onPress = jest.fn();
const callButton: NavigationButtonProps = {
id: 'calls',
iconName: 'phone',
onPress,
testID: 'call-button',
};
props.rightButtons = [
callButton,
{
id: 'channel-quick-actions',
iconName: 'dots-horizontal',
onPress: jest.fn(),
testID: 'quick-actions-button',
},
];
const {getByTestId, rerender} = render(<Header {...props}/>);

expect(getByTestId('call-button')).toBeOnTheScreen();
expect(getByTestId('quick-actions-button')).toBeOnTheScreen();

// A call starting swaps the icon; the button must stay usable so an in-flight press is not lost.
props.rightButtons = [{...callButton, iconName: 'phone-in-talk'}, props.rightButtons[1]];
rerender(<Header {...props}/>);

expect(getByTestId('quick-actions-button')).toBeOnTheScreen();
fireEvent.press(getByTestId('call-button'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});

6 changes: 5 additions & 1 deletion app/components/navigation_header/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,13 @@ const Header = ({
{Boolean(rightButtons?.length) &&
rightButtons?.map((r) => (
<NavigationButton
key={r.iconName}
id={r.id}
key={r.id}
accessibilityLabel={r.accessibilityLabel}
borderless={r.borderless}
disabled={r.disabled}
iconName={r.iconName}
isLoading={r.isLoading}
count={r.count}
onPress={r.onPress}
rippleRadius={r.rippleRadius}
Expand Down
5 changes: 5 additions & 0 deletions app/constants/calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const CALL_QUALITY_RESET_MS = toMilliseconds({minutes: 1});
const CAPTION_TIMEOUT = 5000;
const RING_LENGTH = 30000;

// How long the caller hears the outbound ringback tone before it auto-stops.
// The same window the callee's phone rings for, so both ends give up together.
const RINGBACK_TONE_TIMEOUT = RING_LENGTH;

export enum Ringtone {
Calm = 'Calm',
Dynamic = 'Dynamic',
Expand Down Expand Up @@ -60,5 +64,6 @@ export default {
JOB_TYPE_TRANSCRIBING,
JOB_TYPE_CAPTIONING,
RING_LENGTH,
RINGBACK_TONE_TIMEOUT,
RINGTONE_DEFAULT,
};
1 change: 1 addition & 0 deletions app/products/agents/screens/agent_chat/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const AgentChatHeader = ({title, subtitle, showSubtitleCompanion, onHistoryPress
}, [showSubtitleCompanion, theme.sidebarText]);

const rightButtons = useMemo<NavigationButtonProps[]>(() => [{
id: 'history',
iconName: 'clock-outline',
onPress: onHistoryPress,
testID: 'agent_chat.history_button',
Expand Down
Loading
Loading