From bb12e7adadac105c9946ae8056c2cb569e8c8b25 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Thu, 29 Feb 2024 17:55:46 +0100 Subject: [PATCH] fix(mentions): parse groups and federated user mentions, add test coverage Signed-off-by: Maksim Sukharev --- jest.config.js | 3 +- .../EditableTextField.vue | 2 +- .../MessageButtonsBar/MessageButtonsBar.vue | 2 +- src/components/NewMessage/NewMessage.vue | 2 +- src/stores/chatExtras.js | 2 +- src/types/index.ts | 27 ++++ src/utils/__tests__/textParse.spec.js | 130 ++++++++++++++++++ src/utils/{textParse.js => textParse.ts} | 44 +++--- 8 files changed, 189 insertions(+), 23 deletions(-) create mode 100644 src/utils/__tests__/textParse.spec.js rename src/utils/{textParse.js => textParse.ts} (57%) diff --git a/jest.config.js b/jest.config.js index db9713b494b..587e4a84bdc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -69,13 +69,14 @@ module.exports = { globalSetup: resolve(__dirname, 'jest.global.setup.js'), collectCoverageFrom: [ - '/src/**/*.{js,vue}', + '/src/**/*.{js,ts,vue}', ], testEnvironment: 'jest-environment-jsdom', moduleFileExtensions: [ 'js', + 'ts', 'vue', ], diff --git a/src/components/ConversationSettings/EditableTextField.vue b/src/components/ConversationSettings/EditableTextField.vue index 42b72f5aadb..afb151d9e18 100644 --- a/src/components/ConversationSettings/EditableTextField.vue +++ b/src/components/ConversationSettings/EditableTextField.vue @@ -87,7 +87,7 @@ import NcRichContenteditable from '@nextcloud/vue/dist/Components/NcRichContente import NcRichText from '@nextcloud/vue/dist/Components/NcRichText.js' import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip.js' -import { parseSpecialSymbols } from '../../utils/textParse.js' +import { parseSpecialSymbols } from '../../utils/textParse.ts' export default { name: 'EditableTextField', diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue index 1a6afb33da7..bb3a1fb6d56 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue @@ -307,7 +307,7 @@ import { getMessageReminder, removeMessageReminder, setMessageReminder } from '. import { copyConversationLinkToClipboard } from '../../../../../services/urlService.js' import { useIntegrationsStore } from '../../../../../stores/integrations.js' import { useReactionsStore } from '../../../../../stores/reactions.js' -import { parseMentions } from '../../../../../utils/textParse.js' +import { parseMentions } from '../../../../../utils/textParse.ts' const EmojiIndex = new EmojiIndexFactory(data) const supportReminders = getCapabilities()?.spreed?.features?.includes('remind-me-later') diff --git a/src/components/NewMessage/NewMessage.vue b/src/components/NewMessage/NewMessage.vue index 35df8311384..fee93892da4 100644 --- a/src/components/NewMessage/NewMessage.vue +++ b/src/components/NewMessage/NewMessage.vue @@ -226,7 +226,7 @@ import { useChatExtrasStore } from '../../stores/chatExtras.js' import { useSettingsStore } from '../../stores/settings.js' import { fetchClipboardContent } from '../../utils/clipboard.js' import { isDarkTheme } from '../../utils/isDarkTheme.js' -import { parseSpecialSymbols } from '../../utils/textParse.js' +import { parseSpecialSymbols } from '../../utils/textParse.ts' const disableKeyboardShortcuts = OCP.Accessibility.disableKeyboardShortcuts() const supportTypingStatus = getCapabilities()?.spreed?.config?.chat?.['typing-privacy'] !== undefined diff --git a/src/stores/chatExtras.js b/src/stores/chatExtras.js index 1d43b09688a..83acceb3719 100644 --- a/src/stores/chatExtras.js +++ b/src/stores/chatExtras.js @@ -26,7 +26,7 @@ import Vue from 'vue' import { EventBus } from '../services/EventBus.js' import { getUserAbsence } from '../services/participantsService.js' -import { parseSpecialSymbols, parseMentions } from '../utils/textParse.js' +import { parseSpecialSymbols, parseMentions } from '../utils/textParse.ts' /** * @typedef {string} Token diff --git a/src/types/index.ts b/src/types/index.ts index 6061db30b8f..79a45fb4459 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,33 @@ type ApiResponse = Promise<{ data: T }> // Conversations export type Conversation = components['schemas']['Room'] +// Chats +type ParamObject = { + id: string, + name: string, + type: string, +} +export type Mention = ParamObject & { + server?: string, + 'call-type'?: string, + 'icon-url'?: string, +} +type File = ParamObject & { + 'size': number, + 'path': string, + 'link': string, + 'etag': string, + 'permissions': number, + 'mimetype': string, + 'preview-available': string, + 'width': number, + 'height': number, +} +type MessageParameters = Record +export type ChatMessage = Omit & { + messageParameters: MessageParameters +} + // Bots export type Bot = components['schemas']['Bot'] export type BotWithDetails = components['schemas']['BotWithDetails'] diff --git a/src/utils/__tests__/textParse.spec.js b/src/utils/__tests__/textParse.spec.js new file mode 100644 index 00000000000..056a6c45606 --- /dev/null +++ b/src/utils/__tests__/textParse.spec.js @@ -0,0 +1,130 @@ +import { parseMentions, parseSpecialSymbols } from '../textParse.ts' + +jest.mock('@nextcloud/router', () => ({ + getBaseUrl: jest.fn().mockReturnValue('server2.com') +})) + +describe('textParse', () => { + describe('parseMentions', () => { + it('replaces {mention-call} correctly', () => { + const input = 'test {mention-call1}' + const output = 'test @all' + const parameters = { + 'mention-call1': { + id: 'room-id', + name: 'Room Display Name', + type: 'call', + }, + } + expect(parseMentions(input, parameters)).toBe(output) + }) + + it('replaces multiple entries correctly', () => { + const input = 'test {mention-call1} test {mention-call1} test' + const output = 'test @all test @all test' + const parameters = { + 'mention-call1': { + id: 'room-id', + name: 'Room Display Name', + type: 'call', + }, + } + expect(parseMentions(input, parameters)).toBe(output) + }) + + it('replaces {mention-user} correctly', () => { + const input = 'test {mention-user1} test {mention-user2}' + const output = 'test @alice test @"alice space@mail.com"' + const parameters = { + 'mention-user1': { + id: 'alice', + name: 'Just Alice', + type: 'user', + }, + 'mention-user2': { + id: 'alice space@mail.com', + name: 'Out of space Alice', + type: 'user', + } + } + expect(parseMentions(input, parameters)).toBe(output) + }) + + it('replaces {mention-group} correctly', () => { + const input = 'test {mention-group1} test {mention-group2}' + const output = 'test @"group/talk" test @"group/space talk"' + const parameters = { + 'mention-group1': { + id: 'talk', + name: 'Talk Group', + type: 'user-group', + }, + 'mention-group2': { + id: 'space talk', + name: 'Out of space Talk Group', + type: 'user-group', + } + } + expect(parseMentions(input, parameters)).toBe(output) + }) + + it('replaces {mention-federated-user} correctly (for host and other federations)', () => { + const input = 'test {mention-federated-user1}' + const output = 'test @"federated_user/alice@server3.com"' + const parameters = { + 'mention-federated-user1': { + id: 'alice', + name: 'Feder Alice', + type: 'user', + server: 'server3.com' + } + } + expect(parseMentions(input, parameters)).toBe(output) + }) + + it('replaces {mention-federated-user} correctly (for user from server2.com)', () => { + const input = 'test {mention-federated-user1}' + const output = 'test @"federated_user/alice@server2.com"' + const parameters = { + 'mention-federated-user1': { + id: 'alice', + name: 'Feder Alice', + type: 'user', + } + } + expect(parseMentions(input, parameters)).toBe(output) + }) + }) + + describe('parseSpecialSymbols', () => { + it('converts escaped HTML correctly', () => { + const input = '<div>Hello&world</div>' + const output = '
Hello&world
' + expect(parseSpecialSymbols(input)).toBe(output) + }) + + it('converts special characters correctly', () => { + const input = 'This is the § symbol.' + const output = 'This is the ยง symbol.' + expect(parseSpecialSymbols(input)).toBe(output) + }) + + it('removes trailing and leading whitespaces', () => { + const input = ' Hello ' + const output = 'Hello' + expect(parseSpecialSymbols(input)).toBe(output) + }) + + it('removes line breaks', () => { + const input = 'Hello\rworld\r\n!' + const output = 'Hello\nworld\n!' + expect(parseSpecialSymbols(input)).toBe(output) + }) + + it('returns the same text when there are no special symbols', () => { + const input = 'Hello world!' + const output = 'Hello world!' + expect(parseSpecialSymbols(input)).toBe(output) + }) + }) +}) diff --git a/src/utils/textParse.js b/src/utils/textParse.ts similarity index 57% rename from src/utils/textParse.js rename to src/utils/textParse.ts index f13ef02155f..3c039c4dc45 100644 --- a/src/utils/textParse.js +++ b/src/utils/textParse.ts @@ -21,25 +21,34 @@ * */ +import { getBaseUrl } from '@nextcloud/router' + +import type { ChatMessage, Mention } from '../types' + /** * Parse message text to return proper formatting for mentions * - * @param {string} text The string to parse - * @param {object} parameters The parameters that contain the mentions - * @return {string} + * @param text The string to parse + * @param parameters The parameters that contain the mentions */ -function parseMentions(text, parameters) { - if (Object.keys(parameters).some(key => key.startsWith('mention'))) { - for (const [key, value] of Object.entries(parameters)) { - let mention = '' - if (value?.type === 'call') { - mention = '@all' - } else if (value?.type === 'user') { - mention = value.id.includes(' ') ? `@"${value.id}"` : `@${value.id}` - } - if (mention) { - text = text.replace(new RegExp(`{${key}}`, 'g'), mention) - } +function parseMentions(text: string, parameters: ChatMessage['messageParameters']): string { + for (const key of Object.keys(parameters).filter(key => key.startsWith('mention'))) { + const value: Mention = parameters[key] + let mention = '' + + if (key.startsWith('mention-call') && value.type === 'call') { + mention = '@all' + } else if (key.startsWith('mention-federated-user') && value.type === 'user') { + const server = value?.server ?? getBaseUrl().replace('https://', '') + mention = `@"federated_user/${value.id}@${server}"` + } else if (key.startsWith('mention-group') && value.type === 'user-group') { + mention = `@"group/${value.id}"` + } else if (key.startsWith('mention-user') && value.type === 'user') { + mention = value.id.includes(' ') ? `@"${value.id}"` : `@${value.id}` + } + + if (mention) { + text = text.replace(new RegExp(`{${key}}`, 'g'), mention) } } return text @@ -49,10 +58,9 @@ function parseMentions(text, parameters) { * Parse special symbols in text like & < > § * FIXME upstream: https://github.com/nextcloud-libraries/nextcloud-vue/issues/4492 * - * @param {string} text The string to parse - * @return {string} + * @param text The string to parse */ -function parseSpecialSymbols(text) { +function parseSpecialSymbols(text: string): string { const temp = document.createElement('textarea') temp.innerHTML = text.replace(/&/gmi, '&') text = temp.value.replace(/&/gmi, '&').replace(/</gmi, '<')