[CHORE] Refactor mention tracking logic (#2997)

* [Improvement] Improve mentions

This PR focuses on improving command, emoji, channel and user mentions.

* [Tests] Added e2e tests for mention improvement

* [Improvement] Modify slash command mention logic.
Added slash command with argument preview
Slash command should show only if the message bigins with /

* Return data on search for empty text

* Minor fixes

* Update e2e tests

* Minor fix

* [FIX] allow command mentioning in between text

Co-authored-by: Diego Mello <diegolmello@gmail.com>
This commit is contained in:
Sumukha Hegde 2021-04-08 00:26:16 +05:30 committed by GitHub
parent d04d0f27b6
commit 9ce374dc2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 79 additions and 76 deletions

View File

@ -18,7 +18,7 @@ const Mentions = React.memo(({ mentions, trackingType, theme }) => {
data={mentions}
extraData={mentions}
renderItem={({ item }) => <MentionItem item={item} trackingType={trackingType} theme={theme} />}
keyExtractor={item => item.id || item.username || item.command || item}
keyExtractor={item => item.rid || item.name || item.command || item}
keyboardShouldPersistTaps='always'
/>
</View>

View File

@ -1,4 +1,5 @@
export const MENTIONS_TRACKING_TYPE_USERS = '@';
export const MENTIONS_TRACKING_TYPE_EMOJIS = ':';
export const MENTIONS_TRACKING_TYPE_COMMANDS = '/';
export const MENTIONS_TRACKING_TYPE_ROOMS = '#';
export const MENTIONS_COUNT_TO_DISPLAY = 4;

View File

@ -41,7 +41,8 @@ import {
MENTIONS_TRACKING_TYPE_EMOJIS,
MENTIONS_TRACKING_TYPE_COMMANDS,
MENTIONS_COUNT_TO_DISPLAY,
MENTIONS_TRACKING_TYPE_USERS
MENTIONS_TRACKING_TYPE_USERS,
MENTIONS_TRACKING_TYPE_ROOMS
} from './constants';
import CommandsPreview from './CommandsPreview';
import { getUserSelector } from '../../selectors/login';
@ -354,58 +355,48 @@ class MessageBox extends Component {
// eslint-disable-next-line react/sort-comp
debouncedOnChangeText = debounce(async(text) => {
const { sharing } = this.props;
const db = database.active;
const isTextEmpty = text.length === 0;
// this.setShowSend(!isTextEmpty);
if (isTextEmpty) {
this.stopTrackingMention();
return;
}
this.handleTyping(!isTextEmpty);
const { start, end } = this.selection;
const cursor = Math.max(start, end);
const txt = cursor < text.length ? text.substr(0, cursor).split(' ') : text.split(' ');
const lastWord = txt[txt.length - 1];
const result = lastWord.substring(1);
if (!sharing) {
// matches if their is text that stats with '/' and group the command and params so we can use it "/command params"
const slashCommand = text.match(/^\/([a-z0-9._-]+) (.+)/im);
if (slashCommand) {
const [, name, params] = slashCommand;
const commandMention = text.match(/^\//); // match only if message begins with /
const channelMention = lastWord.match(/^#/);
const userMention = lastWord.match(/^@/);
const emojiMention = lastWord.match(/^:/);
if (commandMention && !sharing) {
const command = text.substr(1);
const commandParameter = text.match(/^\/([a-z0-9._-]+) (.+)/im);
if (commandParameter) {
const db = database.active;
const [, name, params] = commandParameter;
const commandsCollection = db.get('slash_commands');
try {
const command = await commandsCollection.find(name);
if (command.providesPreview) {
return this.setCommandPreview(command, name, params);
const commandRecord = await commandsCollection.find(name);
if (commandRecord.providesPreview) {
return this.setCommandPreview(commandRecord, name, params);
}
} catch (e) {
console.log('Slash command not found');
// do nothing
}
}
}
if (!isTextEmpty) {
try {
const { start, end } = this.selection;
const cursor = Math.max(start, end);
const lastNativeText = this.text;
// matches if text either starts with '/' or have (@,#,:) then it groups whatever comes next of mention type
let regexp = /(#|@|:|^\/)([a-z0-9._-]+)$/im;
// if sharing, track #|@|:
if (sharing) {
regexp = /(#|@|:)([a-z0-9._-]+)$/im;
}
const result = lastNativeText.substr(0, cursor).match(regexp);
if (!result) {
if (!sharing) {
const slash = lastNativeText.match(/^\/$/); // matches only '/' in input
if (slash) {
return this.identifyMentionKeyword('', MENTIONS_TRACKING_TYPE_COMMANDS);
}
}
return this.stopTrackingMention();
}
const [, lastChar, name] = result;
this.identifyMentionKeyword(name, lastChar);
} catch (e) {
log(e);
}
return this.identifyMentionKeyword(command, MENTIONS_TRACKING_TYPE_COMMANDS);
} else if (channelMention) {
return this.identifyMentionKeyword(result, MENTIONS_TRACKING_TYPE_ROOMS);
} else if (userMention) {
return this.identifyMentionKeyword(result, MENTIONS_TRACKING_TYPE_USERS);
} else if (emojiMention) {
return this.identifyMentionKeyword(result, MENTIONS_TRACKING_TYPE_EMOJIS);
} else {
this.stopTrackingMention();
return this.stopTrackingMention();
}
}, 100)
@ -483,10 +474,10 @@ class MessageBox extends Component {
getFixedMentions = (keyword) => {
let result = [];
if ('all'.indexOf(keyword) !== -1) {
result = [{ id: -1, username: 'all' }];
result = [{ rid: -1, username: 'all' }];
}
if ('here'.indexOf(keyword) !== -1) {
result = [{ id: -2, username: 'here' }, ...result];
result = [{ rid: -2, username: 'here' }, ...result];
}
return result;
}
@ -504,17 +495,17 @@ class MessageBox extends Component {
getEmojis = debounce(async(keyword) => {
const db = database.active;
if (keyword) {
const customEmojisCollection = db.get('custom_emojis');
const likeString = sanitizeLikeString(keyword);
let customEmojis = await customEmojisCollection.query(
Q.where('name', Q.like(`${ likeString }%`))
).fetch();
customEmojis = customEmojis.slice(0, MENTIONS_COUNT_TO_DISPLAY);
const filteredEmojis = emojis.filter(emoji => emoji.indexOf(keyword) !== -1).slice(0, MENTIONS_COUNT_TO_DISPLAY);
const mergedEmojis = [...customEmojis, ...filteredEmojis].slice(0, MENTIONS_COUNT_TO_DISPLAY);
this.setState({ mentions: mergedEmojis || [] });
const customEmojisCollection = db.get('custom_emojis');
const likeString = sanitizeLikeString(keyword);
const whereClause = [];
if (likeString) {
whereClause.push(Q.where('name', Q.like(`${ likeString }%`)));
}
let customEmojis = await customEmojisCollection.query(...whereClause).fetch();
customEmojis = customEmojis.slice(0, MENTIONS_COUNT_TO_DISPLAY);
const filteredEmojis = emojis.filter(emoji => emoji.indexOf(keyword) !== -1).slice(0, MENTIONS_COUNT_TO_DISPLAY);
const mergedEmojis = [...customEmojis, ...filteredEmojis].slice(0, MENTIONS_COUNT_TO_DISPLAY);
this.setState({ mentions: mergedEmojis || [] });
}, 300)
getSlashCommands = debounce(async(keyword) => {

View File

@ -618,9 +618,6 @@ const RocketChat = {
async localSearch({ text, filterUsers = true, filterRooms = true }) {
const searchText = text.trim();
if (searchText === '') {
return [];
}
const db = database.active;
const likeString = sanitizeLikeString(searchText);
let data = await db.get('subscriptions').query(
@ -659,10 +656,6 @@ const RocketChat = {
this.oldPromise('cancel');
}
if (searchText === '') {
return [];
}
const data = await this.localSearch({ text, filterUsers, filterRooms });
const usernames = data.map(sub => sub.name);

View File

@ -3,7 +3,7 @@ import { compareServerVersion, methods } from '../lib/utils';
const formatUrl = (url, size, query) => `${ url }?format=png&size=${ size }${ query }`;
export const avatarURL = ({
type, text, size, user = {}, avatar, server, avatarETag, rid, blockUnauthenticatedAccess, serverVersion
type, text, size = 25, user = {}, avatar, server, avatarETag, rid, blockUnauthenticatedAccess, serverVersion
}) => {
let room;
if (type === 'd') {

View File

@ -519,6 +519,7 @@ class RoomsListView extends React.Component {
const { openSearchHeader } = this.props;
this.internalSetState({ searching: true }, () => {
openSearchHeader();
this.search('');
this.setHeader();
});
};

View File

@ -101,6 +101,8 @@ async function searchRoom(room) {
await expect(element(by.id('rooms-list-view-search-input'))).toExist();
await waitFor(element(by.id('rooms-list-view-search-input'))).toExist().withTimeout(5000);
await element(by.id('rooms-list-view-search-input')).typeText(room);
await sleep(300);
await waitFor(element(by.id(`rooms-list-view-item-${ room }`))).toBeVisible().withTimeout(60000);
}
async function tryTapping(theElement, timeout, longtap = false){

View File

@ -22,7 +22,6 @@ const checkBanner = async() => {
async function navigateToRoom(roomName) {
await searchRoom(`${ roomName }`);
await waitFor(element(by.id(`rooms-list-view-item-${ roomName }`))).toExist().withTimeout(60000);
await element(by.id(`rooms-list-view-item-${ roomName }`)).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000);
}

View File

@ -61,7 +61,6 @@ describe('Broadcast room', () => {
//await element(by.id('two-factor-send')).tap();
await searchRoom(`broadcast${ data.random }`);
await waitFor(element(by.id(`rooms-list-view-item-broadcast${ data.random }`))).toExist().withTimeout(60000);
await element(by.id(`rooms-list-view-item-broadcast${ data.random }`)).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000);
await waitFor(element(by.id(`room-view-title-broadcast${ data.random }`))).toBeVisible().withTimeout(60000);

View File

@ -83,7 +83,11 @@ describe('Settings screen', () => {
await element(by.label('Clear').and(by.type('_UIAlertControllerActionView'))).tap();
await waitFor(element(by.id('rooms-list-view'))).toBeVisible().withTimeout(5000);
// Database was cleared, so the room shouldn't be there anymore while it's fetched again from the server
await waitFor(element(by.id(`rooms-list-view-item-${ data.groups.private.name }`))).toNotExist().withTimeout(10000);
/**
* FIXME: rooms are fetched to quickly on docker and the test below fails
* We need to think on another way to test database being resetted
*/
// await waitFor(element(by.id(`rooms-list-view-item-${ data.groups.private.name }`))).toNotExist().withTimeout(10000);
await waitFor(element(by.id(`rooms-list-view-item-${ data.groups.private.name }`))).toExist().withTimeout(10000);
})
});

View File

@ -9,7 +9,6 @@ const room = data.channels.detoxpublic.name;
async function navigateToRoom() {
await searchRoom(room);
await waitFor(element(by.id(`rooms-list-view-item-${ room }`))).toBeVisible().withTimeout(60000);
await element(by.id(`rooms-list-view-item-${ room }`)).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000);
}

View File

@ -10,7 +10,6 @@ const joinCode = data.channels.detoxpublicprotected.joinCode
async function navigateToRoom() {
await searchRoom(room);
await waitFor(element(by.id(`rooms-list-view-item-${ room }`))).toBeVisible().withTimeout(60000);
await element(by.id(`rooms-list-view-item-${ room }`)).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000);
}

View File

@ -36,7 +36,6 @@ describe('Rooms list screen', () => {
describe('Usage', () => {
it('should search room and navigate', async() => {
await searchRoom('rocket.cat');
await waitFor(element(by.id('rooms-list-view-item-rocket.cat'))).toBeVisible().withTimeout(60000);
await element(by.id('rooms-list-view-item-rocket.cat')).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(10000);
await waitFor(element(by.id('room-view-title-rocket.cat'))).toBeVisible().withTimeout(60000);

View File

@ -6,7 +6,6 @@ const { navigateToLogin, login, mockMessage, tapBack, sleep, searchRoom, starMes
async function navigateToRoom(roomName) {
await searchRoom(`${ roomName }`);
await waitFor(element(by.id(`rooms-list-view-item-${ roomName }`))).toExist().withTimeout(60000);
await element(by.id(`rooms-list-view-item-${ roomName }`)).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000);
}
@ -103,6 +102,14 @@ describe('Room screen', () => {
await element(by.id('messagebox-input')).clearText();
});
it('should not show emoji autocomplete on semicolon in middle of a string', async() => {
await element(by.id('messagebox-input')).tap();
// await element(by.id('messagebox-input')).replaceText(':');
await element(by.id('messagebox-input')).typeText('name:is');
await waitFor(element(by.id('messagebox-container'))).toNotExist().withTimeout(20000);
await element(by.id('messagebox-input')).clearText();
});
it('should show and tap on user autocomplete and send mention', async() => {
const username = data.users.regular.username
await element(by.id('messagebox-input')).tap();
@ -117,6 +124,14 @@ describe('Room screen', () => {
// await waitFor(element(by.label(`@${ data.user } ${ data.random }mention`)).atIndex(0)).toExist().withTimeout(60000);
});
it('should not show user autocomplete on @ in the middle of a string', async() => {
const username = data.users.regular.username
await element(by.id('messagebox-input')).tap();
await element(by.id('messagebox-input')).typeText(`email@gmail`);
await waitFor(element(by.id('messagebox-container'))).toNotExist().withTimeout(4000);
await element(by.id('messagebox-input')).clearText();
});
it('should show and tap on room autocomplete', async() => {
await element(by.id('messagebox-input')).tap();
await element(by.id('messagebox-input')).typeText('#general');
@ -127,6 +142,12 @@ describe('Room screen', () => {
await element(by.id('messagebox-input')).clearText();
});
it('should not show room autocomplete on # in middle of a string', async() => {
await element(by.id('messagebox-input')).tap();
await element(by.id('messagebox-input')).typeText('te#gen');
await waitFor(element(by.id('messagebox-container'))).toNotExist().withTimeout(4000);
await element(by.id('messagebox-input')).clearText();
});
it('should draft message', async () => {
await element(by.id('messagebox-input')).atIndex(0).tap();
await element(by.id('messagebox-input')).atIndex(0).typeText(`${ data.random }draft`);

View File

@ -13,7 +13,6 @@ async function navigateToRoomActions(type) {
room = data.groups.private.name;
}
await searchRoom(room);
await waitFor(element(by.id(`rooms-list-view-item-${ room }`))).toExist().withTimeout(60000);
await element(by.id(`rooms-list-view-item-${ room }`)).tap();
await waitFor(element(by.id('room-view'))).toExist().withTimeout(2000);
await element(by.id('room-header')).tap();

View File

@ -8,7 +8,6 @@ const channel = data.groups.private.name;
const navigateToRoom = async() => {
await searchRoom(channel);
await waitFor(element(by.id(`rooms-list-view-item-${ channel }`))).toExist().withTimeout(60000);
await element(by.id(`rooms-list-view-item-${ channel }`)).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000);
}

View File

@ -9,7 +9,6 @@ async function navigateToRoom(roomName) {
await navigateToLogin();
await login(data.users.regular.username, data.users.regular.password);
await searchRoom(`${ roomName }`);
await waitFor(element(by.id(`rooms-list-view-item-${ roomName }`))).toExist().withTimeout(60000);
await element(by.id(`rooms-list-view-item-${ roomName }`)).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000);
}

View File

@ -6,7 +6,6 @@ const { navigateToLogin, login, mockMessage, tapBack, searchRoom, logout } = req
async function navigateToRoom(user) {
await searchRoom(`${ user }`);
await waitFor(element(by.id(`rooms-list-view-item-${ user }`))).toExist().withTimeout(60000);
await element(by.id(`rooms-list-view-item-${ user }`)).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000);
}

View File

@ -14,7 +14,6 @@ async function navigateToRoomInfo(type) {
room = privateRoomName;
}
await searchRoom(room);
await waitFor(element(by.id(`rooms-list-view-item-${ room }`))).toExist().withTimeout(60000);
await element(by.id(`rooms-list-view-item-${ room }`)).tap();
await waitFor(element(by.id('room-view'))).toExist().withTimeout(2000);
await element(by.id('room-header')).tap();