Tencent IM Overview and Component Design for Instant Messaging Applications
This article provides a comprehensive technical guide on Tencent Cloud's instant messaging (IM) service, comparing UI‑integrated and non‑UI integration approaches, detailing the core chat and input components, their Vue/TypeScript implementations, rendering logic, event handling, and auxiliary features such as file upload simulation, scroll management, and voice/video calling.
Tencent IM Overview
Tencent is one of the earliest and largest instant‑messaging providers in China, offering QQ and WeChat. To support digital transformation, Tencent Cloud exposes high‑concurrency, high‑reliability IM capabilities via SDKs and REST APIs, allowing developers to embed chat functionality into their applications.
Access Methods
Tencent IM offers two integration modes:
UI‑integrated solution (quick to integrate, full feature set, but UI cannot be customized and component code may clash with existing projects).
Non‑UI solution (API‑only, lightweight code that matches project style, but developers must design the UI and implement basic chat logic).
IM Framework Design
The core of the chat UI consists of two components: the chat content area and the message input area.
<template>
<div>
<ChatContent />
<ChatFooter />
</div>
</template>Component List
Chat box components include MessageLoadMore, MessageItem, MessageTimestamp, MessageTip, MessageBubble, MessageTool, MessageText, ProgressMessage, MessageImage, MessageFile, MessageVideo, MessageRevoked, and ScrollButton.
Message input components include MessageInputToolbar, EmojiPicker, ImageUpload, FileUpload, VideoUpload, VoiceCall, VideoCall, and MessageInputEditor.
Message Rendering Logic
The message list is fetched via getMessageList , which returns messageList , nextReqMessageID , and isCompleted . When isCompleted is false, a "Load more" button is displayed.
export interface IMResponseData {
/** 消息列表 */
messageList: any[]
/** 用于续拉,分页续拉时需传入该字段 */
nextReqMessageID: string
/** 表示是否已经拉完所有消息 */
isCompleted: boolean
}Scrolling to a specific message after loading older messages is handled by scrollToPosition which uses element.scrollIntoView .
const scrollToPosition = async (config: ScrollConfig = {}): Promise<void> => {
return new Promise((resolve, reject) => {
requestAnimationFrame(() => {
const targetMessageDom = document.querySelector(`#tui-${config.scrollToMessage}`)
if (targetMessageDom?.scrollIntoView) {
targetMessageDom.scrollIntoView({ behavior: 'smooth' })
}
resolve()
})
})
}Timestamp Formatting
Messages are grouped by time; the calculateTimestamp function formats timestamps as "hh:mm", "昨天 hh:mm", weekday, month/day, or year/month/day depending on recency.
function calculateTimestamp(timestamp: number): string {
const todayZero = new Date().setHours(0, 0, 0, 0)
const thisYear = new Date(new Date().getFullYear(), 0, 1).getTime()
const target = new Date(timestamp)
const oneDay = 24 * 60 * 60 * 1000
const oneWeek = 7 * oneDay
const diff = todayZero - target.getTime()
function formatNum(num: number): string { return num < 10 ? '0' + num : num.toString() }
if (diff <= 0) { return `${formatNum(target.getHours())}:${formatNum(target.getMinutes())}` }
else if (diff <= oneDay) { return `昨天 ${formatNum(target.getHours())}:${formatNum(target.getMinutes())}` }
else if (diff <= oneWeek - oneDay) {
const weekdays = ['星期日','星期一','星期二','星期三','星期四','星期五','星期六']
const weekday = weekdays[target.getDay()]
return `${weekday} ${formatNum(target.getHours())}:${formatNum(target.getMinutes())}`
} else if (target.getTime() >= thisYear) {
return `${target.getMonth() + 1}/${target.getDate()} ${formatNum(target.getHours())}:${formatNum(target.getMinutes())}`
} else {
return `${target.getFullYear()}/${target.getMonth() + 1}/${target.getDate()} ${formatNum(target.getHours())}:${formatNum(target.getMinutes())}`
}
}System Tips, Bubbles, and Risk Content
System messages are rendered with MessageTip . Message bubbles are aligned left or right based on flow . Risky content (e.g., unsafe images) is replaced with a placeholder using the hasRiskContent flag.
<img v-if="item.type === TYPES.MSG_IMAGE && item.hasRiskContent" :src="riskImageReplaceUrl" alt="图片无法查看" />
<div v-else>{{ riskContentText }}</div>Loading Indicators and Failure Handling
When status === 'unSend' , a loading icon is shown; when status === 'fail' , a tooltip with a retry button appears.
<Icon v-if="item.status === 'unSend' && needLoadingIconMessageType.includes(item.type)" icon="eos-icons:three-dots-loading" :color="item.flow === 'in' ? '#38bdf8' : '#d4d4d8'" size="24" />
<Tooltip v-if="item.status === 'fail' || item.hasRiskContent" @click="resendMessage">
<template #title>发送失败</template>!</Tooltip>Right‑Click Menu
Message actions such as revoke, delete, and copy are provided via a Dropdown triggered on the context menu.
<Dropdown :dropMenuList="MessageDropMenuList" :trigger="['contextmenu']" placement="bottom" overlayClassName="message__dropdown" @menu-event="handleMenuEvent">
<slot></slot>
</Dropdown>Text Rendering and Emoji Parsing
Plain text is rendered safely without v-html to avoid XSS. Emoji shortcuts like [调皮] are parsed and replaced with image URLs.
const text = computed(() => {
const brackets = parseBrackets(payload.value.text || '')
return brackets.map(item => {
if (item === '\n') { return { name: 'br' } }
else if (item.startsWith('[') && item.endsWith(']') && basicEmojiMap[item]) {
return { name: 'img', src: basicEmojiUrl + basicEmojiMap[item] }
} else { return { name: 'text', text: item } }
})
})File Upload Simulation
A simple upload‑progress simulator assumes a network speed of 512 KB/s and updates progress up to 99 %.
function simulateFileUpload(fileSize: number, callback: { (p: any): void }) {
const totalUploadTime = fileSize / (1024 * 512)
const totalIntervals = totalUploadTime * 10
let currentInterval = 0
timer = setInterval(() => {
currentInterval++
const progress = (currentInterval / totalIntervals) * 100
callback(parseInt(Math.min(progress, 99)))
if (currentInterval >= totalIntervals) { clearInterval(timer) }
}, 100)
}Image Component
Image payloads contain multiple resolutions; the component selects a compressed version for display and the original for preview, while also providing explicit height and width to avoid layout shifts.
export interface ImagePayload { uuid: string; imageFormat: 1|2|3|4|255; imageInfoArray: ImageInfo[] }
export interface ImageInfo { type: 0|1|2; width: number; height: number; size: number; url: string }File and Video Components
File messages show name and size with a download click; video messages use the video tag with a poster image.
<div title="单击下载" @click="download">
<Icon icon="ant-design:file-twotone" :size="40" />
<div>{{ item.payload.fileName }}</div>
<div>{{ fileSize }}</div>
</div>Scroll Button Logic
The button appears when the scroll offset exceeds one screen height and scrolls the view to the latest message.
function judgeScrollOverOneScreen(e: Event) {
const scrollListDom = e.target as HTMLElement
const { height } = scrollListDom.getBoundingClientRect()
const { scrollHeight, scrollTop } = scrollListDom
if (height && scrollHeight) { isScrollOverOneScreen.value = scrollTop < scrollHeight - 2 * height }
}Message Input Features
The input toolbar integrates emoji picker, image/file/video upload, voice/video call, and a rich‑text editor based on @tiptap/vue-3 . Sending is handled by sendMessages , which creates and dispatches text, image, file, or video messages via the Tencent Cloud Chat SDK.
export const sendMessages = async (chat: ChatSDK, messageList: ITipTapEditorContent[], currentConversationID: string, beforeSend?: (msg: Message) => void) => {
for (const content of messageList) {
const options: MESSAGE_OPTIONS = { to: currentConversationID, conversationType: TencentCloudChat.TYPES.CONV_GROUP, payload: {}, needReadReceipt: false, onProgress: () => {} }
switch (content?.type) {
case 'text': /* create and send text */ break;
case 'image': /* create and send image */ break;
case 'file': /* create and send file */ break;
case 'video': /* create and send video */ break;
}
}
}Toolbar Sub‑Features
Emoji picker uses a predefined list (e.g., [龇牙] ) mapped to image URLs.
Image, file, and video uploads are triggered by hidden input elements and emitted to the parent.
Voice and video calls leverage @tencentcloud/call-uikit-vue with dynamic sizing and minimization support.
Editor Keyboard Handling
Enter sends the message; Shift + Enter inserts a new paragraph.
const handleEnter = (e: any) => {
e?.preventDefault(); e?.stopPropagation();
if (e.keyCode === 13 && e.shiftKey) { editor?.commands?.insertContent('<p></p>') }
else if (e.keyCode === 13) { emits('sendMessage') }
}Paste Handling for Images and Files
Paste events are intercepted; images are inserted as custom nodes, while files are rendered as canvas‑generated thumbnails.
const handleFilePaste = async (e: any) => {
e.preventDefault(); e.stopPropagation();
const files = e?.clipboardData?.files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith('image/')) {
const fileSrc = URL.createObjectURL(file);
editor?.commands?.insertContent({ type: 'custom-image', attrs: { src: fileSrc, alt: file?.name, title: file?.name, class: 'normal' } })
}
}
}Exposed Editor Methods
The component exposes getEditorContent , addEmoji , resetEditor , and setEditorContent for external control.
defineExpose({ getEditorContent, addEmoji, resetEditor, setEditorContent })Conclusion
The article demonstrates a full‑stack instant‑messaging UI built with Vue 3, TypeScript, and Tencent Cloud's IM SDK, covering component architecture, rendering logic, user interaction, and auxiliary features such as file handling and voice/video calling, providing a solid reference for developers building similar chat solutions.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.