Vue即时聊天(集成极光IM)

UI界面


图片名称

带表情输入框

HTML中,input、textarea不太好处理表情、图片的插入。一般有两种处理方案,一是用input来控制输入,再用一个div来控制显示;另外一种就是利用HTML5提供的新属性contenteditable,该属性可以让div或者p等标签变成可输入的的标签,缺点是低版本浏览器不兼容(contenteditable兼容)

难点:

  1. 光标位置的控制
  2. 在指定光标位置插入图片
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
<template>
<div class="edit-div needsclick" v-html="innerText" ref="editDiv" :contenteditable="canEdit" @input="changeText">
</div>
</template>
<script type="text/ecmascript-6">
export default {
name: 'editDiv',
props: {
value: {
type: String,
default: ''
},
canEdit: {
type: Boolean,
default: true
}
},
data () {
return {
innerText: this.value,
isLocked: false
}
},
watch: {
'value' (newValue, oldValue) {
this.innerText = newValue
this.$nextTick(() => {
this.setCaretPosition()
})
}
},
methods: {
changeText () {
this.$emit('input', this.$el.innerHTML)
},
setCaretPosition () { // 设置光标到末尾
const obj = this.$refs.editDiv
let range
if (window.getSelection) {
obj.focus()
range = window.getSelection()
range.selectAllChildren(obj)
range.collapseToEnd()
} else if (document.selection) {
range = document.selection.createRange()
range.moveToElementText(obj)
range.collapse(false)
range.select()
}
},
getCursortPosition () {
const element = this.$refs.editDiv
var caretOffset = 0
var doc = element.ownerDocument || element.document
var win = doc.defaultView || doc.parentWindow
var sel
if (win.getSelection != null) { // 谷歌、火狐
sel = win.getSelection()
if (sel.rangeCount > 0) { // 选中的区域
var range = win.getSelection().getRangeAt(0)
var preCaretRange = range.cloneRange() // 克隆一个选中区域
preCaretRange.selectNodeContents(element) // 设置选中区域的节点内容为当前节点
preCaretRange.setEnd(range.endContainer, range.endOffset) // 重置选中区域的结束位置
caretOffset = preCaretRange.toString().length
}
} else if ((sel = doc.selection) && sel.type !== 'Control') { // IE
var textRange = sel.createRange()
var preCaretTextRange = doc.body.createTextRange()
preCaretTextRange.moveToElementText(element)
preCaretTextRange.setEndPoint('EndToEnd', textRange)
caretOffset = preCaretTextRange.text.length
}
return caretOffset
}
}
}
</script>
<style lang="stylus" rel="stylesheet/scss">
.edit-div {
width: 100%;
height: 100%;
overflow: auto;
word-break: break-all;
outline: none;
user-select: text;
white-space: pre-wrap;
text-align: left;
img {
width: 21px;
height: 21px;
}

&[contenteditable='true'] {
user-modify: read-write-plaintext-only;

&:empty:before {
content: attr(placeholder);
display: block;
color: #ccc;
}
}
}
</style>

上面代码中,通过value和镜像值innerText来进行双向绑定,使用该组件时可利用v-model,详情请看自定义组件的v-model

聊天气泡

聊天气泡分两种,自己发的消息和别人发的消息。该区域的滚动是用better-scroll来完成的

HTML代码

1
2
3
4
5
6
7
8
9
10
11
<scroll ref="scroll" :list="chatMsgList" :style="{height: mainHeight + 'px'}" @click.native="$refs.inputWrapper.$el.blur()" style="overflow: hidden;">
<ul class="message">
<li v-for="msg in chatMsgList" :key="msg.id" :class="msg.type">
<p class="time" v-if="msg.sendTimeStr !== ''"> <span>{{msg.sendTimeStr}}</span></p>
<div class="main">
<img v-lazy="msg.headUrl" class="avatar"/>
<div class="text" v-html="msg.content"></div>
</div>
</li>
</ul>
</scroll>

CSS代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
.message {
padding: 10px 15px;
list-style: none;
li {
margin-bottom: 15px;
.time {
margin: 4px 0;
text-align: center;
span{
display: inline-block;
padding: 4px 18px;
font-size: 13px;
color: #fff;
border-radius: 2px;
background-color: #dcdcdc;
line-height: 1;
}
}
.avatar {
float: left;
width: 40px;
height: 40px;
margin: 0 10px 0 0;
border-radius: 3px;
}
.text {
display: inline-flex;
position: relative;
padding: 3px 10px;
max-width: calc(100% - 100px);
min-height: 30px;
line-height: 1.8;
font-size: 15px;
text-align: left;
word-break: break-all;
background-color: #ffffff;
border-radius: 4px;
align-items: center;

&:before {
content: " ";
position: absolute;
top: 9px;
right: 100%;
border: 6px solid transparent;
border-right-color: #ffffff;
}
}
}
.to {
text-align: right;
.avatar {
float: right;
margin: 0 0 0 10px;
}
.text {
background-color: #b2e281;
&:before {
right: inherit;
left: 100%;
border-right-color: transparent;
border-left-color: #b2e281;
}
}
}
}

软键盘的冲突

注:IOS还未做处理,只针对Android

Android中,打开软键盘,并不会把输入框给顶上去。所以需要手动处理软键盘打开或者关闭事件。

经过测试(华为P10,荣耀V9),软键盘的打开和关闭并不会触发window.onresize事件。因此,让Android原生来判断软键盘的打开或者关闭状态,然后调用本地js定义的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
window._Android_heightChange = (height, visibleHeight) => {
// 1px在移动端所占像素点
const scale = height / window.innerHeight
// 滚动区域的高度=屏幕高度-顶部高度-输入栏高度-软键盘高度
this.mainHeight = window.innerHeight - 60 - 45 - (height - visibleHeight) / scale + 24
if ((height - visibleHeight) / scale <= 24) {
// 当软键盘收起是,输入框失去焦点
this.$refs.inputWrapper.$el.blur()
}
this.$nextTick(() => {
// 高度刷新一次,滚动区域滚到最末尾
this.$refs.scroll.scroll.refresh()
this.$refs.scroll.scroll.scrollTo(0, this.$refs.scroll.scroll.maxScrollY)
})
}

极光IM集成

极光IM文档地址

JMessage思维导图

需要注意的点

  1. 所有聊天操作都应该在同一个JMessage对象
  2. JMessage的生命周期
  3. 在自己的用户模块中添加相应的登录、注册等功能,保证项目用户和极光IM中的用户数据同步。
  4. 消息的分发与监听

引入JMessage.js

把JMessage.js下载到本地,或者使用CDN,然后在index.html中引入该js。引入成功过后,会在window中挂载一个JMessage对象,用window.JMessage直接使用。

去除eslint报错。在.eslintrc.js文件中添加globals: {‘JMessage’: false}

全局JMessage的处理方案

既然是全局的,想到的自然是使用vuex来处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import * as types from '../mutation-types'

// 时间格式化
import moment from 'moment'
// 本地user对象
import { localUser } from '@/assets/js/local'
// JIM配置文件
import { JIMConf } from '@/api/config'
// 随机字符串
import { randomWord } from '@/assets/js/utils'
// MD5加密
import md5 from 'js-md5'

const state = {
JIM: null,
chatObject: {}, // 聊天对象属性。ID、昵称、头像等
chatMsgList: [], // 消息列表,最终用于聊天气泡渲染
receiveMsg: [], // 接收到的消息列表
syncConversation: [] // 离线消息列表
}

const getters = {
JIM: state => state.JIM,
chatObject: state => state.chatObject,
chatMsgList: state => state.chatMsgList,
receiveMsg: state => state.receiveMsg,
syncConversation: state => state.syncConversation
}

const mutations = {
[types.SET_JIM] (state, JIM) {
state.JIM = JIM
},
[types.SET_CHAT_OBJECT] (state, chatObject) {
state.chatObject = chatObject
},
[types.SET_CHAT_MSG_LIST](state, msgList) {
state.chatMsgList = msgList
},
[types.PUSH_CHAT_MSG_LIST](state, msg) {
state.chatMsgList.push(msg)
},
[types.SET_RECEIVE_MSG](state, receiveMsg) {
state.receiveMsg = receiveMsg
},
[types.SET_SYNC_CONVERSATION](state, syncConversation) {
state.syncConversation = syncConversation
}
}

const actions = {
initJIM ({ commit }) {
// 注册全局JMessage对象
let params = {
appkey: JIMConf.key,
random_str: randomWord(1, 20, 32),
timestamp: new Date().getTime(),
flag: 1
}
params.signature = md5(`appkey=${params.appkey}&timestamp=${params.timestamp}&random_str=${params.random_str}&key=${JIMConf.secret}`)
// 开启debug模式
const JIM = new JMessage({ debug: true })
// 本地的用户数据
const user = localUser.get()
// 初始化JMessage对象
JIM.init(params).onSuccess((data) => {
if (user && !JIM.isLogin()) {
// 登录
JIM.login({
username: user.id,
password: user.password,
is_md5: true
}).onFail(function (arg) {
if (arg.code === 880103) {
// 注册用户,把之前已经有的项目用户数据和极光IM用户数据同步
JIM.register({
username: user.id,
password: user.password,
is_md5: true
}).onSuccess(function () {
localUser.setItem('id', user.id)
})
}
}).onSuccess(function () {
// 不知道为什么,极光IM登录时,会把localStorage里面的用户删除。重新设置
localUser.setItem('id', user.id)
commit(types.SET_JIM, JIM)
})
} else {
commit(types.SET_JIM, JIM)
}
// 实时监听收到的消息
JIM.onMsgReceive(function (rmsg) {
commit(types.SET_RECEIVE_MSG, rmsg)
let tmp = []
rmsg.messages.forEach(item => {
tmp.push({
id: item.msg_id,
type: 'from',
nickname: state.chatObject.nickname,
headUrl: state.chatObject.headUrl,
content: item.content.msg_body.text,
contentType: 0,
sendTime: item.content.create_time
})
})
commit(types.SET_CHAT_MSG_LIST, state.chatMsgList.concat(tmp))
})
JIM.onSyncConversation(function (scon) {
// 获取离线数据的时候还没有设置聊天对象
commit(types.SET_SYNC_CONVERSATION, scon)
})
JIM.onMutiUnreadMsgUpdate(function (data) {
console.log(data)
})
})
},
setChatObject({ commit, state }, chatObj) {
commit(types.SET_CHAT_OBJECT, chatObj)
// 设置聊天对象的时候就把离线记录加载出来
const user = localUser.get()
let tmp = []
state.syncConversation.forEach(item => {
if (item.from_username === chatObj.id && item.msgs) {
item.msgs.forEach((msg, index) => {
tmp.push({
id: msg.msg_id,
type: msg.content.from_id === user.id ? 'to' : 'from',
nickname: msg.content.from_name,
headUrl: msg.content.from_id === user.id ? user.headUrl : chatObj.headUrl,
content: msg.content.msg_body.text,
contentType: 0,
sendTime: msg.content.create_time
})
})
}
})
// 时间格式化
tmp.forEach((item, index) => {
if (moment().diff(moment(item.sendTime), 'days') <= 1) {
item.sendTimeStr = moment(item.sendTime).format('HH:mm')
} else {
item.sendTimeStr = moment(item.sendTime).format('YYYY-MM-DD HH:mm')
}
if (index > 0) {
if (moment(item.sendTime).diff(moment(tmp[index - 1].sendTime), 'minutes') < 4) {
item.sendTimeStr = ''
}
}
})
commit(types.SET_CHAT_MSG_LIST, tmp)
}
}

export default {
state,
getters,
actions,
mutations
}

疑问: 不知道为什么,极光IM登录时,会把localStorage里面的用户删除?????。有知道的请回复我一下,谢谢

坚持原创技术分享,您的支持将鼓励我继续创作!