Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(mentions): parse groups and federated user mentions #11676

Merged
merged 1 commit into from
Feb 29, 2024
Merged
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
3 changes: 2 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,14 @@ module.exports = {
globalSetup: resolve(__dirname, 'jest.global.setup.js'),

collectCoverageFrom: [
'<rootDir>/src/**/*.{js,vue}',
'<rootDir>/src/**/*.{js,ts,vue}',
],

testEnvironment: 'jest-environment-jsdom',

moduleFileExtensions: [
'js',
'ts',
'vue',
],

Expand Down
2 changes: 1 addition & 1 deletion src/components/ConversationSettings/EditableTextField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion src/components/NewMessage/NewMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/stores/chatExtras.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,33 @@ type ApiResponse<T> = 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<string, ParamObject | Mention | File>
export type ChatMessage = Omit<components['schemas']['ChatMessage'], 'messageParameters'> & {
messageParameters: MessageParameters
}

// Bots
export type Bot = components['schemas']['Bot']
export type BotWithDetails = components['schemas']['BotWithDetails']
Expand Down
130 changes: 130 additions & 0 deletions src/utils/__tests__/textParse.spec.js
Original file line number Diff line number Diff line change
@@ -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 [email protected]"'
const parameters = {
'mention-user1': {
id: 'alice',
name: 'Just Alice',
type: 'user',
},
'mention-user2': {
id: 'alice [email protected]',
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/[email protected]"'
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/[email protected]"'
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 = '&lt;div&gt;Hello&amp;world&lt;/div&gt;'
const output = '<div>Hello&world</div>'
expect(parseSpecialSymbols(input)).toBe(output)
})

it('converts special characters correctly', () => {
const input = 'This is the &sect; 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)
})
})
})
44 changes: 26 additions & 18 deletions src/utils/textParse.js → src/utils/textParse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,10 +58,9 @@ function parseMentions(text, parameters) {
* Parse special symbols in text like &amp; &lt; &gt; &sect;
* 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, '&amp;')
text = temp.value.replace(/&amp;/gmi, '&').replace(/&lt;/gmi, '<')
Expand Down
Loading