Add Report a Problem functionality (#8605)

* Add Report a Problem functionality

* update cache pinned SHA version from 4.0.2 to 4.2.0

* Address feedback

* Fix tests

* Fix some issues and update kotlin coroutines version

* Fix delete file for iOS

* Bump 1 more version for coroutines

* Use rxjava instead of kotlin coroutines to avoid security issue

* Move path prefix to avoid test error

* Address feedback

* Address feedback

* Address feedback

* Use mailto on iOS

* Fix tests related to button changes

* Address feedback

* Update icon and fix onboarding buttons

* Fix test

---------

Co-authored-by: Angelos Kyratzakos <angelos.kyratzakos@mattermost.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Daniel Espino García 2025-04-24 11:12:55 +02:00 committed by GitHub
parent 3cbde1663d
commit 5c2153f83b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 3139 additions and 597 deletions

View file

@ -22,6 +22,7 @@ import NetworkManager from '@managers/network_manager';
import {getDeviceToken} from '@queries/app/global'; import {getDeviceToken} from '@queries/app/global';
import {getCurrentChannelId, getCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system'; import {getCurrentChannelId, getCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system';
import NavigationStore from '@store/navigation_store'; import NavigationStore from '@store/navigation_store';
import {logDebug} from '@utils/log';
import {entry, setExtraSessionProps, verifyPushProxy, entryInitialChannelId, restDeferredAppEntryActions, handleEntryAfterLoadNavigation, deferredAppEntryActions} from './common'; import {entry, setExtraSessionProps, verifyPushProxy, entryInitialChannelId, restDeferredAppEntryActions, handleEntryAfterLoadNavigation, deferredAppEntryActions} from './common';
@ -866,7 +867,6 @@ describe('actions/remote/entry/common', () => {
}); });
it('should handle error gracefully', async () => { it('should handle error gracefully', async () => {
const consoleDebugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
(DatabaseManager.getServerDatabaseAndOperator as jest.Mock).mockImplementation(() => { (DatabaseManager.getServerDatabaseAndOperator as jest.Mock).mockImplementation(() => {
throw new Error('Test error'); throw new Error('Test error');
}); });
@ -882,8 +882,7 @@ describe('actions/remote/entry/common', () => {
false, false,
); );
expect(consoleDebugSpy).toHaveBeenCalled(); expect(logDebug).toHaveBeenCalled();
consoleDebugSpy.mockRestore();
}); });
it('should handle tablet device scenario', async () => { it('should handle tablet device scenario', async () => {

View file

@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, render, within} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';
import {View, Text} from 'react-native';
import {Preferences} from '@constants';
import Button from './index';
describe('components/button', () => {
const getBaseProps = (): ComponentProps<typeof Button> => ({
onPress: jest.fn(),
text: 'Test Button',
theme: Preferences.THEMES.denim,
testID: 'test-button',
});
it('should render button with text', () => {
const props = getBaseProps();
const {getByText} = render(<Button {...props}/>);
expect(getByText('Test Button')).toBeTruthy();
});
it('should handle onPress', () => {
const props = getBaseProps();
const {getByTestId} = render(<Button {...props}/>);
fireEvent.press(getByTestId('test-button'));
expect(props.onPress).toHaveBeenCalled();
});
it('should render with icon', () => {
const props = getBaseProps();
props.iconName = 'close';
const {getByTestId} = render(<Button {...props}/>);
const icon = getByTestId('test-button-icon');
expect(icon).toBeTruthy();
expect(icon.props.name).toBe('close');
});
it('should render disabled button', () => {
const props = getBaseProps();
props.disabled = true;
const {getByTestId} = render(<Button {...props}/>);
fireEvent.press(getByTestId('test-button'));
expect(props.onPress).not.toHaveBeenCalled();
});
it('should render icon on the right', () => {
const props = getBaseProps();
props.isIconOnTheRight = false;
props.iconName = 'close';
const {getByTestId, rerender} = render(<Button {...props}/>);
const container = getByTestId('test-button-text-container');
// When icon is on the left, it should be the first child
expect(within(container.children[0]).getByTestId('test-button-icon')).toBeVisible();
props.isIconOnTheRight = true;
rerender(<Button {...props}/>);
// When icon is on the right, it should be the last child
expect(within(container.children[1]).getByTestId('test-button-icon')).toBeVisible();
});
it('should render custom icon component', () => {
const CustomIcon = () => (
<View testID='custom-icon'>
<Text>{'Custom Icon'}</Text>
</View>
);
const props = getBaseProps();
props.iconComponent = <CustomIcon/>;
const {getByTestId} = render(<Button {...props}/>);
expect(getByTestId('custom-icon')).toBeTruthy();
});
});

View file

@ -9,9 +9,7 @@ import CompassIcon from '@components/compass_icon';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {changeOpacity} from '@utils/theme'; import {changeOpacity} from '@utils/theme';
type ConditionalProps = | {iconName: string; iconSize: number} | {iconName?: never; iconSize?: never} type Props = {
type Props = ConditionalProps & {
theme: Theme; theme: Theme;
backgroundStyle?: StyleProp<ViewStyle>; backgroundStyle?: StyleProp<ViewStyle>;
buttonContainerStyle?: StyleProp<ViewStyle>; buttonContainerStyle?: StyleProp<ViewStyle>;
@ -26,19 +24,31 @@ type Props = ConditionalProps & {
iconComponent?: ReactNode; iconComponent?: ReactNode;
disabled?: boolean; disabled?: boolean;
hitSlop?: Insets; hitSlop?: Insets;
isIconOnTheRight?: boolean;
iconName?: string;
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: {flexDirection: 'row'}, container: {
icon: {marginRight: 7}, flexDirection: 'row',
gap: 7,
alignItems: 'center',
},
}); });
const iconSizePerSize: Record<ButtonSize, number> = {
xs: 14,
s: 14,
m: 18,
lg: 22,
};
const Button = ({ const Button = ({
theme, theme,
backgroundStyle, backgroundStyle,
buttonContainerStyle, buttonContainerStyle,
textStyle, textStyle,
size, size = 'm',
emphasis, emphasis,
buttonType, buttonType,
buttonState, buttonState,
@ -46,7 +56,7 @@ const Button = ({
text, text,
testID, testID,
iconName, iconName,
iconSize, isIconOnTheRight = false,
iconComponent, iconComponent,
disabled, disabled,
hitSlop, hitSlop,
@ -61,15 +71,6 @@ const Button = ({
textStyle, textStyle,
], [theme, textStyle, size, emphasis, buttonType]); ], [theme, textStyle, size, emphasis, buttonType]);
const textContainerStyle = useMemo(
() =>
(iconSize ? [
styles.container,
{minHeight: iconSize},
] : styles.container),
[iconSize],
);
let buttonStyle = StyleSheet.flatten(bgStyle); let buttonStyle = StyleSheet.flatten(bgStyle);
if (disabled) { if (disabled) {
buttonStyle = { buttonStyle = {
@ -83,13 +84,16 @@ const Button = ({
if (iconComponent) { if (iconComponent) {
icon = iconComponent; icon = iconComponent;
} else if (iconName) { } else if (iconName) {
// We wrap the icon in a view to avoid it to follow text layout
icon = ( icon = (
<CompassIcon <View>
name={iconName!} <CompassIcon
size={iconSize} name={iconName!}
color={StyleSheet.flatten(txtStyle).color} size={iconSizePerSize[size]}
style={styles.icon} color={StyleSheet.flatten(txtStyle).color}
/> testID={`${testID}-icon`}
/>
</View>
); );
} }
@ -102,14 +106,18 @@ const Button = ({
disabled={disabled} disabled={disabled}
hitSlop={hitSlop} hitSlop={hitSlop}
> >
<View style={textContainerStyle}> <View
{icon} style={styles.container}
testID={`${testID}-text-container`}
>
{!isIconOnTheRight && icon}
<Text <Text
style={txtStyle} style={[txtStyle]}
numberOfLines={1} numberOfLines={1}
> >
{text} {text}
</Text> </Text>
{isIconOnTheRight && icon}
</View> </View>
</ElementButton> </ElementButton>
); );

View file

@ -102,16 +102,12 @@ exports[`Loading Error should match snapshot 1`] = `
onStartShouldSetResponder={[Function]} onStartShouldSetResponder={[Function]}
style={ style={
{ {
"alignItems": "center",
"backgroundColor": "#ffffff", "backgroundColor": "#ffffff",
"borderRadius": 4, "borderRadius": 4,
"flex": 0,
"height": 48,
"justifyContent": "center",
"marginTop": 24, "marginTop": 24,
"opacity": 1, "opacity": 1,
"paddingHorizontal": 24, "paddingHorizontal": 24,
"paddingVertical": 14, "paddingVertical": 12,
} }
} }
> >
@ -119,17 +115,10 @@ exports[`Loading Error should match snapshot 1`] = `
style={ style={
[ [
{ {
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold", "fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
{
"fontSize": 16, "fontSize": 16,
"lineHeight": 18, "fontWeight": "600",
"marginTop": 1, "lineHeight": 24,
}, },
{ {
"color": "#1c58d9", "color": "#1c58d9",

View file

@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render} from '@testing-library/react-native';
import React from 'react';
import {Preferences} from '@constants';
import MenuDivider from './index';
describe('components/menu_divider', () => {
const theme = Preferences.THEMES.denim;
it('should render divider with default margins', () => {
const {root} = render(<MenuDivider/>);
expect(root).toHaveStyle({
marginTop: 8,
marginBottom: 8,
marginLeft: 0,
marginRight: 0,
});
});
it('should render divider with custom margins', () => {
const margins = {
marginTop: 16,
marginBottom: 24,
marginLeft: 12,
marginRight: 13,
};
const {root} = render(<MenuDivider {...margins}/>);
expect(root).toHaveStyle({
marginTop: 16,
marginBottom: 24,
marginLeft: 12,
marginRight: 13,
});
});
it('should apply correct theme styles', () => {
const {root} = render(<MenuDivider/>);
expect(root).toHaveStyle({
backgroundColor: theme.centerChannelColor,
height: 1,
opacity: 0.08,
});
});
});

View file

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {View} from 'react-native';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
divider: {
backgroundColor: theme.centerChannelColor,
height: 1,
opacity: 0.08,
},
}));
type Props = {
marginTop?: number;
marginBottom?: number;
marginLeft?: number;
marginRight?: number;
}
// Menu divider as per https://www.figma.com/design/ZV4NeVyUZoKfKRcRGyFJiT/Components---Mobile---Menu-Divider?node-id=1215-1535&t=qHU2DwCWm5cSbiq4-0
const MenuDivider = ({marginTop = 8, marginBottom = 8, marginLeft = 0, marginRight = 0}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const style = useMemo(() => [
styles.divider,
{marginTop, marginBottom, marginLeft, marginRight},
], [styles.divider, marginTop, marginBottom, marginLeft, marginRight]);
return (<View style={style}/>);
};
export default MenuDivider;

View file

@ -156,61 +156,50 @@ exports[`Section notice match snapshot 1`] = `
"borderColor": "#2089dc", "borderColor": "#2089dc",
"borderRadius": 4, "borderRadius": 4,
"borderWidth": 0, "borderWidth": 0,
"flex": 0,
"flexDirection": "row", "flexDirection": "row",
"height": 40,
"justifyContent": "center", "justifyContent": "center",
"padding": 8, "padding": 8,
"paddingHorizontal": 20, "paddingHorizontal": 20,
"paddingVertical": 12, "paddingVertical": 10,
} }
} }
> >
<View <View
style={ style={
[ {
{ "alignItems": "center",
"flexDirection": "row", "flexDirection": "row",
}, "gap": 7,
{
"minHeight": 18,
},
]
}
>
<Icon
color="#ffffff"
name="chevron-right"
size={18}
style={
{
"marginRight": 7,
}
} }
/> }
testID="undefined-text-container"
>
<View>
<Icon
color="#ffffff"
name="chevron-right"
size={18}
testID="undefined-icon"
/>
</View>
<Text <Text
numberOfLines={1} numberOfLines={1}
style={ style={
[ [
[ [
{ [
"alignItems": "center", {
"fontFamily": "OpenSans-SemiBold", "fontFamily": "OpenSans-SemiBold",
"fontWeight": "600", "fontSize": 14,
"justifyContent": "center", "fontWeight": "600",
"padding": 1, "lineHeight": 20,
"textAlignVertical": "center", },
}, {
{ "color": "#ffffff",
"fontSize": 14, },
"lineHeight": 14, ],
"marginTop": 3, undefined,
},
{
"color": "#ffffff",
},
], ],
undefined,
] ]
} }
> >
@ -278,61 +267,50 @@ exports[`Section notice match snapshot 1`] = `
"borderColor": "#2089dc", "borderColor": "#2089dc",
"borderRadius": 4, "borderRadius": 4,
"borderWidth": 0, "borderWidth": 0,
"flex": 0,
"flexDirection": "row", "flexDirection": "row",
"height": 40,
"justifyContent": "center", "justifyContent": "center",
"padding": 8, "padding": 8,
"paddingHorizontal": 20, "paddingHorizontal": 20,
"paddingVertical": 12, "paddingVertical": 10,
} }
} }
> >
<View <View
style={ style={
[ {
{ "alignItems": "center",
"flexDirection": "row", "flexDirection": "row",
}, "gap": 7,
{
"minHeight": 18,
},
]
}
>
<Icon
color="#1c58d9"
name="chevron-right"
size={18}
style={
{
"marginRight": 7,
}
} }
/> }
testID="undefined-text-container"
>
<View>
<Icon
color="#1c58d9"
name="chevron-right"
size={18}
testID="undefined-icon"
/>
</View>
<Text <Text
numberOfLines={1} numberOfLines={1}
style={ style={
[ [
[ [
{ [
"alignItems": "center", {
"fontFamily": "OpenSans-SemiBold", "fontFamily": "OpenSans-SemiBold",
"fontWeight": "600", "fontSize": 14,
"justifyContent": "center", "fontWeight": "600",
"padding": 1, "lineHeight": 20,
"textAlignVertical": "center", },
}, {
{ "color": "#1c58d9",
"fontSize": 14, },
"lineHeight": 14, ],
"marginTop": 3, undefined,
},
{
"color": "#1c58d9",
},
], ],
undefined,
] ]
} }
> >
@ -400,61 +378,50 @@ exports[`Section notice match snapshot 1`] = `
"borderColor": "#2089dc", "borderColor": "#2089dc",
"borderRadius": 4, "borderRadius": 4,
"borderWidth": 0, "borderWidth": 0,
"flex": 0,
"flexDirection": "row", "flexDirection": "row",
"height": 40,
"justifyContent": "center", "justifyContent": "center",
"padding": 8, "padding": 8,
"paddingHorizontal": 20, "paddingHorizontal": 20,
"paddingVertical": 12, "paddingVertical": 10,
} }
} }
> >
<View <View
style={ style={
[ {
{ "alignItems": "center",
"flexDirection": "row", "flexDirection": "row",
}, "gap": 7,
{
"minHeight": 18,
},
]
}
>
<Icon
color="#1c58d9"
name="chevron-right"
size={18}
style={
{
"marginRight": 7,
}
} }
/> }
testID="undefined-text-container"
>
<View>
<Icon
color="#1c58d9"
name="chevron-right"
size={18}
testID="undefined-icon"
/>
</View>
<Text <Text
numberOfLines={1} numberOfLines={1}
style={ style={
[ [
[ [
{ [
"alignItems": "center", {
"fontFamily": "OpenSans-SemiBold", "fontFamily": "OpenSans-SemiBold",
"fontWeight": "600", "fontSize": 14,
"justifyContent": "center", "fontWeight": "600",
"padding": 1, "lineHeight": 20,
"textAlignVertical": "center", },
}, {
{ "color": "#1c58d9",
"fontSize": 14, },
"lineHeight": 14, ],
"marginTop": 3, undefined,
},
{
"color": "#1c58d9",
},
], ],
undefined,
] ]
} }
> >

View file

@ -17,8 +17,8 @@ const SectionNoticeButton = ({
emphasis, emphasis,
}: ButtonProps) => { }: ButtonProps) => {
const theme = useTheme(); const theme = useTheme();
const leadingIcon = button.leadingIcon ? {iconName: button.leadingIcon, iconSize: 18} : {}; const leadingIcon = button.leadingIcon ? {iconName: button.leadingIcon} : {};
const trailingIcon = button.trailingIcon ? {iconName: button.trailingIcon, iconSize: 18} : {}; const trailingIcon = button.trailingIcon ? {iconName: button.trailingIcon} : {};
return ( return (
<Button <Button

View file

@ -196,7 +196,7 @@ export default function SelectedUsers({
USERS_CHIPS_MAX_HEIGHT, USERS_CHIPS_MAX_HEIGHT,
e.nativeEvent.layout.height, e.nativeEvent.layout.height,
); );
}, []); }, [usersChipsHeight]);
const androidMaxHeight = Platform.select({ const androidMaxHeight = Platform.select({
android: { android: {
@ -269,7 +269,6 @@ export default function SelectedUsers({
onPress={handlePress} onPress={handlePress}
iconName={buttonIcon} iconName={buttonIcon}
text={buttonText} text={buttonText}
iconSize={20}
theme={theme} theme={theme}
buttonType={isDisabled ? 'disabled' : 'default'} buttonType={isDisabled ? 'disabled' : 'default'}
emphasis={'primary'} emphasis={'primary'}

View file

@ -50,6 +50,7 @@ export const PINNED_MESSAGES = 'PinnedMessages';
export const POST_OPTIONS = 'PostOptions'; export const POST_OPTIONS = 'PostOptions';
export const POST_PRIORITY_PICKER = 'PostPriorityPicker'; export const POST_PRIORITY_PICKER = 'PostPriorityPicker';
export const REACTIONS = 'Reactions'; export const REACTIONS = 'Reactions';
export const REPORT_PROBLEM = 'ReportProblem';
export const RESCHEDULE_DRAFT = 'RescheduleDraft'; export const RESCHEDULE_DRAFT = 'RescheduleDraft';
export const REVIEW_APP = 'ReviewApp'; export const REVIEW_APP = 'ReviewApp';
export const SAVED_MESSAGES = 'SavedMessages'; export const SAVED_MESSAGES = 'SavedMessages';
@ -136,6 +137,7 @@ export default {
POST_OPTIONS, POST_OPTIONS,
POST_PRIORITY_PICKER, POST_PRIORITY_PICKER,
REACTIONS, REACTIONS,
REPORT_PROBLEM,
RESCHEDULE_DRAFT, RESCHEDULE_DRAFT,
REVIEW_APP, REVIEW_APP,
SAVED_MESSAGES, SAVED_MESSAGES,

View file

@ -1,14 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {Platform} from 'react-native';
import {SYSTEM_IDENTIFIERS} from '@constants/database'; import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager'; import DatabaseManager from '@database/manager';
import {observeCanDownloadFiles, observeCanUploadFiles} from './system'; import {observeCanDownloadFiles, observeCanUploadFiles, observeReportAProblemMetadata} from './system';
import type ServerDataOperator from '@database/operator/server_data_operator'; import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database} from '@nozbe/watermelondb'; import type {Database} from '@nozbe/watermelondb';
jest.mock('expo-application', () => {
return {
nativeApplicationVersion: '1.2.3',
nativeBuildVersion: '456',
};
});
describe('observeCanUploadFiles', () => { describe('observeCanUploadFiles', () => {
const serverUrl = 'baseHandler.test.com'; const serverUrl = 'baseHandler.test.com';
let database: Database; let database: Database;
@ -252,3 +261,68 @@ describe('observeCanDownloadFiles', () => {
}); });
}, 1500); }, 1500);
}); });
describe('observeReportAProblemMetadata', () => {
const serverUrl = 'baseHandler.test.com';
let database: Database;
let operator: ServerDataOperator;
let originalPlatform: typeof Platform.OS;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
operator = serverDatabaseAndOperator.operator;
originalPlatform = Platform.OS;
// @ts-expect-error Platform.OS is mocked
Platform.OS = 'somePlatform';
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
Platform.OS = originalPlatform;
});
it('should return correct metadata', (done) => {
operator.handleConfigs({
configs: [
{id: 'Version', value: '7.8.0'},
{id: 'BuildNumber', value: '123'},
],
prepareRecordsOnly: false,
configsToDelete: [],
}).then(() => {
operator.handleSystem({
systems: [
{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'true', Compliance: 'true'}},
{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'user1'},
{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: 'team1'},
],
prepareRecordsOnly: false,
}).then(() => {
observeReportAProblemMetadata(database).subscribe((data) => {
expect(data).toEqual({
currentUserId: 'user1',
currentTeamId: 'team1',
serverVersion: '7.8.0 (Build 123)',
appVersion: '1.2.3 (Build 456)',
appPlatform: 'somePlatform',
});
done();
});
});
});
});
it('should handle empty or undefined values', (done) => {
observeReportAProblemMetadata(database).subscribe((data) => {
expect(data).toEqual({
currentUserId: '',
currentTeamId: '',
serverVersion: 'Unknown (Build Unknown)',
appVersion: '1.2.3 (Build 456)',
appPlatform: 'somePlatform',
});
done();
});
});
});

View file

@ -2,6 +2,8 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb'; import {Database, Q} from '@nozbe/watermelondb';
import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application';
import {Platform} from 'react-native';
import {of as of$, Observable, combineLatest} from 'rxjs'; import {of as of$, Observable, combineLatest} from 'rxjs';
import {switchMap, distinctUntilChanged} from 'rxjs/operators'; import {switchMap, distinctUntilChanged} from 'rxjs/operators';
@ -624,3 +626,24 @@ const observeIsSelfHosterStarter = (database: Database) => {
); );
}; };
export const observeReportAProblemMetadata = (database: Database) => {
const currentUserId = observeCurrentUserId(database);
const currentTeamId = observeCurrentTeamId(database);
const serverVersion = observeConfigValue(database, 'Version');
const buildNumber = observeConfigValue(database, 'BuildNumber');
return combineLatest([
currentUserId,
currentTeamId,
serverVersion,
buildNumber,
]).pipe(
switchMap(([userId, teamId, version = 'Unknown', build = 'Unknown']) => of$({
currentUserId: userId,
currentTeamId: teamId,
serverVersion: `${version} (Build ${build})`,
appVersion: `${nativeApplicationVersion} (Build ${nativeBuildVersion})`,
appPlatform: Platform.OS,
})),
);
};

View file

@ -99,7 +99,7 @@ const ChannelBookmarkAddOrEdit = ({
const setBookmarkToSave = useCallback((b?: ChannelBookmark) => { const setBookmarkToSave = useCallback((b?: ChannelBookmark) => {
enableSaveButton((b?.type === 'link' && Boolean(b?.link_url)) || (b?.type === 'file' && Boolean(b.file_id))); enableSaveButton((b?.type === 'link' && Boolean(b?.link_url)) || (b?.type === 'file' && Boolean(b.file_id)));
setBookmark(b); setBookmark(b);
}, []); }, [enableSaveButton]);
const handleError = useCallback((error: string, buttons?: AlertButton[]) => { const handleError = useCallback((error: string, buttons?: AlertButton[]) => {
const title = original ? formatMessage({id: 'channel_bookmark.edit.failed_title', defaultMessage: 'Error editing bookmark'}) : formatMessage({id: 'channel_bookmark.add.failed_title', defaultMessage: 'Error adding bookmark'}); const title = original ? formatMessage({id: 'channel_bookmark.edit.failed_title', defaultMessage: 'Error editing bookmark'}) : formatMessage({id: 'channel_bookmark.add.failed_title', defaultMessage: 'Error adding bookmark'});
@ -115,7 +115,7 @@ const ChannelBookmarkAddOrEdit = ({
const enabled = Boolean(bookmark?.display_name && const enabled = Boolean(bookmark?.display_name &&
((bookmark?.type === 'link' && Boolean(bookmark?.link_url)) || (bookmark?.type === 'file' && Boolean(bookmark.file_id)))); ((bookmark?.type === 'link' && Boolean(bookmark?.link_url)) || (bookmark?.type === 'file' && Boolean(bookmark.file_id))));
enableSaveButton(enabled); enableSaveButton(enabled);
}, [bookmark, enableSaveButton, formatMessage]); }, [bookmark?.display_name, bookmark?.file_id, bookmark?.link_url, bookmark?.type, enableSaveButton, formatMessage, original]);
const close = useCallback(() => { const close = useCallback(() => {
return dismissModal({componentId}); return dismissModal({componentId});
@ -129,7 +129,7 @@ const ChannelBookmarkAddOrEdit = ({
} }
handleError((res.error as Error).message); handleError((res.error as Error).message);
}, [channelId, handleError, serverUrl]); }, [channelId, close, handleError, serverUrl]);
const updateBookmark = useCallback(async (b: ChannelBookmark) => { const updateBookmark = useCallback(async (b: ChannelBookmark) => {
const res = await editChannelBookmark(serverUrl, b); const res = await editChannelBookmark(serverUrl, b);
@ -139,7 +139,7 @@ const ChannelBookmarkAddOrEdit = ({
} }
handleError((res.error as Error).message); handleError((res.error as Error).message);
}, [handleError, serverUrl]); }, [close, handleError, serverUrl]);
const setLinkBookmark = useCallback((url: string, title: string, imageUrl: string) => { const setLinkBookmark = useCallback((url: string, title: string, imageUrl: string) => {
const b: ChannelBookmark = { const b: ChannelBookmark = {
@ -195,12 +195,12 @@ const ChannelBookmarkAddOrEdit = ({
if (emoji) { if (emoji) {
addRecentReaction(serverUrl, [emoji]); addRecentReaction(serverUrl, [emoji]);
} }
}, [bookmark, enableSaveButton, serverUrl]); }, [bookmark, enableSaveButton, original, serverUrl]);
const resetBookmark = useCallback(() => { const resetBookmark = useCallback(() => {
setBookmarkToSave(original); setBookmarkToSave(original);
setFile(originalFile); setFile(originalFile);
}, [setBookmarkToSave]); }, [original, originalFile, setBookmarkToSave]);
const onSaveBookmark = useCallback(async () => { const onSaveBookmark = useCallback(async () => {
if (bookmark) { if (bookmark) {
@ -213,7 +213,7 @@ const ChannelBookmarkAddOrEdit = ({
createBookmark(bookmark); createBookmark(bookmark);
} }
}, [bookmark, createBookmark, updateBookmark]); }, [bookmark, createBookmark, enableSaveButton, original, updateBookmark]);
const handleDelete = useCallback(async () => { const handleDelete = useCallback(async () => {
if (bookmark) { if (bookmark) {
@ -235,7 +235,7 @@ const ChannelBookmarkAddOrEdit = ({
close(); close();
} }
}, [bookmark, serverUrl, close]); }, [bookmark, enableSaveButton, serverUrl, close, formatMessage]);
const onDelete = useCallback(async () => { const onDelete = useCallback(async () => {
if (bookmark) { if (bookmark) {
@ -255,7 +255,7 @@ const ChannelBookmarkAddOrEdit = ({
}], }],
); );
} }
}, [bookmark, handleDelete]); }, [bookmark, formatMessage, handleDelete]);
useEffect(() => { useEffect(() => {
enableSaveButton(false); enableSaveButton(false);
@ -307,9 +307,7 @@ const ChannelBookmarkAddOrEdit = ({
size='m' size='m'
text='Delete bookmark' text='Delete bookmark'
iconName='trash-can-outline' iconName='trash-can-outline'
textStyle={styles.deleteText} emphasis='tertiary'
backgroundStyle={styles.deleteBg}
iconSize={18}
onPress={onDelete} onPress={onDelete}
theme={theme} theme={theme}
disabled={isSaving} disabled={isSaving}

View file

@ -12,7 +12,7 @@ import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text'; import FormattedText from '@components/formatted_text';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {preventDoubleTap} from '@utils/tap'; import {usePreventDoubleTap} from '@hooks/utils';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography'; import {typography} from '@utils/typography';
@ -57,9 +57,9 @@ const MutedBanner = ({channelId}: Props) => {
const theme = useTheme(); const theme = useTheme();
const styles = getStyleSheet(theme); const styles = getStyleSheet(theme);
const onPress = useCallback(preventDoubleTap(() => { const onPress = usePreventDoubleTap(useCallback(() => {
toggleMuteChannel(serverUrl, channelId, false); toggleMuteChannel(serverUrl, channelId, false);
}), [channelId, serverUrl]); }, [channelId, serverUrl]));
return ( return (
<Animated.View <Animated.View
@ -83,18 +83,18 @@ const MutedBanner = ({channelId}: Props) => {
defaultMessage='You can change the notification settings, but you will not receive notifications until the channel is unmuted.' defaultMessage='You can change the notification settings, but you will not receive notifications until the channel is unmuted.'
style={styles.contentText} style={styles.contentText}
/> />
<Button <View style={styles.button}>
buttonType='default' <Button
onPress={onPress} buttonType='default'
text={formatMessage({ onPress={onPress}
id: 'channel_notification_preferences.unmute_content', text={formatMessage({
defaultMessage: 'Unmute channel', id: 'channel_notification_preferences.unmute_content',
})} defaultMessage: 'Unmute channel',
theme={theme} })}
backgroundStyle={styles.button} theme={theme}
iconName='bell-outline' iconName='bell-outline'
iconSize={18} />
/> </View>
</Animated.View> </Animated.View>
); );
}; };

View file

@ -36,7 +36,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
color: theme.dndIndicator, color: theme.dndIndicator,
}, },
loadingContainerStyle: { loadingContainerStyle: {
marginRight: 10,
padding: 0, padding: 0,
top: -2, top: -2,
}, },

View file

@ -23,8 +23,7 @@ import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
import {alertFailedToOpenDocument} from '@utils/document'; import {alertFailedToOpenDocument} from '@utils/document';
import {getFullErrorMessage} from '@utils/errors'; import {getFullErrorMessage} from '@utils/errors';
import {fileExists, getLocalFilePathFromFile, hasWriteStoragePermission} from '@utils/file'; import {fileExists, getLocalFilePathFromFile, hasWriteStoragePermission, pathWithPrefix} from '@utils/file';
import {pathWithPrefix} from '@utils/files';
import {galleryItemToFileInfo} from '@utils/gallery'; import {galleryItemToFileInfo} from '@utils/gallery';
import {logDebug} from '@utils/log'; import {logDebug} from '@utils/log';
import {typography} from '@utils/typography'; import {typography} from '@utils/typography';

View file

@ -11,7 +11,6 @@ import Button from '@components/button';
import CompassIcon from '@components/compass_icon'; import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text'; import FormattedText from '@components/formatted_text';
import {Preferences} from '@constants'; import {Preferences} from '@constants';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {calculateDimensions} from '@utils/images'; import {calculateDimensions} from '@utils/images';
import {typography} from '@utils/typography'; import {typography} from '@utils/typography';
@ -121,9 +120,7 @@ const VideoError = ({filename, height, isDownloading, isRemote, onShouldHideCont
onPress={handleDownload} onPress={handleDownload}
theme={Preferences.THEMES.onyx} theme={Preferences.THEMES.onyx}
size={'lg'} size={'lg'}
textStyle={buttonTextStyle(Preferences.THEMES.onyx, 'lg', 'primary', isDownloading ? 'disabled' : 'default')}
text={intl.formatMessage({id: 'video.download', defaultMessage: 'Download video'})} text={intl.formatMessage({id: 'video.download', defaultMessage: 'Download video'})}
backgroundStyle={buttonBackgroundStyle(Preferences.THEMES.onyx, 'lg', 'primary', isDownloading ? 'disabled' : 'default')}
/> />
</View> </View>
} }

View file

@ -285,16 +285,12 @@ exports[`components/categories_list should render channels error 1`] = `
onStartShouldSetResponder={[Function]} onStartShouldSetResponder={[Function]}
style={ style={
{ {
"alignItems": "center",
"backgroundColor": "#ffffff", "backgroundColor": "#ffffff",
"borderRadius": 4, "borderRadius": 4,
"flex": 0,
"height": 48,
"justifyContent": "center",
"marginTop": 24, "marginTop": 24,
"opacity": 1, "opacity": 1,
"paddingHorizontal": 24, "paddingHorizontal": 24,
"paddingVertical": 14, "paddingVertical": 12,
} }
} }
> >
@ -302,17 +298,10 @@ exports[`components/categories_list should render channels error 1`] = `
style={ style={
[ [
{ {
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold", "fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
{
"fontSize": 16, "fontSize": 16,
"lineHeight": 18, "fontWeight": "600",
"marginTop": 1, "lineHeight": 24,
}, },
{ {
"color": "#1c58d9", "color": "#1c58d9",
@ -683,16 +672,12 @@ exports[`components/categories_list should render team error 1`] = `
onStartShouldSetResponder={[Function]} onStartShouldSetResponder={[Function]}
style={ style={
{ {
"alignItems": "center",
"backgroundColor": "#ffffff", "backgroundColor": "#ffffff",
"borderRadius": 4, "borderRadius": 4,
"flex": 0,
"height": 48,
"justifyContent": "center",
"marginTop": 24, "marginTop": 24,
"opacity": 1, "opacity": 1,
"paddingHorizontal": 24, "paddingHorizontal": 24,
"paddingVertical": 14, "paddingVertical": 12,
} }
} }
> >
@ -700,17 +685,10 @@ exports[`components/categories_list should render team error 1`] = `
style={ style={
[ [
{ {
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold", "fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
{
"fontSize": 16, "fontSize": 16,
"lineHeight": 18, "fontWeight": "600",
"marginTop": 1, "lineHeight": 24,
}, },
{ {
"color": "#1c58d9", "color": "#1c58d9",

196
app/screens/index.test.tsx Normal file
View file

@ -0,0 +1,196 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Provider as EMMProvider} from '@mattermost/react-native-emm';
import {render} from '@testing-library/react-native';
import React from 'react';
import {IntlProvider} from 'react-intl';
import {Platform, Text, View} from 'react-native';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
import {Navigation} from 'react-native-navigation';
import {SafeAreaProvider} from 'react-native-safe-area-context';
import {Screens} from '@constants';
import {withServerDatabase} from '@database/components';
import EditServer from './edit_server';
import InAppNotification from './in_app_notification';
import ReportProblem from './report_a_problem';
// Mock the dependencies
jest.mock('react-native-navigation', () => ({
Navigation: {
setLazyComponentRegistrator: jest.fn(),
registerComponent: jest.fn(),
},
}));
// Mock providers
jest.mock('react-intl', () => ({
...jest.requireActual('react-intl'),
IntlProvider: jest.fn(),
}));
jest.mocked(IntlProvider).mockImplementation((props) => (
<View
{...props}
testID='IntlProvider'
/>
) as any);
jest.mock('react-native-gesture-handler', () => ({
GestureHandlerRootView: jest.fn(),
}));
jest.mocked(GestureHandlerRootView).mockImplementation((props) => (
<View
{...props}
testID='GestureHandlerRootView'
/>
));
jest.mock('react-native-safe-area-context', () => ({
SafeAreaProvider: jest.fn(),
}));
jest.mocked(SafeAreaProvider).mockImplementation((props) => (
<View
{...props}
testID='SafeAreaProvider'
/>
));
jest.mock('@mattermost/react-native-emm', () => ({
Provider: jest.fn(),
}));
jest.mocked(EMMProvider).mockImplementation((props) => (
<View
{...props}
testID='EMMProvider'
/>
));
jest.mock('@database/components', () => ({
withServerDatabase: jest.fn(),
}));
jest.mocked(withServerDatabase).mockImplementation((Component) => function MockedWithServerDatabase(props: any) {
return <View testID='withDatabase'><Component {...props}/></View>;
});
// Mock some screen components
jest.mock('@screens/report_a_problem', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(ReportProblem).mockImplementation((props) => <Text {...props}>{Screens.REPORT_PROBLEM}</Text>);
jest.mock('@screens/edit_server', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(EditServer).mockImplementation((props) => <Text {...props}>{Screens.EDIT_SERVER}</Text>);
jest.mock('@screens/in_app_notification', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(InAppNotification).mockImplementation((props) => <Text {...props}>{Screens.IN_APP_NOTIFICATION}</Text>);
describe('Screen Registration', () => {
let registrator: (screenName: string) => void;
beforeAll(async () => {
await require('./index');
registrator = jest.mocked(Navigation.setLazyComponentRegistrator).mock.calls[0][0];
});
beforeEach(() => {
jest.clearAllMocks();
});
const ttcc: Array<[string, {
withServerDatabase: boolean;
withGestures: boolean;
withSafeAreaInsets: boolean;
withManagedConfig: boolean;
withIntl: boolean;
platform: string | undefined;
}]> = [
[
Screens.REPORT_PROBLEM,
{
withServerDatabase: true,
withGestures: true,
withSafeAreaInsets: true,
withManagedConfig: true,
withIntl: false,
platform: undefined,
},
],
[
Screens.EDIT_SERVER,
{
withServerDatabase: false,
withGestures: true,
withSafeAreaInsets: true,
withManagedConfig: true,
withIntl: true,
platform: undefined,
},
],
[
Screens.IN_APP_NOTIFICATION,
{
withServerDatabase: false,
withGestures: false,
withSafeAreaInsets: true,
withManagedConfig: false,
withIntl: false,
platform: 'ios',
},
],
[
Screens.IN_APP_NOTIFICATION,
{
withServerDatabase: false,
withGestures: false,
withSafeAreaInsets: false,
withManagedConfig: false,
withIntl: false,
platform: 'android',
},
],
];
it.each(ttcc)('register screen %s when requested', (screenName, testCase) => {
const originalPlatform = Platform.OS;
if (testCase.platform) {
Platform.OS = testCase.platform as any;
}
registrator(screenName);
expect(Navigation.registerComponent).toHaveBeenCalledWith(
screenName,
expect.any(Function),
);
const ResultingComponent = jest.mocked(Navigation.registerComponent).mock.calls[0][1]();
const {getByTestId, getByText} = render(<ResultingComponent componentId={screenName}/>);
const endComponent = getByText(screenName);
expect(endComponent).toBeDefined();
expect(endComponent.props.componentId).toBe(screenName);
if (testCase.withGestures) {
expect(getByTestId('GestureHandlerRootView')).toBeDefined();
}
if (testCase.withSafeAreaInsets) {
expect(getByTestId('SafeAreaProvider')).toBeDefined();
}
if (testCase.withManagedConfig) {
expect(getByTestId('EMMProvider')).toBeDefined();
}
if (testCase.withServerDatabase) {
expect(getByTestId('withDatabase')).toBeDefined();
}
Platform.OS = originalPlatform;
});
it('handles unknown screen names gracefully', () => {
registrator('UNKNOWN_SCREEN');
expect(Navigation.registerComponent).not.toHaveBeenCalled();
});
});

View file

@ -4,7 +4,7 @@
import {Provider as EMMProvider} from '@mattermost/react-native-emm'; import {Provider as EMMProvider} from '@mattermost/react-native-emm';
import React, {type ComponentType} from 'react'; import React, {type ComponentType} from 'react';
import {IntlProvider} from 'react-intl'; import {IntlProvider} from 'react-intl';
import {Platform, type StyleProp, type ViewStyle} from 'react-native'; import {Platform} from 'react-native';
import {GestureHandlerRootView} from 'react-native-gesture-handler'; import {GestureHandlerRootView} from 'react-native-gesture-handler';
import {Navigation} from 'react-native-navigation'; import {Navigation} from 'react-native-navigation';
import {SafeAreaProvider} from 'react-native-safe-area-context'; import {SafeAreaProvider} from 'react-native-safe-area-context';
@ -13,10 +13,10 @@ import {Screens} from '@constants';
import {withServerDatabase} from '@database/components'; import {withServerDatabase} from '@database/components';
import {DEFAULT_LOCALE, getTranslations} from '@i18n'; import {DEFAULT_LOCALE, getTranslations} from '@i18n';
const withGestures = (Screen: React.ComponentType, styles: StyleProp<ViewStyle>) => { const withGestures = (Screen: React.ComponentType) => {
return function gestureHoc(props: any) { return function gestureHoc(props: any) {
return ( return (
<GestureHandlerRootView style={[{flex: 1}, styles]}> <GestureHandlerRootView style={{flex: 1}}>
<Screen {...props}/> <Screen {...props}/>
</GestureHandlerRootView> </GestureHandlerRootView>
); );
@ -58,7 +58,6 @@ const withManagedConfig = (Screen: React.ComponentType) => {
Navigation.setLazyComponentRegistrator((screenName) => { Navigation.setLazyComponentRegistrator((screenName) => {
let screen: any|undefined; let screen: any|undefined;
let extraStyles: StyleProp<ViewStyle>;
switch (screenName) { switch (screenName) {
case Screens.ABOUT: case Screens.ABOUT:
screen = withServerDatabase(require('@screens/settings/about').default); screen = withServerDatabase(require('@screens/settings/about').default);
@ -68,10 +67,7 @@ Navigation.setLazyComponentRegistrator((screenName) => {
break; break;
case Screens.BOTTOM_SHEET: case Screens.BOTTOM_SHEET:
screen = withServerDatabase(require('@screens/bottom_sheet').default); screen = withServerDatabase(require('@screens/bottom_sheet').default);
Navigation.registerComponent(Screens.BOTTOM_SHEET, () => break;
withGestures(withSafeAreaInsets(withManagedConfig(screen)), undefined),
);
return;
case Screens.BROWSE_CHANNELS: case Screens.BROWSE_CHANNELS:
screen = withServerDatabase(require('@screens/browse_channels').default); screen = withServerDatabase(require('@screens/browse_channels').default);
break; break;
@ -199,6 +195,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.REACTIONS: case Screens.REACTIONS:
screen = withServerDatabase(require('@screens/reactions').default); screen = withServerDatabase(require('@screens/reactions').default);
break; break;
case Screens.REPORT_PROBLEM:
screen = withServerDatabase(require('@screens/report_a_problem').default);
break;
case Screens.RESCHEDULE_DRAFT: case Screens.RESCHEDULE_DRAFT:
screen = withServerDatabase(require('@screens/reschedule_draft').default); screen = withServerDatabase(require('@screens/reschedule_draft').default);
break; break;
@ -301,7 +300,7 @@ Navigation.setLazyComponentRegistrator((screenName) => {
} }
if (screen) { if (screen) {
Navigation.registerComponent(screenName, () => withGestures(withSafeAreaInsets(withManagedConfig(screen)), extraStyles)); Navigation.registerComponent(screenName, () => withGestures(withSafeAreaInsets(withManagedConfig(screen))));
} }
}); });
@ -309,7 +308,7 @@ export function registerScreens() {
const homeScreen = require('@screens/home').default; const homeScreen = require('@screens/home').default;
const serverScreen = require('@screens/server').default; const serverScreen = require('@screens/server').default;
const onboardingScreen = require('@screens/onboarding').default; const onboardingScreen = require('@screens/onboarding').default;
Navigation.registerComponent(Screens.ONBOARDING, () => withGestures(withIntl(withManagedConfig(onboardingScreen)), undefined)); Navigation.registerComponent(Screens.ONBOARDING, () => withGestures(withIntl(withManagedConfig(onboardingScreen))));
Navigation.registerComponent(Screens.SERVER, () => withSafeAreaInsets(withGestures(withIntl(withManagedConfig(serverScreen)), undefined))); Navigation.registerComponent(Screens.SERVER, () => withSafeAreaInsets(withGestures(withIntl(withManagedConfig(serverScreen)))));
Navigation.registerComponent(Screens.HOME, () => withGestures(withSafeAreaInsets(withServerDatabase(withManagedConfig(homeScreen))), undefined)); Navigation.registerComponent(Screens.HOME, () => withGestures(withSafeAreaInsets(withServerDatabase(withManagedConfig(homeScreen)))));
} }

View file

@ -22,6 +22,7 @@ type Props = {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
button: { button: {
marginTop: 5, marginTop: 5,
flex: 1,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
@ -37,9 +38,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
justifyContent: 'center', justifyContent: 'center',
width: 120, width: 120,
}, },
signInButtonText: {
flexDirection: 'row',
},
footerButtonsContainer: { footerButtonsContainer: {
flexDirection: 'column', flexDirection: 'column',
height: 120, height: 120,
@ -131,7 +129,7 @@ const FooterButtons = ({
); );
const signInButtonText = ( const signInButtonText = (
<Animated.View style={[styles.signInButtonText, opacitySignInTextStyle]}> <Animated.View style={opacitySignInTextStyle}>
<FormattedText <FormattedText
id='mobile.onboarding.sign_in_to_get_started' id='mobile.onboarding.sign_in_to_get_started'
defaultMessage='Sign in to get started' defaultMessage='Sign in to get started'

View file

@ -0,0 +1,165 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import TurboLogger from '@mattermost/react-native-turbo-log';
import RNUtils from '@mattermost/rnutils';
import {fireEvent, waitFor, waitForElementToBeRemoved} from '@testing-library/react-native';
import React from 'react';
import {Platform} from 'react-native';
import Share from 'react-native-share';
import {renderWithIntl} from '@test/intl-test-helper';
import {deleteFile} from '@utils/file';
import {logDebug} from '@utils/log';
import AppLogs from './app_logs';
jest.mock('react-native-share', () => ({
open: jest.fn(),
}));
jest.mock('@utils/file', () => ({
...jest.requireActual('@utils/file'),
deleteFile: jest.fn(),
}));
describe('screens/report_a_problem/app_logs', () => {
const file1 = 'log1.txt';
const file2 = 'log2.txt';
const logPaths = [`/path/to/${file1}`, `/path/to/${file2}`];
const zipPath = '/path/to/logs.zip';
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(TurboLogger.getLogPaths).mockResolvedValue(logPaths);
jest.mocked(RNUtils.createZipFile).mockResolvedValue(zipPath);
// React TouchableOpacity seems to be causing warnings about state
// changes outside of an act. This is a workaround to silence the warnings.
// We keep nextTick as it is needed for the tests to work.
jest.useFakeTimers({
doNotFake: [
'nextTick',
],
});
});
afterEach(() => {
Platform.OS = 'ios';
jest.useRealTimers();
});
it('renders loading state initially', async () => {
const {getByTestId, getByText} = renderWithIntl(
<AppLogs/>,
);
expect(getByTestId('logs-loading')).toBeTruthy();
expect(getByText('Download App Logs')).toBeDisabled();
// This removes a jest warning
await waitForElementToBeRemoved(() => getByTestId('logs-loading'));
});
it('renders log files after loading', async () => {
const {getByTestId, getByText} = renderWithIntl(
<AppLogs/>,
);
await waitForElementToBeRemoved(() => getByTestId('logs-loading'));
expect(getByText(/Logs_/)).toBeTruthy();
expect(getByText('Download App Logs')).toBeEnabled();
});
it('handles download on iOS', async () => {
Platform.OS = 'ios';
const {getByText} = renderWithIntl(
<AppLogs/>,
);
await waitFor(() => {
expect(TurboLogger.getLogPaths).toHaveBeenCalled();
});
const downloadButton = getByText('Download App Logs');
fireEvent.press(downloadButton);
await waitFor(() => {
expect(RNUtils.createZipFile).toHaveBeenCalledWith(logPaths);
expect(Share.open).toHaveBeenCalledWith({
url: 'file://' + zipPath,
saveToFiles: true,
});
expect(deleteFile).toHaveBeenCalledWith(zipPath);
});
});
it('handles download on Android', async () => {
Platform.OS = 'android';
const {getByText} = renderWithIntl(
<AppLogs/>,
);
await waitFor(() => {
expect(TurboLogger.getLogPaths).toHaveBeenCalled();
});
const downloadButton = getByText('Download App Logs');
fireEvent.press(downloadButton);
await waitFor(() => {
expect(RNUtils.createZipFile).toHaveBeenCalledWith(logPaths);
expect(RNUtils.saveFile).toHaveBeenCalledWith(zipPath);
expect(deleteFile).toHaveBeenCalledWith(zipPath);
});
});
it('handles errors during zip creation', async () => {
jest.mocked(RNUtils.createZipFile).mockRejectedValue(new Error('Zip failed'));
const {getByText} = renderWithIntl(
<AppLogs/>,
);
await waitFor(() => {
expect(TurboLogger.getLogPaths).toHaveBeenCalled();
});
const downloadButton = getByText('Download App Logs');
fireEvent.press(downloadButton);
await waitFor(() => {
expect(RNUtils.createZipFile).toHaveBeenCalled();
expect(logDebug).toHaveBeenCalledWith('Failed to create save file', new Error('Zip failed'));
expect(deleteFile).not.toHaveBeenCalled();
});
});
it('handles errors during file deletion', async () => {
jest.mocked(deleteFile).mockRejectedValue(new Error('Delete failed'));
const {getByText} = renderWithIntl(
<AppLogs/>,
);
await waitFor(() => {
expect(TurboLogger.getLogPaths).toHaveBeenCalled();
});
const downloadButton = getByText('Download App Logs');
fireEvent.press(downloadButton);
await waitFor(() => {
expect(RNUtils.createZipFile).toHaveBeenCalled();
expect(deleteFile).toHaveBeenCalled();
expect(logDebug).toHaveBeenCalledWith('Failed to delete zip file', new Error('Delete failed'));
});
});
it('handles empty log paths', async () => {
jest.mocked(TurboLogger.getLogPaths).mockResolvedValue([]);
const {getByText, getByTestId} = renderWithIntl(
<AppLogs/>,
);
await waitForElementToBeRemoved(() => getByTestId('logs-loading'));
expect(getByText('Download App Logs')).toBeDisabled();
});
});

View file

@ -0,0 +1,114 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import TurboLogger from '@mattermost/react-native-turbo-log';
import RNUtils from '@mattermost/rnutils';
import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {View, Text, ActivityIndicator, Platform} from 'react-native';
import Share from 'react-native-share';
import Button from '@components/button';
import {useTheme} from '@context/theme';
import {deleteFile, pathWithPrefix} from '@utils/file';
import {logDebug} from '@utils/log';
import {makeStyleSheetFromTheme} from '@utils/theme';
import LogFileItem from './log_file_item';
import {getCommonStyleSheet} from './styles';
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
...getCommonStyleSheet(theme),
buttonContainer: {
alignItems: 'flex-start',
},
container: {
gap: 16,
},
}));
const AppLogs = () => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const intl = useIntl();
const [logFiles, setLogFiles] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchLogs = async () => {
try {
const paths = await TurboLogger.getLogPaths();
setLogFiles(paths);
} finally {
setIsLoading(false);
}
};
fetchLogs();
}, []);
const handleDownload = useCallback(async () => {
let zipFilePath = '';
try {
zipFilePath = await RNUtils.createZipFile(logFiles);
if (Platform.OS === 'android') {
await RNUtils.saveFile(zipFilePath);
} else {
await Share.open({
url: pathWithPrefix('file://', zipFilePath),
saveToFiles: true,
});
}
} catch (error) {
logDebug('Failed to create save file', error);
}
if (zipFilePath) {
try {
await deleteFile(zipFilePath);
} catch (error) {
logDebug('Failed to delete zip file', error);
}
}
}, [logFiles]);
const hasLogs = logFiles.length > 0;
return (
<View style={styles.container}>
<Text style={styles.sectionTitle}>
{intl.formatMessage({
id: 'screen.report_problem.logs.title',
defaultMessage: 'APP LOGS:',
})}
</Text>
<View>
{isLoading ? (
<ActivityIndicator
testID='logs-loading'
color={theme.buttonBg}
size='large'
/>
) : (hasLogs && (
<LogFileItem/>
))}
</View>
<View style={styles.buttonContainer}>
<Button
onPress={handleDownload}
text={intl.formatMessage({
id: 'screen.report_problem.logs.download',
defaultMessage: 'Download App Logs',
})}
emphasis='tertiary'
theme={theme}
iconName='download-outline'
disabled={!hasLogs}
/>
</View>
</View>
);
};
export default AppLogs;

View file

@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Clipboard from '@react-native-clipboard/clipboard';
import {fireEvent} from '@testing-library/react-native';
import React from 'react';
import {SNACK_BAR_TYPE} from '@constants/snack_bar';
import {renderWithIntl} from '@test/intl-test-helper';
import {metadataToString} from '@utils/share_logs';
import {showSnackBar} from '@utils/snack_bar';
import CopyMetadata from './copy_metadata';
jest.mock('@react-native-clipboard/clipboard', () => ({
setString: jest.fn(),
}));
jest.mock('@utils/snack_bar', () => ({
showSnackBar: jest.fn(),
}));
describe('screens/report_a_problem/copy_metadata', () => {
const metadata = {
currentUserId: 'user1',
currentTeamId: 'team1',
serverVersion: '7.8.0',
appVersion: '2.0.0',
appPlatform: 'ios',
};
const componentId = 'ReportProblem';
beforeEach(() => {
jest.clearAllMocks();
});
it('renders all metadata fields', () => {
const {getByText} = renderWithIntl(
<CopyMetadata
metadata={metadata}
componentId={componentId}
/>,
);
// Check title
expect(getByText('METADATA:')).toBeTruthy();
// Check each metadata field is displayed
expect(getByText('Current User ID: user1')).toBeTruthy();
expect(getByText('Current Team ID: team1')).toBeTruthy();
expect(getByText('Server Version: 7.8.0')).toBeTruthy();
expect(getByText('App Version: 2.0.0')).toBeTruthy();
expect(getByText('App Platform: ios')).toBeTruthy();
});
it('copies metadata to clipboard when copy button is pressed', () => {
const {getByText} = renderWithIntl(
<CopyMetadata
metadata={metadata}
componentId={componentId}
/>,
);
const copyButton = getByText('Copy');
fireEvent.press(copyButton);
expect(Clipboard.setString).toHaveBeenCalledWith(
metadataToString(metadata),
);
expect(showSnackBar).toHaveBeenCalledWith({
barType: SNACK_BAR_TYPE.INFO_COPIED,
sourceScreen: componentId,
});
});
it('handles empty metadata', () => {
const emptyMetadata = {
currentUserId: '',
currentTeamId: '',
serverVersion: '',
appVersion: '',
appPlatform: '',
};
const {getByText} = renderWithIntl(
<CopyMetadata
metadata={emptyMetadata}
componentId={componentId}
/>,
);
// Check that empty values are displayed correctly
expect(getByText('Current User ID: ')).toBeTruthy();
expect(getByText('Current Team ID: ')).toBeTruthy();
expect(getByText('Server Version: ')).toBeTruthy();
expect(getByText('App Version: ')).toBeTruthy();
expect(getByText('App Platform: ')).toBeTruthy();
const copyButton = getByText('Copy');
fireEvent.press(copyButton);
expect(Clipboard.setString).toHaveBeenCalledWith(
metadataToString(emptyMetadata),
);
expect(showSnackBar).toHaveBeenCalledWith({
barType: SNACK_BAR_TYPE.INFO_COPIED,
sourceScreen: componentId,
});
});
});

View file

@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Clipboard from '@react-native-clipboard/clipboard';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {View, Text, StyleSheet} from 'react-native';
import Button from '@components/button';
import {SNACK_BAR_TYPE} from '@constants/snack_bar';
import {useTheme} from '@context/theme';
import {metadataToString, reportAProblemMessages} from '@utils/share_logs';
import {showSnackBar} from '@utils/snack_bar';
import {getCommonStyleSheet} from './styles';
import type {AvailableScreens} from '@typings/screens/navigation';
import type {ReportAProblemMetadata} from '@typings/screens/report_a_problem';
type Props = {
metadata: ReportAProblemMetadata;
componentId: AvailableScreens;
}
const styles = StyleSheet.create({
buttonContainer: {
alignItems: 'flex-start',
},
container: {
gap: 16,
},
});
const CopyMetadata = ({
metadata,
componentId,
}: Props) => {
const theme = useTheme();
const commonStyles = getCommonStyleSheet(theme);
const intl = useIntl();
const handleCopy = useCallback(() => {
Clipboard.setString(metadataToString(metadata));
showSnackBar({barType: SNACK_BAR_TYPE.INFO_COPIED, sourceScreen: componentId});
}, [componentId, metadata]);
return (
<View style={styles.container}>
<Text style={commonStyles.sectionTitle}>
{intl.formatMessage({
id: 'screen.report_problem.metadata.title',
defaultMessage: 'METADATA:',
})}
</Text>
<View>
{Object.entries(metadata).map(([key, value]) => (
<Text
key={key}
style={commonStyles.bodyText}
numberOfLines={1}
>
{`${intl.formatMessage(reportAProblemMessages[key as keyof Props['metadata']])}: ${value}`}
</Text>
))}
</View>
<View style={styles.buttonContainer}>
<Button
onPress={handleCopy}
emphasis='tertiary'
iconName='content-copy'
theme={theme}
text={intl.formatMessage({id: 'report_a_problem.metadata.copy', defaultMessage: 'Copy'})}
/>
</View>
</View>
);
};
export default CopyMetadata;

View file

@ -0,0 +1,158 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database} from '@nozbe/watermelondb';
import React, {type ComponentProps} from 'react';
import {View, Text} from 'react-native';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {renderWithEverything} from '@test/intl-test-helper';
import ReportProblem from './report_problem';
import enhanced from './index';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {ReportAProblemMetadata} from '@typings/screens/report_a_problem';
jest.mock('./report_problem', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(ReportProblem).mockImplementation((props) => {
return (
<View>
{Object.keys(props).map((key) => {
if (key === 'metadata') {
return Object.keys(props[key]).map((metadataKey) => (
<Text
key={metadataKey}
testID={metadataKey}
>{`${props.metadata[metadataKey as keyof ReportAProblemMetadata]}`}</Text>
));
}
return (
<Text
key={key}
testID={key}
>{`${props[key as keyof ComponentProps<typeof ReportProblem>]}`}</Text>
);
})}
</View>
);
});
describe('screens/report_a_problem/index', () => {
const serverUrl = 'baseHandler.test.com';
let database: Database;
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
operator = serverDatabaseAndOperator.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
it('should handle default values', async () => {
const Component = enhanced;
const {getByTestId} = renderWithEverything(<Component componentId={'ReportProblem'}/>, {database});
expect(getByTestId('currentUserId')).toHaveTextContent('');
expect(getByTestId('currentTeamId')).toHaveTextContent('');
expect(getByTestId('serverVersion')).toHaveTextContent('Unknown (Build Unknown)');
expect(getByTestId('appVersion')).toHaveTextContent('0.0.0 (Build 0)');
expect(getByTestId('appPlatform')).toHaveTextContent('ios');
expect(getByTestId('reportAProblemType')).toHaveTextContent('undefined');
expect(getByTestId('reportAProblemMail')).toHaveTextContent('undefined');
expect(getByTestId('reportAProblemLink')).toHaveTextContent('undefined');
expect(getByTestId('siteName')).toHaveTextContent('undefined');
expect(getByTestId('allowDownloadLogs')).toHaveTextContent('true');
expect(getByTestId('isLicensed')).toHaveTextContent('false');
});
it('should enhance ReportProblem with correct observables', async () => {
await operator.handleConfigs({
configs: [
{id: 'ReportAProblemType', value: 'email'},
{id: 'ReportAProblemMail', value: 'test@example.com'},
{id: 'ReportAProblemLink', value: 'https://example.com'},
{id: 'SiteName', value: 'Test Site'},
{id: 'AllowDownloadLogs', value: 'true'},
{id: 'Version', value: '7.8.0'},
{id: 'BuildNumber', value: '123'},
],
prepareRecordsOnly: false,
configsToDelete: [],
});
await operator.handleSystem({
systems: [
{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'false'}},
{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'user1'},
{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: 'team1'},
],
prepareRecordsOnly: false,
});
const Component = enhanced;
const {getByTestId} = renderWithEverything(<Component componentId={'ReportProblem'}/>, {database});
expect(getByTestId('currentUserId')).toHaveTextContent('user1');
expect(getByTestId('currentTeamId')).toHaveTextContent('team1');
expect(getByTestId('serverVersion')).toHaveTextContent('7.8.0 (Build 123)');
expect(getByTestId('appVersion')).toHaveTextContent('0.0.0 (Build 0)');
expect(getByTestId('appPlatform')).toHaveTextContent('ios');
expect(getByTestId('reportAProblemType')).toHaveTextContent('email');
expect(getByTestId('reportAProblemMail')).toHaveTextContent('test@example.com');
expect(getByTestId('reportAProblemLink')).toHaveTextContent('https://example.com');
expect(getByTestId('siteName')).toHaveTextContent('Test Site');
expect(getByTestId('allowDownloadLogs')).toHaveTextContent('true');
expect(getByTestId('isLicensed')).toHaveTextContent('false');
});
it('different data should show different values', async () => {
await operator.handleConfigs({
configs: [
{id: 'ReportAProblemType', value: 'link'},
{id: 'ReportAProblemMail', value: 'test2@example.com'},
{id: 'ReportAProblemLink', value: 'https://example2.com'},
{id: 'SiteName', value: 'Test Site2'},
{id: 'AllowDownloadLogs', value: 'false'},
{id: 'Version', value: '7.8.1'},
{id: 'BuildNumber', value: '124'},
],
prepareRecordsOnly: false,
configsToDelete: [],
});
await operator.handleSystem({
systems: [
{id: SYSTEM_IDENTIFIERS.LICENSE, value: {IsLicensed: 'false'}},
{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'user2'},
{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: 'team2'},
],
prepareRecordsOnly: false,
});
const Component = enhanced;
const {getByTestId} = renderWithEverything(<Component componentId={'ReportProblem'}/>, {database});
expect(getByTestId('currentUserId')).toHaveTextContent('user2');
expect(getByTestId('currentTeamId')).toHaveTextContent('team2');
expect(getByTestId('serverVersion')).toHaveTextContent('7.8.1 (Build 124)');
expect(getByTestId('appVersion')).toHaveTextContent('0.0.0 (Build 0)');
expect(getByTestId('appPlatform')).toHaveTextContent('ios');
expect(getByTestId('reportAProblemType')).toHaveTextContent('link');
expect(getByTestId('reportAProblemMail')).toHaveTextContent('test2@example.com');
expect(getByTestId('reportAProblemLink')).toHaveTextContent('https://example2.com');
expect(getByTestId('siteName')).toHaveTextContent('Test Site2');
expect(getByTestId('allowDownloadLogs')).toHaveTextContent('false');
expect(getByTestId('isLicensed')).toHaveTextContent('false');
});
});

View file

@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import {withObservables} from '@nozbe/watermelondb/react';
import {switchMap} from '@nozbe/watermelondb/utils/rx';
import {of as of$} from 'rxjs';
import {observeConfigBooleanValue, observeConfigValue, observeLicense, observeReportAProblemMetadata} from '@queries/servers/system';
import ReportProblem from './report_problem';
const enhanced = withObservables([], ({database}) => {
return {
reportAProblemType: observeConfigValue(database, 'ReportAProblemType'),
reportAProblemMail: observeConfigValue(database, 'ReportAProblemMail'),
reportAProblemLink: observeConfigValue(database, 'ReportAProblemLink'),
siteName: observeConfigValue(database, 'SiteName'),
allowDownloadLogs: observeConfigBooleanValue(database, 'AllowDownloadLogs', true),
isLicensed: observeLicense(database).pipe(switchMap((license) => (license ? of$(license.IsLicensed) : of$(false)))),
metadata: observeReportAProblemMetadata(database),
};
});
export default withDatabase(enhanced(ReportProblem));

View file

@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render, waitFor} from '@testing-library/react-native';
import React from 'react';
import LogFileItem from './log_file_item';
jest.mock('@utils/file', () => ({
...jest.requireActual('@utils/file'),
getFileSize: jest.fn(),
}));
describe('screens/report_a_problem/log_file_item', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders log file item with correct filename', async () => {
const {getByText} = render(
<LogFileItem/>,
);
await waitFor(() => {
expect(getByText(/Logs_/)).toBeTruthy();
});
});
it('displays file type', async () => {
const {getByText} = render(
<LogFileItem/>,
);
await waitFor(() => {
expect(getByText('ZIP')).toBeTruthy();
});
});
it('displays file icon', async () => {
const {getByTestId} = render(
<LogFileItem/>,
);
await waitFor(() => {
const icon = getByTestId('log-file-icon');
expect(icon.props.name).toBe('file-zip-outline-large');
});
});
});

View file

@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View, Text} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {getCommonFileStyles} from './styles';
const getCurrentDate = () => {
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
};
const LogFileItem = () => {
const theme = useTheme();
const styles = getCommonFileStyles(theme);
const currentDate = getCurrentDate();
return (
<View style={styles.container}>
<CompassIcon
name='file-zip-outline-large'
size={40}
color={theme.centerChannelColor}
testID='log-file-icon'
/>
<View style={styles.header}>
<Text style={styles.name}>
{`Logs_${currentDate}`}
</Text>
<Text style={styles.type}>
{'ZIP'}
</Text>
</View>
</View>
);
};
export default LogFileItem;

View file

@ -0,0 +1,237 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, fireEvent} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';
import {View} from 'react-native';
import {Screens} from '@constants';
import {renderWithIntl} from '@test/intl-test-helper';
import {logDebug} from '@utils/log';
import {emailLogs, getDefaultReportAProblemLink, shareLogs} from '@utils/share_logs';
import {tryOpenURL} from '@utils/url';
import AppLogs from './app_logs';
import ReportProblem from './report_problem';
jest.mock('@utils/share_logs', () => ({
...jest.requireActual('@utils/share_logs'),
shareLogs: jest.fn(),
emailLogs: jest.fn(),
getDefaultReportAProblemLink: jest.fn().mockReturnValue('default-link'),
}));
jest.mock('@utils/url', () => ({
tryOpenURL: jest.fn(),
}));
jest.mock('@screens/navigation', () => ({
popTopScreen: jest.fn(),
}));
// We mock the app logs to simplify the testing and avoid
// warnings about updating component state outside of an act
jest.mock('@screens/report_a_problem/app_logs', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(AppLogs).mockImplementation(() => <View testID='app-logs'/>);
describe('screens/report_a_problem/report_problem', () => {
const baseProps: ComponentProps<typeof ReportProblem> = {
componentId: Screens.REPORT_PROBLEM,
allowDownloadLogs: true,
isLicensed: true,
metadata: {
currentUserId: 'user1',
currentTeamId: 'team1',
serverVersion: '7.8.0',
appVersion: '2.0.0',
appPlatform: 'ios',
},
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders with logs section when allowDownloadLogs is true', () => {
const {getByText, getByTestId} = renderWithIntl(
<ReportProblem {...baseProps}/>,
);
expect(getByText('Troubleshouting details')).toBeTruthy();
expect(getByText('When reporting a problem, share the metadata and app logs given below to help troubleshoot your problem faster')).toBeTruthy();
expect(getByTestId('app-logs')).toBeVisible();
});
it('renders without logs section when allowDownloadLogs is false', () => {
const props = {...baseProps, allowDownloadLogs: false};
const {getByText, queryByTestId} = renderWithIntl(
<ReportProblem {...props}/>,
);
expect(getByText('When reporting a problem, share the metadata given below to help troubleshoot your problem faster')).toBeTruthy();
expect(queryByTestId('app-logs')).not.toBeVisible();
});
it('handles email report type and allows sharing logs', async () => {
const props = {
...baseProps,
reportAProblemType: 'email',
reportAProblemMail: 'test@example.com',
siteName: 'Test Site',
allowDownloadLogs: true,
};
const {getByText} = renderWithIntl(
<ReportProblem {...props}/>,
);
await act(async () => {
fireEvent.press(getByText('Report a problem'));
expect(emailLogs).toHaveBeenCalledWith(
props.metadata,
props.siteName,
props.reportAProblemMail,
false,
);
});
});
it('handles email report type and does not allow downloading logs', async () => {
const props = {
...baseProps,
reportAProblemType: 'email',
reportAProblemMail: 'test@example.com',
siteName: 'Test Site',
allowDownloadLogs: false,
};
const {getByText} = renderWithIntl(
<ReportProblem {...props}/>,
);
await act(async () => {
fireEvent.press(getByText('Report a problem'));
expect(emailLogs).toHaveBeenCalledWith(
props.metadata,
props.siteName,
props.reportAProblemMail,
true,
);
});
});
it('handles link report type', async () => {
const props = {
...baseProps,
reportAProblemType: 'link',
reportAProblemLink: 'https://example.com/report',
};
const {getByText} = renderWithIntl(
<ReportProblem {...props}/>,
);
await act(async () => {
fireEvent.press(getByText('Report a problem'));
expect(tryOpenURL).toHaveBeenCalledWith(props.reportAProblemLink);
});
});
it('handles missing report link', async () => {
const props = {
...baseProps,
reportAProblemType: 'link',
reportAProblemLink: undefined,
};
const {getByText} = renderWithIntl(
<ReportProblem {...props}/>,
);
await act(async () => {
fireEvent.press(getByText('Report a problem'));
expect(logDebug).toHaveBeenCalledWith('Report a problem link is not set');
expect(tryOpenURL).toHaveBeenCalledWith('default-link');
});
});
it('handles default report type when licensed', async () => {
const props = {
...baseProps,
reportAProblemType: 'default',
isLicensed: true,
};
const {getByText} = renderWithIntl(
<ReportProblem {...props}/>,
);
await act(async () => {
fireEvent.press(getByText('Report a problem'));
expect(getDefaultReportAProblemLink).toHaveBeenCalledWith(true);
expect(tryOpenURL).toHaveBeenCalledWith('default-link');
});
});
it('handles default report type when not licensed', async () => {
const props = {
...baseProps,
reportAProblemType: 'default',
isLicensed: false,
};
const {getByText} = renderWithIntl(
<ReportProblem {...props}/>,
);
await act(async () => {
fireEvent.press(getByText('Report a problem'));
expect(getDefaultReportAProblemLink).toHaveBeenCalledWith(false);
expect(tryOpenURL).toHaveBeenCalledWith('default-link');
});
});
it('handles legacy behavior when reportAProblemType is not defined', async () => {
const props = {
...baseProps,
reportAProblemType: undefined,
reportAProblemLink: 'https://example.com/report',
};
const {getByText} = renderWithIntl(
<ReportProblem {...props}/>,
);
await act(async () => {
fireEvent.press(getByText('Report a problem'));
expect(tryOpenURL).toHaveBeenCalledWith(props.reportAProblemLink);
});
});
it('handles legacy behavior with no link', async () => {
const props = {
...baseProps,
reportAProblemType: undefined,
reportAProblemLink: undefined,
reportAProblemMail: 'test@example.com',
siteName: 'Test Site',
};
const {getByText} = renderWithIntl(
<ReportProblem {...props}/>,
);
await act(async () => {
fireEvent.press(getByText('Report a problem'));
expect(shareLogs).toHaveBeenCalledWith(
props.metadata,
props.siteName,
undefined,
false,
);
});
});
});

View file

@ -0,0 +1,171 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {View, Text, ScrollView} from 'react-native';
import Button from '@components/button';
import MenuDivider from '@components/menu_divider';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {popTopScreen} from '@screens/navigation';
import {logDebug} from '@utils/log';
import {emailLogs, getDefaultReportAProblemLink, shareLogs} from '@utils/share_logs';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {tryOpenURL} from '@utils/url';
import AppLogs from './app_logs';
import CopyMetadata from './copy_metadata';
import {getCommonStyleSheet} from './styles';
import type {AvailableScreens} from '@typings/screens/navigation';
import type {ReportAProblemMetadata} from '@typings/screens/report_a_problem';
export const REPORT_PROBLEM_CLOSE_BUTTON_ID = 'close-report-problem';
type Props = {
componentId: AvailableScreens;
reportAProblemMail?: string;
reportAProblemLink?: string;
siteName?: string;
allowDownloadLogs: boolean;
reportAProblemType?: string;
isLicensed: boolean;
metadata: ReportAProblemMetadata;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
...getCommonStyleSheet(theme),
container: {
flex: 1,
backgroundColor: theme.centerChannelBg,
paddingVertical: 20,
gap: 20,
},
body: {
paddingHorizontal: 20,
},
content: {
gap: 16,
},
detailsTitle: {
...typography('Heading', 200, 'SemiBold'),
color: theme.centerChannelColor,
},
detailsSection: {
gap: 8,
},
buttonContainer: {
paddingHorizontal: 20,
marginTop: 'auto',
borderTopWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.08),
width: '100%',
paddingTop: 20,
},
}));
const ReportProblem = ({
componentId,
reportAProblemMail,
reportAProblemLink,
siteName,
allowDownloadLogs,
reportAProblemType,
isLicensed,
metadata,
}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const intl = useIntl();
const handleReport = useCallback(async () => {
switch (reportAProblemType) {
case 'email':
await emailLogs(metadata, siteName, reportAProblemMail, !allowDownloadLogs);
return;
case 'link': {
let linkToUse = reportAProblemLink;
if (!linkToUse) {
logDebug('Report a problem link is not set');
linkToUse = getDefaultReportAProblemLink(isLicensed);
}
tryOpenURL(linkToUse);
return;
}
case 'default': {
tryOpenURL(getDefaultReportAProblemLink(isLicensed));
return;
}
}
// Old servers where reportAProblemType is not defined
if (!reportAProblemLink) {
await shareLogs(metadata, siteName, undefined, false);
return;
}
tryOpenURL(reportAProblemLink);
}, [reportAProblemType, reportAProblemLink, reportAProblemMail, metadata, siteName, allowDownloadLogs, isLicensed]);
const close = useCallback(() => {
popTopScreen(componentId);
}, [componentId]);
useAndroidHardwareBackHandler(componentId, close);
const descriptionText = allowDownloadLogs ? intl.formatMessage({
id: 'screen.report_problem.details.description',
defaultMessage: 'When reporting a problem, share the metadata and app logs given below to help troubleshoot your problem faster',
}) : intl.formatMessage({
id: 'screen.report_problem.details.description_without_logs',
defaultMessage: 'When reporting a problem, share the metadata given below to help troubleshoot your problem faster',
});
return (
<View style={styles.container}>
<View style={styles.body}>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.detailsSection}>
<Text style={styles.detailsTitle}>
{intl.formatMessage({
id: 'screen.report_problem.details.title',
defaultMessage: 'Troubleshouting details',
})}
</Text>
<Text style={styles.bodyText}>
{descriptionText}
</Text>
</View>
<MenuDivider/>
<CopyMetadata
metadata={metadata}
componentId={componentId}
/>
{allowDownloadLogs && (
<>
<MenuDivider/>
<AppLogs/>
</>
)}
</ScrollView>
</View>
{reportAProblemType !== 'hidden' && (
<View style={styles.buttonContainer}>
<Button
theme={theme}
text={intl.formatMessage({
id: 'screen.report_problem.button',
defaultMessage: 'Report a problem',
})}
onPress={handleReport}
iconName='open-in-new'
size='lg'
isIconOnTheRight={true}
/>
</View>
)}
</View>
);
};
export default ReportProblem;

View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
export const getCommonStyleSheet = makeStyleSheetFromTheme((theme) => ({
bodyText: {
...typography('Body', 200),
color: theme.centerChannelColor,
},
sectionTitle: {
...typography('Body', 75, 'SemiBold'),
color: theme.centerChannelColor,
opacity: 0.64,
textTransform: 'uppercase',
},
}));
export const getCommonFileStyles = makeStyleSheetFromTheme((theme) => ({
container: {
backgroundColor: theme.centerChannelBg,
borderRadius: 4,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
padding: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
header: {
flex: 1,
},
name: {
...typography('Body', 100, 'SemiBold'),
color: theme.centerChannelColor,
},
type: {
...typography('Body', 75),
color: changeOpacity(theme.centerChannelColor, 0.64),
},
}));

View file

@ -18,11 +18,10 @@ import {SNACK_BAR_TYPE} from '@constants/snack_bar';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {usePreventDoubleTap} from '@hooks/utils';
import {t} from '@i18n'; import {t} from '@i18n';
import {popTopScreen} from '@screens/navigation'; import {popTopScreen} from '@screens/navigation';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {showSnackBar} from '@utils/snack_bar'; import {showSnackBar} from '@utils/snack_bar';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography'; import {typography} from '@utils/typography';
import {tryOpenURL} from '@utils/url'; import {tryOpenURL} from '@utils/url';
@ -153,27 +152,27 @@ const About = ({componentId, config, license}: AboutProps) => {
}; };
tryOpenURL(url, onError); tryOpenURL(url, onError);
}, []); }, [intl]);
const handleAboutTeam = useCallback(preventDoubleTap(() => { const handleAboutTeam = usePreventDoubleTap(useCallback(() => {
return openURL(Config.WebsiteURL); return openURL(Config.WebsiteURL);
}), []); }, [openURL]));
const handlePlatformNotice = useCallback(preventDoubleTap(() => { const handlePlatformNotice = usePreventDoubleTap(useCallback(() => {
return openURL(Config.ServerNoticeURL); return openURL(Config.ServerNoticeURL);
}), []); }, [openURL]));
const handleMobileNotice = useCallback(preventDoubleTap(() => { const handleMobileNotice = usePreventDoubleTap(useCallback(() => {
return openURL(Config.MobileNoticeURL); return openURL(Config.MobileNoticeURL);
}), []); }, [openURL]));
const handleTermsOfService = useCallback(preventDoubleTap(() => { const handleTermsOfService = usePreventDoubleTap(useCallback(() => {
return openURL(AboutLinks.TERMS_OF_SERVICE); return openURL(AboutLinks.TERMS_OF_SERVICE);
}), []); }, [openURL]));
const handlePrivacyPolicy = useCallback(preventDoubleTap(() => { const handlePrivacyPolicy = usePreventDoubleTap(useCallback(() => {
return openURL(AboutLinks.PRIVACY_POLICY); return openURL(AboutLinks.PRIVACY_POLICY);
}), []); }, [openURL]));
const serverVersion = useMemo(() => { const serverVersion = useMemo(() => {
const buildNumber = config.BuildNumber; const buildNumber = config.BuildNumber;
@ -210,7 +209,7 @@ const About = ({componentId, config, license}: AboutProps) => {
Clipboard.setString(copiedString); Clipboard.setString(copiedString);
showSnackBar({barType: SNACK_BAR_TYPE.INFO_COPIED, sourceScreen: componentId}); showSnackBar({barType: SNACK_BAR_TYPE.INFO_COPIED, sourceScreen: componentId});
}, },
[intl, config, loadMetric], [intl, config.BuildNumber, config.Version, config.SQLDriverName, config.SchemaVersion, loadMetric, componentId],
); );
return ( return (
@ -306,17 +305,17 @@ const About = ({componentId, config, license}: AboutProps) => {
{config.SchemaVersion} {config.SchemaVersion}
</Text> </Text>
</View> </View>
<Button <View style={styles.copyInfoButtonContainer}>
theme={theme} <Button
backgroundStyle={[buttonBackgroundStyle(theme, 'm', 'tertiary'), styles.copyInfoButtonContainer]} theme={theme}
onPress={copyToClipboard} onPress={copyToClipboard}
textStyle={buttonTextStyle(theme, 'm', 'tertiary', 'default')} text={intl.formatMessage({id: 'settings.about.button.copyInfo', defaultMessage: 'Copy info'})}
text={intl.formatMessage({id: 'settings.about.button.copyInfo', defaultMessage: 'Copy info'})} testID={'about.copy_info'}
testID={'about.copy_info'} iconName='content-copy'
iconName='content-copy' emphasis='tertiary'
iconSize={15} size='m'
buttonType={'default'} />
/> </View>
{license.IsLicensed === 'true' && ( {license.IsLicensed === 'true' && (
<View style={styles.licenseContainer}> <View style={styles.licenseContainer}>
<FormattedText <FormattedText

View file

@ -1,12 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {t} from '@i18n'; import {defineMessages, type IntlShape} from 'react-intl';
import {goToScreen} from '@screens/navigation'; import {goToScreen} from '@screens/navigation';
import {typography} from '@utils/typography'; import {typography} from '@utils/typography';
import type {AvailableScreens} from '@typings/screens/navigation'; import type {AvailableScreens} from '@typings/screens/navigation';
import type {IntlShape} from 'react-intl';
export const getSaveButton = (buttonId: string, intl: IntlShape, color: string) => ({ export const getSaveButton = (buttonId: string, intl: IntlShape, color: string) => ({
color, color,
@ -37,42 +37,109 @@ type SettingConfigDetails = {
testID?: string; testID?: string;
} }
export const SettingOptionConfig: Record<string, SettingConfigDetails> = { const messages = defineMessages({
notification: { download_logs: {
defaultMessage: 'Notifications', defaultMessage: 'Download app logs',
i18nId: t('general_settings.notifications'), id: 'general_settings.download_logs',
icon: 'bell-outline',
testID: 'general_settings.notifications',
},
display: {
defaultMessage: 'Display',
i18nId: t('general_settings.display'),
icon: 'layers-outline',
testID: 'general_settings.display',
},
advanced_settings: {
defaultMessage: 'Advanced Settings',
i18nId: t('general_settings.advanced_settings'),
icon: 'tune',
testID: 'general_settings.advanced',
},
about: {
defaultMessage: 'About {appTitle}',
i18nId: t('general_settings.about'),
icon: 'information-outline',
testID: 'general_settings.about',
},
help: {
defaultMessage: 'Help',
i18nId: t('general_settings.help'),
testID: 'general_settings.help',
}, },
report_problem: { report_problem: {
defaultMessage: 'Report a Problem', defaultMessage: 'Report a Problem',
i18nId: t('general_settings.report_problem'), id: 'general_settings.report_problem',
testID: 'general_settings.report_problem',
}, },
notifications: {
defaultMessage: 'Notifications',
id: 'general_settings.notifications',
},
display: {
defaultMessage: 'Display',
id: 'general_settings.display',
},
advanced_settings: {
defaultMessage: 'Advanced Settings',
id: 'general_settings.advanced_settings',
},
about: {
defaultMessage: 'About {appTitle}',
id: 'general_settings.about',
},
help: {
defaultMessage: 'Help',
id: 'general_settings.help',
},
push_notifications: {
defaultMessage: 'Push Notifications',
id: 'notification_settings.mobile',
},
call_notifications: {
defaultMessage: 'Call Notifications',
id: 'notification_settings.calls',
},
email: {
defaultMessage: 'Email',
id: 'notification_settings.email',
},
automatic_replies: {
defaultMessage: 'Automatic replies',
id: 'notification_settings.ooo_auto_responder',
},
clock_display: {
defaultMessage: 'Clock Display',
id: 'mobile.display_settings.clockDisplay',
},
crt: {
defaultMessage: 'Collapsed Reply Threads',
id: 'mobile.display_settings.crt',
},
theme: {
defaultMessage: 'Theme',
id: 'mobile.display_settings.theme',
},
timezone: {
defaultMessage: 'Timezone',
id: 'mobile.display_settings.timezone',
},
});
export const SettingOptionConfig: Record<string, SettingConfigDetails> = {
notification: {
defaultMessage: messages.notifications.defaultMessage,
i18nId: messages.notifications.id,
icon: 'bell-outline',
testID: messages.notifications.id,
},
display: {
defaultMessage: messages.display.defaultMessage,
i18nId: messages.display.id,
icon: 'layers-outline',
testID: messages.display.id,
},
advanced_settings: {
defaultMessage: messages.advanced_settings.defaultMessage,
i18nId: messages.advanced_settings.id,
icon: 'tune',
testID: messages.advanced_settings.id,
},
about: {
defaultMessage: messages.about.defaultMessage,
i18nId: messages.about.id,
icon: 'information-outline',
testID: messages.about.id,
},
help: {
defaultMessage: messages.help.defaultMessage,
i18nId: messages.help.id,
testID: messages.help.id,
},
report_problem: {
defaultMessage: messages.report_problem.defaultMessage,
i18nId: messages.report_problem.id,
testID: messages.report_problem.id,
},
download_logs: {
defaultMessage: messages.download_logs.defaultMessage,
i18nId: messages.download_logs.id,
testID: messages.download_logs.id,
},
}; };
export const NotificationsOptionConfig: Record<string, SettingConfigDetails> = { export const NotificationsOptionConfig: Record<string, SettingConfigDetails> = {
@ -81,55 +148,55 @@ export const NotificationsOptionConfig: Record<string, SettingConfigDetails> = {
testID: 'notification_settings.mentions_replies', testID: 'notification_settings.mentions_replies',
}, },
push_notification: { push_notification: {
defaultMessage: 'Push Notifications', defaultMessage: messages.push_notifications.defaultMessage,
i18nId: t('notification_settings.mobile'), i18nId: messages.push_notifications.id,
icon: 'cellphone', icon: 'cellphone',
testID: 'notification_settings.push_notification', testID: messages.push_notifications.id,
}, },
call_notification: { call_notification: {
defaultMessage: 'Call Notifications', defaultMessage: messages.call_notifications.defaultMessage,
i18nId: t('notification_settings.calls'), i18nId: messages.call_notifications.id,
icon: 'phone-in-talk', icon: 'phone-in-talk',
testID: 'notification_settings.call_notification', testID: messages.call_notifications.id,
}, },
email: { email: {
defaultMessage: 'Email', defaultMessage: messages.email.defaultMessage,
i18nId: t('notification_settings.email'), i18nId: messages.email.id,
icon: 'email-outline', icon: 'email-outline',
testID: 'notification_settings.email', testID: messages.email.id,
}, },
automatic_dm_replies: { automatic_dm_replies: {
defaultMessage: 'Automatic replies', defaultMessage: messages.automatic_replies.defaultMessage,
i18nId: t('notification_settings.ooo_auto_responder'), i18nId: messages.automatic_replies.id,
icon: 'reply-outline', icon: 'reply-outline',
testID: 'notification_settings.automatic_dm_replies', testID: messages.automatic_replies.id,
}, },
}; };
export const DisplayOptionConfig: Record<string, SettingConfigDetails> = { export const DisplayOptionConfig: Record<string, SettingConfigDetails> = {
clock: { clock: {
defaultMessage: 'Clock Display', defaultMessage: messages.clock_display.defaultMessage,
i18nId: t('mobile.display_settings.clockDisplay'), i18nId: messages.clock_display.id,
icon: 'clock-outline', icon: 'clock-outline',
testID: 'display_settings.clock', testID: messages.clock_display.id,
}, },
crt: { crt: {
defaultMessage: 'Collapsed Reply Threads', defaultMessage: messages.crt.defaultMessage,
i18nId: t('mobile.display_settings.crt'), i18nId: messages.crt.id,
icon: 'message-text-outline', icon: 'message-text-outline',
testID: 'display_settings.crt', testID: messages.crt.id,
}, },
theme: { theme: {
defaultMessage: 'Theme', defaultMessage: messages.theme.defaultMessage,
i18nId: t('mobile.display_settings.theme'), i18nId: messages.theme.id,
icon: 'palette-outline', icon: 'palette-outline',
testID: 'display_settings.theme', testID: messages.theme.id,
}, },
timezone: { timezone: {
defaultMessage: 'Timezone', defaultMessage: messages.timezone.defaultMessage,
i18nId: t('mobile.display_settings.timezone'), i18nId: messages.timezone.id,
icon: 'globe', icon: 'globe',
testID: 'display_settings.timezone', testID: messages.timezone.id,
}, },
}; };

View file

@ -165,46 +165,42 @@ exports[`SendTestNotificationNotice should match snapshot 1`] = `
"borderColor": "#2089dc", "borderColor": "#2089dc",
"borderRadius": 4, "borderRadius": 4,
"borderWidth": 0, "borderWidth": 0,
"flex": 0,
"flexDirection": "row", "flexDirection": "row",
"height": 40,
"justifyContent": "center", "justifyContent": "center",
"padding": 8, "padding": 8,
"paddingHorizontal": 20, "paddingHorizontal": 20,
"paddingVertical": 12, "paddingVertical": 10,
} }
} }
> >
<View <View
style={ style={
{ {
"alignItems": "center",
"flexDirection": "row", "flexDirection": "row",
"gap": 7,
} }
} }
testID="undefined-text-container"
> >
<Text <Text
numberOfLines={1} numberOfLines={1}
style={ style={
[ [
[ [
{ [
"alignItems": "center", {
"fontFamily": "OpenSans-SemiBold", "fontFamily": "OpenSans-SemiBold",
"fontWeight": "600", "fontSize": 14,
"justifyContent": "center", "fontWeight": "600",
"padding": 1, "lineHeight": 20,
"textAlignVertical": "center", },
}, {
{ "color": "#ffffff",
"fontSize": 14, },
"lineHeight": 14, ],
"marginTop": 3, undefined,
},
{
"color": "#ffffff",
},
], ],
undefined,
] ]
} }
> >
@ -272,61 +268,50 @@ exports[`SendTestNotificationNotice should match snapshot 1`] = `
"borderColor": "#2089dc", "borderColor": "#2089dc",
"borderRadius": 4, "borderRadius": 4,
"borderWidth": 0, "borderWidth": 0,
"flex": 0,
"flexDirection": "row", "flexDirection": "row",
"height": 40,
"justifyContent": "center", "justifyContent": "center",
"padding": 8, "padding": 8,
"paddingHorizontal": 20, "paddingHorizontal": 20,
"paddingVertical": 12, "paddingVertical": 10,
} }
} }
> >
<View <View
style={ style={
[ {
{ "alignItems": "center",
"flexDirection": "row", "flexDirection": "row",
}, "gap": 7,
{
"minHeight": 18,
},
]
}
>
<Icon
color="#1c58d9"
name="open-in-new"
size={18}
style={
{
"marginRight": 7,
}
} }
/> }
testID="undefined-text-container"
>
<View>
<Icon
color="#1c58d9"
name="open-in-new"
size={18}
testID="undefined-icon"
/>
</View>
<Text <Text
numberOfLines={1} numberOfLines={1}
style={ style={
[ [
[ [
{ [
"alignItems": "center", {
"fontFamily": "OpenSans-SemiBold", "fontFamily": "OpenSans-SemiBold",
"fontWeight": "600", "fontSize": 14,
"justifyContent": "center", "fontWeight": "600",
"padding": 1, "lineHeight": 20,
"textAlignVertical": "center", },
}, {
{ "color": "#1c58d9",
"fontSize": 14, },
"lineHeight": 14, ],
"marginTop": 3, undefined,
},
{
"color": "#1c58d9",
},
], ],
undefined,
] ]
} }
> >

View file

@ -0,0 +1,157 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database} from '@nozbe/watermelondb';
import React, {type ComponentProps} from 'react';
import {View, Text} from 'react-native';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {renderWithEverything} from '@test/intl-test-helper';
import ReportProblem from './report_problem';
import enhanced from './index';
import type {ReportAProblemMetadata} from '@typings/screens/report_a_problem';
jest.mock('./report_problem', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(ReportProblem).mockImplementation((props) => {
return (
<View>
{Object.keys(props).map((key) => {
if (key === 'metadata') {
return Object.keys(props[key]).map((metadataKey) => (
<Text
key={metadataKey}
testID={metadataKey}
>{`${props.metadata[metadataKey as keyof ReportAProblemMetadata]}`}</Text>
));
}
return (
<Text
key={key}
testID={key}
>{`${props[key as keyof ComponentProps<typeof ReportProblem>]}`}</Text>
);
})}
</View>
);
});
describe('screens/settings/report_problem/index', () => {
const serverUrl = 'baseHandler.test.com';
let database: Database;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
it('should handle default values', async () => {
const Component = enhanced;
const {getByTestId} = renderWithEverything(
<Component/>,
{database},
);
expect(getByTestId('currentUserId')).toHaveTextContent('');
expect(getByTestId('currentTeamId')).toHaveTextContent('');
expect(getByTestId('serverVersion')).toHaveTextContent('Unknown (Build Unknown)');
expect(getByTestId('appVersion')).toHaveTextContent('0.0.0 (Build 0)');
expect(getByTestId('appPlatform')).toHaveTextContent('ios');
expect(getByTestId('reportAProblemType')).toHaveTextContent('undefined');
expect(getByTestId('reportAProblemMail')).toHaveTextContent('undefined');
expect(getByTestId('siteName')).toHaveTextContent('undefined');
expect(getByTestId('allowDownloadLogs')).toHaveTextContent('true');
});
it('should enhance ReportProblem with correct observables', async () => {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
await operator.handleConfigs({
configs: [
{id: 'ReportAProblemType', value: 'email'},
{id: 'ReportAProblemMail', value: 'test@example.com'},
{id: 'SiteName', value: 'Test Site'},
{id: 'AllowDownloadLogs', value: 'true'},
{id: 'Version', value: '7.8.0'},
{id: 'BuildNumber', value: '123'},
],
prepareRecordsOnly: false,
configsToDelete: [],
});
await operator.handleSystem({
systems: [
{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'user1'},
{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: 'team1'},
],
prepareRecordsOnly: false,
});
const Component = enhanced;
const {getByTestId} = renderWithEverything(
<Component/>,
{database},
);
expect(getByTestId('currentUserId')).toHaveTextContent('user1');
expect(getByTestId('currentTeamId')).toHaveTextContent('team1');
expect(getByTestId('serverVersion')).toHaveTextContent('7.8.0 (Build 123)');
expect(getByTestId('appVersion')).toHaveTextContent('0.0.0 (Build 0)');
expect(getByTestId('appPlatform')).toHaveTextContent('ios');
expect(getByTestId('reportAProblemType')).toHaveTextContent('email');
expect(getByTestId('reportAProblemMail')).toHaveTextContent('test@example.com');
expect(getByTestId('siteName')).toHaveTextContent('Test Site');
expect(getByTestId('allowDownloadLogs')).toHaveTextContent('true');
});
it('different data should show different values', async () => {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
await operator.handleConfigs({
configs: [
{id: 'ReportAProblemType', value: 'link'},
{id: 'ReportAProblemMail', value: 'test2@example.com'},
{id: 'SiteName', value: 'Test Site2'},
{id: 'AllowDownloadLogs', value: 'false'},
{id: 'Version', value: '7.8.1'},
{id: 'BuildNumber', value: '124'},
],
prepareRecordsOnly: false,
configsToDelete: [],
});
await operator.handleSystem({
systems: [
{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'user2'},
{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: 'team2'},
],
prepareRecordsOnly: false,
});
const Component = enhanced;
const {getByTestId} = renderWithEverything(
<Component/>,
{database},
);
expect(getByTestId('currentUserId')).toHaveTextContent('user2');
expect(getByTestId('currentTeamId')).toHaveTextContent('team2');
expect(getByTestId('serverVersion')).toHaveTextContent('7.8.1 (Build 124)');
expect(getByTestId('appVersion')).toHaveTextContent('0.0.0 (Build 0)');
expect(getByTestId('appPlatform')).toHaveTextContent('ios');
expect(getByTestId('reportAProblemType')).toHaveTextContent('link');
expect(getByTestId('reportAProblemMail')).toHaveTextContent('test2@example.com');
expect(getByTestId('siteName')).toHaveTextContent('Test Site2');
expect(getByTestId('allowDownloadLogs')).toHaveTextContent('false');
});
});

View file

@ -3,26 +3,16 @@
import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {observeConfigValue, observeCurrentUserId, observeCurrentTeamId} from '@queries/servers/system'; import {observeConfigBooleanValue, observeConfigValue, observeReportAProblemMetadata} from '@queries/servers/system';
import ReportProblem from './report_problem'; import ReportProblem from './report_problem';
import type {WithDatabaseArgs} from '@typings/database/database'; const enhanced = withObservables([], ({database}) => ({
allowDownloadLogs: observeConfigBooleanValue(database, 'AllowDownloadLogs', true),
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { reportAProblemMail: observeConfigValue(database, 'ReportAProblemMail'),
const buildNumber = observeConfigValue(database, 'BuildNumber'); reportAProblemType: observeConfigValue(database, 'ReportAProblemType'),
const currentTeamId = observeCurrentTeamId(database); siteName: observeConfigValue(database, 'SiteName'),
const currentUserId = observeCurrentUserId(database); metadata: observeReportAProblemMetadata(database),
const supportEmail = observeConfigValue(database, 'SupportEmail'); }));
const version = observeConfigValue(database, 'Version');
return {
buildNumber,
currentTeamId,
currentUserId,
supportEmail,
version,
};
});
export default withDatabase(enhanced(ReportProblem)); export default withDatabase(enhanced(ReportProblem));

View file

@ -0,0 +1,123 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, fireEvent} from '@testing-library/react-native';
import React from 'react';
import {Screens} from '@constants';
import {goToScreen} from '@screens/navigation';
import {renderWithIntl} from '@test/intl-test-helper';
import {emailLogs} from '@utils/share_logs';
import ReportProblem from './report_problem';
jest.mock('@screens/navigation', () => ({
goToScreen: jest.fn(),
}));
jest.mock('@utils/share_logs', () => ({
emailLogs: jest.fn(),
}));
describe('screens/settings/report_problem/report_problem', () => {
const baseProps = {
allowDownloadLogs: true,
metadata: {
currentUserId: 'user1',
currentTeamId: 'team1',
serverVersion: '7.8.0',
appVersion: '2.0.0',
appPlatform: 'ios',
},
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render report problem option', () => {
const {getByTestId} = renderWithIntl(
<ReportProblem {...baseProps}/>,
);
expect(getByTestId('settings.report_problem.option')).toBeTruthy();
});
it('should not render when type is hidden', () => {
const props = {
...baseProps,
reportAProblemType: 'hidden',
};
const {queryByTestId} = renderWithIntl(
<ReportProblem {...props}/>,
);
expect(queryByTestId('settings.report_problem.option')).toBeNull();
});
it('should navigate to report problem screen when logs are allowed', async () => {
const props = {
...baseProps,
allowDownloadLogs: true,
reportAProblemType: 'email',
};
const {getByTestId} = renderWithIntl(
<ReportProblem {...props}/>,
);
await act(async () => {
fireEvent.press(getByTestId('settings.report_problem.option'));
expect(goToScreen).toHaveBeenCalledWith(
Screens.REPORT_PROBLEM,
'Report a problem',
);
});
});
it('should navigate to report problem screen when type is not email', async () => {
const props = {
...baseProps,
allowDownloadLogs: false,
reportAProblemType: 'link',
};
const {getByTestId} = renderWithIntl(
<ReportProblem {...props}/>,
);
await act(async () => {
fireEvent.press(getByTestId('settings.report_problem.option'));
expect(goToScreen).toHaveBeenCalledWith(
Screens.REPORT_PROBLEM,
'Report a problem',
);
});
});
it('should share logs directly when logs are not allowed and type is email', async () => {
const props = {
...baseProps,
allowDownloadLogs: false,
reportAProblemType: 'email',
reportAProblemMail: 'test@example.com',
siteName: 'Test Site',
};
const {getByTestId} = renderWithIntl(
<ReportProblem {...props}/>,
);
await act(async () => {
fireEvent.press(getByTestId('settings.report_problem.option'));
expect(emailLogs).toHaveBeenCalledWith(
props.metadata,
props.siteName,
props.reportAProblemMail,
true,
);
expect(goToScreen).not.toHaveBeenCalled();
});
});
});

View file

@ -1,58 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import TurboLogger from '@mattermost/react-native-turbo-log'; import React, {useCallback} from 'react';
import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application'; import {defineMessages, useIntl} from 'react-intl';
import {deviceName} from 'expo-device';
import React from 'react';
import {Alert, Platform} from 'react-native';
import Share from 'react-native-share';
import SettingItem from '@components/settings/item'; import SettingItem from '@components/settings/item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {pathWithPrefix} from '@utils/files'; import {goToScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap'; import {emailLogs} from '@utils/share_logs';
import type {ReportAProblemMetadata} from '@typings/screens/report_a_problem';
type ReportProblemProps = { type ReportProblemProps = {
buildNumber: string; allowDownloadLogs?: boolean;
currentTeamId: string; reportAProblemMail?: string;
currentUserId: string; reportAProblemType?: string;
supportEmail: string; siteName?: string;
version: string; metadata: ReportAProblemMetadata;
siteName: string; }
};
const ReportProblem = ({buildNumber, currentTeamId, currentUserId, siteName, supportEmail, version}: ReportProblemProps) => { const messages = defineMessages({
downloadLogs: {id: 'report_problem.download_logs.title', defaultMessage: 'Download app logs'},
reportProblem: {id: 'report_problem.title', defaultMessage: 'Report a problem'},
});
const ReportProblem = ({
allowDownloadLogs,
reportAProblemMail,
reportAProblemType,
siteName,
metadata,
}: ReportProblemProps) => {
const theme = useTheme(); const theme = useTheme();
const intl = useIntl();
const onlyAllowLogs = allowDownloadLogs && reportAProblemType === 'hidden';
const skipReportAProblemScreen = reportAProblemType === 'email' && !allowDownloadLogs;
const openEmailClient = preventDoubleTap(async () => { const onPress = useCallback(() => {
try { if (skipReportAProblemScreen) {
const logPaths = await TurboLogger.getLogPaths(); emailLogs(metadata, siteName, reportAProblemMail, true);
const attachments = logPaths.map((path) => pathWithPrefix('file://', path)); } else {
await Share.open({ const message = onlyAllowLogs ? messages.downloadLogs : messages.reportProblem;
subject: `Problem with ${siteName} React Native app`, const title = intl.formatMessage(message);
email: supportEmail, goToScreen(Screens.REPORT_PROBLEM, title);
failOnCancel: false,
urls: attachments,
message: [
'Please share a description of the problem:\n\n',
`Current User Id: ${currentUserId}`,
`Current Team Id: ${currentTeamId}`,
`Server Version: ${version} (Build ${buildNumber})`,
`App Version: ${nativeApplicationVersion} (Build ${nativeBuildVersion})`,
`App Platform: ${Platform.OS}`,
`Device Model: ${deviceName}`,
].join('\n'),
});
} catch (e: any) {
Alert.alert('Error', e.message);
} }
}); }, [intl, metadata, onlyAllowLogs, reportAProblemMail, siteName, skipReportAProblemScreen]);
if (onlyAllowLogs) {
return (
<SettingItem
optionLabelTextStyle={{color: theme.linkColor}}
onPress={onPress}
optionName='download_logs'
separator={false}
testID='settings.download_logs.option'
type='default'
/>
);
}
if (reportAProblemType === 'hidden') {
return null;
}
return ( return (
<SettingItem <SettingItem
optionLabelTextStyle={{color: theme.linkColor}} optionLabelTextStyle={{color: theme.linkColor}}
onPress={openEmailClient} onPress={onPress}
optionName='report_problem' optionName='report_problem'
separator={false} separator={false}
testID='settings.report_problem.option' testID='settings.report_problem.option'

View file

@ -149,7 +149,7 @@ const Settings = ({componentId, helpLink, showHelp, siteName}: SettingsProps) =>
type='default' type='default'
/> />
} }
<ReportProblem siteName={siteName}/> <ReportProblem/>
</SettingContainer> </SettingContainer>
); );
}; };

View file

@ -84,12 +84,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
color: theme.centerChannelColor, color: theme.centerChannelColor,
...typography('Body', 200, 'Regular'), ...typography('Body', 200, 'Regular'),
}, },
firstButton: {
marginBottom: 10,
},
loadingContainer: { loadingContainer: {
justifyContent: 'center', justifyContent: 'center',
}, },
buttonContainer: {
flexDirection: 'column',
gap: 10,
},
}; };
}); });
@ -145,7 +146,7 @@ const TermsOfService = ({
onPress: retry, onPress: retry,
}], }],
); );
}, [intl, closeTermsAndLogout]); }, [siteName, intl, closeTermsAndLogout]);
const alertDecline = useCallback(() => { const alertDecline = useCallback(() => {
Alert.alert( Alert.alert(
@ -162,7 +163,7 @@ const TermsOfService = ({
onPress: closeTermsAndLogout, onPress: closeTermsAndLogout,
}], }],
); );
}, [intl, siteName, closeTermsAndLogout]); }, [intl, closeTermsAndLogout]);
const acceptTerms = useCallback(async () => { const acceptTerms = useCallback(async () => {
setLoading(true); setLoading(true);
@ -170,7 +171,7 @@ const TermsOfService = ({
if (error) { if (error) {
alertError(acceptTerms); alertError(acceptTerms);
} }
}, [alertError, alertDecline, termsId, serverUrl, componentId]); }, [alertError, termsId, serverUrl]);
const declineTerms = useCallback(async () => { const declineTerms = useCallback(async () => {
setLoading(true); setLoading(true);
@ -180,7 +181,7 @@ const TermsOfService = ({
} else { } else {
alertDecline(); alertDecline();
} }
}, [serverUrl, termsId, closeTermsAndLogout]); }, [serverUrl, termsId, alertError, alertDecline]);
const onPressClose = useCallback(async () => { const onPressClose = useCallback(async () => {
if (getTermsError) { if (getTermsError) {
@ -227,20 +228,21 @@ const TermsOfService = ({
<Text style={styles.errorDescription}> <Text style={styles.errorDescription}>
{intl.formatMessage({id: 'terms_of_service.error.description', defaultMessage: 'It was not possible to get the Terms of Service from the Server.'})} {intl.formatMessage({id: 'terms_of_service.error.description', defaultMessage: 'It was not possible to get the Terms of Service from the Server.'})}
</Text> </Text>
<Button <View style={styles.buttonContainer}>
onPress={getTerms} <Button
theme={theme} onPress={getTerms}
text={intl.formatMessage({id: 'terms_of_service.error.retry', defaultMessage: 'Retry'})} theme={theme}
size={'lg'} text={intl.formatMessage({id: 'terms_of_service.error.retry', defaultMessage: 'Retry'})}
backgroundStyle={styles.firstButton} size={'lg'}
/> />
<Button <Button
onPress={onPressClose} onPress={onPressClose}
theme={theme} theme={theme}
text={intl.formatMessage({id: 'terms_of_service.error.logout', defaultMessage: 'Logout'})} text={intl.formatMessage({id: 'terms_of_service.error.logout', defaultMessage: 'Logout'})}
size={'lg'} size={'lg'}
emphasis={'link'} emphasis={'link'}
/> />
</View>
</> </>
); );
} else { } else {
@ -267,7 +269,6 @@ const TermsOfService = ({
theme={theme} theme={theme}
text={intl.formatMessage({id: 'terms_of_service.acceptButton', defaultMessage: 'Accept'})} text={intl.formatMessage({id: 'terms_of_service.acceptButton', defaultMessage: 'Accept'})}
size={'lg'} size={'lg'}
backgroundStyle={styles.firstButton}
/> />
<Button <Button

View file

@ -3,7 +3,7 @@
import Preferences from '@constants/preferences'; import Preferences from '@constants/preferences';
import {buttonBackgroundStyle, buttonStyles, getBackgroundStyles, buttonSizeStyles, buttonTextStyle, buttonTextStyles, buttonTextSizeStyles} from './buttonStyles'; import {buttonBackgroundStyle, buttonStyles, getBackgroundStyles, buttonSizeStyles, buttonTextStyle, buttonTextSizeStyles} from './buttonStyles';
import {changeOpacity} from './theme'; import {changeOpacity} from './theme';
describe('get the style of a button', () => { describe('get the style of a button', () => {
@ -65,28 +65,24 @@ describe('get the style of a button', () => {
}); });
describe('get the text style of a button', () => { describe('get the text style of a button', () => {
const defaultTextStyle = buttonTextStyles.main;
const theme = Preferences.THEMES.denim; const theme = Preferences.THEMES.denim;
test('button text default values', () => { test('button text default values', () => {
const tests = [{ const tests = [{
getStyle: () => buttonTextStyle(theme), getStyle: () => buttonTextStyle(theme),
expected: [ expected: [
defaultTextStyle,
buttonTextSizeStyles.m, buttonTextSizeStyles.m,
{color: theme.buttonColor}, {color: theme.buttonColor},
], ],
}, { }, {
getStyle: () => buttonTextStyle(theme, 'xs'), getStyle: () => buttonTextStyle(theme, 'xs'),
expected: [ expected: [
defaultTextStyle,
buttonTextSizeStyles.xs, buttonTextSizeStyles.xs,
{color: theme.buttonColor}, {color: theme.buttonColor},
], ],
}, { }, {
getStyle: () => buttonTextStyle(theme, 'lg', 'secondary'), getStyle: () => buttonTextStyle(theme, 'lg', 'secondary'),
expected: [ expected: [
defaultTextStyle,
buttonTextSizeStyles.lg, buttonTextSizeStyles.lg,
{color: theme.buttonBg}, {color: theme.buttonBg},
], ],
@ -104,26 +100,29 @@ describe('get the text style of a button', () => {
const btnTypes: ButtonType[] = ['default', 'destructive', 'disabled', 'inverted']; const btnTypes: ButtonType[] = ['default', 'destructive', 'disabled', 'inverted'];
const getColor = (type: ButtonType, emphasis: ButtonEmphasis) => { const getColor = (type: ButtonType, emphasis: ButtonEmphasis) => {
let color: string = theme.buttonColor;
if (type === 'disabled') { if (type === 'disabled') {
color = changeOpacity(theme.centerChannelColor, 0.32); return changeOpacity(theme.centerChannelColor, 0.32);
} }
if ((type === 'destructive' && emphasis !== 'primary')) { if (type === 'destructive') {
color = theme.errorTextColor; if (emphasis === 'primary') {
return theme.buttonColor;
}
return theme.errorTextColor;
} }
if ((type === 'inverted' && emphasis === 'primary') || if (type === 'inverted') {
(type !== 'inverted' && emphasis !== 'primary')) { if (emphasis === 'primary') {
color = theme.buttonBg; return theme.buttonBg;
}
return theme.buttonColor;
} }
if (type === 'inverted' && emphasis === 'tertiary') { if (emphasis === 'primary') {
color = theme.sidebarText; return theme.buttonColor;
} }
return color; return theme.buttonBg;
}; };
btnEnphasis.forEach((emphasis) => { btnEnphasis.forEach((emphasis) => {
@ -131,7 +130,6 @@ describe('get the text style of a button', () => {
btnTypes.forEach((type) => { btnTypes.forEach((type) => {
const style = buttonTextStyle(theme, size, emphasis, type); const style = buttonTextStyle(theme, size, emphasis, type);
expect(style).toEqual([ expect(style).toEqual([
defaultTextStyle,
buttonTextSizeStyles[size], buttonTextSizeStyles[size],
{color: getColor(type, emphasis)}, {color: getColor(type, emphasis)},
]); ]);

View file

@ -5,6 +5,8 @@ import {type StyleProp, StyleSheet, type TextStyle, type ViewStyle} from 'react-
import {blendColors, changeOpacity} from '@utils/theme'; import {blendColors, changeOpacity} from '@utils/theme';
import {typography} from './typography';
export const getBackgroundStyles = (theme: Theme): BackgroundStyles => { export const getBackgroundStyles = (theme: Theme): BackgroundStyles => {
return { return {
primary: { primary: {
@ -288,32 +290,25 @@ export const getBackgroundStyles = (theme: Theme): BackgroundStyles => {
export const buttonSizeStyles: ButtonSizes = StyleSheet.create({ export const buttonSizeStyles: ButtonSizes = StyleSheet.create({
xs: { xs: {
height: 24, paddingVertical: 4,
paddingVertical: 6,
paddingHorizontal: 10, paddingHorizontal: 10,
}, },
s: { s: {
height: 32, paddingVertical: 8,
paddingVertical: 10,
paddingHorizontal: 16, paddingHorizontal: 16,
}, },
m: { m: {
height: 40, paddingVertical: 10,
paddingVertical: 12,
paddingHorizontal: 20, paddingHorizontal: 20,
}, },
lg: { lg: {
height: 48, paddingVertical: 12,
paddingVertical: 14,
paddingHorizontal: 24, paddingHorizontal: 24,
}, },
}); });
export const buttonStyles = StyleSheet.create({ export const buttonStyles = StyleSheet.create({
main: { main: {
flex: 0,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4, borderRadius: 4,
}, },
fullWidth: { fullWidth: {
@ -321,41 +316,18 @@ export const buttonStyles = StyleSheet.create({
}, },
}); });
export const buttonTextStyles = StyleSheet.create({
main: {
fontFamily: 'OpenSans-SemiBold',
fontWeight: '600',
textAlignVertical: 'center',
alignItems: 'center',
justifyContent: 'center',
padding: 1,
},
underline: {
textDecorationLine: 'underline',
},
});
export const buttonTextSizeStyles = StyleSheet.create({ export const buttonTextSizeStyles = StyleSheet.create({
xs: { xs: {
fontSize: 11, ...typography('Body', 50, 'SemiBold'),
lineHeight: 10,
letterSpacing: 0.02,
marginTop: 2,
}, },
s: { s: {
fontSize: 12, ...typography('Body', 75, 'SemiBold'),
lineHeight: 12,
marginTop: 1,
}, },
m: { m: {
fontSize: 14, ...typography('Body', 100, 'SemiBold'),
lineHeight: 14,
marginTop: 3,
}, },
lg: { lg: {
fontSize: 16, ...typography('Body', 200, 'SemiBold'),
lineHeight: 18,
marginTop: 1,
}, },
}); });
@ -398,25 +370,31 @@ export const buttonTextStyle = (
emphasis: ButtonEmphasis = 'primary', emphasis: ButtonEmphasis = 'primary',
type: ButtonType = 'default', type: ButtonType = 'default',
): StyleProp<TextStyle> => { ): StyleProp<TextStyle> => {
// Color return [buttonTextSizeStyles[size], {color: getColorByType(theme, type, emphasis)}];
let color: string = theme.buttonColor; };
if (type === 'disabled') { const getColorByType = (theme: Theme, type: ButtonType, emphasis: ButtonEmphasis) => {
color = changeOpacity(theme.centerChannelColor, 0.32); if (type === 'disabled') {
} return changeOpacity(theme.centerChannelColor, 0.32);
}
if ((type === 'destructive' && emphasis !== 'primary')) {
color = theme.errorTextColor; if (type === 'destructive') {
} if (emphasis === 'primary') {
return theme.buttonColor;
if ((type === 'inverted' && emphasis === 'primary') || }
(type !== 'inverted' && emphasis !== 'primary')) { return theme.errorTextColor;
color = theme.buttonBg; }
}
if (type === 'inverted') {
if (type === 'inverted' && emphasis === 'tertiary') { if (emphasis === 'primary') {
color = theme.sidebarText; return theme.buttonBg;
} }
return theme.buttonColor;
return [buttonTextStyles.main, buttonTextSizeStyles[size], {color}]; }
if (emphasis === 'primary') {
return theme.buttonColor;
}
return theme.buttonBg;
}; };

View file

@ -9,7 +9,31 @@ import {getIntlShape} from '@utils/general';
import {logError} from '@utils/log'; import {logError} from '@utils/log';
import {urlSafeBase64Encode} from '@utils/security'; import {urlSafeBase64Encode} from '@utils/security';
import {deleteFileCache, deleteFileCacheByDir, deleteV1Data, extractFileInfo, fileExists, fileMaxWarning, fileSizeWarning, filterFileExtensions, getAllFilesInCachesDirectory, getAllowedServerMaxFileSize, getExtensionFromContentDisposition, getExtensionFromMime, getFileType, getFormattedFileSize, getLocalFilePathFromFile, hasWriteStoragePermission, isDocument, isGif, isImage, isVideo, lookupMimeType, uploadDisabledWarning} from '.'; import {
deleteFileCache,
deleteFileCacheByDir,
deleteV1Data,
extractFileInfo,
fileExists,
fileMaxWarning,
fileSizeWarning,
filterFileExtensions,
getAllFilesInCachesDirectory,
getAllowedServerMaxFileSize,
getExtensionFromContentDisposition,
getExtensionFromMime,
getFileType,
getFormattedFileSize,
getLocalFilePathFromFile,
hasWriteStoragePermission,
isDocument,
isGif,
isImage,
isVideo,
lookupMimeType,
pathWithPrefix,
uploadDisabledWarning,
} from '.';
jest.mock('expo-file-system'); jest.mock('expo-file-system');
jest.mock('react-native', () => { jest.mock('react-native', () => {
@ -272,5 +296,12 @@ describe('Image utils', () => {
expect(result.files).toEqual(expect.any(Array)); expect(result.files).toEqual(expect.any(Array));
}); });
}); });
describe('pathWithPrefix', () => {
it('should return correct path with prefix', () => {
expect(pathWithPrefix('file://', 'file://something')).toEqual('file://something');
expect(pathWithPrefix('file://', 'something')).toEqual('file://something');
});
});
}); });

View file

@ -573,3 +573,12 @@ export const getAllFilesInCachesDirectory = async (serverUrl: string) => {
return {error}; return {error};
} }
}; };
export const pathWithPrefix = (prefix: string, path: string) => {
const p = path.startsWith(prefix) ? '' : prefix;
return `${p}${path}`;
};
export const deleteFile = async (path: string) => {
await deleteAsync(pathWithPrefix('file://', path));
};

View file

@ -4,7 +4,7 @@
import testHelper from '@test/test_helper'; import testHelper from '@test/test_helper';
import {toMilliseconds} from './datetime'; import {toMilliseconds} from './datetime';
import {getNumberFileMenuOptions, getChannelNamesWithID, getOrderedFileInfos, getFileInfosIndexes, getOrderedGalleryItems, pathWithPrefix} from './files'; import {getNumberFileMenuOptions, getChannelNamesWithID, getOrderedFileInfos, getFileInfosIndexes, getOrderedGalleryItems} from './files';
import type ChannelModel from '@typings/database/models/servers/channel'; import type ChannelModel from '@typings/database/models/servers/channel';
@ -102,9 +102,4 @@ describe('Files utils', () => {
const types = result.map((f) => f.type); const types = result.map((f) => f.type);
expect(types).toEqual(['file', 'image', 'video']); expect(types).toEqual(['file', 'image', 'video']);
}); });
test('pathWithPrefix', () => {
expect(pathWithPrefix('file://', 'file://something')).toEqual('file://something');
expect(pathWithPrefix('file://', 'something')).toEqual('file://something');
});
}); });

View file

@ -42,8 +42,3 @@ export const getFileInfosIndexes = (orderedFilesForGallery: FileInfo[]) => {
export const getOrderedGalleryItems = (orderedFileInfos: FileInfo[]) => { export const getOrderedGalleryItems = (orderedFileInfos: FileInfo[]) => {
return orderedFileInfos.map((f) => fileToGalleryItem(f, f.user_id)); return orderedFileInfos.map((f) => fileToGalleryItem(f, f.user_id));
}; };
export const pathWithPrefix = (prefix: string, path: string) => {
const p = path.startsWith(prefix) ? '' : prefix;
return `${p}${path}`;
};

View file

@ -11,6 +11,12 @@ jest.mock('@sentry/react-native', () => ({
addBreadcrumb: jest.fn(), addBreadcrumb: jest.fn(),
})); }));
// We need to get back the original functions
// since we are mocking them in the setup file
jest.mock('./log', () => ({
...jest.requireActual('./log'),
}));
// Mock console methods // Mock console methods
const originalConsole = global.console; const originalConsole = global.console;

View file

@ -0,0 +1,250 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import TurboLogger from '@mattermost/react-native-turbo-log';
import {Alert, Platform} from 'react-native';
import Share from 'react-native-share';
import {shareLogs, getDefaultReportAProblemLink, metadataToString, emailLogs} from './share_logs';
import {tryOpenURL} from './url';
jest.mock('react-native-share', () => ({
open: jest.fn(),
shareSingle: jest.fn(),
Social: {
EMAIL: 'email',
},
}));
jest.mock('./url', () => ({
tryOpenURL: jest.fn(),
}));
jest.mock('react-native/Libraries/Alert/Alert', () => ({
alert: jest.fn(),
}));
describe('shareLogs', () => {
const metadata = {
currentUserId: 'user1',
currentTeamId: 'team1',
serverVersion: '1.0.0',
appVersion: '2.0.0',
appPlatform: 'ios',
};
beforeEach(() => {
jest.clearAllMocks();
const logPaths = ['/path/to/log1', '/path/to/log2'];
jest.mocked(TurboLogger.getLogPaths).mockResolvedValue(logPaths);
});
it('should share logs with attachments when excludeLogs is false', async () => {
await shareLogs(metadata, 'My Site', 'support@example.com', false);
expect(Share.open).toHaveBeenCalledWith(expect.objectContaining({
subject: 'Problem with My Site React Native app',
email: 'support@example.com',
urls: ['file:///path/to/log1', 'file:///path/to/log2'],
}));
});
it('should handle an empty list of log paths', async () => {
jest.mocked(TurboLogger.getLogPaths).mockResolvedValue([]);
await shareLogs(metadata, 'My Site', 'support@example.com', false);
expect(Share.open).toHaveBeenCalledWith(expect.objectContaining({
subject: 'Problem with My Site React Native app',
email: 'support@example.com',
urls: undefined,
}));
});
it('should share without logs when excludeLogs is true', async () => {
await shareLogs(metadata, 'My Site', 'support@example.com', true);
expect(Share.open).toHaveBeenCalledWith(expect.objectContaining({
subject: 'Problem with My Site React Native app',
email: 'support@example.com',
urls: undefined,
}));
});
it('should handle errors', async () => {
const error = new Error('Share failed');
jest.mocked(Share.open).mockRejectedValue(error);
await shareLogs(metadata, 'My Site', 'support@example.com');
expect(Alert.alert).toHaveBeenCalledWith('Error', 'Error: Share failed');
});
it('should pass the correct metadata to the share function', async () => {
await shareLogs(metadata, 'My Site', 'support@example.com', false);
expect(Share.open).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('Current User ID: user1'),
}));
expect(Share.open).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('Current Team ID: team1'),
}));
expect(Share.open).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('Server Version: 1.0.0'),
}));
expect(Share.open).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('App Version: 2.0.0'),
}));
expect(Share.open).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('App Platform: ios'),
}));
});
});
describe('emailLogs', () => {
const metadata = {
currentUserId: 'user1',
currentTeamId: 'team1',
serverVersion: '1.0.0',
appVersion: '2.0.0',
appPlatform: 'ios',
};
let originalPlatform: typeof Platform.OS;
beforeAll(() => {
originalPlatform = Platform.OS;
});
afterAll(() => {
Platform.OS = originalPlatform;
});
describe('android', () => {
beforeAll(() => {
Platform.OS = 'android';
});
beforeEach(() => {
jest.clearAllMocks();
const logPaths = ['/path/to/log1', '/path/to/log2'];
jest.mocked(TurboLogger.getLogPaths).mockResolvedValue(logPaths);
});
it('should share logs with attachments when excludeLogs is false', async () => {
await emailLogs(metadata, 'My Site', 'support@example.com', false);
expect(Share.shareSingle).toHaveBeenCalledWith(expect.objectContaining({
subject: 'Problem with My Site React Native app',
email: 'support@example.com',
urls: ['file:///path/to/log1', 'file:///path/to/log2'],
social: Share.Social.EMAIL,
}));
});
it('should handle an empty list of log paths', async () => {
jest.mocked(TurboLogger.getLogPaths).mockResolvedValue([]);
await emailLogs(metadata, 'My Site', 'support@example.com', false);
expect(Share.shareSingle).toHaveBeenCalledWith(expect.objectContaining({
subject: 'Problem with My Site React Native app',
email: 'support@example.com',
urls: undefined,
social: Share.Social.EMAIL,
}));
});
it('should share without logs when excludeLogs is true', async () => {
await emailLogs(metadata, 'My Site', 'support@example.com', true);
expect(Share.shareSingle).toHaveBeenCalledWith(expect.objectContaining({
subject: 'Problem with My Site React Native app',
email: 'support@example.com',
urls: undefined,
social: Share.Social.EMAIL,
}));
});
it('should handle errors', async () => {
const error = new Error('Share failed');
jest.mocked(Share.shareSingle).mockRejectedValue(error);
await emailLogs(metadata, 'My Site', 'support@example.com');
expect(Alert.alert).toHaveBeenCalledWith('Error', 'Error: Share failed');
});
it('should pass the correct metadata to the share function', async () => {
await emailLogs(metadata, 'My Site', 'support@example.com', false);
expect(Share.shareSingle).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('Current User ID: user1'),
}));
expect(Share.shareSingle).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('Current Team ID: team1'),
}));
expect(Share.shareSingle).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('Server Version: 1.0.0'),
}));
expect(Share.shareSingle).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('App Version: 2.0.0'),
}));
expect(Share.shareSingle).toHaveBeenCalledWith(expect.objectContaining({
message: expect.stringContaining('App Platform: ios'),
}));
});
});
describe('ios', () => {
beforeAll(() => {
Platform.OS = 'ios';
});
it('should open the correct mailto link', async () => {
await emailLogs(metadata, 'My Site', 'support@example.com', false);
expect(tryOpenURL).toHaveBeenCalledTimes(1);
expect(tryOpenURL).toHaveBeenCalledWith(expect.stringMatching(/^mailto:support@example\.com\?subject=Problem%20with%20My%20Site%20React%20Native%20app&body=/));
expect(tryOpenURL).toHaveBeenCalledWith(expect.stringContaining(encodeURIComponent('Current User ID: user1')));
expect(tryOpenURL).toHaveBeenCalledWith(expect.stringContaining(encodeURIComponent('Current Team ID: team1')));
expect(tryOpenURL).toHaveBeenCalledWith(expect.stringContaining(encodeURIComponent('Server Version: 1.0.0')));
expect(tryOpenURL).toHaveBeenCalledWith(expect.stringContaining(encodeURIComponent('App Version: 2.0.0')));
expect(tryOpenURL).toHaveBeenCalledWith(expect.stringContaining(encodeURIComponent('App Platform: ios')));
});
});
});
describe('getDefaultReportAProblemLink', () => {
it('should return licensed link when isLicensed is true', () => {
const link = getDefaultReportAProblemLink(true);
expect(link).toBe('https://mattermost.com/pl/report_a_problem_licensed');
});
it('should return unlicensed link when isLicensed is false', () => {
const link = getDefaultReportAProblemLink(false);
expect(link).toBe('https://mattermost.com/pl/report_a_problem_unlicensed');
});
});
describe('metadataToString', () => {
it('should return a string with the correct metadata', () => {
const metadata = {
currentUserId: 'user1',
currentTeamId: 'team1',
serverVersion: '1.0.0',
appVersion: '2.0.0',
appPlatform: 'ios',
};
const string = metadataToString(metadata);
expect(string).toBe('Current User ID: user1\nCurrent Team ID: team1\nServer Version: 1.0.0\nApp Version: 2.0.0\nApp Platform: ios');
});
});

92
app/utils/share_logs.ts Normal file
View file

@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import TurboLogger from '@mattermost/react-native-turbo-log';
import {defineMessages} from 'react-intl';
import {Alert, Platform} from 'react-native';
import Share from 'react-native-share';
import {pathWithPrefix} from '@utils/file';
import {tryOpenURL} from './url';
import type {ReportAProblemMetadata} from '@typings/screens/report_a_problem';
export const shareLogs = async (metadata: ReportAProblemMetadata, siteName: string | undefined, reportAProblemMail: string | undefined, excludeLogs: boolean = false) => {
try {
const logPaths = await TurboLogger.getLogPaths();
const attachments = excludeLogs ? [] : logPaths.map((path) => pathWithPrefix('file://', path));
await Share.open({
subject: `Problem with ${siteName} React Native app`,
email: reportAProblemMail,
failOnCancel: false,
urls: attachments.length ? attachments : undefined,
message: [
'Please share a description of the problem:\n\n',
metadataToString(metadata),
].join('\n'),
});
} catch (e: unknown) {
Alert.alert('Error', `${e}`);
}
};
export const emailLogs = async (metadata: ReportAProblemMetadata, siteName: string | undefined, reportAProblemMail: string | undefined, excludeLogs: boolean = false) => {
try {
if (Platform.OS === 'ios') {
// iOS does not support sharing with different mail apps, so we use a mailto link
const subject = `Problem with ${siteName || 'Mattermost'} React Native app`;
const body = metadataToString(metadata);
const url = `mailto:${reportAProblemMail}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
tryOpenURL(url);
return;
}
const logPaths = await TurboLogger.getLogPaths();
const attachments = excludeLogs ? [] : logPaths.map((path) => pathWithPrefix('file://', path));
await Share.shareSingle({
social: Share.Social.EMAIL as any, // The type is not correct in the library
subject: `Problem with ${siteName} React Native app`,
email: reportAProblemMail,
urls: attachments.length ? attachments : undefined,
message: [
'Please share a description of the problem:\n\n',
metadataToString(metadata),
].join('\n'),
});
} catch (e: unknown) {
Alert.alert('Error', `${e}`);
}
};
export const getDefaultReportAProblemLink = (isLicensed: boolean) => {
return isLicensed ? 'https://mattermost.com/pl/report_a_problem_licensed' : 'https://mattermost.com/pl/report_a_problem_unlicensed';
};
export function metadataToString(metadata: ReportAProblemMetadata): string {
return Object.entries(metadata).
map(([key, value]) => `${reportAProblemMessages[key as keyof ReportAProblemMetadata].defaultMessage}: ${value}`).
join('\n');
}
export const reportAProblemMessages = defineMessages({
currentUserId: {
id: 'report_a_problem.metadata.currentUserId',
defaultMessage: 'Current User ID',
},
currentTeamId: {
id: 'report_a_problem.metadata.currentTeamId',
defaultMessage: 'Current Team ID',
},
serverVersion: {
id: 'report_a_problem.metadata.serverVersion',
defaultMessage: 'Server Version',
},
appVersion: {
id: 'report_a_problem.metadata.appVersion',
defaultMessage: 'App Version',
},
appPlatform: {
id: 'report_a_problem.metadata.appPlatform',
defaultMessage: 'App Platform',
},
});

View file

@ -380,6 +380,7 @@
"general_settings.about": "About {appTitle}", "general_settings.about": "About {appTitle}",
"general_settings.advanced_settings": "Advanced Settings", "general_settings.advanced_settings": "Advanced Settings",
"general_settings.display": "Display", "general_settings.display": "Display",
"general_settings.download_logs": "Download app logs",
"general_settings.help": "Help", "general_settings.help": "Help",
"general_settings.notifications": "Notifications", "general_settings.notifications": "Notifications",
"general_settings.report_problem": "Report a problem", "general_settings.report_problem": "Report a problem",
@ -1008,6 +1009,14 @@
"rate.error.title": "Error", "rate.error.title": "Error",
"rate.subtitle": "Let us know what you think.", "rate.subtitle": "Let us know what you think.",
"rate.title": "Enjoying Mattermost?", "rate.title": "Enjoying Mattermost?",
"report_a_problem.metadata.appPlatform": "App Platform",
"report_a_problem.metadata.appVersion": "App Version",
"report_a_problem.metadata.copy": "Copy",
"report_a_problem.metadata.currentTeamId": "Current Team ID",
"report_a_problem.metadata.currentUserId": "Current User ID",
"report_a_problem.metadata.serverVersion": "Server Version",
"report_problem.download_logs.title": "Download app logs",
"report_problem.title": "Report a problem",
"requested_ack.title": "Request Acknowledgements", "requested_ack.title": "Request Acknowledgements",
"saved_messages.empty.paragraph": "To save something for later, long-press on a message and choose Save from the menu. Saved messages are only visible to you.", "saved_messages.empty.paragraph": "To save something for later, long-press on a message and choose Save from the menu. Saved messages are only visible to you.",
"saved_messages.empty.title": "No saved messages yet", "saved_messages.empty.title": "No saved messages yet",
@ -1060,6 +1069,13 @@
"screen.channel_files.results.filter.title": "Filter by file type", "screen.channel_files.results.filter.title": "Filter by file type",
"screen.mentions.subtitle": "Messages you've been mentioned in", "screen.mentions.subtitle": "Messages you've been mentioned in",
"screen.mentions.title": "Recent Mentions", "screen.mentions.title": "Recent Mentions",
"screen.report_problem.button": "Report a problem",
"screen.report_problem.details.description": "When reporting a problem, share the metadata and app logs given below to help troubleshoot your problem faster",
"screen.report_problem.details.description_without_logs": "When reporting a problem, share the metadata given below to help troubleshoot your problem faster",
"screen.report_problem.details.title": "Troubleshouting details",
"screen.report_problem.logs.download": "Download App Logs",
"screen.report_problem.logs.title": "APP LOGS:",
"screen.report_problem.metadata.title": "METADATA:",
"screen.saved_messages.subtitle": "All messages you've saved for follow up", "screen.saved_messages.subtitle": "All messages you've saved for follow up",
"screen.saved_messages.title": "Saved Messages", "screen.saved_messages.title": "Saved Messages",
"screen.search.header.files": "Files", "screen.search.header.files": "Files",

View file

@ -64,8 +64,75 @@ class RNUtilsModuleImpl(private val reactContext: ReactApplicationContext) {
promise?.resolve(result) promise?.resolve(result)
} }
fun createZipFile(paths: List<String>, promise: Promise?) {
var zipFile: java.io.File? = null
var fos: java.io.FileOutputStream? = null
var zos: java.util.zip.ZipOutputStream? = null
try {
val dateFormat = java.text.SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.getDefault())
val date = java.util.Date()
val fileName = "Logs_${dateFormat.format(date)}.zip"
val tempDir = reactContext.cacheDir
zipFile = java.io.File(tempDir, fileName)
fos = java.io.FileOutputStream(zipFile)
zos = java.util.zip.ZipOutputStream(fos)
paths.forEach { path ->
val file = java.io.File(path)
if (file.exists()) {
var fis: java.io.FileInputStream? = null
try {
fis = java.io.FileInputStream(file)
val zipEntry = java.util.zip.ZipEntry(file.name)
zos.putNextEntry(zipEntry)
val buffer = ByteArray(1024)
var length: Int
while (fis.read(buffer).also { length = it } >= 0) {
zos.write(buffer, 0, length)
}
zos.closeEntry()
} finally {
fis?.close()
}
} else {
// Clean up if a file is missing
cleanupResources(zos, fos, zipFile)
promise?.reject("File does not exist: $path")
return
}
}
zos.close()
fos.close()
promise?.resolve(zipFile.absolutePath)
} catch (e: Exception) {
// Clean up on any error
cleanupResources(zos, fos, zipFile)
promise?.reject("Error creating ZIP file", e)
}
}
private fun cleanupResources(
zos: java.util.zip.ZipOutputStream?,
fos: java.io.FileOutputStream?,
zipFile: java.io.File?
) {
try {
zos?.close()
fos?.close()
zipFile?.delete()
} catch (e: Exception) {
// Log cleanup errors but don't throw as we're already in error handling
android.util.Log.e("RNUtils", "Error cleaning up resources", e)
}
}
fun saveFile(filePath: String?, promise: Promise?) { fun saveFile(filePath: String?, promise: Promise?) {
val task = SaveDataTask(reactContext) val task = SaveDataTask.getInstance(reactContext)
task.saveFile(filePath, promise) task.saveFile(filePath, promise)
} }

View file

@ -11,76 +11,102 @@ import com.facebook.react.bridge.BaseActivityEventListener
import com.facebook.react.bridge.Promise import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
import com.mattermost.rnutils.enums.Events import com.mattermost.rnutils.enums.Events
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.Locale import java.util.Locale
import java.util.Objects import java.util.Objects
import java.util.concurrent.Executors
open class SaveDataTask(val reactContext: ReactApplicationContext) { open class SaveDataTask(private val reactContext: ReactApplicationContext) {
private var mPickerPromise: Promise? = null private var mPickerPromise: Promise? = null
private var fileContent: String? = null private var fileContent: String? = null
private val weakContext = WeakReference(reactContext.applicationContext) private val weakContext = WeakReference(reactContext.applicationContext)
private val myExecutor = Executors.newSingleThreadExecutor()
private lateinit var mActivityEventListener: ActivityEventListener private lateinit var mActivityEventListener: ActivityEventListener
companion object { companion object {
const val SAVE_REQUEST: Int = 38641 const val SAVE_REQUEST: Int = 38641
}
init { // Store a single instance of the listener
mActivityEventListener = object : BaseActivityEventListener() { private var activityEventListener: ActivityEventListener? = null
override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, intent: Intent?) { private var instance: SaveDataTask? = null
if (requestCode == SAVE_REQUEST) {
if (resultCode == Activity.RESULT_CANCELED) {
mPickerPromise?.reject(Events.SAVE_ERROR_EVENT.event, "Save operation cancelled")
} else if (resultCode == Activity.RESULT_OK) {
val uri = intent!!.data
if (uri == null) {
mPickerPromise?.reject(Events.SAVE_ERROR_EVENT.event, "No data found")
} else {
try {
fileContent?.let { save(it, uri) }
mPickerPromise?.resolve(uri.toString())
} catch (e: java.lang.Exception) {
mPickerPromise?.reject(Events.SAVE_ERROR_EVENT.event, e.message)
}
}
}
mPickerPromise = null fun getInstance(reactContext: ReactApplicationContext): SaveDataTask {
reactContext.removeActivityEventListener(mActivityEventListener) if (instance == null) {
} instance = SaveDataTask(reactContext)
registerListener(reactContext)
} }
return instance!!
} }
reactContext.addActivityEventListener(mActivityEventListener) private fun registerListener(reactContext: ReactApplicationContext) {
} if (activityEventListener == null) {
activityEventListener = object : BaseActivityEventListener() {
private fun save(fromFile: String, toFile: Uri) { override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, intent: Intent?) {
myExecutor.execute { if (requestCode == SAVE_REQUEST) {
try { val taskInstance = instance // Ensure we access the latest instance
val pfd = weakContext.get()?.contentResolver?.openFileDescriptor(toFile, "w") if (taskInstance?.mPickerPromise != null) {
val input = File(fromFile) if (resultCode == Activity.RESULT_CANCELED) {
FileInputStream(input).use { fileInputStream -> taskInstance.mPickerPromise?.reject(Events.SAVE_ERROR_EVENT.event, "Save operation cancelled")
if (pfd != null) { taskInstance.mPickerPromise = null
FileOutputStream(pfd.fileDescriptor).use { fileOutputStream -> } else if (resultCode == Activity.RESULT_OK) {
val source = fileInputStream.channel val uri = intent?.data
val dest = fileOutputStream.channel if (uri == null) {
dest.transferFrom(source, 0, source.size()) taskInstance.mPickerPromise?.reject(Events.SAVE_ERROR_EVENT.event, "No data found")
source.close() taskInstance.mPickerPromise = null
dest.close() } else {
taskInstance.save(taskInstance.fileContent!!, uri)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ success ->
if (success) {
taskInstance.mPickerPromise?.resolve(uri.toString())
} else {
taskInstance.mPickerPromise?.reject(Events.SAVE_ERROR_EVENT.event, "Save failed")
}
taskInstance.mPickerPromise = null
}, { error ->
taskInstance.mPickerPromise?.reject(Events.SAVE_ERROR_EVENT.event, error.message)
taskInstance.mPickerPromise = null
})
}
} }
} }
} }
pfd?.close()
} catch (e: Exception) {
e.printStackTrace()
} }
}
reactContext.addActivityEventListener(activityEventListener!!)
}
}
}
private fun save(fromFile: String, toFile: Uri): Single<Boolean> {
return Single.create { emitter ->
try {
val pfd = weakContext.get()?.contentResolver?.openFileDescriptor(toFile, "w")
val input = File(fromFile)
FileInputStream(input).use { fileInputStream ->
if (pfd != null) {
FileOutputStream(pfd.fileDescriptor).use { fileOutputStream ->
val source = fileInputStream.channel
val dest = fileOutputStream.channel
dest.transferFrom(source, 0, source.size())
source.close()
dest.close()
}
}
}
pfd?.close()
emitter.onSuccess(true)
} catch (e: Exception) {
e.printStackTrace()
emitter.onSuccess(false)
}
} }
} }
@ -148,4 +174,4 @@ open class SaveDataTask(val reactContext: ReactApplicationContext) {
} }
} }
} }
} }

View file

@ -3,6 +3,7 @@ package com.mattermost.rnutils
import com.facebook.react.bridge.Promise import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.ReadableArray
class RNUtilsModule(val reactContext: ReactApplicationContext) : NativeRNUtilsSpec(reactContext) { class RNUtilsModule(val reactContext: ReactApplicationContext) : NativeRNUtilsSpec(reactContext) {
private var implementation: RNUtilsModuleImpl = RNUtilsModuleImpl(reactContext) private var implementation: RNUtilsModuleImpl = RNUtilsModuleImpl(reactContext)
@ -75,4 +76,9 @@ class RNUtilsModule(val reactContext: ReactApplicationContext) : NativeRNUtilsSp
override fun setSoftKeyboardToAdjustNothing() { override fun setSoftKeyboardToAdjustNothing() {
implementation.setSoftKeyboardToAdjustNothing() implementation.setSoftKeyboardToAdjustNothing()
} }
override fun createZipFile(paths: ReadableArray, promise: Promise?) {
val pathList = paths.toArrayList().map { it.toString() }
implementation.createZipFile(pathList, promise)
}
} }

View file

@ -5,6 +5,7 @@ import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.ReadableArray
class RNUtilsModule(context: ReactApplicationContext) : class RNUtilsModule(context: ReactApplicationContext) :
ReactContextBaseJavaModule(context) { ReactContextBaseJavaModule(context) {
@ -108,4 +109,10 @@ class RNUtilsModule(context: ReactApplicationContext) :
fun setSoftKeyboardToAdjustNothing() { fun setSoftKeyboardToAdjustNothing() {
implementation.setSoftKeyboardToAdjustNothing() implementation.setSoftKeyboardToAdjustNothing()
} }
@ReactMethod
fun createZipFile(paths: ReadableArray, promise: Promise?) {
val pathList = paths.toArrayList().map { it.toString() }
implementation.createZipFile(pathList, promise)
}
} }

View file

@ -131,6 +131,12 @@ RCT_REMAP_METHOD(setSoftKeyboardToAdjustNothing, setAdjustNothing) {
[self setSoftKeyboardToAdjustNothing]; [self setSoftKeyboardToAdjustNothing];
} }
RCT_EXPORT_METHOD(createZipFile:(NSArray<NSString *> *)paths
withResolver:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject) {
[self createZipFile:paths resolve:resolve reject:reject];
}
// Don't compile this code when we build for the old architecture. // Don't compile this code when we build for the old architecture.
#ifdef RCT_NEW_ARCH_ENABLED #ifdef RCT_NEW_ARCH_ENABLED
- (std::shared_ptr<TurboModule>)getTurboModule: - (std::shared_ptr<TurboModule>)getTurboModule:
@ -224,6 +230,16 @@ RCT_REMAP_METHOD(setSoftKeyboardToAdjustNothing, setAdjustNothing) {
resolve(@""); resolve(@"");
} }
- (void)createZipFile:(NSArray<NSString *> *)paths resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
NSDictionary *result = [wrapper createZipFileWithSourcePaths:paths];
if ([[result objectForKey:@"success"] boolValue]) {
resolve([result objectForKey:@"zipFilePath"]);
} else {
reject(@"create_zip_error", [result objectForKey:@"error"], nil);
}
}
-(void)setSoftKeyboardToAdjustResize { -(void)setSoftKeyboardToAdjustResize {
// Do nothing as it does not apply to iOS // Do nothing as it does not apply to iOS
} }

View file

@ -274,6 +274,77 @@ import React
UINavigationController.attemptRotationToDeviceOrientation() UINavigationController.attemptRotationToDeviceOrientation()
} }
} }
@objc public func createZipFile(sourcePaths: [String]) -> Dictionary<String, Any> {
let fileManager = FileManager.default
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd-HHmmss"
let currentDate = dateFormatter.string(from: Date())
let destinationURL = fileManager.temporaryDirectory.appendingPathComponent("Logs_\(currentDate).zip")
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString)
do {
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
let coordinator = NSFileCoordinator()
var coordinatorError: NSError?
var zipFilePath: String?
try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true, attributes: nil)
// Copy files to temp directory
for sourcePath in sourcePaths {
let sourceURL = URL(fileURLWithPath: sourcePath)
let destinationTempURL = tempDirectory.appendingPathComponent(sourceURL.lastPathComponent)
do {
try fileManager.copyItem(at: sourceURL, to: destinationTempURL)
} catch {
// Clean up temp directory if copy fails
try? fileManager.removeItem(at: tempDirectory)
return [
"error": "Failed to copy file: \(error.localizedDescription)",
"success": false
]
}
}
var moveError: Error?
coordinator.coordinate(readingItemAt: tempDirectory, options: [.forUploading], error: &coordinatorError) { (zipURL) in
do {
try fileManager.moveItem(at: zipURL, to: destinationURL)
zipFilePath = destinationURL.path
} catch let error {
moveError = error
}
}
// Clean up temp directory regardless of success or failure
try? fileManager.removeItem(at: tempDirectory)
if let error = moveError ?? coordinatorError {
// Clean up destination file if move failed
try? fileManager.removeItem(at: destinationURL)
return [
"error": error.localizedDescription,
"success": false
]
}
return [
"error": "",
"success": true,
"zipFilePath": zipFilePath ?? ""
]
} catch {
// Clean up both temp directory and destination file in case of any other errors
try? fileManager.removeItem(at: tempDirectory)
try? fileManager.removeItem(at: destinationURL)
return [
"error": error.localizedDescription,
"success": false
]
}
}
} }

View file

@ -73,6 +73,8 @@ export interface Spec extends TurboModule {
setSoftKeyboardToAdjustResize(): void; setSoftKeyboardToAdjustResize(): void;
setSoftKeyboardToAdjustNothing(): void; setSoftKeyboardToAdjustNothing(): void;
createZipFile: (paths: string[]) => Promise<string>;
} }
export default TurboModuleRegistry.getEnforcing<Spec>('RNUtils'); export default TurboModuleRegistry.getEnforcing<Spec>('RNUtils');

View file

@ -0,0 +1,16 @@
diff --git a/node_modules/expo-file-system/ios/FileSystemModule.swift b/node_modules/expo-file-system/ios/FileSystemModule.swift
index d564ae7..ace718c 100644
--- a/node_modules/expo-file-system/ios/FileSystemModule.swift
+++ b/node_modules/expo-file-system/ios/FileSystemModule.swift
@@ -80,7 +80,10 @@ public final class FileSystemModule: Module {
guard url.isFileURL else {
throw InvalidFileUrlException(url)
}
- try ensurePathPermission(appContext, path: url.appendingPathComponent("..").path, flag: .write)
+
+ // This line was impeding us to delete files in the tmp folder,
+ // since the ensure permission was mistakenly returning false.
+ //try ensurePathPermission(appContext, path: url.appendingPathComponent("..").path, flag: .write)
try removeFile(path: url.path, idempotent: options.idempotent)
}

View file

@ -69,6 +69,10 @@ jest.mock('expo-web-browser', () => ({
})), })),
})); }));
jest.mock('@mattermost/react-native-turbo-log', () => ({
getLogPaths: jest.fn(),
}));
jest.mock('@nozbe/watermelondb/utils/common/randomId/randomId', () => ({})); jest.mock('@nozbe/watermelondb/utils/common/randomId/randomId', () => ({}));
jest.mock('@nozbe/watermelondb/react/withObservables/garbageCollector', () => { jest.mock('@nozbe/watermelondb/react/withObservables/garbageCollector', () => {
@ -196,6 +200,9 @@ jest.doMock('react-native', () => {
removeThreadNotifications: jest.fn().mockImplementation(), removeThreadNotifications: jest.fn().mockImplementation(),
removeServerNotifications: jest.fn().mockImplementation(), removeServerNotifications: jest.fn().mockImplementation(),
createZipFile: jest.fn(),
saveFile: jest.fn(),
unlockOrientation: jest.fn(), unlockOrientation: jest.fn(),
getWindowDimensions: jest.fn().mockReturnValue({width: 426, height: 952}), getWindowDimensions: jest.fn().mockReturnValue({width: 426, height: 952}),
}, },
@ -450,6 +457,13 @@ jest.mock('react-native-haptic-feedback', () => {
}; };
}); });
jest.mock('@utils/log', () => ({
logError: jest.fn(),
logDebug: jest.fn(),
logInfo: jest.fn(),
logWarning: jest.fn(),
}));
declare const global: { declare const global: {
requestAnimationFrame: (callback: () => void) => void; requestAnimationFrame: (callback: () => void) => void;
performance: { performance: {
@ -483,3 +497,7 @@ console.warn = filterStackTrace(colors.yellow, '⚠️ Warning:');
console.error = filterStackTrace(colors.red, '🚨 Error:'); console.error = filterStackTrace(colors.red, '🚨 Error:');
console.log = filterStackTrace(colors.cyan, '📢 Log:'); console.log = filterStackTrace(colors.cyan, '📢 Log:');
console.debug = filterStackTrace(colors.blue, '🐞 Debug:'); console.debug = filterStackTrace(colors.blue, '🐞 Debug:');
// Silence warnings about missing EXPO_OS environment variable
// on tests
process.env.EXPO_OS = 'ios'; // eslint-disable-line no-process-env

View file

@ -7,6 +7,7 @@ interface ClientConfig {
AllowCustomThemes: string; AllowCustomThemes: string;
AllowEditPost: string; AllowEditPost: string;
AllowedThemes: string; AllowedThemes: string;
AllowDownloadLogs: string;
AndroidAppDownloadLink: string; AndroidAppDownloadLink: string;
AndroidLatestVersion: string; AndroidLatestVersion: string;
AndroidMinVersion: string; AndroidMinVersion: string;
@ -172,6 +173,8 @@ interface ClientConfig {
PersistentNotificationIntervalMinutes: string; PersistentNotificationIntervalMinutes: string;
PrivacyPolicyLink: string; PrivacyPolicyLink: string;
ReportAProblemLink: string; ReportAProblemLink: string;
ReportAProblemMail: string;
ReportAProblemType: string;
RequireEmailVerification: string; RequireEmailVerification: string;
RestrictDirectMessage: string; RestrictDirectMessage: string;
RunJobs: string; RunJobs: string;

View file

@ -0,0 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type ReportAProblemMetadata = {
currentUserId: string;
currentTeamId: string;
serverVersion: string;
appVersion: string;
appPlatform: string;
};