Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
);

109 changes: 109 additions & 0 deletions app/containers/markdown/components/Plain.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('does not highlight partial words', () => {
const tree = render(
<ThemeContext.Provider value={{ theme: 'light', colors }}>
<MarkdownContext.Provider value={{ highlights: ['rocket'] }}>
<Plain value={'hello rockets and rocketing world'} />
</MarkdownContext.Provider>
</ThemeContext.Provider>
);

// 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(
<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();
});
});
63 changes: 60 additions & 3 deletions app/containers/markdown/components/Plain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,69 @@ interface IPlainProps {
value: PlainProps['value'];
}

const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

const Plain = ({ value }: IPlainProps): React.ReactElement => {
const { colors } = useTheme();
const { textStyle } = useContext(MarkdownContext);
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
// 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 accessibilityLabel={text} style={[styles.plainText, { color: colors.fontDefault }]}>
{text}
</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 (
<Text accessibilityLabel={value} style={[styles.plainText, { color: colors.fontDefault }, ...(textStyle ? [textStyle] : [])]}>
{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
7 changes: 4 additions & 3 deletions app/containers/markdown/contexts/MarkdownContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,17 @@ interface IMarkdownContext {
navToRoomInfo?: Function;
getCustomEmoji?: Function;
onLinkPress?: Function;
textStyle?: StyleProp<TextStyle>;
highlights?: string[];
}

const defaultState = {
mentions: [],
channels: [],
useRealName: false,
username: '',
navToRoomInfo: () => {},
textStyle: undefined
navToRoomInfo: () => {}
,
highlights: []
};

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

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

Expand All @@ -70,7 +70,7 @@ const Markdown: React.FC<IMarkdownProps> = ({
navToRoomInfo,
getCustomEmoji,
onLinkPress,
textStyle
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
7 changes: 4 additions & 3 deletions app/definitions/IUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,9 @@ export interface IUserEmail {

export interface IUserSettings {
profile?: any;
preferences: {
[key: string]: any;
};
// preferences should follow the structured notification and message preferences
// which include `highlights` and other typed keys
preferences: INotificationPreferences & IMessagePreferences;
}
export type TNotifications = 'default' | 'all' | 'mentions' | 'nothing';

Expand All @@ -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 @@ -404,6 +404,12 @@
"Hide_room": "Hide",
"Hide_System_Messages": "Hide system messages",
"Hide_type_messages": "Hide \"{{type}}\" messages",
"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",
"How_It_Works": "How it works",
"I_Saved_My_E2E_Password": "I saved my E2E password",
"Ignore": "Ignore",
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 @@ -1475,6 +1475,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
isBeingEdited={isBeingEdited}
dateSeparator={dateSeparator}
showUnreadSeparator={showUnreadSeparator}
highlights={user.settings?.preferences?.highlights}
/>
);
}
Expand Down
Loading