diff --git a/app/containers/markdown/HighlightWords.stories.tsx b/app/containers/markdown/HighlightWords.stories.tsx new file mode 100644 index 00000000000..965efc91dd3 --- /dev/null +++ b/app/containers/markdown/HighlightWords.stories.tsx @@ -0,0 +1,37 @@ + +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { NavigationContainer } from '@react-navigation/native'; + +import Markdown from '.'; +import { themes } from '../../lib/constants/colors'; + +const theme = 'light'; + +const styles = StyleSheet.create({ + container: { + marginHorizontal: 15, + backgroundColor: themes[theme].surfaceRoom, + marginVertical: 50 + } +}); + +export default { + title: 'Markdown/Highlights', + decorators: [ + (Story: any) => ( + + + + ) + ] +}; + +export const Highlights = () => ( + + + + + +); + diff --git a/app/containers/markdown/components/Plain.test.tsx b/app/containers/markdown/components/Plain.test.tsx new file mode 100644 index 00000000000..0ff72b214c8 --- /dev/null +++ b/app/containers/markdown/components/Plain.test.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; + +import { ThemeContext } from '../../../theme'; +import { colors as themeColors } from '../../../lib/constants/colors'; +import MarkdownContext from '../contexts/MarkdownContext'; +import Plain from './Plain'; + +describe('Plain (highlights)', () => { + const colors = themeColors.light; + + const getBackground = (node: any) => { + const { style } = node.props; + return Array.isArray(style) ? style.find((s: any) => s && s.backgroundColor)?.backgroundColor : style?.backgroundColor; + }; + + it('renders highlighted words with theme highlight background', () => { + const tree = render( + + + + + + ); + + const highlighted = tree.getByText('rocket'); + expect(highlighted).toBeTruthy(); + expect(getBackground(highlighted)).toBe(colors.statusBackgroundDanger); + }); + + it('is case-insensitive when matching highlight words', () => { + const tree = render( + + + + + + ); + + const highlighted = tree.getByText('rocket'); + expect(highlighted).toBeTruthy(); + expect(getBackground(highlighted)).toBe(colors.statusBackgroundDanger); + }); + + it('handles punctuation after words', () => { + const tree = render( + + + + + + ); + + const highlighted = tree.getByText('rocket'); + expect(highlighted).toBeTruthy(); + expect(getBackground(highlighted)).toBe(colors.statusBackgroundDanger); + }); + + it('renders multiple highlights', () => { + const tree = render( + + + + + + ); + + const h1 = tree.getByText('rocket'); + const h2 = tree.getByText('world'); + expect(h1).toBeTruthy(); + expect(h2).toBeTruthy(); + expect(getBackground(h1)).toBe(colors.statusBackgroundDanger); + expect(getBackground(h2)).toBe(colors.statusBackgroundDanger); + }); + + it('does not highlight partial words', () => { + const tree = render( + + + + + + ); + + // there should be no separate node with text 'rocket' (partial matches shouldn't count) + const rocket = tree.queryByText('rocket'); + expect(rocket).toBeNull(); + + // full text should still be rendered + const full = tree.getByText('hello rockets and rocketing world'); + expect(full).toBeTruthy(); + }); + + it('when no highlights configured returns full text and does not create separate highlighted nodes', () => { + const tree = render( + + + + + + ); + + const full = tree.getByText('hello rocket world'); + expect(full).toBeTruthy(); + // there should be no separate node with text 'rocket' + const rocket = tree.queryByText('rocket'); + expect(rocket).toBeNull(); + }); +}); diff --git a/app/containers/markdown/components/Plain.tsx b/app/containers/markdown/components/Plain.tsx index cdc70f9476b..a39d43aec40 100644 --- a/app/containers/markdown/components/Plain.tsx +++ b/app/containers/markdown/components/Plain.tsx @@ -1,19 +1,78 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { Text } from 'react-native'; import { type Plain as PlainProps } from '@rocket.chat/message-parser'; import { useTheme } from '../../../theme'; import styles from '../styles'; +import MarkdownContext from '../contexts/MarkdownContext'; interface IPlainProps { value: PlainProps['value']; } +const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const Plain = ({ value }: IPlainProps): React.ReactElement => { const { colors } = useTheme(); + const { highlights = [] } = useContext(MarkdownContext); + + const text = (value ?? '').toString(); + + if (!highlights || !highlights.length) { + return ( + + {text} + + ); + } + + // prepare case-insensitive set of highlight words + const words = highlights.map((w: any) => w?.toString().trim()).filter(Boolean); + if (!words.length) { + return ( + + {text} + + ); + } + + const wordsLower = new Set(words.map((w: string) => w.toLowerCase())); + // build regex to split and keep matched parts; guard pattern + // build alternation pattern from escaped words; word-boundaries are applied + // around the full pattern when constructing the RegExp below to avoid + // duplicating boundary anchors per item. + const pattern = words.map((w: string) => escapeRegExp(w)).filter(Boolean).join('|'); + if (!pattern) { + return ( + + {text} + + ); + } + // ensure the overall pattern is anchored to word boundaries so only whole words match + // use a non-capturing group for the alternation to avoid nested captured groups + // which would cause duplicate entries when splitting. + const re = new RegExp(`(\\b(?:${pattern})\\b)`, 'ig'); + const parts = text.split(re); + + // use red highlight for matched words (theme-aware tokens) + const bg = colors.statusBackgroundDanger ?? '#FFC1C9'; + const matchTextColor = colors.statusFontDanger ?? colors.fontDefault; + return ( - - {value} + + {parts.map((part, i) => { + if (!part) return null; + const isMatch = wordsLower.has(part.toLowerCase()); + if (isMatch) { + return ( + + {part} + + ); + } + return {part}; + })} ); }; diff --git a/app/containers/markdown/contexts/MarkdownContext.ts b/app/containers/markdown/contexts/MarkdownContext.ts index 68b327fd02e..d12fcc252e3 100644 --- a/app/containers/markdown/contexts/MarkdownContext.ts +++ b/app/containers/markdown/contexts/MarkdownContext.ts @@ -10,6 +10,7 @@ interface IMarkdownContext { navToRoomInfo?: Function; getCustomEmoji?: Function; onLinkPress?: Function; + highlights?: string[]; } const defaultState = { @@ -18,6 +19,8 @@ const defaultState = { useRealName: false, username: '', navToRoomInfo: () => {} + , + highlights: [] }; const MarkdownContext = React.createContext(defaultState); diff --git a/app/containers/markdown/index.tsx b/app/containers/markdown/index.tsx index 5140f89890a..d70d7a8793b 100644 --- a/app/containers/markdown/index.tsx +++ b/app/containers/markdown/index.tsx @@ -32,6 +32,7 @@ interface IMarkdownProps { navToRoomInfo?: Function; onLinkPress?: TOnLinkPress; isTranslated?: boolean; + highlights?: string[]; } const Markdown: React.FC = ({ @@ -44,7 +45,8 @@ const Markdown: React.FC = ({ username = '', getCustomEmoji, onLinkPress, - isTranslated + isTranslated, + highlights = [] }: IMarkdownProps) => { if (!msg) return null; @@ -67,7 +69,8 @@ const Markdown: React.FC = ({ username, navToRoomInfo, getCustomEmoji, - onLinkPress + onLinkPress, + highlights }}> {tokens?.map(block => { switch (block.type) { diff --git a/app/containers/message/Content.tsx b/app/containers/message/Content.tsx index fb2d34fc5d4..765e1c25ab8 100644 --- a/app/containers/message/Content.tsx +++ b/app/containers/message/Content.tsx @@ -67,6 +67,7 @@ const Content = React.memo( useRealName={props.useRealName} onLinkPress={onLinkPress} isTranslated={props.isTranslated} + highlights={props.highlights} /> ); } diff --git a/app/containers/message/index.tsx b/app/containers/message/index.tsx index 31522bd7f5c..8c458b5849e 100644 --- a/app/containers/message/index.tsx +++ b/app/containers/message/index.tsx @@ -63,6 +63,7 @@ interface IMessageContainerProps { isPreview?: boolean; dateSeparator?: Date | string | null; showUnreadSeparator?: boolean; + highlights?: string[]; } interface IMessageContainerState { @@ -375,11 +376,12 @@ class MessageContainer extends React.Component safeMessage.toLowerCase().includes(word.toLowerCase()))); + return ( { isBeingEdited={isBeingEdited} dateSeparator={dateSeparator} showUnreadSeparator={showUnreadSeparator} + highlights={user.settings?.preferences?.highlights} /> ); } diff --git a/app/views/UserPreferencesView/index.tsx b/app/views/UserPreferencesView/index.tsx index 9b37c39e57b..c5e4f12e789 100644 --- a/app/views/UserPreferencesView/index.tsx +++ b/app/views/UserPreferencesView/index.tsx @@ -1,5 +1,5 @@ import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState, useLayoutEffect } from 'react'; import { useDispatch } from 'react-redux'; import { setUser } from '../../actions/login'; @@ -10,11 +10,14 @@ import SafeAreaView from '../../containers/SafeAreaView'; import * as List from '../../containers/List'; import { getUserSelector } from '../../selectors/login'; import { type ProfileStackParamList } from '../../stacks/types'; -import { saveUserPreferences } from '../../lib/services/restApi'; +import { saveUserPreferences, setUserPreferences, getUserPreferences } from '../../lib/services/restApi'; +import { showToast } from '../../lib/methods/helpers/showToast'; import { useAppSelector } from '../../lib/hooks/useAppSelector'; import ListPicker from './ListPicker'; import Switch from '../../containers/Switch'; import { type IUser } from '../../definitions'; +import { FormTextInput } from '../../containers/TextInput'; +import Button from '../../containers/Button'; interface IUserPreferencesViewProps { navigation: NativeStackNavigationProp; @@ -27,6 +30,19 @@ const UserPreferencesView = ({ navigation }: IUserPreferencesViewProps): JSX.Ele const serverVersion = useAppSelector(state => state.server.version); const dispatch = useDispatch(); const convertAsciiEmoji = settings?.preferences?.convertAsciiEmoji; + const [serverHighlights, setServerHighlights] = useState(settings?.preferences?.highlights?.join(', ') || ''); + const [highlights, setHighlights] = useState(serverHighlights); + const [dirty, setDirty] = useState(false); + + useLayoutEffect(() => { + const initial = settings?.preferences?.highlights?.join(', ') || ''; + if (initial !== serverHighlights) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setServerHighlights(initial); + setHighlights(initial); + setDirty(false); + } + }, [settings?.preferences?.highlights, serverHighlights]); useEffect(() => { navigation.setOptions({ @@ -42,8 +58,10 @@ const UserPreferencesView = ({ navigation }: IUserPreferencesViewProps): JSX.Ele const toggleMessageParser = async (value: boolean) => { try { - dispatch(setUser({ enableMessageParserEarlyAdoption: value })); - await saveUserPreferences({ id, enableMessageParserEarlyAdoption: value }); + // optimistic update + dispatch(setUser({ settings: { ...settings, preferences: { ...settings?.preferences, enableMessageParserEarlyAdoption: value } } } as Partial)); + // send properly shaped payload (userId separate) + await setUserPreferences(id, { enableMessageParserEarlyAdoption: value }); } catch (e) { log(e); } @@ -58,10 +76,77 @@ const UserPreferencesView = ({ navigation }: IUserPreferencesViewProps): JSX.Ele } }; + const saveHighlights = async (value: string) => { + try { + const words = value.split(',').map(w => w.trim()).filter(w => w); + const current = Array.isArray(settings?.preferences?.highlights) + ? settings.preferences.highlights.map((s: string) => (s || '').trim()) + : []; + const unchanged = JSON.stringify(current) === JSON.stringify(words); + if (unchanged && !dirty) { + // No change, skip network/save and toasts + return; + } + + // optimistic update: merge highlights into existing preferences + dispatch(setUser({ + settings: { + ...settings, + preferences: { + ...settings?.preferences, + highlights: words + } + } + })); + + // attempt save and capture server response or error + let saveRes: any; + try { + saveRes = await saveUserPreferences({ highlights: words }); + log({ saveUserPreferencesResponse: saveRes }); + } catch (err) { + log(err); + showToast(I18n.t('Highlights_save_failed')); + return; + } + + // verify server-side saved value and inform the user; normalize values to avoid ordering/spacing mismatches + try { + const result = await getUserPreferences(id); + log({ getUserPreferencesResponse: result }); + if (result?.success && result?.preferences) { + const saved: string[] = Array.isArray(result.preferences.highlights) + ? result.preferences.highlights.map((s: string) => (s || '').trim().toLowerCase()) + : []; + const expected = words.map(w => w.trim().toLowerCase()); + const sortA = [...saved].sort(); + const sortB = [...expected].sort(); + if (JSON.stringify(sortA) === JSON.stringify(sortB)) { + setServerHighlights(value); + setDirty(false); + showToast(I18n.t('Highlights_saved_successfully')); + } else { + log({ highlightsMismatch: { saved, expected } }); + showToast(I18n.t('Highlights_save_failed')); + } + } else { + showToast(I18n.t('Highlights_save_failed')); + } + } catch (err) { + log(err); + showToast(I18n.t('Highlights_save_failed')); + } + } catch (e) { + log(e); + showToast(I18n.t('Highlights_save_failed')); + } + }; + const setAlsoSendThreadToChannel = async (param: { [key: string]: string }, onError: () => void) => { try { await saveUserPreferences(param); - dispatch(setUser(param)); + // optimistic update merging into preferences + dispatch(setUser({ settings: { ...settings, preferences: { ...settings?.preferences, ...param } } } as Partial)); } catch (e) { log(e); onError(); @@ -116,6 +201,43 @@ const UserPreferencesView = ({ navigation }: IUserPreferencesViewProps): JSX.Ele /> + + + + + { + setHighlights(value); + setDirty(value !== serverHighlights); + }} + testID='highlightsInput' + // Call saveHighlights on blur; it internally checks dirty/changed + onBlur={() => { + saveHighlights(highlights); + }} + placeholder={I18n.t('Highlight_Words_Placeholder')} + /> + {dirty ? ( + <> + +