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);
});

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();
});
});
65 changes: 62 additions & 3 deletions app/containers/markdown/components/Plain.tsx
Original file line number Diff line number Diff line change
@@ -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<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 }]}>
{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
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 @@ -662,6 +662,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 @@ -1470,6 +1470,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
isBeingEdited={isBeingEdited}
dateSeparator={dateSeparator}
showUnreadSeparator={showUnreadSeparator}
highlights={user.settings?.preferences?.highlights}
/>
);
}
Expand Down
Loading