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 {getCurrentChannelId, getCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system';
import NavigationStore from '@store/navigation_store';
import {logDebug} from '@utils/log';
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 () => {
const consoleDebugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {});
(DatabaseManager.getServerDatabaseAndOperator as jest.Mock).mockImplementation(() => {
throw new Error('Test error');
});
@ -882,8 +882,7 @@ describe('actions/remote/entry/common', () => {
false,
);
expect(consoleDebugSpy).toHaveBeenCalled();
consoleDebugSpy.mockRestore();
expect(logDebug).toHaveBeenCalled();
});
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 {changeOpacity} from '@utils/theme';
type ConditionalProps = | {iconName: string; iconSize: number} | {iconName?: never; iconSize?: never}
type Props = ConditionalProps & {
type Props = {
theme: Theme;
backgroundStyle?: StyleProp<ViewStyle>;
buttonContainerStyle?: StyleProp<ViewStyle>;
@ -26,19 +24,31 @@ type Props = ConditionalProps & {
iconComponent?: ReactNode;
disabled?: boolean;
hitSlop?: Insets;
isIconOnTheRight?: boolean;
iconName?: string;
};
const styles = StyleSheet.create({
container: {flexDirection: 'row'},
icon: {marginRight: 7},
container: {
flexDirection: 'row',
gap: 7,
alignItems: 'center',
},
});
const iconSizePerSize: Record<ButtonSize, number> = {
xs: 14,
s: 14,
m: 18,
lg: 22,
};
const Button = ({
theme,
backgroundStyle,
buttonContainerStyle,
textStyle,
size,
size = 'm',
emphasis,
buttonType,
buttonState,
@ -46,7 +56,7 @@ const Button = ({
text,
testID,
iconName,
iconSize,
isIconOnTheRight = false,
iconComponent,
disabled,
hitSlop,
@ -61,15 +71,6 @@ const Button = ({
textStyle,
], [theme, textStyle, size, emphasis, buttonType]);
const textContainerStyle = useMemo(
() =>
(iconSize ? [
styles.container,
{minHeight: iconSize},
] : styles.container),
[iconSize],
);
let buttonStyle = StyleSheet.flatten(bgStyle);
if (disabled) {
buttonStyle = {
@ -83,13 +84,16 @@ const Button = ({
if (iconComponent) {
icon = iconComponent;
} else if (iconName) {
// We wrap the icon in a view to avoid it to follow text layout
icon = (
<CompassIcon
name={iconName!}
size={iconSize}
color={StyleSheet.flatten(txtStyle).color}
style={styles.icon}
/>
<View>
<CompassIcon
name={iconName!}
size={iconSizePerSize[size]}
color={StyleSheet.flatten(txtStyle).color}
testID={`${testID}-icon`}
/>
</View>
);
}
@ -102,14 +106,18 @@ const Button = ({
disabled={disabled}
hitSlop={hitSlop}
>
<View style={textContainerStyle}>
{icon}
<View
style={styles.container}
testID={`${testID}-text-container`}
>
{!isIconOnTheRight && icon}
<Text
style={txtStyle}
style={[txtStyle]}
numberOfLines={1}
>
{text}
</Text>
{isIconOnTheRight && icon}
</View>
</ElementButton>
);

View file

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Platform} from 'react-native';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
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 {Database} from '@nozbe/watermelondb';
jest.mock('expo-application', () => {
return {
nativeApplicationVersion: '1.2.3',
nativeBuildVersion: '456',
};
});
describe('observeCanUploadFiles', () => {
const serverUrl = 'baseHandler.test.com';
let database: Database;
@ -252,3 +261,68 @@ describe('observeCanDownloadFiles', () => {
});
}, 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.
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 {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) => {
enableSaveButton((b?.type === 'link' && Boolean(b?.link_url)) || (b?.type === 'file' && Boolean(b.file_id)));
setBookmark(b);
}, []);
}, [enableSaveButton]);
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'});
@ -115,7 +115,7 @@ const ChannelBookmarkAddOrEdit = ({
const enabled = Boolean(bookmark?.display_name &&
((bookmark?.type === 'link' && Boolean(bookmark?.link_url)) || (bookmark?.type === 'file' && Boolean(bookmark.file_id))));
enableSaveButton(enabled);
}, [bookmark, enableSaveButton, formatMessage]);
}, [bookmark?.display_name, bookmark?.file_id, bookmark?.link_url, bookmark?.type, enableSaveButton, formatMessage, original]);
const close = useCallback(() => {
return dismissModal({componentId});
@ -129,7 +129,7 @@ const ChannelBookmarkAddOrEdit = ({
}
handleError((res.error as Error).message);
}, [channelId, handleError, serverUrl]);
}, [channelId, close, handleError, serverUrl]);
const updateBookmark = useCallback(async (b: ChannelBookmark) => {
const res = await editChannelBookmark(serverUrl, b);
@ -139,7 +139,7 @@ const ChannelBookmarkAddOrEdit = ({
}
handleError((res.error as Error).message);
}, [handleError, serverUrl]);
}, [close, handleError, serverUrl]);
const setLinkBookmark = useCallback((url: string, title: string, imageUrl: string) => {
const b: ChannelBookmark = {
@ -195,12 +195,12 @@ const ChannelBookmarkAddOrEdit = ({
if (emoji) {
addRecentReaction(serverUrl, [emoji]);
}
}, [bookmark, enableSaveButton, serverUrl]);
}, [bookmark, enableSaveButton, original, serverUrl]);
const resetBookmark = useCallback(() => {
setBookmarkToSave(original);
setFile(originalFile);
}, [setBookmarkToSave]);
}, [original, originalFile, setBookmarkToSave]);
const onSaveBookmark = useCallback(async () => {
if (bookmark) {
@ -213,7 +213,7 @@ const ChannelBookmarkAddOrEdit = ({
createBookmark(bookmark);
}
}, [bookmark, createBookmark, updateBookmark]);
}, [bookmark, createBookmark, enableSaveButton, original, updateBookmark]);
const handleDelete = useCallback(async () => {
if (bookmark) {
@ -235,7 +235,7 @@ const ChannelBookmarkAddOrEdit = ({
close();
}
}, [bookmark, serverUrl, close]);
}, [bookmark, enableSaveButton, serverUrl, close, formatMessage]);
const onDelete = useCallback(async () => {
if (bookmark) {
@ -255,7 +255,7 @@ const ChannelBookmarkAddOrEdit = ({
}],
);
}
}, [bookmark, handleDelete]);
}, [bookmark, formatMessage, handleDelete]);
useEffect(() => {
enableSaveButton(false);
@ -307,9 +307,7 @@ const ChannelBookmarkAddOrEdit = ({
size='m'
text='Delete bookmark'
iconName='trash-can-outline'
textStyle={styles.deleteText}
backgroundStyle={styles.deleteBg}
iconSize={18}
emphasis='tertiary'
onPress={onDelete}
theme={theme}
disabled={isSaving}

View file

@ -12,7 +12,7 @@ import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {preventDoubleTap} from '@utils/tap';
import {usePreventDoubleTap} from '@hooks/utils';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -57,9 +57,9 @@ const MutedBanner = ({channelId}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const onPress = useCallback(preventDoubleTap(() => {
const onPress = usePreventDoubleTap(useCallback(() => {
toggleMuteChannel(serverUrl, channelId, false);
}), [channelId, serverUrl]);
}, [channelId, serverUrl]));
return (
<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.'
style={styles.contentText}
/>
<Button
buttonType='default'
onPress={onPress}
text={formatMessage({
id: 'channel_notification_preferences.unmute_content',
defaultMessage: 'Unmute channel',
})}
theme={theme}
backgroundStyle={styles.button}
iconName='bell-outline'
iconSize={18}
/>
<View style={styles.button}>
<Button
buttonType='default'
onPress={onPress}
text={formatMessage({
id: 'channel_notification_preferences.unmute_content',
defaultMessage: 'Unmute channel',
})}
theme={theme}
iconName='bell-outline'
/>
</View>
</Animated.View>
);
};

View file

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

View file

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

View file

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

View file

@ -285,16 +285,12 @@ exports[`components/categories_list should render channels error 1`] = `
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"backgroundColor": "#ffffff",
"borderRadius": 4,
"flex": 0,
"height": 48,
"justifyContent": "center",
"marginTop": 24,
"opacity": 1,
"paddingHorizontal": 24,
"paddingVertical": 14,
"paddingVertical": 12,
}
}
>
@ -302,17 +298,10 @@ exports[`components/categories_list should render channels error 1`] = `
style={
[
{
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
{
"fontSize": 16,
"lineHeight": 18,
"marginTop": 1,
"fontWeight": "600",
"lineHeight": 24,
},
{
"color": "#1c58d9",
@ -683,16 +672,12 @@ exports[`components/categories_list should render team error 1`] = `
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"backgroundColor": "#ffffff",
"borderRadius": 4,
"flex": 0,
"height": 48,
"justifyContent": "center",
"marginTop": 24,
"opacity": 1,
"paddingHorizontal": 24,
"paddingVertical": 14,
"paddingVertical": 12,
}
}
>
@ -700,17 +685,10 @@ exports[`components/categories_list should render team error 1`] = `
style={
[
{
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
{
"fontSize": 16,
"lineHeight": 18,
"marginTop": 1,
"fontWeight": "600",
"lineHeight": 24,
},
{
"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 React, {type ComponentType} from 'react';
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 {Navigation} from 'react-native-navigation';
import {SafeAreaProvider} from 'react-native-safe-area-context';
@ -13,10 +13,10 @@ import {Screens} from '@constants';
import {withServerDatabase} from '@database/components';
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 (
<GestureHandlerRootView style={[{flex: 1}, styles]}>
<GestureHandlerRootView style={{flex: 1}}>
<Screen {...props}/>
</GestureHandlerRootView>
);
@ -58,7 +58,6 @@ const withManagedConfig = (Screen: React.ComponentType) => {
Navigation.setLazyComponentRegistrator((screenName) => {
let screen: any|undefined;
let extraStyles: StyleProp<ViewStyle>;
switch (screenName) {
case Screens.ABOUT:
screen = withServerDatabase(require('@screens/settings/about').default);
@ -68,10 +67,7 @@ Navigation.setLazyComponentRegistrator((screenName) => {
break;
case Screens.BOTTOM_SHEET:
screen = withServerDatabase(require('@screens/bottom_sheet').default);
Navigation.registerComponent(Screens.BOTTOM_SHEET, () =>
withGestures(withSafeAreaInsets(withManagedConfig(screen)), undefined),
);
return;
break;
case Screens.BROWSE_CHANNELS:
screen = withServerDatabase(require('@screens/browse_channels').default);
break;
@ -199,6 +195,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.REACTIONS:
screen = withServerDatabase(require('@screens/reactions').default);
break;
case Screens.REPORT_PROBLEM:
screen = withServerDatabase(require('@screens/report_a_problem').default);
break;
case Screens.RESCHEDULE_DRAFT:
screen = withServerDatabase(require('@screens/reschedule_draft').default);
break;
@ -301,7 +300,7 @@ Navigation.setLazyComponentRegistrator((screenName) => {
}
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 serverScreen = require('@screens/server').default;
const onboardingScreen = require('@screens/onboarding').default;
Navigation.registerComponent(Screens.ONBOARDING, () => withGestures(withIntl(withManagedConfig(onboardingScreen)), undefined));
Navigation.registerComponent(Screens.SERVER, () => withSafeAreaInsets(withGestures(withIntl(withManagedConfig(serverScreen)), undefined)));
Navigation.registerComponent(Screens.HOME, () => withGestures(withSafeAreaInsets(withServerDatabase(withManagedConfig(homeScreen))), undefined));
Navigation.registerComponent(Screens.ONBOARDING, () => withGestures(withIntl(withManagedConfig(onboardingScreen))));
Navigation.registerComponent(Screens.SERVER, () => withSafeAreaInsets(withGestures(withIntl(withManagedConfig(serverScreen)))));
Navigation.registerComponent(Screens.HOME, () => withGestures(withSafeAreaInsets(withServerDatabase(withManagedConfig(homeScreen)))));
}

View file

@ -22,6 +22,7 @@ type Props = {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
button: {
marginTop: 5,
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
@ -37,9 +38,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
justifyContent: 'center',
width: 120,
},
signInButtonText: {
flexDirection: 'row',
},
footerButtonsContainer: {
flexDirection: 'column',
height: 120,
@ -131,7 +129,7 @@ const FooterButtons = ({
);
const signInButtonText = (
<Animated.View style={[styles.signInButtonText, opacitySignInTextStyle]}>
<Animated.View style={opacitySignInTextStyle}>
<FormattedText
id='mobile.onboarding.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 {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {usePreventDoubleTap} from '@hooks/utils';
import {t} from '@i18n';
import {popTopScreen} from '@screens/navigation';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {showSnackBar} from '@utils/snack_bar';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {tryOpenURL} from '@utils/url';
@ -153,27 +152,27 @@ const About = ({componentId, config, license}: AboutProps) => {
};
tryOpenURL(url, onError);
}, []);
}, [intl]);
const handleAboutTeam = useCallback(preventDoubleTap(() => {
const handleAboutTeam = usePreventDoubleTap(useCallback(() => {
return openURL(Config.WebsiteURL);
}), []);
}, [openURL]));
const handlePlatformNotice = useCallback(preventDoubleTap(() => {
const handlePlatformNotice = usePreventDoubleTap(useCallback(() => {
return openURL(Config.ServerNoticeURL);
}), []);
}, [openURL]));
const handleMobileNotice = useCallback(preventDoubleTap(() => {
const handleMobileNotice = usePreventDoubleTap(useCallback(() => {
return openURL(Config.MobileNoticeURL);
}), []);
}, [openURL]));
const handleTermsOfService = useCallback(preventDoubleTap(() => {
const handleTermsOfService = usePreventDoubleTap(useCallback(() => {
return openURL(AboutLinks.TERMS_OF_SERVICE);
}), []);
}, [openURL]));
const handlePrivacyPolicy = useCallback(preventDoubleTap(() => {
const handlePrivacyPolicy = usePreventDoubleTap(useCallback(() => {
return openURL(AboutLinks.PRIVACY_POLICY);
}), []);
}, [openURL]));
const serverVersion = useMemo(() => {
const buildNumber = config.BuildNumber;
@ -210,7 +209,7 @@ const About = ({componentId, config, license}: AboutProps) => {
Clipboard.setString(copiedString);
showSnackBar({barType: SNACK_BAR_TYPE.INFO_COPIED, sourceScreen: componentId});
},
[intl, config, loadMetric],
[intl, config.BuildNumber, config.Version, config.SQLDriverName, config.SchemaVersion, loadMetric, componentId],
);
return (
@ -306,17 +305,17 @@ const About = ({componentId, config, license}: AboutProps) => {
{config.SchemaVersion}
</Text>
</View>
<Button
theme={theme}
backgroundStyle={[buttonBackgroundStyle(theme, 'm', 'tertiary'), styles.copyInfoButtonContainer]}
onPress={copyToClipboard}
textStyle={buttonTextStyle(theme, 'm', 'tertiary', 'default')}
text={intl.formatMessage({id: 'settings.about.button.copyInfo', defaultMessage: 'Copy info'})}
testID={'about.copy_info'}
iconName='content-copy'
iconSize={15}
buttonType={'default'}
/>
<View style={styles.copyInfoButtonContainer}>
<Button
theme={theme}
onPress={copyToClipboard}
text={intl.formatMessage({id: 'settings.about.button.copyInfo', defaultMessage: 'Copy info'})}
testID={'about.copy_info'}
iconName='content-copy'
emphasis='tertiary'
size='m'
/>
</View>
{license.IsLicensed === 'true' && (
<View style={styles.licenseContainer}>
<FormattedText

View file

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

View file

@ -165,46 +165,42 @@ exports[`SendTestNotificationNotice should match snapshot 1`] = `
"borderColor": "#2089dc",
"borderRadius": 4,
"borderWidth": 0,
"flex": 0,
"flexDirection": "row",
"height": 40,
"justifyContent": "center",
"padding": 8,
"paddingHorizontal": 20,
"paddingVertical": 12,
"paddingVertical": 10,
}
}
>
<View
style={
{
"alignItems": "center",
"flexDirection": "row",
"gap": 7,
}
}
testID="undefined-text-container"
>
<Text
numberOfLines={1}
style={
[
[
{
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
{
"fontSize": 14,
"lineHeight": 14,
"marginTop": 3,
},
{
"color": "#ffffff",
},
[
{
"fontFamily": "OpenSans-SemiBold",
"fontSize": 14,
"fontWeight": "600",
"lineHeight": 20,
},
{
"color": "#ffffff",
},
],
undefined,
],
undefined,
]
}
>
@ -272,61 +268,50 @@ exports[`SendTestNotificationNotice should match snapshot 1`] = `
"borderColor": "#2089dc",
"borderRadius": 4,
"borderWidth": 0,
"flex": 0,
"flexDirection": "row",
"height": 40,
"justifyContent": "center",
"padding": 8,
"paddingHorizontal": 20,
"paddingVertical": 12,
"paddingVertical": 10,
}
}
>
<View
style={
[
{
"flexDirection": "row",
},
{
"minHeight": 18,
},
]
}
>
<Icon
color="#1c58d9"
name="open-in-new"
size={18}
style={
{
"marginRight": 7,
}
{
"alignItems": "center",
"flexDirection": "row",
"gap": 7,
}
/>
}
testID="undefined-text-container"
>
<View>
<Icon
color="#1c58d9"
name="open-in-new"
size={18}
testID="undefined-icon"
/>
</View>
<Text
numberOfLines={1}
style={
[
[
{
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
{
"fontSize": 14,
"lineHeight": 14,
"marginTop": 3,
},
{
"color": "#1c58d9",
},
[
{
"fontFamily": "OpenSans-SemiBold",
"fontSize": 14,
"fontWeight": "600",
"lineHeight": 20,
},
{
"color": "#1c58d9",
},
],
undefined,
],
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 {observeConfigValue, observeCurrentUserId, observeCurrentTeamId} from '@queries/servers/system';
import {observeConfigBooleanValue, observeConfigValue, observeReportAProblemMetadata} from '@queries/servers/system';
import ReportProblem from './report_problem';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const buildNumber = observeConfigValue(database, 'BuildNumber');
const currentTeamId = observeCurrentTeamId(database);
const currentUserId = observeCurrentUserId(database);
const supportEmail = observeConfigValue(database, 'SupportEmail');
const version = observeConfigValue(database, 'Version');
return {
buildNumber,
currentTeamId,
currentUserId,
supportEmail,
version,
};
});
const enhanced = withObservables([], ({database}) => ({
allowDownloadLogs: observeConfigBooleanValue(database, 'AllowDownloadLogs', true),
reportAProblemMail: observeConfigValue(database, 'ReportAProblemMail'),
reportAProblemType: observeConfigValue(database, 'ReportAProblemType'),
siteName: observeConfigValue(database, 'SiteName'),
metadata: observeReportAProblemMetadata(database),
}));
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.
// See LICENSE.txt for license information.
import TurboLogger from '@mattermost/react-native-turbo-log';
import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application';
import {deviceName} from 'expo-device';
import React from 'react';
import {Alert, Platform} from 'react-native';
import Share from 'react-native-share';
import React, {useCallback} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import SettingItem from '@components/settings/item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {pathWithPrefix} from '@utils/files';
import {preventDoubleTap} from '@utils/tap';
import {goToScreen} from '@screens/navigation';
import {emailLogs} from '@utils/share_logs';
import type {ReportAProblemMetadata} from '@typings/screens/report_a_problem';
type ReportProblemProps = {
buildNumber: string;
currentTeamId: string;
currentUserId: string;
supportEmail: string;
version: string;
siteName: string;
};
allowDownloadLogs?: boolean;
reportAProblemMail?: string;
reportAProblemType?: string;
siteName?: string;
metadata: ReportAProblemMetadata;
}
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 intl = useIntl();
const onlyAllowLogs = allowDownloadLogs && reportAProblemType === 'hidden';
const skipReportAProblemScreen = reportAProblemType === 'email' && !allowDownloadLogs;
const openEmailClient = preventDoubleTap(async () => {
try {
const logPaths = await TurboLogger.getLogPaths();
const attachments = logPaths.map((path) => pathWithPrefix('file://', path));
await Share.open({
subject: `Problem with ${siteName} React Native app`,
email: supportEmail,
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);
const onPress = useCallback(() => {
if (skipReportAProblemScreen) {
emailLogs(metadata, siteName, reportAProblemMail, true);
} else {
const message = onlyAllowLogs ? messages.downloadLogs : messages.reportProblem;
const title = intl.formatMessage(message);
goToScreen(Screens.REPORT_PROBLEM, title);
}
});
}, [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 (
<SettingItem
optionLabelTextStyle={{color: theme.linkColor}}
onPress={openEmailClient}
onPress={onPress}
optionName='report_problem'
separator={false}
testID='settings.report_problem.option'

View file

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

View file

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

View file

@ -3,7 +3,7 @@
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';
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', () => {
const defaultTextStyle = buttonTextStyles.main;
const theme = Preferences.THEMES.denim;
test('button text default values', () => {
const tests = [{
getStyle: () => buttonTextStyle(theme),
expected: [
defaultTextStyle,
buttonTextSizeStyles.m,
{color: theme.buttonColor},
],
}, {
getStyle: () => buttonTextStyle(theme, 'xs'),
expected: [
defaultTextStyle,
buttonTextSizeStyles.xs,
{color: theme.buttonColor},
],
}, {
getStyle: () => buttonTextStyle(theme, 'lg', 'secondary'),
expected: [
defaultTextStyle,
buttonTextSizeStyles.lg,
{color: theme.buttonBg},
],
@ -104,26 +100,29 @@ describe('get the text style of a button', () => {
const btnTypes: ButtonType[] = ['default', 'destructive', 'disabled', 'inverted'];
const getColor = (type: ButtonType, emphasis: ButtonEmphasis) => {
let color: string = theme.buttonColor;
if (type === 'disabled') {
color = changeOpacity(theme.centerChannelColor, 0.32);
return changeOpacity(theme.centerChannelColor, 0.32);
}
if ((type === 'destructive' && emphasis !== 'primary')) {
color = theme.errorTextColor;
if (type === 'destructive') {
if (emphasis === 'primary') {
return theme.buttonColor;
}
return theme.errorTextColor;
}
if ((type === 'inverted' && emphasis === 'primary') ||
(type !== 'inverted' && emphasis !== 'primary')) {
color = theme.buttonBg;
if (type === 'inverted') {
if (emphasis === 'primary') {
return theme.buttonBg;
}
return theme.buttonColor;
}
if (type === 'inverted' && emphasis === 'tertiary') {
color = theme.sidebarText;
if (emphasis === 'primary') {
return theme.buttonColor;
}
return color;
return theme.buttonBg;
};
btnEnphasis.forEach((emphasis) => {
@ -131,7 +130,6 @@ describe('get the text style of a button', () => {
btnTypes.forEach((type) => {
const style = buttonTextStyle(theme, size, emphasis, type);
expect(style).toEqual([
defaultTextStyle,
buttonTextSizeStyles[size],
{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 {typography} from './typography';
export const getBackgroundStyles = (theme: Theme): BackgroundStyles => {
return {
primary: {
@ -288,32 +290,25 @@ export const getBackgroundStyles = (theme: Theme): BackgroundStyles => {
export const buttonSizeStyles: ButtonSizes = StyleSheet.create({
xs: {
height: 24,
paddingVertical: 6,
paddingVertical: 4,
paddingHorizontal: 10,
},
s: {
height: 32,
paddingVertical: 10,
paddingVertical: 8,
paddingHorizontal: 16,
},
m: {
height: 40,
paddingVertical: 12,
paddingVertical: 10,
paddingHorizontal: 20,
},
lg: {
height: 48,
paddingVertical: 14,
paddingVertical: 12,
paddingHorizontal: 24,
},
});
export const buttonStyles = StyleSheet.create({
main: {
flex: 0,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4,
},
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({
xs: {
fontSize: 11,
lineHeight: 10,
letterSpacing: 0.02,
marginTop: 2,
...typography('Body', 50, 'SemiBold'),
},
s: {
fontSize: 12,
lineHeight: 12,
marginTop: 1,
...typography('Body', 75, 'SemiBold'),
},
m: {
fontSize: 14,
lineHeight: 14,
marginTop: 3,
...typography('Body', 100, 'SemiBold'),
},
lg: {
fontSize: 16,
lineHeight: 18,
marginTop: 1,
...typography('Body', 200, 'SemiBold'),
},
});
@ -398,25 +370,31 @@ export const buttonTextStyle = (
emphasis: ButtonEmphasis = 'primary',
type: ButtonType = 'default',
): StyleProp<TextStyle> => {
// Color
let color: string = theme.buttonColor;
if (type === 'disabled') {
color = changeOpacity(theme.centerChannelColor, 0.32);
}
if ((type === 'destructive' && emphasis !== 'primary')) {
color = theme.errorTextColor;
}
if ((type === 'inverted' && emphasis === 'primary') ||
(type !== 'inverted' && emphasis !== 'primary')) {
color = theme.buttonBg;
}
if (type === 'inverted' && emphasis === 'tertiary') {
color = theme.sidebarText;
}
return [buttonTextStyles.main, buttonTextSizeStyles[size], {color}];
return [buttonTextSizeStyles[size], {color: getColorByType(theme, type, emphasis)}];
};
const getColorByType = (theme: Theme, type: ButtonType, emphasis: ButtonEmphasis) => {
if (type === 'disabled') {
return changeOpacity(theme.centerChannelColor, 0.32);
}
if (type === 'destructive') {
if (emphasis === 'primary') {
return theme.buttonColor;
}
return theme.errorTextColor;
}
if (type === 'inverted') {
if (emphasis === 'primary') {
return theme.buttonBg;
}
return theme.buttonColor;
}
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 {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('react-native', () => {
@ -272,5 +296,12 @@ describe('Image utils', () => {
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};
}
};
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 {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';
@ -102,9 +102,4 @@ describe('Files utils', () => {
const types = result.map((f) => f.type);
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[]) => {
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(),
}));
// 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
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.advanced_settings": "Advanced Settings",
"general_settings.display": "Display",
"general_settings.download_logs": "Download app logs",
"general_settings.help": "Help",
"general_settings.notifications": "Notifications",
"general_settings.report_problem": "Report a problem",
@ -1008,6 +1009,14 @@
"rate.error.title": "Error",
"rate.subtitle": "Let us know what you think.",
"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",
"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",
@ -1060,6 +1069,13 @@
"screen.channel_files.results.filter.title": "Filter by file type",
"screen.mentions.subtitle": "Messages you've been mentioned in",
"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.title": "Saved Messages",
"screen.search.header.files": "Files",

View file

@ -64,8 +64,75 @@ class RNUtilsModuleImpl(private val reactContext: ReactApplicationContext) {
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?) {
val task = SaveDataTask(reactContext)
val task = SaveDataTask.getInstance(reactContext)
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.ReactApplicationContext
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.FileInputStream
import java.io.FileOutputStream
import java.lang.ref.WeakReference
import java.util.Locale
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 fileContent: String? = null
private val weakContext = WeakReference(reactContext.applicationContext)
private val myExecutor = Executors.newSingleThreadExecutor()
private lateinit var mActivityEventListener: ActivityEventListener
companion object {
const val SAVE_REQUEST: Int = 38641
}
init {
mActivityEventListener = object : BaseActivityEventListener() {
override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, intent: Intent?) {
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)
}
}
}
// Store a single instance of the listener
private var activityEventListener: ActivityEventListener? = null
private var instance: SaveDataTask? = null
mPickerPromise = null
reactContext.removeActivityEventListener(mActivityEventListener)
}
fun getInstance(reactContext: ReactApplicationContext): SaveDataTask {
if (instance == null) {
instance = SaveDataTask(reactContext)
registerListener(reactContext)
}
return instance!!
}
reactContext.addActivityEventListener(mActivityEventListener)
}
private fun save(fromFile: String, toFile: Uri) {
myExecutor.execute {
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()
private fun registerListener(reactContext: ReactApplicationContext) {
if (activityEventListener == null) {
activityEventListener = object : BaseActivityEventListener() {
override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, intent: Intent?) {
if (requestCode == SAVE_REQUEST) {
val taskInstance = instance // Ensure we access the latest instance
if (taskInstance?.mPickerPromise != null) {
if (resultCode == Activity.RESULT_CANCELED) {
taskInstance.mPickerPromise?.reject(Events.SAVE_ERROR_EVENT.event, "Save operation cancelled")
taskInstance.mPickerPromise = null
} else if (resultCode == Activity.RESULT_OK) {
val uri = intent?.data
if (uri == null) {
taskInstance.mPickerPromise?.reject(Events.SAVE_ERROR_EVENT.event, "No data found")
taskInstance.mPickerPromise = null
} 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.ReactApplicationContext
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.ReadableArray
class RNUtilsModule(val reactContext: ReactApplicationContext) : NativeRNUtilsSpec(reactContext) {
private var implementation: RNUtilsModuleImpl = RNUtilsModuleImpl(reactContext)
@ -75,4 +76,9 @@ class RNUtilsModule(val reactContext: ReactApplicationContext) : NativeRNUtilsSp
override fun 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.ReactMethod
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.ReadableArray
class RNUtilsModule(context: ReactApplicationContext) :
ReactContextBaseJavaModule(context) {
@ -108,4 +109,10 @@ class RNUtilsModule(context: ReactApplicationContext) :
fun 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];
}
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.
#ifdef RCT_NEW_ARCH_ENABLED
- (std::shared_ptr<TurboModule>)getTurboModule:
@ -224,6 +230,16 @@ RCT_REMAP_METHOD(setSoftKeyboardToAdjustNothing, setAdjustNothing) {
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 {
// Do nothing as it does not apply to iOS
}

View file

@ -274,6 +274,77 @@ import React
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;
setSoftKeyboardToAdjustNothing(): void;
createZipFile: (paths: string[]) => Promise<string>;
}
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/react/withObservables/garbageCollector', () => {
@ -196,6 +200,9 @@ jest.doMock('react-native', () => {
removeThreadNotifications: jest.fn().mockImplementation(),
removeServerNotifications: jest.fn().mockImplementation(),
createZipFile: jest.fn(),
saveFile: jest.fn(),
unlockOrientation: jest.fn(),
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: {
requestAnimationFrame: (callback: () => void) => void;
performance: {
@ -483,3 +497,7 @@ console.warn = filterStackTrace(colors.yellow, '⚠️ Warning:');
console.error = filterStackTrace(colors.red, '🚨 Error:');
console.log = filterStackTrace(colors.cyan, '📢 Log:');
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;
AllowEditPost: string;
AllowedThemes: string;
AllowDownloadLogs: string;
AndroidAppDownloadLink: string;
AndroidLatestVersion: string;
AndroidMinVersion: string;
@ -172,6 +173,8 @@ interface ClientConfig {
PersistentNotificationIntervalMinutes: string;
PrivacyPolicyLink: string;
ReportAProblemLink: string;
ReportAProblemMail: string;
ReportAProblemType: string;
RequireEmailVerification: string;
RestrictDirectMessage: 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;
};