Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// 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
129 changes: 124 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,77 @@ const UserPreferencesView = ({ navigation }: IUserPreferencesViewProps): JSX.Ele
}
};

const saveHighlights = async (value: string) => {
try {
const words = value.split(',').map(w => w.trim()).filter(w => w);
const current = Array.isArray(settings?.preferences?.highlights)
? settings.preferences.highlights.map((s: string) => (s || '').trim())
: [];
const unchanged = JSON.stringify(current) === JSON.stringify(words);
if (unchanged && !dirty) {
// No change, skip network/save and toasts
return;
}

// optimistic update: merge highlights into existing preferences
dispatch(setUser({
settings: {
...settings,
preferences: {
...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'));
}
};
Comment thread
DSingh0304 marked this conversation as resolved.

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 +198,43 @@ 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={() => {
// Only save if dirty or changed
const words = highlights.split(',').map(w => w.trim()).filter(w => w);
const current = Array.isArray(settings?.preferences?.highlights)
? settings.preferences.highlights.map((s: string) => (s || '').trim())
: [];
const unchanged = JSON.stringify(current) === JSON.stringify(words);
if (!dirty && unchanged) return;
saveHighlights(highlights);
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
placeholder={I18n.t('Highlight_Words_Placeholder')}
/>
{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>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</List.Container>
</SafeAreaView>
);
Expand Down