From 7368f3f910edbfc105605762fd21c291cdbec9e8 Mon Sep 17 00:00:00 2001 From: Sebin Song Date: Fri, 3 May 2024 03:27:03 +1200 Subject: [PATCH] #1947 - Channel links in chat (#1976) * make sure to typing '#' is detected as well in SendArea.vue * store mentionType in the ephemeral state of SendArea.vue * disallow white-space in the channel name * implement mention menu list for channels * fix the linter error * implement auto-injection of the channel-mention / DRY addSelectedMention() method * implement converting #channel-name -> #channel-id * DRY various mention-related stuff / make sure channel mention is corrected rendered * fix various linter/flow errors * make sure chatroom navigation works * make sure #chatroomID -> #chatroomName in various places * remove consolelog * fix the cypress failure * work on PR review feedbacks * restore the watcher --- frontend/model/chatroom/vuexModule.js | 17 +- frontend/model/contracts/chatroom.js | 4 +- frontend/model/contracts/shared/constants.js | 6 +- frontend/model/contracts/shared/functions.js | 39 +++- .../chatroom/CreateNewChannelModal.vue | 11 + .../views/containers/chatroom/MessageBase.vue | 159 +++++++++++--- .../views/containers/chatroom/SendArea.vue | 203 +++++++++++++----- .../views/containers/chatroom/ViewArea.vue | 8 - frontend/views/pages/GroupChat.vue | 1 - .../integration/group-member-removal.spec.js | 2 +- 10 files changed, 344 insertions(+), 106 deletions(-) diff --git a/frontend/model/chatroom/vuexModule.js b/frontend/model/chatroom/vuexModule.js index f0461df61..52f516fb6 100644 --- a/frontend/model/chatroom/vuexModule.js +++ b/frontend/model/chatroom/vuexModule.js @@ -3,7 +3,7 @@ import sbp from '@sbp/sbp' import { Vue } from '@common/common.js' import { merge, cloneDeep, union } from '@model/contracts/shared/giLodash.js' -import { MESSAGE_NOTIFY_SETTINGS, MESSAGE_TYPES } from '@model/contracts/shared/constants.js' +import { MESSAGE_NOTIFY_SETTINGS, MESSAGE_TYPES, CHATROOM_PRIVACY_LEVEL } from '@model/contracts/shared/constants.js' const defaultState = { currentChatRoomIDs: {}, // { [groupId]: currentChatRoomId } chatRoomScrollPosition: {}, // [chatRoomId]: messageHash @@ -154,6 +154,21 @@ const getters = { } return chatRoomsInDetail }, + mentionableChatroomsInDetails (state, getters) { + // NOTE: Channel types a user can mention + // 1. All public/group channels (regardless of whether joined or not). + // 2. A private channel that he/she has joined. + return Object.values(getters.chatRoomsInDetail).filter( + (details: any) => [CHATROOM_PRIVACY_LEVEL.GROUP, CHATROOM_PRIVACY_LEVEL.PUBLIC].includes(details.privacyLevel) || + (details.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE && details.joined) + ) + }, + getChatroomNameById (state, getters) { + return chatroomId => { + const found: any = Object.values(getters.chatRoomsInDetail).find((details: any) => details.id === chatroomId) + return found ? found.name : null + } + }, chatRoomMembersInSort (state, getters) { return getters.groupMembersSorted .map(member => ({ contractID: member.contractID, username: member.username, displayName: member.displayName })) diff --git a/frontend/model/contracts/chatroom.js b/frontend/model/contracts/chatroom.js index 777f4d7d0..59f621b54 100644 --- a/frontend/model/contracts/chatroom.js +++ b/frontend/model/contracts/chatroom.js @@ -24,7 +24,7 @@ import { findMessageIdx, leaveChatRoom, makeMentionFromUserID, - swapUserIDForUsername + swapMentionIDForDisplayname } from './shared/functions.js' import { cloneDeep, merge } from './shared/giLodash.js' import { makeNotification } from './shared/nativeNotification.js' @@ -111,7 +111,7 @@ function messageReceivePostEffect ({ shouldNotifyMessage && makeNotification({ title, - body: swapUserIDForUsername(text), + body: swapMentionIDForDisplayname(text), icon, path }) diff --git a/frontend/model/contracts/shared/constants.js b/frontend/model/contracts/shared/constants.js index 1d740224d..4c6def97a 100644 --- a/frontend/model/contracts/shared/constants.js +++ b/frontend/model/contracts/shared/constants.js @@ -42,11 +42,15 @@ export const STREAK_NOT_LOGGED_IN_DAYS = 14 // chatroom.js related -export const CHATROOM_GENERAL_NAME = 'General' +export const CHATROOM_GENERAL_NAME = 'general' // Chatroom name must be lowercase-only. export const CHATROOM_NAME_LIMITS_IN_CHARS = 50 export const CHATROOM_DESCRIPTION_LIMITS_IN_CHARS = 280 export const CHATROOM_ACTIONS_PER_PAGE = 40 export const CHATROOM_MAX_ARCHIVE_ACTION_PAGES = 2 // 2 pages of actions + +export const CHATROOM_MEMBER_MENTION_SPECIAL_CHAR = '@' +export const CHATROOM_CHANNEL_MENTION_SPECIAL_CHAR = '#' + // chatroom events export const CHATROOM_MESSAGE_ACTION = 'chatroom-message-action' export const MESSAGE_RECEIVE = 'message-receive' diff --git a/frontend/model/contracts/shared/functions.js b/frontend/model/contracts/shared/functions.js index 3646bdb3e..8a8b9aa9a 100644 --- a/frontend/model/contracts/shared/functions.js +++ b/frontend/model/contracts/shared/functions.js @@ -1,7 +1,12 @@ 'use strict' import sbp from '@sbp/sbp' -import { MESSAGE_TYPES, POLL_STATUS } from './constants.js' +import { + MESSAGE_TYPES, + POLL_STATUS, + CHATROOM_MEMBER_MENTION_SPECIAL_CHAR, + CHATROOM_CHANNEL_MENTION_SPECIAL_CHAR +} from './constants.js' import { logExceptNavigationDuplicated } from '~/frontend/views/utils/misc.js' // !!!!!!!!!!!!!!! @@ -158,17 +163,35 @@ export function makeMentionFromUserID (userID: string): { me: string, all: string } { return { - me: userID ? `@${userID}` : '', - all: '@all' + me: userID ? `${CHATROOM_MEMBER_MENTION_SPECIAL_CHAR}${userID}` : '', + all: `${CHATROOM_MEMBER_MENTION_SPECIAL_CHAR}all` } } -export function swapUserIDForUsername (text: string): string { - const rootGetters = sbp('state/vuex/getters') - const possibleMentions = Object.keys(rootGetters.ourContactProfilesById) - .map(u => makeMentionFromUserID(u).me).filter(v => !!v) +export function makeChannelMention (string: string): string { + return `${CHATROOM_CHANNEL_MENTION_SPECIAL_CHAR}${string}` +} + +export function swapMentionIDForDisplayname (text: string): string { + const { + chatRoomsInDetail, + ourContactProfilesById, + getChatroomNameById, + usernameFromID + } = sbp('state/vuex/getters') + const possibleMentions = [ + ...Object.keys(ourContactProfilesById).map(u => makeMentionFromUserID(u).me).filter(v => !!v), + ...Object.values(chatRoomsInDetail).map((details: any) => makeChannelMention(details.id)) + ] + return text .split(new RegExp(`(?<=\\s|^)(${possibleMentions.join('|')})(?=[^\\w\\d]|$)`)) - .map(t => !possibleMentions.includes(t) ? t : t[0] + rootGetters.usernameFromID(t.slice(1))) + .map(t => { + return possibleMentions.includes(t) + ? t[0] === CHATROOM_MEMBER_MENTION_SPECIAL_CHAR + ? t[0] + usernameFromID(t.slice(1)) + : t[0] + getChatroomNameById(t.slice(1)) + : t + }) .join('') } diff --git a/frontend/views/containers/chatroom/CreateNewChannelModal.vue b/frontend/views/containers/chatroom/CreateNewChannelModal.vue index d49d5e959..5b44f6102 100644 --- a/frontend/views/containers/chatroom/CreateNewChannelModal.vue +++ b/frontend/views/containers/chatroom/CreateNewChannelModal.vue @@ -232,6 +232,17 @@ export default ({ } } } + }, + watch: { + 'form.name' (newVal, oldVal) { + if (newVal.length) { + if (newVal === `${oldVal} `) { + this.form.name = `${oldVal}-` + } else { + this.form.name = newVal.toLowerCase() + } + } + } } }: Object) diff --git a/frontend/views/containers/chatroom/MessageBase.vue b/frontend/views/containers/chatroom/MessageBase.vue index 3be5224b7..81996cb30 100644 --- a/frontend/views/containers/chatroom/MessageBase.vue +++ b/frontend/views/containers/chatroom/MessageBase.vue @@ -28,13 +28,22 @@ v-if='isText(objReplyMessage)' v-safe-html:a='objReplyMessage.text' ) - span.c-mention( - v-else-if='isMention(objReplyMessage)' + span.c-member-mention( + v-else-if='isMemberMention(objReplyMessage)' :class='{"c-mention-to-me": objReplyMessage.toMe}' ) {{ objReplyMessage.text }} + span.c-channel-mention( + v-else-if='isChannelMention(objReplyMessage)' + :tabindex='objReplyMessage.disabled ? undefined : 0' + :class='{ "is-disabled": objReplyMessage.disabled }' + @click='navigateToChatroom(objReplyMessage)' + @keyup.enter='navigateToChatroom(objReplyMessage)' + ) + i(:class='"icon-" + objText.icon') + span {{ objText.text }} send-area( v-if='isEditing' - :defaultText='swapUserIDForUsername(text)' + :defaultText='swapMentionIDForDisplayname(text)' :isEditing='true' @send='onMessageEdited' @cancelEdit='cancelEdit' @@ -46,10 +55,19 @@ v-if='isText(objText)' v-safe-html:a='objText.text' ) - span.c-mention( - v-else-if='isMention(objText)' + span.c-member-mention( + v-else-if='isMemberMention(objText)' :class='{"c-mention-to-me": objText.toMe}' ) {{ objText.text }} + span.c-channel-mention( + v-else-if='isChannelMention(objText)' + :tabindex='objText.disabled ? undefined : 0' + :class='{ "is-disabled": objText.disabled }' + @click='navigateToChatroom(objText)' + @keyup.enter='navigateToChatroom(objText)' + ) + i(:class='"icon-" + objText.icon') + span {{ objText.text }} i18n.c-edited(v-if='edited') (edited) .c-attachments-wrapper(v-if='hasAttachments') @@ -103,11 +121,21 @@ import MessageReactions from './MessageReactions.vue' import SendArea from './SendArea.vue' import ChatAttachmentPreview from './file-attachment/ChatAttachmentPreview.vue' import { humanDate } from '@model/contracts/shared/time.js' -import { makeMentionFromUserID, swapUserIDForUsername } from '@model/contracts/shared/functions.js' -import { MESSAGE_TYPES, MESSAGE_VARIANTS } from '@model/contracts/shared/constants.js' +import { makeMentionFromUserID, swapMentionIDForDisplayname, makeChannelMention } from '@model/contracts/shared/functions.js' +import { + MESSAGE_TYPES, + MESSAGE_VARIANTS, + CHATROOM_PRIVACY_LEVEL, + CHATROOM_MEMBER_MENTION_SPECIAL_CHAR, + CHATROOM_CHANNEL_MENTION_SPECIAL_CHAR +} from '@model/contracts/shared/constants.js' import { convertToMarkdown } from '@view-utils/convert-to-markdown.js' -const TextObjectType = { Text: 'TEXT', Mention: 'MENTION' } +const TextObjectType = { + Text: 'TEXT', + MemberMention: 'MEMBER_MENTION', + ChannelMention: 'CHANNEL_MENTION' +} export default ({ name: 'MessageBase', mixins: [emoticonsMixins], @@ -156,7 +184,11 @@ export default ({ convertTextToMarkdown: Boolean }, computed: { - ...mapGetters(['ourContactProfilesById', 'usernameFromID']), + ...mapGetters([ + 'ourContactProfilesById', + 'usernameFromID', + 'chatRoomsInDetail' + ]), textObjects () { return this.generateTextObjectsFromText(this.text) }, @@ -165,11 +197,17 @@ export default ({ }, hasAttachments () { return Boolean(this.attachments?.length) + }, + possibleMentions () { + return [ + ...Object.keys(this.ourContactProfilesById).map(u => makeMentionFromUserID(u).me).filter(v => !!v), + ...Object.values(this.chatRoomsInDetail).map(details => makeChannelMention(details.id)) + ] } }, methods: { humanDate, - swapUserIDForUsername, + swapMentionIDForDisplayname, editMessage () { if (this.type === MESSAGE_TYPES.POLL) { alert('TODO: implement editting a poll') @@ -209,13 +247,18 @@ export default ({ isText (o) { return o.type === TextObjectType.Text }, - isMention (o) { - return o.type === TextObjectType.Mention + isMemberMention (o) { + return o.type === TextObjectType.MemberMention + }, + isChannelMention (o) { + return o.type === TextObjectType.ChannelMention }, generateTextObjectsFromText (text) { + const containsMentionChar = str => new RegExp(`[${CHATROOM_MEMBER_MENTION_SPECIAL_CHAR}${CHATROOM_CHANNEL_MENTION_SPECIAL_CHAR}]`, 'g').test(text) + if (!text) { return [] - } else if (!text.includes('@')) { + } else if (!containsMentionChar(text)) { return [ { type: TextObjectType.Text, @@ -224,7 +267,6 @@ export default ({ ] } const allMention = makeMentionFromUserID('').all - const possibleMentions = Object.keys(this.ourContactProfilesById).map(u => makeMentionFromUserID(u).me).filter(v => !!v) return text // We try to find all the mentions and render them as mentions instead @@ -232,18 +274,49 @@ export default ({ // preceded by a space or is the start of a line and the `(?=[^\\w\\d]|$)` // ensures that it's followed by an end-of-line or a character that's not // a letter or a number (so `Hi @user!` works). - .split(new RegExp(`(?<=\\s|^)(${allMention}|${possibleMentions.join('|')})(?=[^\\w\\d]|$)`)) + .split(new RegExp(`(?<=\\s|^)(${allMention}|${this.possibleMentions.join('|')})(?=[^\\w\\d]|$)`)) .map(t => { + const genDefaultTextObj = (text) => ({ + type: TextObjectType.Text, + text: this.convertTextToMarkdown ? convertToMarkdown(text) : text + }) + const genChannelMentionObj = (text) => { + const chatroomId = text.slice(1) + const found = Object.values(this.chatRoomsInDetail).find(details => details.id === chatroomId) + + return found + ? { + type: TextObjectType.ChannelMention, + text: found.name, + icon: found.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE ? 'lock' : 'hashtag', + disabled: found.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE && !found.joined, + chatroomId: found.id + } + : genDefaultTextObj(text) + } + if (t === allMention) { - return { type: TextObjectType.Mention, text: t } + return { type: TextObjectType.MemberMention, text: t } } - return possibleMentions.includes(t) - ? { type: TextObjectType.Mention, text: t[0] + this.usernameFromID(t.slice(1)) } - : { - type: TextObjectType.Text, - text: this.convertTextToMarkdown ? convertToMarkdown(t) : t - } + + return this.possibleMentions.includes(t) + ? t.startsWith(CHATROOM_MEMBER_MENTION_SPECIAL_CHAR) + ? { + type: TextObjectType.MemberMention, + text: CHATROOM_MEMBER_MENTION_SPECIAL_CHAR + this.usernameFromID(t.slice(1)) + } + : genChannelMentionObj(t) + : genDefaultTextObj(t) }) + }, + navigateToChatroom (obj) { + if (obj.disabled || + obj.chatroomId === this.$route.params?.chatRoomId) { return } + + this.$router.push({ + name: 'GroupChatConversation', + params: { chatRoomId: obj.chatroomId } + }) } } }: Object) @@ -386,7 +459,8 @@ export default ({ border-color: var(--text_1); // var(--text_2); } - .c-mention { + .c-member-mention, + .c-channel-mention { background-color: transparent; } } @@ -397,13 +471,46 @@ export default ({ color: var(--text_1); } -.c-mention { +.c-member-mention, +.c-channel-mention { background-color: $primary_2; color: $primary_0; - padding: 0 0.1rem; + padding: 0 0.1rem 0.1rem; } -.c-mention.c-mention-to-me { +.c-member-mention.c-mention-to-me { background-color: $warning_1; } + +.c-channel-mention { + cursor: pointer; + transition: color 150ms; + outline: none; + + &:hover, + &:focus { + text-decoration: underline; + } + + &:focus { + color: $text_1; + } + + &.is-disabled { + cursor: inherit; + background-color: $general_1; + color: $text_1; + + &:hover, + &:focus { + text-decoration: none; + background-color: $general_1; + } + } + + i { + font-size: 0.75em; + margin-right: 2px; + } +} diff --git a/frontend/views/containers/chatroom/SendArea.vue b/frontend/views/containers/chatroom/SendArea.vue index e7f0f6bfa..7cc5fd675 100644 --- a/frontend/views/containers/chatroom/SendArea.vue +++ b/frontend/views/containers/chatroom/SendArea.vue @@ -16,18 +16,30 @@ v-if='ephemeral.mention.options.length' ref='mentionWrapper' ) - .c-mention-user( - v-for='(user, index) in ephemeral.mention.options' - :key='user.memberID' - ref='mention' - :class='{"is-selected": index === ephemeral.mention.index}' - @click.stop='onClickMention(index)' - ) - avatar(:src='user.picture' size='xs') - .c-username {{user.username}} - .c-display-name( - v-if='user.displayName !== user.username' - ) ({{user.displayName}}) + template(v-if='ephemeral.mention.type === "member"') + .c-mention-user( + v-for='(user, index) in ephemeral.mention.options' + :key='user.memberID' + ref='mention' + :class='{"is-selected": index === ephemeral.mention.index}' + @click.stop='onClickMention(index)' + ) + avatar(:src='user.picture' size='xs') + .c-username {{user.username}} + .c-display-name( + v-if='user.displayName !== user.username' + ) ({{user.displayName}}) + + template(v-else-if='ephemeral.mention.type ==="channel"') + .c-mention-channel( + v-for='(channel, index) in ephemeral.mention.options' + :key='channel.id' + ref='mention' + :class='{"is-selected": index === ephemeral.mention.index}' + @click.stop='onClickMention(index)' + ) + i(:class='[channel.privacyLevel === "private" ? "icon-lock" : "icon-hashtag", "c-channel-icon"]') + .c-channel-name {{ channel.name }} .c-jump-to-latest( v-if='scrolledUp && !replyingMessage' @@ -246,8 +258,12 @@ import CreatePoll from './CreatePoll.vue' import Avatar from '@components/Avatar.vue' import Tooltip from '@components/Tooltip.vue' import ChatAttachmentPreview from './file-attachment/ChatAttachmentPreview.vue' -import { makeMentionFromUsername } from '@model/contracts/shared/functions.js' -import { CHATROOM_PRIVACY_LEVEL } from '@model/contracts/shared/constants.js' +import { makeMentionFromUsername, makeChannelMention } from '@model/contracts/shared/functions.js' +import { + CHATROOM_PRIVACY_LEVEL, + CHATROOM_MEMBER_MENTION_SPECIAL_CHAR, + CHATROOM_CHANNEL_MENTION_SPECIAL_CHAR +} from '@model/contracts/shared/constants.js' import { CHAT_ATTACHMENT_SUPPORTED_EXTENSIONS, CHAT_ATTACHMENT_SIZE_LIMIT } from '~/frontend/utils/constants.js' import { OPEN_MODAL, CHATROOM_USER_TYPING, CHATROOM_USER_STOP_TYPING } from '@utils/events.js' import { uniq, throttle, cloneDeep } from '@model/contracts/shared/giLodash.js' @@ -312,7 +328,8 @@ export default ({ mention: { position: -1, options: [], - index: -1 + index: -1, + type: 'member' // enum of ['member', 'channel'] }, attachments: [], // [ { url: instace of URL.createObjectURL , name: string }, ... ] staleObjectURLs: [], @@ -368,7 +385,8 @@ export default ({ 'chatRoomAttributes', 'ourContactProfilesById', 'globalProfile', - 'ourIdentityContractId' + 'ourIdentityContractId', + 'mentionableChatroomsInDetails' ]), members () { return Object.keys(this.chatRoomMembers) @@ -441,16 +459,27 @@ export default ({ }, updateMentionKeyword () { let value = this.$refs.textarea.value.slice(0, this.$refs.textarea.selectionStart) - const lastIndex = value.lastIndexOf('@') + const channelCharIndex = value.lastIndexOf(CHATROOM_CHANNEL_MENTION_SPECIAL_CHAR) + const memberCharIndex = value.lastIndexOf(CHATROOM_MEMBER_MENTION_SPECIAL_CHAR) + + if (channelCharIndex === -1 && memberCharIndex === -1) { + return this.endMention() + } + + const lastIndex = Math.max(channelCharIndex, memberCharIndex) + const mentionType = channelCharIndex > memberCharIndex ? 'channel' : 'member' const regExWordStart = /(\s)/g // RegEx Metacharacter \s - if (lastIndex === -1 || (lastIndex > 0 && !regExWordStart.test(value[lastIndex - 1]))) { + + if (lastIndex > 0 && !regExWordStart.test(value[lastIndex - 1])) { return this.endMention() } + value = value.slice(lastIndex + 1) if (regExWordStart.test(value)) { return this.endMention() } - this.startMention(value, lastIndex) + + this.startMention(value, lastIndex, mentionType) }, handleKeydown (e) { if (caretKeyCodeValues[e.keyCode]) { @@ -507,14 +536,30 @@ export default ({ addSelectedMention (index) { const curValue = this.$refs.textarea.value const curPosition = this.$refs.textarea.selectionStart - const selection = this.ephemeral.mention.options[index] + const { + options, + position: mentionStartPosition, + type: mentionType + } = this.ephemeral.mention + const selection = options[index] + let mentionString = '' + + if (mentionType === 'member') { + const mentionObj = makeMentionFromUsername(selection.username || selection.memberID, true) + mentionString = selection.memberID === mentionObj.all + ? mentionObj.all + : mentionObj.me + } else if (mentionType === 'channel') { + mentionString = makeChannelMention(selection.name) + } - const mentionObj = makeMentionFromUsername(selection.username || selection.memberID, true) - const mention = selection.memberID === mentionObj.all ? mentionObj.all : mentionObj.me - const value = curValue.slice(0, this.ephemeral.mention.position) + - mention + ' ' + curValue.slice(curPosition) + // Insert the selected mention into the input text. + const value = curValue.slice(0, mentionStartPosition) + + mentionString + ' ' + curValue.slice(curPosition) this.$refs.textarea.value = value - const selectionStart = this.ephemeral.mention.position + mention.length + 1 + + // Move the cursor in the text-input to the end of the inserted mention string, and hide the selection menu. + const selectionStart = mentionStartPosition + mentionString.length + 1 this.moveCursorTo(selectionStart) this.endMention() }, @@ -566,16 +611,34 @@ export default ({ let msgToSend = this.$refs.textarea.value || '' - /* Process mentions in the form @username => @userID */ - const mentionStart = makeMentionFromUsername('').all[0] - const availableMentions = this.members.map(memberID => memberID.username) + /* + Process member/channel mentions in the form: + member - @username => @userID + channel - #channel-name => #channelID + */ + const genMentionRegExp = (type = 'member') => { + // This regular expression matches all mentions (e.g. @username, #channel-name) that are standing alone between spaces + const mentionStart = type === 'member' ? CHATROOM_MEMBER_MENTION_SPECIAL_CHAR : CHATROOM_CHANNEL_MENTION_SPECIAL_CHAR + const availableMentions = type === 'member' + ? this.members.map(memberID => memberID.username) + : this.mentionableChatroomsInDetails.map(channel => channel.name) + + return new RegExp(`(?<=\\s|^)${mentionStart}(${availableMentions.join('|')})(?=[^\\w\\d]|$)`, 'g') + } + const convertChannelMentionToId = name => { + const found = this.mentionableChatroomsInDetails.find(entry => entry.name === name) + return found ? makeChannelMention(found.id) : '' + } + + // 1. replace all member mentions. msgToSend = msgToSend.replace( - // This regular expression matches all @username mentions that are - // standing alone between spaces - new RegExp(`(?<=\\s|^)${mentionStart}(${availableMentions.join('|')})(?=[^\\w\\d]|$)`, 'g'), - (_, username) => { - return makeMentionFromUsername(username).me - } + genMentionRegExp('member'), + (_, username) => makeMentionFromUsername(username).me + ) + // 2. replace all channel mentions. + msgToSend = msgToSend.replace( + genMentionRegExp('channel'), + (_, channelName) => convertChannelMentionToId(channelName) ) this.$emit( @@ -670,21 +733,39 @@ export default ({ this.closeEmoticon() this.updateTextWithLines() }, - startMention (keyword, position) { - const all = makeMentionFromUsername('').all - const availableMentions = Array.from(this.members) - // NOTE: '@all' mention should only be needed when the members are more than 3 - if (availableMentions.length > 2) { - availableMentions.push({ - memberID: all, - displayName: all.slice(1), - picture: '/assets/images/horn.png' - }) + startMention (keyword, position, mentionType = 'member') { + const checkIfContainsKeyword = str => { + if (typeof str !== 'string') { return false } + + const normalKeyword = keyword.normalize().toUpperCase() + return str.normalize().toUpperCase().includes(normalKeyword) } - const normalKeyword = keyword.normalize().toUpperCase() - this.ephemeral.mention.options = availableMentions.filter(user => - user.username?.normalize().toUpperCase().includes(normalKeyword) || - user.displayName?.normalize().toUpperCase().includes(normalKeyword)) + + switch (mentionType) { + case 'member': { + const all = makeMentionFromUsername('').all + const availableMentions = Array.from(this.members) + // NOTE: '@all' mention should only be needed when the members are more than 3 + if (availableMentions.length > 2) { + availableMentions.push({ + memberID: all, + displayName: all.slice(1), + picture: '/assets/images/horn.png' + }) + } + + this.ephemeral.mention.options = availableMentions.filter( + user => checkIfContainsKeyword(user.username) || checkIfContainsKeyword(user.displayName) + ) + + break + } + case 'channel': { + this.ephemeral.mention.options = this.mentionableChatroomsInDetails.filter(channel => checkIfContainsKeyword(channel.name)) + } + } + + this.ephemeral.mention.type = mentionType this.ephemeral.mention.position = position this.ephemeral.mention.index = 0 }, @@ -1012,26 +1093,32 @@ export default ({ overflow-y: auto; overflow-x: hidden; max-height: 12rem; -} -.c-mentions .c-mention-user { - display: flex; - align-items: center; - padding: 0.2rem; - cursor: pointer; + .c-mention-user, + .c-mention-channel { + display: flex; + align-items: center; + padding: 0.2rem 0.4rem; + cursor: pointer; - &.is-selected { - background-color: $primary_2; + &.is-selected { + background-color: $primary_2; + } } - .c-username { + .c-username, + .c-display-name, + .c-channel-name { margin-left: 0.3rem; } .c-display-name { - margin-left: 0.3rem; color: $text_1; } + + .c-channel-icon { + font-size: 0.875em; + } } .c-clear { diff --git a/frontend/views/containers/chatroom/ViewArea.vue b/frontend/views/containers/chatroom/ViewArea.vue index 663758c1f..da75ea3d3 100644 --- a/frontend/views/containers/chatroom/ViewArea.vue +++ b/frontend/views/containers/chatroom/ViewArea.vue @@ -5,11 +5,6 @@ :args='{title: title, ...LTags("b")}' ) You are viewing {b_} # {title}{_b} .c-view-actions-wrapper - button.button.is-small.is-outlined( - @click='see' - data-test='channelDescription' - ) - i18n Channel Description button-submit.is-success.is-small( @click='join' data-test='joinChannel' @@ -50,9 +45,6 @@ export default ({ alert(e.message) console.error('ViewArea join() error:', e) } - }, - see: function () { - console.log('TODO') } } }) diff --git a/frontend/views/pages/GroupChat.vue b/frontend/views/pages/GroupChat.vue index 3bb886a3a..7add6b590 100644 --- a/frontend/views/pages/GroupChat.vue +++ b/frontend/views/pages/GroupChat.vue @@ -234,7 +234,6 @@ export default ({ } .c-header { - text-transform: capitalize; display: flex; align-items: center; position: relative; diff --git a/test/cypress/integration/group-member-removal.spec.js b/test/cypress/integration/group-member-removal.spec.js index 120a2fb87..5fc9ee8c7 100644 --- a/test/cypress/integration/group-member-removal.spec.js +++ b/test/cypress/integration/group-member-removal.spec.js @@ -94,7 +94,7 @@ describe('Group - Removing a member', () => { cy.giRedirectToGroupChat() cy.get('div.c-message:last-child .c-who > span:first-child').should('contain', `user2-${userId}`) - cy.get('div.c-message:last-child .c-notification').should('contain', 'Left General') + cy.get('div.c-message:last-child .c-notification').should('contain', 'Left general') cy.giLogout() })