Skip to content
Open
37 changes: 37 additions & 0 deletions app/containers/markdown/HighlightWords.stories.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<NavigationContainer>
<Story />
</NavigationContainer>
)
]
};

export const Highlights = () => (
<View style={styles.container}>
<Markdown highlights={['rocket', 'Lorem', 'mixed']} msg={'This is Rocket.Chat — highlight the word rocket (case-insensitive).'} />
<Markdown highlights={['rocket', 'Lorem', 'mixed']} msg={'Lorem ipsum dolor sit amet, this should highlight Lorem and mixed-case Mixed.'} />
<Markdown highlights={['rocket', 'Lorem', 'mixed']} msg={'Edge cases: rockets, rocketing (only exact words defined will match).'} />
</View>
);

91 changes: 91 additions & 0 deletions app/containers/markdown/components/Plain.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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(
<ThemeContext.Provider value={{ theme: 'light', colors }}>
<MarkdownContext.Provider value={{ highlights: ['rocket'] }}>
<Plain value={'hello rocket world'} />
</MarkdownContext.Provider>
</ThemeContext.Provider>
);

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(
<ThemeContext.Provider value={{ theme: 'light', colors }}>
<MarkdownContext.Provider value={{ highlights: ['Rocket'] }}>
<Plain value={'hello rocket world'} />
</MarkdownContext.Provider>
</ThemeContext.Provider>
);

const highlighted = tree.getByText('rocket');
expect(highlighted).toBeTruthy();
expect(getBackground(highlighted)).toBe(colors.statusBackgroundDanger);
});

it('handles punctuation after words', () => {
const tree = render(
<ThemeContext.Provider value={{ theme: 'light', colors }}>
<MarkdownContext.Provider value={{ highlights: ['rocket'] }}>
<Plain value={'hello rocket, world!'} />
</MarkdownContext.Provider>
</ThemeContext.Provider>
);

const highlighted = tree.getByText('rocket');
expect(highlighted).toBeTruthy();
expect(getBackground(highlighted)).toBe(colors.statusBackgroundDanger);
});

it('renders multiple highlights', () => {
const tree = render(
<ThemeContext.Provider value={{ theme: 'light', colors }}>
<MarkdownContext.Provider value={{ highlights: ['rocket', 'world'] }}>
<Plain value={'hello rocket world'} />
</MarkdownContext.Provider>
</ThemeContext.Provider>
);

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('when no highlights configured returns full text and does not create separate highlighted nodes', () => {
const tree = render(
<ThemeContext.Provider value={{ theme: 'light', colors }}>
<MarkdownContext.Provider value={{ highlights: [] }}>
<Plain value={'hello rocket world'} />
</MarkdownContext.Provider>
</ThemeContext.Provider>
);

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();
});
});
59 changes: 56 additions & 3 deletions app/containers/markdown/components/Plain.tsx
Original file line number Diff line number Diff line change
@@ -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 { highlights = [] } = useContext<any>(MarkdownContext);

const text = (value ?? '').toString();

if (!highlights || !highlights.length) {
return (
<Text accessibilityLabel={text} style={[styles.plainText, { color: colors.fontDefault }]}>
{text}
</Text>
);
}

// prepare case-insensitive set of highlight words
const words = highlights.map((w: any) => w?.toString().trim()).filter(Boolean);
if (!words.length) {
return (
<Text accessibilityLabel={text} style={[styles.plainText, { color: colors.fontDefault }]}>
{text}
</Text>
);
}

const wordsLower = new Set(words.map((w: string) => w.toLowerCase()));
// build regex to split and keep matched parts; guard pattern
const pattern = words.map(escapeRegExp).filter(Boolean).join('|');
if (!pattern) {
return (
<Text accessibilityLabel={text} style={[styles.plainText, { color: colors.fontDefault }]}>
{text}
</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 (
<Text accessibilityLabel={value} style={[styles.plainText, { color: colors.fontDefault }]}>
{value}
<Text accessibilityLabel={text} style={[styles.plainText, { color: colors.fontDefault }]}>
{parts.map((part, i) => {
if (!part) return null;
const isMatch = wordsLower.has(part.toLowerCase());
if (isMatch) {
return (
<Text key={`h-${i}`} style={{ backgroundColor: bg, color: matchTextColor }}>
{part}
</Text>
);
}
return <Text key={`p-${i}`}>{part}</Text>;
})}
</Text>
);
};
Expand Down
3 changes: 3 additions & 0 deletions app/containers/markdown/contexts/MarkdownContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface IMarkdownContext {
navToRoomInfo?: Function;
getCustomEmoji?: Function;
onLinkPress?: Function;
highlights?: string[];
}

const defaultState = {
Expand All @@ -18,6 +19,8 @@ const defaultState = {
useRealName: false,
username: '',
navToRoomInfo: () => {}
,
highlights: []
};

const MarkdownContext = React.createContext<IMarkdownContext>(defaultState);
Expand Down
7 changes: 5 additions & 2 deletions app/containers/markdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface IMarkdownProps {
navToRoomInfo?: Function;
onLinkPress?: TOnLinkPress;
isTranslated?: boolean;
highlights?: string[];
}

const Markdown: React.FC<IMarkdownProps> = ({
Expand All @@ -44,7 +45,8 @@ const Markdown: React.FC<IMarkdownProps> = ({
username = '',
getCustomEmoji,
onLinkPress,
isTranslated
isTranslated,
highlights = []
}: IMarkdownProps) => {
if (!msg) return null;

Expand All @@ -67,7 +69,8 @@ const Markdown: React.FC<IMarkdownProps> = ({
username,
navToRoomInfo,
getCustomEmoji,
onLinkPress
onLinkPress,
highlights
}}>
{tokens?.map(block => {
switch (block.type) {
Expand Down
1 change: 1 addition & 0 deletions app/containers/message/Content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const Content = React.memo(
useRealName={props.useRealName}
onLinkPress={onLinkPress}
isTranslated={props.isTranslated}
highlights={props.highlights}
/>
);
}
Expand Down
13 changes: 10 additions & 3 deletions app/containers/message/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ interface IMessageContainerProps {
isPreview?: boolean;
dateSeparator?: Date | string | null;
showUnreadSeparator?: boolean;
highlights?: string[];
}

interface IMessageContainerState {
Expand Down Expand Up @@ -375,11 +376,12 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
threadBadgeColor,
toggleFollowThread,
jumpToMessage,
highlighted,
highlighted: propHighlighted,
isBeingEdited,
isPreview,
showUnreadSeparator,
dateSeparator
dateSeparator,
highlights
} = this.props;
const {
id,
Expand Down Expand Up @@ -426,6 +428,10 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC

const canTranslateMessage = autoTranslateRoom && autoTranslateLanguage && autoTranslateMessage !== false && otherUserMessage;

const safeMessage = (message ?? '').toString();
const isHighlighted =
propHighlighted || (highlights && highlights.some(word => safeMessage.toLowerCase().includes(word.toLowerCase())));

return (
<MessageContext.Provider
value={{
Expand Down Expand Up @@ -499,7 +505,8 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
navToRoomInfo={navToRoomInfo}
handleEnterCall={handleEnterCall}
blockAction={blockAction}
highlighted={highlighted}
highlighted={isHighlighted}
highlights={highlights}
comment={comment}
isTranslated={isTranslated}
isBeingEdited={isBeingEdited}
Expand Down
1 change: 1 addition & 0 deletions app/containers/message/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export interface IMessageContent {
hasError: boolean;
isHeader: boolean;
isTranslated: boolean;
highlights?: string[];
pinned?: boolean;
}

Expand Down
1 change: 1 addition & 0 deletions app/definitions/IUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export interface INotificationPreferences {
pushNotifications: TNotifications;
emailNotificationMode: 'mentions' | 'nothing';
language?: string;
highlights?: string[];
}

export interface IMessagePreferences {
Expand Down
6 changes: 6 additions & 0 deletions app/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,12 @@
"Please_enter_your_password": "Please enter your password",
"Please_wait": "Please wait.",
"Preferences": "Preferences",
"Highlight_Words": "Highlight words",
"Highlight_Words_Description": "Words to highlight in messages, separated by commas",
"Highlights": "Highlights",
"Highlights_Description": "Words to highlight in messages, separated by commas",
"Highlights_save_failed": "Failed to save highlights",
"Highlights_saved_successfully": "Highlights saved successfully",
"Presence_Cap_Warning_Description": "Active connections have reached the limit for the workspace, thus the service that handles user status is disabled. It can be re-enabled manually in workspace settings.",
"Presence_Cap_Warning_Title": "User status temporarily disabled",
"Privacy_Policy": " Privacy policy",
Expand Down
1 change: 1 addition & 0 deletions app/views/RoomView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
isBeingEdited={isBeingEdited}
dateSeparator={dateSeparator}
showUnreadSeparator={showUnreadSeparator}
highlights={(user as any).settings?.preferences?.highlights}
/>
);
}
Expand Down
Loading