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 ? (
+ <>
+
+
);