Skip to content

Commit

Permalink
#1947 - Channel links in chat (#1976)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
SebinSong authored May 2, 2024
1 parent 690c7a6 commit 7368f3f
Show file tree
Hide file tree
Showing 10 changed files with 344 additions and 106 deletions.
17 changes: 16 additions & 1 deletion frontend/model/chatroom/vuexModule.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }))
Expand Down
4 changes: 2 additions & 2 deletions frontend/model/contracts/chatroom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -111,7 +111,7 @@ function messageReceivePostEffect ({

shouldNotifyMessage && makeNotification({
title,
body: swapUserIDForUsername(text),
body: swapMentionIDForDisplayname(text),
icon,
path
})
Expand Down
6 changes: 5 additions & 1 deletion frontend/model/contracts/shared/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
39 changes: 31 additions & 8 deletions frontend/model/contracts/shared/functions.js
Original file line number Diff line number Diff line change
@@ -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'

// !!!!!!!!!!!!!!!
Expand Down Expand Up @@ -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('')
}
11 changes: 11 additions & 0 deletions frontend/views/containers/chatroom/CreateNewChannelModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)
</script>
Expand Down
159 changes: 133 additions & 26 deletions frontend/views/containers/chatroom/MessageBase.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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')
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -156,7 +184,11 @@ export default ({
convertTextToMarkdown: Boolean
},
computed: {
...mapGetters(['ourContactProfilesById', 'usernameFromID']),
...mapGetters([
'ourContactProfilesById',
'usernameFromID',
'chatRoomsInDetail'
]),
textObjects () {
return this.generateTextObjectsFromText(this.text)
},
Expand All @@ -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')
Expand Down Expand Up @@ -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,
Expand All @@ -224,26 +267,56 @@ 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
// of regular text. The `(?<=\\s|^)` part ensures that a mention is
// 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)
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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;
}
}
</style>
Loading

0 comments on commit 7368f3f

Please sign in to comment.