From 5ba5e44595115d0731721ce3fdd63c8eb97a1b04 Mon Sep 17 00:00:00 2001 From: DSingh0304 Date: Mon, 17 Nov 2025 22:10:46 +0530 Subject: [PATCH 1/7] feat: add highlight words feature for messages and user preferences --- app/containers/markdown/components/Plain.tsx | 61 ++++++++++- .../markdown/contexts/MarkdownContext.ts | 3 + app/containers/markdown/index.tsx | 5 +- app/containers/message/Content.tsx | 1 + app/containers/message/index.tsx | 13 ++- app/containers/message/interfaces.ts | 1 + app/definitions/IUser.ts | 1 + app/i18n/locales/en.json | 6 + app/views/RoomView/index.tsx | 1 + app/views/UserPreferencesView/index.tsx | 103 +++++++++++++++++- 10 files changed, 182 insertions(+), 13 deletions(-) diff --git a/app/containers/markdown/components/Plain.tsx b/app/containers/markdown/components/Plain.tsx index cdc70f9476b..1618f9adde2 100644 --- a/app/containers/markdown/components/Plain.tsx +++ b/app/containers/markdown/components/Plain.tsx @@ -1,19 +1,72 @@ -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 { colors, theme } = useTheme(); + const { highlights = [] } = useContext(MarkdownContext as any); + + 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 => w.toLowerCase())); + // build regex to split and keep matched parts; guard pattern + const pattern = words.map(escapeRegExp).filter(Boolean).join('|'); + if (!pattern) { + return ( + + {text} + + ); + } + const re = new RegExp(`(${pattern})`, '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..c1e857d8abb 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; @@ -68,6 +70,7 @@ const Markdown: React.FC = ({ navToRoomInfo, getCustomEmoji, onLinkPress + ,highlights }}> {tokens?.map(block => { switch (block.type) { diff --git a/app/containers/message/Content.tsx b/app/containers/message/Content.tsx index fb2d34fc5d4..f4f5def5549 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} + highlightWords={props.highlights} /> ); } diff --git a/app/containers/message/index.tsx b/app/containers/message/index.tsx index 6fcf3dacf0b..2d0ce477147 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} + highlightWords={user.settings?.preferences?.highlights} /> ); } diff --git a/app/views/UserPreferencesView/index.tsx b/app/views/UserPreferencesView/index.tsx index 9b37c39e57b..fc65fcb5f95 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, useRef } 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,16 @@ const UserPreferencesView = ({ navigation }: IUserPreferencesViewProps): JSX.Ele const serverVersion = useAppSelector(state => state.server.version); const dispatch = useDispatch(); const convertAsciiEmoji = settings?.preferences?.convertAsciiEmoji; + const initialHighlightRef = useRef(settings?.preferences?.highlights?.join(', ') || ''); + const [highlights, setHighlights] = useState(initialHighlightRef.current); + const [dirty, setDirty] = useState(false); + + useEffect(() => { + const initial = settings?.preferences?.highlights?.join(', ') || ''; + initialHighlightRef.current = initial; + setHighlights(initial); + setDirty(false); + }, [settings?.preferences?.highlights]); useEffect(() => { navigation.setOptions({ @@ -42,8 +55,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 +73,60 @@ const UserPreferencesView = ({ navigation }: IUserPreferencesViewProps): JSX.Ele } }; + const saveHighlights = async (value: string) => { + try { + const words = value.split(',').map(w => w.trim()).filter(w => w); + // optimistic update + dispatch(setUser({ settings: { ...settings, preferences: { highlights: words } } } as Partial)); + + // 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)) { + initialHighlightRef.current = 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 +181,34 @@ const UserPreferencesView = ({ navigation }: IUserPreferencesViewProps): JSX.Ele /> + + + + + { + setHighlights(value); + setDirty(value !== initialHighlightRef.current); + }} + onBlur={() => saveHighlights(highlights)} + placeholder='Enter words separated by commas' + /> + {dirty ? ( + <> + +