Skip to content
Open
61 changes: 57 additions & 4 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 { colors, theme } = useTheme();
const { highlights = [] } = useContext(MarkdownContext as any);

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 => 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.settings?.preferences?.highlights}
/>
);
}
Expand Down
103 changes: 98 additions & 5 deletions app/views/UserPreferencesView/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<ProfileStackParamList, 'UserPreferencesView'>;
Expand All @@ -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({
Expand All @@ -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<IUser>));
// send properly shaped payload (userId separate)
await setUserPreferences(id, { enableMessageParserEarlyAdoption: value });
} catch (e) {
log(e);
}
Expand All @@ -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<IUser>));

// 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<IUser>));
} catch (e) {
log(e);
onError();
Expand Down Expand Up @@ -116,6 +181,34 @@ const UserPreferencesView = ({ navigation }: IUserPreferencesViewProps): JSX.Ele
/>
<List.Separator />
</List.Section>
<List.Section>
<List.Separator />
<List.Item title='Highlights' testID='preferences-view-highlights' />
<List.Separator />
<FormTextInput
value={highlights}
onChangeText={value => {
setHighlights(value);
setDirty(value !== initialHighlightRef.current);
}}
onBlur={() => saveHighlights(highlights)}
placeholder='Enter words separated by commas'
/>
{dirty ? (
<>
<List.Separator />
<Button
title={I18n.t('Save')}
small
onPress={() => saveHighlights(highlights)}
testID='preferences-view-highlights-save'
style={{ alignSelf: 'center', marginTop: 15}}
/>
</>
) : null}
<List.Separator />
<List.Info info='Highlights_Description' />
</List.Section>
</List.Container>
</SafeAreaView>
);
Expand Down