更新
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<view class="bubble" :class="{ 'bubble-me': message?.flow === 'out' }">
|
||||
<div v-if="isCallSignaling(message)" @click="rePlayCall(handleCallKitSignaling(message).callType)"
|
||||
style="display: flex; align-items: center;">
|
||||
<image class="callMessage-icon"
|
||||
:src="handleCallKitSignaling(message).callType === 1 ? CallVoiceIcon : CallVideoIcon" />
|
||||
<text class="text">{{ handleCallKitSignaling(message).callTip }}</text>
|
||||
</div>
|
||||
<div v-else>
|
||||
<text class="text">{{ message?.payload }}</text>
|
||||
</div>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useMessageListState } from '../../../index';
|
||||
import CallVoiceIcon from '../../../assets/chat/voice-call-message.svg';
|
||||
import CallVideoIcon from '../../../assets/chat/call-video.svg';
|
||||
import { removeC2C } from '../../../utils';
|
||||
import { handleCallKitSignaling, isCallSignaling } from '../../../utils/processCallSignaling';
|
||||
import { TUIBridge } from '../../../TUIBridge';
|
||||
import { EVENT } from '../../../constants/event';
|
||||
const { activeConversationID } = useMessageListState();
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
},
|
||||
})
|
||||
|
||||
async function rePlayCall(callType) {
|
||||
const userID = removeC2C(activeConversationID.value)
|
||||
TUIBridge.notifyEvent({
|
||||
eventName: EVENT.ON_CALLS,
|
||||
params: {
|
||||
userIDList: [userID],
|
||||
type: callType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './Message.module.scss';
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<view class="group-tip-message">
|
||||
<text class="tip-text">{{ renderText() }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { handleCallKitSignaling, isCallSignaling } from '../../../utils/processCallSignaling';
|
||||
|
||||
interface Props {
|
||||
message: any;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const renderText = () => {
|
||||
const messageContent = props.message.getMessageContent()
|
||||
if (messageContent.businessID === 'group_create') {
|
||||
return `${messageContent.showName || ''} 创建群组`;
|
||||
}
|
||||
if (isCallSignaling(props.message) && props.message.conversationType === 'GROUP') {
|
||||
return handleCallKitSignaling(props.message);
|
||||
}
|
||||
return resolveGroupTipMessage(props.message).text;
|
||||
};
|
||||
|
||||
const resolveGroupTipMessage = (message: any) => {
|
||||
const ret: {
|
||||
text: string;
|
||||
} = {
|
||||
text: '',
|
||||
};
|
||||
|
||||
let showName: string = message?.nick || message?.payload?.userIDList?.join(',');
|
||||
if (message?.payload?.memberList?.length > 0) {
|
||||
showName = '';
|
||||
message?.payload?.memberList?.map((user: any) => {
|
||||
const _showName = user?.nick || user?.userID;
|
||||
showName += `${_showName},`;
|
||||
return user;
|
||||
});
|
||||
showName = showName?.slice(0, -1);
|
||||
}
|
||||
|
||||
switch (message.payload.operationType) {
|
||||
case 1: // GRP_TIP_MBR_JOIN
|
||||
ret.text = `${showName} 加入群组`;
|
||||
break;
|
||||
case 2: // GRP_TIP_MBR_QUIT
|
||||
ret.text = `${showName} 退出群组`;
|
||||
break;
|
||||
case 3: // GRP_TIP_MBR_KICKED_OUT
|
||||
ret.text = `${showName} 被踢出群组`;
|
||||
break;
|
||||
case 4: // GRP_TIP_MBR_SET_ADMIN
|
||||
ret.text = `${showName} 成为管理员`;
|
||||
break;
|
||||
case 5: // GRP_TIP_MBR_CANCELED_ADMIN
|
||||
ret.text = `${showName} 被撤销管理员`;
|
||||
break;
|
||||
case 6: // GRP_TIP_GRP_PROFILE_UPDATED
|
||||
ret.text = handleGroupProfileUpdated(message);
|
||||
break;
|
||||
case 7: // GRP_TIP_MBR_PROFILE_UPDATED
|
||||
message.payload.memberList.forEach((member: any) => {
|
||||
if (member.muteTime > 0) {
|
||||
ret.text = `${showName} 被禁言`;
|
||||
} else {
|
||||
ret.text = `${showName} 被取消禁言`;
|
||||
}
|
||||
});
|
||||
break;
|
||||
default:
|
||||
ret.text = `[群提示消息]`;
|
||||
break;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
const handleGroupProfileUpdated = (message: any) => {
|
||||
const { nick, payload } = message;
|
||||
const { newGroupProfile, memberList, operatorID } = payload;
|
||||
let text = '';
|
||||
|
||||
const showName: string = nick || operatorID;
|
||||
const key: string = Object.keys(newGroupProfile)[0];
|
||||
switch (key) {
|
||||
case 'muteAllMembers':
|
||||
if (newGroupProfile[key]) {
|
||||
text = `管理员 ${showName} 开启全员禁言`;
|
||||
} else {
|
||||
text = `管理员 ${showName} 取消全员禁言`;
|
||||
}
|
||||
break;
|
||||
case 'ownerID':
|
||||
if (memberList && memberList.length > 0) {
|
||||
text = `${memberList[0].nick || memberList[0].userID} 成为新的群主`;
|
||||
}
|
||||
break;
|
||||
case 'groupName':
|
||||
text = `${showName} 修改群名为 ${newGroupProfile[key]}`;
|
||||
break;
|
||||
case 'notification':
|
||||
text = `${showName} 发布新公告`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.group-tip-message {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16rpx 32rpx;
|
||||
margin: 8rpx 0;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
max-width: 80%;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="image-message">
|
||||
<image :src="message?.payload.imageInfoArray[0]?.url" :style="imgStyle" @click="previewImage" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
},
|
||||
})
|
||||
|
||||
const imgStyle = ref({});
|
||||
|
||||
const previewImage = (e: Event) => {
|
||||
const currentUrl = props.message?.payload.imageInfoArray[0]?.url;
|
||||
if (!currentUrl) return;
|
||||
uni.previewImage({
|
||||
current: currentUrl,
|
||||
urls: [currentUrl],
|
||||
indicator: 'default',
|
||||
loop: false,
|
||||
});
|
||||
}
|
||||
|
||||
const calculateImageSize = (width: number, height: number) => {
|
||||
const maxWidth = 200;
|
||||
const maxHeight = 200;
|
||||
|
||||
if (width > maxWidth) {
|
||||
height = (maxWidth / width) * height;
|
||||
width = maxWidth;
|
||||
}
|
||||
if (height > maxHeight) {
|
||||
width = (maxHeight / height) * width;
|
||||
height = maxHeight;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
const setImageStyle = (width: number, height: number) => {
|
||||
imgStyle.value = {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`
|
||||
};
|
||||
}
|
||||
|
||||
const getImageSizeFromProps = () => {
|
||||
const imageInfo = props.message?.payload.imageInfoArray[0];
|
||||
if (imageInfo?.width && imageInfo?.height) {
|
||||
return { width: imageInfo.width, height: imageInfo.height };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const detectImageSize = () => {
|
||||
uni.getImageInfo({
|
||||
src: props.message?.payload.imageInfoArray[0]?.url,
|
||||
success: (res) => {
|
||||
const { width, height } = calculateImageSize(res.width, res.height);
|
||||
setImageStyle(width, height);
|
||||
},
|
||||
fail: () => {
|
||||
setImageStyle(200, 200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const sizeFromProps = getImageSizeFromProps();
|
||||
if (sizeFromProps) {
|
||||
const { width, height } = calculateImageSize(sizeFromProps.width, sizeFromProps.height);
|
||||
setImageStyle(width, height);
|
||||
} else {
|
||||
detectImageSize();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.image-message {
|
||||
image {
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
.bubble {
|
||||
background-color: #F0F2F7;
|
||||
border-radius: 0px 10px 10px 10px;
|
||||
padding: 15rpx;
|
||||
position: relative;
|
||||
|
||||
&.bubble-me {
|
||||
border-radius: 10px 0 10px 10px;
|
||||
background-color: #CCE2FF;
|
||||
}
|
||||
|
||||
|
||||
.callMessage-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<view class="bubble" :class="{ 'bubble-me': message?.flow === 'out' }">
|
||||
<text class="text" :style="{ wordBreak: shouldBreakWord ? 'break-all' : 'normal' }">
|
||||
{{ message.payload.text }}
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
},
|
||||
})
|
||||
|
||||
const shouldBreakWord = computed(() => {
|
||||
return props.message?.payload?.text?.length > 50
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './Message.module.scss';
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<view class="video-message">
|
||||
<!-- 视频预览图 -->
|
||||
<view class="video-preview" @click="handleVideoClick">
|
||||
<image class="video-poster" :src="message.payload.snapshotUrl" mode="aspectFill" />
|
||||
<!-- 播放按钮图标 -->
|
||||
<view class="play-icon">
|
||||
<image :src="playControlIcon" mode="aspectFit" />
|
||||
</view>
|
||||
<!-- 视频时长 -->
|
||||
<view class="video-duration">
|
||||
{{ formatDuration(message.payload.videoSecond) }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 全屏播放器 -->
|
||||
<view class="fullscreen-container" v-if="isPlaying">
|
||||
<video id="videoPlayer" :src="message.payload.remoteVideoUrl" :autoplay="true" :controls="true"
|
||||
:show-fullscreen-btn="false" :show-center-play-btn="false" @ended="handleVideoEnd"
|
||||
@pause="handleVideoPause" @fullscreenchange="handleFullscreenChange" class="fullscreen-video"
|
||||
:enable-progress-gesture="true" objectFit="contain" />
|
||||
<!-- 关闭按钮 -->
|
||||
<view class="video-controls">
|
||||
<view class="close-btn" @click="handleCloseVideo">
|
||||
<view class="close-icon"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import playControlIcon from '../../../assets/chat/play-control.svg';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
});
|
||||
|
||||
const isPlaying = ref(false);
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
||||
};
|
||||
|
||||
const handleVideoClick = () => {
|
||||
isPlaying.value = true;
|
||||
setTimeout(() => {
|
||||
const videoContext = uni.createVideoContext('videoPlayer', this);
|
||||
videoContext.requestFullScreen({
|
||||
direction: 0 // 0表示正常竖屏
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleVideoPause = () => {
|
||||
};
|
||||
|
||||
const handleVideoEnd = () => {
|
||||
isPlaying.value = false;
|
||||
};
|
||||
|
||||
const handleFullscreenChange = (e: any) => {
|
||||
if (!e.detail.fullScreen) {
|
||||
isPlaying.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseVideo = () => {
|
||||
const videoContext = uni.createVideoContext('videoPlayer', this);
|
||||
videoContext.pause();
|
||||
videoContext.exitFullScreen();
|
||||
isPlaying.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.video-message {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: black;
|
||||
|
||||
.video-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-poster {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.video-duration {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
padding: 2px 6px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fullscreen-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 999;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.fullscreen-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
box-sizing: border-box;
|
||||
z-index: 1000;
|
||||
/* 确保关闭按钮在控制条上方 */
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.close-icon::before,
|
||||
.close-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: white;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.close-icon::before {
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.close-icon::after {
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,325 @@
|
||||
<template>
|
||||
<div class="message-container">
|
||||
<!-- 消息列表滚动区域 -->
|
||||
<scroll-view ref="scrollViewRef" scroll-y class="message-list" :scroll-into-view="currentMessage"
|
||||
scroll-with-animation @scrolltolower="handleScrollToBottom" @scrolltoupper="handleScrollToTop">
|
||||
<!-- 下拉加载提示 -->
|
||||
<view v-if="isLoadingOlderMessage || showNoMoreTip" class="loading-tip">
|
||||
<text>{{ isLoadingOlderMessage ? '加载中...' : '没有更多消息了' }}</text>
|
||||
</view>
|
||||
<!-- 消息列表 -->
|
||||
<view v-for="(message, index) in messageList">
|
||||
|
||||
<!-- 群 tips 消息 -->
|
||||
<GroupTipMessage v-if="shouldRenderAsGroupTip(message)" :message="message" />
|
||||
|
||||
<!-- 消息时间分割线 -->
|
||||
<MessageTimestamp v-if="!shouldRenderAsGroupTip(message)" :currTime="message.time"
|
||||
:prevTime="index > 0 ? messageList[index - 1].time : 0" />
|
||||
<!-- 非群 tips 消息 -->
|
||||
<view v-if="isSupportedMessageType(message)" :id="`msg-${message.ID}`" class="message-item"
|
||||
:class="{ 'message-item-me': message?.flow === 'out' }">
|
||||
<!-- 消息头像 -->
|
||||
<Avatar :avatarStyle="avatarStyle" :src="message?.avatar || DefaultAvatarIcon" />
|
||||
<!-- 消息内容 -->
|
||||
<view class="message-content">
|
||||
<!-- 群聊用户名显示 -->
|
||||
<view v-if="activeConversation?.type === 'GROUP' && message?.flow === 'in'" class="sender-name">
|
||||
{{ getDisplayName(message) }}
|
||||
</view>
|
||||
<!-- 文本消息 -->
|
||||
<TextMessage v-if="message?.type === MessageType.MSG_TEXT" :message="message" />
|
||||
<!-- 通话消息 -->
|
||||
<CustomMessage v-if="message?.type === MessageType.MSG_CUSTOM" :message="message" />
|
||||
<!-- 图片消息 -->
|
||||
<ImageMessage v-if="message?.type === MessageType.MSG_IMAGE" :message="message" />
|
||||
<!-- 视频消息 -->
|
||||
<VideoMessage v-if="message?.type === MessageType.MSG_VIDEO" :message="message" />
|
||||
</view>
|
||||
<!-- 消息状态 -->
|
||||
<MessageStatus class="message-status" :message="message" />
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 新消息提示条,当有新消息且不在底部时显示 -->
|
||||
<view v-if="showNewMessageTip" class="new-message-tip" @click="handleNewMessageTipClick">
|
||||
<image :src="NewMessageTipIcon"></image>
|
||||
{{ newMessageTipText }}
|
||||
</view>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
options: {
|
||||
virtualHost: true,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
|
||||
import Avatar from '../Avatar/Avatar.vue'
|
||||
import TextMessage from './Message/TextMessage.vue';
|
||||
import CustomMessage from './Message/CustomMessage.vue';
|
||||
import ImageMessage from './Message/ImageMessage.vue';
|
||||
import VideoMessage from './Message/VideoMessage.vue';
|
||||
import GroupTipMessage from './Message/GroupTipMessage.vue';
|
||||
import MessageStatus from './MessageStatus/MessageStatus.vue';
|
||||
import MessageTimestamp from './MessageTimeDivider/MessageTimeDivider.vue';
|
||||
import DefaultAvatarIcon from '../../assets/base/default-avatar.png';
|
||||
import NewMessageTipIcon from '../../assets/chat/new-message.svg';
|
||||
import { useMessageListState, useConversationListState } from '../../chat';
|
||||
import { isCallSignaling } from '../../utils/processCallSignaling';
|
||||
import { MessageType } from '../../constants/chat'
|
||||
|
||||
const { setActiveConversation, activeConversation } = useConversationListState()
|
||||
const { messageList, loadMoreOlderMessage, hasMoreOlderMessage } = useMessageListState();
|
||||
const scrollViewRef = ref();
|
||||
const showNewMessageTip = ref(false);
|
||||
const newMessageCount = ref(0);
|
||||
const autoScroll = ref(true);
|
||||
const isLoadingOlderMessage = ref(false);
|
||||
const showNoMoreTip = ref(false);
|
||||
const historyFirstMessageID = ref<string>('');
|
||||
const currentMessage = ref('');
|
||||
const avatarStyle = ref({
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '5px'
|
||||
})
|
||||
|
||||
// 获取显示的用户名
|
||||
const getDisplayName = (message: any) => {
|
||||
const nameCard = message?.nameCard || '';
|
||||
const senderNick = message?.nick || '';
|
||||
const senderUserID = message?.from || '';
|
||||
|
||||
// 如果是群聊,按优先级显示:nameCard > nick > userID
|
||||
if (activeConversation.value?.type === 'GROUP') {
|
||||
return nameCard || senderNick || senderUserID;
|
||||
}
|
||||
|
||||
// 如果不是群聊,只显示昵称
|
||||
return senderNick || senderUserID;
|
||||
};
|
||||
|
||||
const isSupportedMessageType = (message) => {
|
||||
const type = message?.type
|
||||
return [
|
||||
MessageType.MSG_TEXT,
|
||||
MessageType.MSG_CUSTOM,
|
||||
MessageType.MSG_IMAGE,
|
||||
MessageType.MSG_VIDEO
|
||||
].includes(type) && !shouldRenderAsGroupTip(message)
|
||||
}
|
||||
|
||||
const newMessageTipText = computed(() => {
|
||||
return `${newMessageCount.value}条新消息`
|
||||
})
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (messageList.value?.length) {
|
||||
const lastMessage = messageList.value[messageList.value.length - 1]
|
||||
const targetId = `msg-${lastMessage.ID}`
|
||||
// Clear and re-set to ensure scroll triggers even when value is the same
|
||||
if (currentMessage.value === targetId) {
|
||||
currentMessage.value = ''
|
||||
setTimeout(() => {
|
||||
currentMessage.value = targetId
|
||||
}, 50)
|
||||
} else {
|
||||
currentMessage.value = targetId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewMessageTipClick = () => {
|
||||
showNewMessageTip.value = false
|
||||
newMessageCount.value = 0
|
||||
autoScroll.value = true
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
const handleScrollToTop = async (e) => {
|
||||
if (isLoadingOlderMessage.value) return;
|
||||
|
||||
if (!hasMoreOlderMessage.value) {
|
||||
showNoMoreTip.value = true;
|
||||
setTimeout(() => {
|
||||
showNoMoreTip.value = false;
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingOlderMessage.value = true;
|
||||
const currentFirstMessageID = messageList.value?.[0]?.ID || '';
|
||||
await loadMoreOlderMessage();
|
||||
historyFirstMessageID.value = currentFirstMessageID;
|
||||
isLoadingOlderMessage.value = false;
|
||||
}
|
||||
|
||||
const handleScrollToBottom = () => {
|
||||
showNewMessageTip.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
// Listen for keyboard pop-up to auto scroll to bottom
|
||||
uni.$on('TUIChat:keyboardHeightChange', () => {
|
||||
scrollToBottom()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
setActiveConversation('')
|
||||
uni.$off('TUIChat:keyboardHeightChange')
|
||||
})
|
||||
|
||||
watch(messageList, (newVal, oldVal) => {
|
||||
if (!newVal?.length) return
|
||||
|
||||
const lastMessage = newVal[newVal.length - 1]
|
||||
const isMyMessage = lastMessage?.flow === 'out'
|
||||
|
||||
if (isMyMessage) {
|
||||
scrollToBottom()
|
||||
} else {
|
||||
if (autoScroll.value) {
|
||||
// 新消息如果在底部一个屏幕范围内,自动滚动
|
||||
scrollToBottom()
|
||||
showNewMessageTip.value = false
|
||||
newMessageCount.value = 0
|
||||
} else {
|
||||
// 否则显示新消息提示
|
||||
newMessageCount.value += 1
|
||||
showNewMessageTip.value = true
|
||||
}
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
const shouldRenderAsGroupTip = (message: any) => {
|
||||
// 创建群消息
|
||||
if (message.type === MessageType.MSG_CUSTOM && message.getMessageContent().businessID === 'group_create') {
|
||||
return true;
|
||||
}
|
||||
// 群通话消息
|
||||
if (
|
||||
message.type === MessageType.MSG_CUSTOM
|
||||
&& isCallSignaling(message)
|
||||
&& message.conversationType === "GROUP"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// 群提示消息
|
||||
if ( message.type === MessageType.MSG_GRP_TIP ) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-container {
|
||||
background-color: #F9FAFC;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading-tip {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
from {
|
||||
transform: rotate(0deg)
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg)
|
||||
}
|
||||
}
|
||||
|
||||
.new-message-tip {
|
||||
position: absolute;
|
||||
bottom: 50px;
|
||||
right: 10px;
|
||||
background-color: #FFFFFF;
|
||||
color: #1C66E5;
|
||||
padding: 8px 16px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
white-space: nowrap;
|
||||
|
||||
image {
|
||||
width: 12px;
|
||||
height: 11px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
margin: 8px;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
margin: 7px 0;
|
||||
|
||||
&.message-item-me {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
.message-status {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
margin: 0 20rpx;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.sender-name {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<view v-if="message?.flow === 'out' && message?.status !== 'success'" class="message-status" :class="positionClass">
|
||||
|
||||
<view v-if="message?.status === 'unSend'" class="status-loading">
|
||||
<image :src="IMG_LOADING" />
|
||||
</view>
|
||||
|
||||
<view v-if="message?.status === 'fail'" class="status-fail">
|
||||
<text>!</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import IMG_LOADING from '../../../assets/chat/message-loading.svg';
|
||||
|
||||
const { message } = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-status {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.status-loading {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
animation: rotating 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.status-fail {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #ff4d4f;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
text {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="timestampShowFlag"
|
||||
class="message-timestamp"
|
||||
>
|
||||
{{ timestampShowContent }}
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { toRefs, ref, watch } from 'vue';
|
||||
import { calculateTimestamp } from '../../../utils/time';
|
||||
|
||||
const props = defineProps({
|
||||
currTime: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
prevTime: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
const { currTime, prevTime } = toRefs(props);
|
||||
const timestampShowFlag = ref(false);
|
||||
const timestampShowContent = ref('');
|
||||
|
||||
const handleItemTime = (currTime: number, prevTime: number) => {
|
||||
timestampShowFlag.value = false;
|
||||
|
||||
if (currTime <= 0) return '';
|
||||
|
||||
const minDiffToShow = 5 * 60;
|
||||
|
||||
// 第一条消息必显示
|
||||
if (!prevTime || prevTime <= 0) {
|
||||
timestampShowFlag.value = true;
|
||||
return calculateTimestamp(currTime);
|
||||
}
|
||||
|
||||
// 计算时间差(秒)
|
||||
const diff = currTime - prevTime;
|
||||
|
||||
// 超过5分钟显示
|
||||
if (diff >= minDiffToShow) {
|
||||
timestampShowFlag.value = true;
|
||||
return calculateTimestamp(currTime);
|
||||
}
|
||||
|
||||
// 跨天必显示(即使间隔<5分钟)
|
||||
const currDate = new Date(currTime * 1000);
|
||||
const prevDate = new Date(prevTime * 1000);
|
||||
if (currDate.getDate() !== prevDate.getDate() ||
|
||||
currDate.getMonth() !== prevDate.getMonth() ||
|
||||
currDate.getFullYear() !== prevDate.getFullYear()) {
|
||||
timestampShowFlag.value = true;
|
||||
return calculateTimestamp(currTime);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [currTime.value, prevTime.value],
|
||||
(newVal: any, oldVal: any) => {
|
||||
if (newVal?.toString() === oldVal?.toString()) {
|
||||
return;
|
||||
} else {
|
||||
timestampShowContent.value = handleItemTime(
|
||||
currTime.value,
|
||||
prevTime.value,
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.message-timestamp {
|
||||
width: 100%;
|
||||
margin: 15px 0;
|
||||
color: #BBBBBB;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user