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.
<span style="line-height: 26px"><template></span>
<span style="line-height: 26px"> <div></span>
<span style="line-height: 26px"> <ChatContent /></span>
<span style="line-height: 26px"> <ChatFooter /></span>
<span style="line-height: 26px"> </div></span>
<span style="line-height: 26px"></template></span>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.
<span style="line-height: 26px">export interface IMResponseData {</span>
<span style="line-height: 26px"> /** 消息列表 */</span>
<span style="line-height: 26px"> messageList: any[]</span>
<span style="line-height: 26px"> /** 用于续拉,分页续拉时需传入该字段 */</span>
<span style="line-height: 26px"> nextReqMessageID: string</span>
<span style="line-height: 26px"> /** 表示是否已经拉完所有消息 */</span>
<span style="line-height: 26px"> isCompleted: boolean</span>
<span style="line-height: 26px">}</span>Scrolling to a specific message after loading older messages is handled by scrollToPosition which uses element.scrollIntoView.
<span style="line-height: 26px">const scrollToPosition = async (config: ScrollConfig = {}): Promise<void> => {</span>
<span style="line-height: 26px"> return new Promise((resolve, reject) => {</span>
<span style="line-height: 26px"> requestAnimationFrame(() => {</span>
<span style="line-height: 26px"> const targetMessageDom = document.querySelector(`#tui-${config.scrollToMessage}`)</span>
<span style="line-height: 26px"> if (targetMessageDom?.scrollIntoView) {</span>
<span style="line-height: 26px"> targetMessageDom.scrollIntoView({ behavior: 'smooth' })</span>
<span style="line-height: 26px"> }</span>
<span style="line-height: 26px"> resolve()</span>
<span style="line-height: 26px"> })</span>
<span style="line-height: 26px"> })</span>
<span style="line-height: 26px">}</span>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.
<span style="line-height: 26px">function calculateTimestamp(timestamp: number): string {</span>
<span style="line-height: 26px"> const todayZero = new Date().setHours(0, 0, 0, 0)</span>
<span style="line-height: 26px"> const thisYear = new Date(new Date().getFullYear(), 0, 1).getTime()</span>
<span style="line-height: 26px"> const target = new Date(timestamp)</span>
<span style="line-height: 26px"> const oneDay = 24 * 60 * 60 * 1000</span>
<span style="line-height: 26px"> const oneWeek = 7 * oneDay</span>
<span style="line-height: 26px"> const diff = todayZero - target.getTime()</span>
<span style="line-height: 26px"> function formatNum(num: number): string { return num < 10 ? '0' + num : num.toString() }</span>
<span style="line-height: 26px"> if (diff <= 0) { return `${formatNum(target.getHours())}:${formatNum(target.getMinutes())}` }</span>
<span style="line-height: 26px"> else if (diff <= oneDay) { return `昨天 ${formatNum(target.getHours())}:${formatNum(target.getMinutes())}` }</span>
<span style="line-height: 26px"> else if (diff <= oneWeek - oneDay) {</span>
<span style="line-height: 26px"> const weekdays = ['星期日','星期一','星期二','星期三','星期四','星期五','星期六']</span>
<span style="line-height: 26px"> const weekday = weekdays[target.getDay()]</span>
<span style="line-height: 26px"> return `${weekday} ${formatNum(target.getHours())}:${formatNum(target.getMinutes())}`</span>
<span style="line-height: 26px"> } else if (target.getTime() >= thisYear) {</span>
<span style="line-height: 26px"> return `${target.getMonth() + 1}/${target.getDate()} ${formatNum(target.getHours())}:${formatNum(target.getMinutes())}`</span>
<span style="line-height: 26px"> } else {</span>
<span style="line-height: 26px"> return `${target.getFullYear()}/${target.getMonth() + 1}/${target.getDate()} ${formatNum(target.getHours())}:${formatNum(target.getMinutes())}`</span>
<span style="line-height: 26px"> }</span>
<span style="line-height: 26px">}</span>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.
<span style="line-height: 26px"><img v-if="item.type === TYPES.MSG_IMAGE && item.hasRiskContent" :src="riskImageReplaceUrl" alt="图片无法查看" /></span>
<span style="line-height: 26px"><div v-else>{{ riskContentText }}</div></span>Loading Indicators and Failure Handling
When status === 'unSend', a loading icon is shown; when status === 'fail', a tooltip with a retry button appears.
<span style="line-height: 26px"><Icon v-if="item.status === 'unSend' && needLoadingIconMessageType.includes(item.type)" icon="eos-icons:three-dots-loading" :color="item.flow === 'in' ? '#38bdf8' : '#d4d4d8'" size="24" /></span>
<span style="line-height: 26px"><Tooltip v-if="item.status === 'fail' || item.hasRiskContent" @click="resendMessage"></span>
<span style="line-height: 26px"> <template #title>发送失败</template>!</Tooltip></span>Right‑Click Menu
Message actions such as revoke, delete, and copy are provided via a Dropdown triggered on the context menu.
<span style="line-height: 26px"><Dropdown :dropMenuList="MessageDropMenuList" :trigger="['contextmenu']" placement="bottom" overlayClassName="message__dropdown" @menu-event="handleMenuEvent"></span>
<span style="line-height: 26px"> <slot></slot></span>
<span style="line-height: 26px"></Dropdown></span>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.
<span style="line-height: 26px">const text = computed(() => {</span>
<span style="line-height: 26px"> const brackets = parseBrackets(payload.value.text || '')</span>
<span style="line-height: 26px"> return brackets.map(item => {</span>
<span style="line-height: 26px"> if (item === '
') { return { name: 'br' } }</span>
<span style="line-height: 26px"> else if (item.startsWith('[') && item.endsWith(']') && basicEmojiMap[item]) {</span>
<span style="line-height: 26px"> return { name: 'img', src: basicEmojiUrl + basicEmojiMap[item] }</span>
<span style="line-height: 26px"> } else { return { name: 'text', text: item } }</span>
<span style="line-height: 26px"> })</span>
<span style="line-height: 26px">})</span>File Upload Simulation
A simple upload‑progress simulator assumes a network speed of 512 KB/s and updates progress up to 99 %.
<span style="line-height: 26px">function simulateFileUpload(fileSize: number, callback: { (p: any): void }) {</span>
<span style="line-height: 26px"> const totalUploadTime = fileSize / (1024 * 512)</span>
<span style="line-height: 26px"> const totalIntervals = totalUploadTime * 10</span>
<span style="line-height: 26px"> let currentInterval = 0</span>
<span style="line-height: 26px"> timer = setInterval(() => {</span>
<span style="line-height: 26px"> currentInterval++</span>
<span style="line-height: 26px"> const progress = (currentInterval / totalIntervals) * 100</span>
<span style="line-height: 26px"> callback(parseInt(Math.min(progress, 99)))</span>
<span style="line-height: 26px"> if (currentInterval >= totalIntervals) { clearInterval(timer) }</span>
<span style="line-height: 26px"> }, 100)</span>
<span style="line-height: 26px">}</span>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.
<span style="line-height: 26px">export interface ImagePayload { uuid: string; imageFormat: 1|2|3|4|255; imageInfoArray: ImageInfo[] }</span>
<span style="line-height: 26px">export interface ImageInfo { type: 0|1|2; width: number; height: number; size: number; url: string }</span>File and Video Components
File messages show name and size with a download click; video messages use the video tag with a poster image.
<span style="line-height: 26px"><div title="单击下载" @click="download"></span>
<span style="line-height: 26px"> <Icon icon="ant-design:file-twotone" :size="40" /></span>
<span style="line-height: 26px"> <div>{{ item.payload.fileName }}</div></span>
<span style="line-height: 26px"> <div>{{ fileSize }}</div></span>
<span style="line-height: 26px"></div></span>Scroll Button Logic
The button appears when the scroll offset exceeds one screen height and scrolls the view to the latest message.
<span style="line-height: 26px">function judgeScrollOverOneScreen(e: Event) {</span>
<span style="line-height: 26px"> const scrollListDom = e.target as HTMLElement</span>
<span style="line-height: 26px"> const { height } = scrollListDom.getBoundingClientRect()</span>
<span style="line-height: 26px"> const { scrollHeight, scrollTop } = scrollListDom</span>
<span style="line-height: 26px"> if (height && scrollHeight) { isScrollOverOneScreen.value = scrollTop < scrollHeight - 2 * height }</span>
<span style="line-height: 26px">}</span>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.
<span style="line-height: 26px">export const sendMessages = async (chat: ChatSDK, messageList: ITipTapEditorContent[], currentConversationID: string, beforeSend?: (msg: Message) => void) => {</span>
<span style="line-height: 26px"> for (const content of messageList) {</span>
<span style="line-height: 26px"> const options: MESSAGE_OPTIONS = { to: currentConversationID, conversationType: TencentCloudChat.TYPES.CONV_GROUP, payload: {}, needReadReceipt: false, onProgress: () => {} }</span>
<span style="line-height: 26px"> switch (content?.type) {</span>
<span style="line-height: 26px"> case 'text': /* create and send text */ break;</span>
<span style="line-height: 26px"> case 'image': /* create and send image */ break;</span>
<span style="line-height: 26px"> case 'file': /* create and send file */ break;</span>
<span style="line-height: 26px"> case 'video': /* create and send video */ break;</span>
<span style="line-height: 26px"> }</span>
<span style="line-height: 26px"> }</span>
<span style="line-height: 26px">}</span>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.
<span style="line-height: 26px">const handleEnter = (e: any) => {</span>
<span style="line-height: 26px"> e?.preventDefault(); e?.stopPropagation();</span>
<span style="line-height: 26px"> if (e.keyCode === 13 && e.shiftKey) { editor?.commands?.insertContent('<p></p>') }</span>
<span style="line-height: 26px"> else if (e.keyCode === 13) { emits('sendMessage') }</span>
<span style="line-height: 26px">}</span>Paste Handling for Images and Files
Paste events are intercepted; images are inserted as custom nodes, while files are rendered as canvas‑generated thumbnails.
<span style="line-height: 26px">const handleFilePaste = async (e: any) => {</span>
<span style="line-height: 26px"> e.preventDefault(); e.stopPropagation();</span>
<span style="line-height: 26px"> const files = e?.clipboardData?.files;</span>
<span style="line-height: 26px"> for (let i = 0; i < files.length; i++) {</span>
<span style="line-height: 26px"> const file = files[i];</span>
<span style="line-height: 26px"> if (file.type.startsWith('image/')) {</span>
<span style="line-height: 26px"> const fileSrc = URL.createObjectURL(file);</span>
<span style="line-height: 26px"> editor?.commands?.insertContent({ type: 'custom-image', attrs: { src: fileSrc, alt: file?.name, title: file?.name, class: 'normal' } })</span>
<span style="line-height: 26px"> }</span>
<span style="line-height: 26px"> }</span>
<span style="line-height: 26px">}</span>Exposed Editor Methods
The component exposes getEditorContent, addEmoji, resetEditor, and setEditorContent for external control.
<span style="line-height: 26px">defineExpose({ getEditorContent, addEmoji, resetEditor, setEditorContent })</span>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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
