diff --git a/app/components/EditedIndicator/index.test.tsx b/app/components/EditedIndicator/index.test.tsx new file mode 100644 index 000000000..393019bfa --- /dev/null +++ b/app/components/EditedIndicator/index.test.tsx @@ -0,0 +1,128 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {Preferences} from '@constants'; +import {renderWithIntlAndTheme} from '@test/intl-test-helper'; + +import EditedIndicator from './index'; + +describe('components/EditedIndicator', () => { + const baseProps = { + baseTextStyle: {fontSize: 16, color: '#000'}, + theme: Preferences.THEMES.denim, + context: ['paragraph'], + testID: 'edited-indicator-test', + }; + + it('should render with default props', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + const indicator = getByTestId('edited-indicator-test'); + expect(indicator).toBeTruthy(); + expect(indicator.props.children).toEqual([ + ' ', // spacer for paragraph context + expect.objectContaining({ + props: expect.objectContaining({ + name: 'pencil-outline', + size: 14, // default icon size + }), + }), + expect.objectContaining({ + props: expect.objectContaining({ + id: 'post_message_view.edited', + defaultMessage: 'Edited', + }), + }), + ]); + }); + + it('should render with custom icon size', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + const indicator = getByTestId('edited-indicator-test'); + const icon = indicator.props.children[1]; + expect(icon.props.size).toBe(20); + }); + + it('should render with custom testID', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + expect(getByTestId('custom-test-id')).toBeTruthy(); + }); + + describe('component structure', () => { + it('should render pencil-outline icon', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + const indicator = getByTestId('edited-indicator-test'); + const icon = indicator.props.children[1]; + expect(icon.props.name).toBe('pencil-outline'); + }); + + it('should render "Edited" text with correct props', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + const indicator = getByTestId('edited-indicator-test'); + const formattedText = indicator.props.children[2]; + expect(formattedText.props.id).toBe('post_message_view.edited'); + expect(formattedText.props.defaultMessage).toBe('Edited'); + }); + }); + + describe('edge cases', () => { + it('should handle empty context array', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + const indicator = getByTestId('edited-indicator-test'); + expect(indicator.props.children[0]).toBe(''); + }); + + it('should handle undefined context elements', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + const indicator = getByTestId('edited-indicator-test'); + expect(indicator.props.children[0]).toBe(''); + }); + + it('should handle context with multiple elements', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + const indicator = getByTestId('edited-indicator-test'); + expect(indicator.props.children[0]).toBe(' '); + }); + }); +}); diff --git a/app/components/EditedIndicator/index.tsx b/app/components/EditedIndicator/index.tsx new file mode 100644 index 000000000..25a9c1e6e --- /dev/null +++ b/app/components/EditedIndicator/index.tsx @@ -0,0 +1,83 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Platform, type StyleProp, Text, type TextStyle} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {blendColors, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type EditedIndicatorProps = { + baseTextStyle: StyleProp; + theme: Theme; + context: string[]; + iconSize?: number; + checkHeadings?: boolean; + testID?: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + // Android has trouble giving text transparency depending on how it's nested, + // so we calculate the resulting colour manually + const editedOpacity = Platform.select({ + ios: 0.56, + android: 1.0, + }); + const editedColor = Platform.select({ + ios: theme.centerChannelColor, + android: blendColors(theme.centerChannelBg, theme.centerChannelColor, 0.3), + }); + + return { + editedIndicatorText: { + color: editedColor, + opacity: editedOpacity, + fontStyle: 'italic', + ...typography('Body', 25, 'Regular'), + }, + editedText: { + ...typography('Body', 100, 'Regular'), + }, + }; +}); + +const EditedIndicator = ({ + baseTextStyle, + theme, + context, + iconSize = 14, + checkHeadings = false, + testID = 'edited_indicator', +}: EditedIndicatorProps) => { + const style = getStyleSheet(theme); + let spacer = ''; + const styles = [baseTextStyle, style.editedIndicatorText]; + + // Add space for paragraphs, and optionally for headings + if (context[0] === 'paragraph' || (checkHeadings && context[0]?.startsWith('heading'))) { + spacer = ' '; + } + + return ( + + {spacer} + + + + ); +}; + +export default EditedIndicator; diff --git a/app/components/jumbo_emoji/index.tsx b/app/components/jumbo_emoji/index.tsx index 53dec971d..7488c7271 100644 --- a/app/components/jumbo_emoji/index.tsx +++ b/app/components/jumbo_emoji/index.tsx @@ -4,12 +4,11 @@ import {Node, Parser} from 'commonmark'; import Renderer from 'commonmark-react-renderer'; import React, {type ReactElement, useRef} from 'react'; -import {Platform, type StyleProp, Text, type TextStyle, View} from 'react-native'; +import {type StyleProp, StyleSheet, Text, type TextStyle, View} from 'react-native'; +import EditedIndicator from '@components/EditedIndicator'; import Emoji from '@components/emoji'; -import FormattedText from '@components/formatted_text'; import {useTheme} from '@context/theme'; -import {blendColors, makeStyleSheetFromTheme} from '@utils/theme'; type JumboEmojiProps = { baseTextStyle: StyleProp; @@ -17,41 +16,23 @@ type JumboEmojiProps = { value: string; } -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { - // Android has trouble giving text transparency depending on how it's nested, - // so we calculate the resulting colour manually - const editedOpacity = Platform.select({ - ios: 0.3, - android: 1.0, - }); - const editedColor = Platform.select({ - ios: theme.centerChannelColor, - android: blendColors(theme.centerChannelBg, theme.centerChannelColor, 0.3), - }); - - return { - block: { - alignItems: 'flex-start', - flexDirection: 'row', - flexWrap: 'wrap', - }, - editedIndicatorText: { - color: editedColor, - opacity: editedOpacity, - }, - jumboEmoji: { - fontSize: 50, - lineHeight: 60, - }, - newLine: { - lineHeight: 60, - }, - }; +const style = StyleSheet.create({ + block: { + alignItems: 'flex-start', + flexDirection: 'row', + flexWrap: 'wrap', + }, + jumboEmoji: { + fontSize: 50, + lineHeight: 60, + }, + newLine: { + lineHeight: 60, + }, }); const JumboEmoji = ({baseTextStyle, isEdited, value}: JumboEmojiProps) => { const theme = useTheme(); - const style = getStyleSheet(theme); const renderEmoji = ({emojiName, literal}: {context: string[]; emojiName: string; literal: string}) => { return ( @@ -81,27 +62,15 @@ const JumboEmoji = ({baseTextStyle, isEdited, value}: JumboEmojiProps) => { }; const renderEditedIndicator = ({context}: {context: string[]}) => { - let spacer = ''; - if (context[0] === 'paragraph') { - spacer = ' '; - } - - const styles = [ - baseTextStyle, - style.editedIndicatorText, - ]; - return ( - - {spacer} - - + /> ); }; diff --git a/app/components/markdown/markdown.tsx b/app/components/markdown/markdown.tsx index 38f23a527..f39473a74 100644 --- a/app/components/markdown/markdown.tsx +++ b/app/components/markdown/markdown.tsx @@ -1,18 +1,20 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ import {useManagedConfig} from '@mattermost/react-native-emm'; import {Parser, Node} from 'commonmark'; import Renderer from 'commonmark-react-renderer'; import React, {type ReactElement, useMemo, useRef} from 'react'; -import {Dimensions, type GestureResponderEvent, Platform, type StyleProp, StyleSheet, Text, type TextStyle, View, type ViewStyle} from 'react-native'; +import {Dimensions, type GestureResponderEvent, type StyleProp, StyleSheet, Text, type TextStyle, View, type ViewStyle} from 'react-native'; import CompassIcon from '@components/compass_icon'; +import EditedIndicator from '@components/EditedIndicator'; import Emoji from '@components/emoji'; import FormattedText from '@components/formatted_text'; import {logError} from '@utils/log'; import {computeTextStyle} from '@utils/markdown'; -import {blendColors, changeOpacity, concatStyles, makeStyleSheetFromTheme} from '@utils/theme'; +import {changeOpacity, concatStyles, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; import {getScheme} from '@utils/url'; @@ -81,27 +83,12 @@ type MarkdownProps = { } const getStyleSheet = makeStyleSheetFromTheme((theme) => { - // Android has trouble giving text transparency depending on how it's nested, - // so we calculate the resulting colour manually - const editedOpacity = Platform.select({ - ios: 0.3, - android: 1.0, - }); - const editedColor = Platform.select({ - ios: theme.centerChannelColor, - android: blendColors(theme.centerChannelBg, theme.centerChannelColor, 0.3), - }); - return { block: { alignItems: 'flex-start', flexDirection: 'row', flexWrap: 'wrap', }, - editedIndicatorText: { - color: editedColor, - opacity: editedOpacity, - }, errorMessage: { color: theme.errorTextColor, ...typography('Body', 100), @@ -262,24 +249,15 @@ const Markdown = ({ }; const renderEditedIndicator = ({context}: {context: string[]}) => { - let spacer = ''; - const styles = [baseTextStyle, style.editedIndicatorText]; - - if (context[0] === 'paragraph') { - spacer = ' '; - } - return ( - - {spacer} - - + /> ); }; @@ -638,7 +616,7 @@ const Markdown = ({ if (isEdited) { const editIndicatorNode = new Node('edited_indicator'); if (ast.lastChild && ['heading', 'paragraph'].includes(ast.lastChild.type)) { - ast.appendChild(editIndicatorNode); + ast.lastChild.appendChild(editIndicatorNode); } else { const node = new Node('paragraph'); node.appendChild(editIndicatorNode); diff --git a/app/components/post_list/post/body/index.tsx b/app/components/post_list/post/body/index.tsx index fa9679dd5..df41b6e7d 100644 --- a/app/components/post_list/post/body/index.tsx +++ b/app/components/post_list/post/body/index.tsx @@ -155,7 +155,7 @@ const Body = ({ value={post.message} /> ); - } else if (post.message.length) { + } else if (post.message.length || isEdited) { // isEdited is added to handle the case where the post is edited and the message is empty message = (