Files
IM-web-new/src/components/chat/MessageList.vue
T

822 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div ref="listEl" class="message-list">
<article
v-for="message in messages"
:key="message.id"
class="message-row"
:class="[message.direction, { selected: isSelected(message), attention: isAttention(message), recalled: message.recalled || message.status === 'recalled' }]"
:data-message-key="messageIdentity(message)"
@click="handleMessageClick(message)"
>
<label v-if="selectionMode" class="message-select">
<input
type="checkbox"
:checked="isSelected(message)"
@click.stop
@change="emit('toggle-select', message)"
>
</label>
<button
v-if="showGroupSender(message)"
type="button"
class="message-sender-avatar"
:title="groupSenderName(message)"
@click.stop="openSenderProfile(message)"
>
<img v-if="message.senderPortrait" :src="message.senderPortrait" alt="">
<span v-else>{{ groupSenderAvatarText(message) }}</span>
</button>
<div class="message-stack">
<button
v-if="showGroupSender(message)"
type="button"
class="message-sender-name"
@click.stop="openSenderProfile(message)"
>
{{ groupSenderName(message) }}
<span v-if="senderRoleLabel(message)" class="message-sender-role">{{ senderRoleLabel(message) }}</span>
</button>
<div
class="message-bubble"
:class="roleBubbleClass(message)"
@contextmenu.prevent="openMenu($event, message)"
>
<button
v-if="isImageMessage(message)"
type="button"
class="message-image"
@click.stop="handleImageClick(message)"
>
<img
:src="imageDisplayUrl(message.attachment.url)"
:alt="message.attachment.name || '图片'"
loading="lazy"
decoding="async"
@error="retryImageLoad"
>
</button>
<a
v-else-if="isFileMessage(message)"
class="message-file"
:href="message.attachment.url"
target="_blank"
rel="noreferrer"
download
>
<span class="message-file-icon">▤</span>
<span class="message-file-main">
<strong>{{ message.attachment.name || '文件' }}</strong>
<small>{{ formatFileSize(message.attachment.size) }}</small>
</span>
</a>
<p v-if="displayMessageText(message)">{{ displayMessageText(message) }}</p>
<time>
<span v-if="isRecallNotice(message)" class="recall-mark">{{ message.recallStatus || '已撤回' }}</span>
<span>{{ formatTime(message.displayTimestamp || message.timestamp) }}</span>
<span v-if="message.editedAt" class="edit-mark">【edit {{ formatTime(message.editedAt) }}】</span>
<span v-if="isFavorite(message)" class="favorite-mark" title="已收藏" aria-label="已收藏">★</span>
<span
v-if="deliveryState(message)"
class="delivery-mark"
:class="`delivery-mark-${deliveryState(message)}`"
:title="deliveryTitle(message)"
:aria-label="deliveryTitle(message)"
>
<span class="delivery-check">✓</span>
<span v-if="deliveryState(message) !== 'sent'" class="delivery-check delivery-check-second">✓</span>
</span>
<span v-if="message.status === 'failed'" class="delivery-error">{{ failureText(message) }}</span>
</time>
</div>
</div>
</article>
<div
v-if="contextMenu.visible"
class="message-context-menu"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@click.stop
>
<button
v-for="item in menuItems"
:key="item.action"
type="button"
@click="chooseMenuAction(item.action)"
>
{{ item.label }}
</button>
</div>
<Teleport to="body">
<div
v-if="imageViewer.visible"
class="image-viewer-backdrop"
@click="closeImageViewer"
>
<div
class="image-viewer-dialog"
@click.stop
>
<div class="image-viewer-toolbar">
<span class="image-viewer-title">{{ imageViewer.name || '图片' }}</span>
<button type="button" title="缩小" aria-label="缩小" @click="zoomImage(-0.25)">−</button>
<button type="button" title="放大" aria-label="放大" @click="zoomImage(0.25)">+</button>
<button type="button" title="还原" aria-label="还原" @click="resetImageViewer">1:1</button>
<button type="button" title="旋转" aria-label="旋转" @click="rotateImage">⟳</button>
<button type="button" title="保存" aria-label="保存" @click="saveViewerImage">⇩</button>
<button type="button" title="关闭" aria-label="关闭" @click="closeImageViewer">×</button>
</div>
<div
ref="viewerStage"
class="image-viewer-stage"
:class="{ dragging: imageViewer.dragging, zoomed: imageViewer.scale > 1 }"
@wheel.prevent="handleImageWheel"
@pointerdown.prevent="startImageDrag"
>
<img
class="image-viewer-img"
:src="imageDisplayUrl(imageViewer.url)"
:alt="imageViewer.name || '图片'"
:style="imageViewerStyle"
decoding="async"
@error="retryImageLoad"
draggable="false"
>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="senderProfile.visible"
class="sender-profile-backdrop"
@click="closeSenderProfile"
>
<section class="sender-profile-dialog" @click.stop>
<header>
<strong>用户资料</strong>
<button type="button" class="icon-button" title="关闭" @click="closeSenderProfile">×</button>
</header>
<div class="sender-profile-main">
<img v-if="senderProfile.portrait" class="profile-avatar image-avatar" :src="senderProfile.portrait" alt="">
<div v-else class="profile-avatar">{{ senderProfile.avatarText }}</div>
<strong>{{ senderProfile.displayName || senderProfile.uid }}</strong>
<span v-if="senderProfile.roleLabel">{{ senderProfile.roleLabel }}</span>
</div>
<dl class="sender-profile-fields">
<div>
<dt>用户ID</dt>
<dd>{{ senderProfile.uid || '-' }}</dd>
</div>
<div>
<dt>昵称</dt>
<dd>{{ senderProfile.rawName || senderProfile.displayName || '-' }}</dd>
</div>
<div>
<dt>手机号</dt>
<dd>{{ senderProfile.mobile || '未知' }}</dd>
</div>
<div>
<dt>地区</dt>
<dd>{{ senderProfile.address || '未知' }}</dd>
</div>
</dl>
<label class="sender-profile-alias">
<span>备注名称</span>
<input
v-model.trim="senderProfile.aliasDraft"
maxlength="32"
autocomplete="off"
placeholder="可给群员设置备注"
:disabled="senderProfile.saving || senderProfile.adding"
>
</label>
<footer class="sender-profile-actions">
<span v-if="senderProfile.notice" class="inline-save-status">{{ senderProfile.notice }}</span>
<span v-else-if="senderProfile.error" class="inline-save-status error">{{ senderProfile.error }}</span>
<button
v-if="!senderProfile.isContact"
type="button"
class="ghost-inline-action"
:disabled="senderProfile.adding || senderProfile.saving || !senderProfile.uid"
@click="addSenderAsContact"
>
{{ senderProfile.adding ? '添加中...' : '添加为好友' }}
</button>
<button
type="button"
class="primary-inline-action"
:disabled="senderProfile.saving || senderProfile.adding || !senderProfile.uid"
@click="saveSenderAlias"
>
{{ senderProfile.saving ? '保存中...' : '保存备注' }}
</button>
</footer>
</section>
</div>
</Teleport>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useContactStore } from '@/stores/contacts';
import { imageDisplayUrl } from '@/services/mediaProxy';
const props = defineProps({
messages: {
type: Array,
default: () => []
},
selectionMode: {
type: Boolean,
default: false
},
selectedMessageIds: {
type: Array,
default: () => []
},
favoriteMessageIds: {
type: Array,
default: () => []
},
attentionMessageId: {
type: String,
default: ''
},
canRecallOthers: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['message-action', 'toggle-select', 'sender-profile-updated']);
const contacts = useContactStore();
const listEl = ref(null);
const viewerStage = ref(null);
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
message: null
});
const IMAGE_MIN_SCALE = 0.5;
const IMAGE_MAX_SCALE = 6;
const messageTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
});
const imageViewer = reactive({
visible: false,
url: '',
name: '',
scale: 1,
rotation: 0,
offsetX: 0,
offsetY: 0,
dragging: false,
dragStartX: 0,
dragStartY: 0,
dragOffsetX: 0,
dragOffsetY: 0
});
const senderProfile = reactive({
visible: false,
uid: '',
displayName: '',
rawName: '',
mobile: '',
address: '',
portrait: '',
avatarText: '',
role: '',
roleLabel: '',
contactAlias: '',
groupAlias: '',
isContact: false,
aliasDraft: '',
saving: false,
adding: false,
notice: '',
error: ''
});
const imageViewerStyle = computed(() => ({
transform: `translate3d(${imageViewer.offsetX}px, ${imageViewer.offsetY}px, 0) scale(${imageViewer.scale}) rotate(${imageViewer.rotation}deg)`
}));
const menuItems = computed(() => {
if (!contextMenu.message) {
return [];
}
const isOwnMessage = contextMenu.message.direction === 'out';
if (isRecalledMessage(contextMenu.message)) {
return [
...(isOwnMessage ? [{ action: 'edit', label: '编辑' }] : []),
{ action: 'copy', label: '复制' },
{ action: 'delete', label: '删除' },
{ action: 'detail', label: '详情' }
];
}
const favoriteLabel = isFavorite(contextMenu.message) ? '取消收藏' : '收藏';
const items = [
{ action: 'copy', label: '复制' },
{ action: 'delete', label: '删除' },
{ action: 'forward', label: '转发' },
{ action: 'favorite', label: favoriteLabel },
{ action: 'quote', label: '引用' },
{ action: 'select', label: '多选' },
{ action: 'detail', label: '详情' }
];
if (isOwnMessage) {
items.unshift({ action: 'edit', label: '编辑' });
items.push({ action: 'recall', label: '撤回' });
} else if (props.canRecallOthers) {
items.push({ action: 'recall', label: '撤回' });
}
return items;
});
function scrollToBottom() {
nextTick(() => {
requestAnimationFrame(() => {
if (listEl.value) {
listEl.value.scrollTop = listEl.value.scrollHeight;
}
});
});
}
function isNearBottom() {
const element = listEl.value;
if (!element) {
return true;
}
return element.scrollHeight - element.scrollTop - element.clientHeight < 120;
}
function scrollToMessage(messageId) {
nextTick(() => {
if (!listEl.value || !messageId) {
return;
}
const row = Array.from(listEl.value.querySelectorAll('.message-row'))
.find(element => element.dataset.messageKey === messageId);
if (row) {
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
}
watch(
() => props.messages.map(messageIdentity).join('|'),
(nextKeys, previousKeys) => {
if (!previousKeys || (nextKeys !== previousKeys && isNearBottom())) {
scrollToBottom();
}
},
{ immediate: true }
);
watch(
() => props.attentionMessageId,
scrollToMessage
);
onMounted(() => {
document.addEventListener('click', closeMenu);
document.addEventListener('keydown', closeMenuOnEscape);
document.addEventListener('pointermove', moveImageDrag);
document.addEventListener('pointerup', stopImageDrag);
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeMenu);
document.removeEventListener('keydown', closeMenuOnEscape);
document.removeEventListener('pointermove', moveImageDrag);
document.removeEventListener('pointerup', stopImageDrag);
});
function messageIdentity(message) {
return String(message.messageUid || message.id || '');
}
function displayMessageText(message) {
const text = String(message?.text || '').replace(/^引用:.*(?:\r?\n|$)/, '').trim();
const contentType = Number(message?.contentType || 0);
if (!text) {
return '';
}
if (contentType !== 1 && text === '[暂不支持的消息类型]') {
return '';
}
if ((contentType === 3 || message?.type === 'image') && (text === '[图片]' || text === '图片')) {
return '';
}
return text;
}
function canOpenMessageMenu(message) {
return message && message.status !== 'failed' && message.status !== 'sending';
}
function isSelected(message) {
return props.selectedMessageIds.includes(messageIdentity(message));
}
function isFavorite(message) {
return props.favoriteMessageIds.includes(messageIdentity(message));
}
function isAttention(message) {
return props.attentionMessageId === messageIdentity(message);
}
function isRecallNotice(message) {
return message.direction === 'out' && (message.recalled || message.status === 'recalled');
}
function isRecalledMessage(message) {
return message.recalled || message.status === 'recalled';
}
function isImageMessage(message) {
return message?.type === 'image' && message?.attachment?.url;
}
function isFileMessage(message) {
return message?.type === 'file' && message?.attachment?.url;
}
function isGroupMessage(message) {
return ['group', 'channel'].includes(message?.conversationType || '');
}
function showGroupSender(message) {
return isGroupMessage(message) && message?.direction === 'in';
}
function senderRoleLabel(message) {
const role = message?.senderProfile?.role || message?.senderRole || '';
if (role === 'owner') {
return '群主';
}
if (role === 'admin') {
return '管理员';
}
return '';
}
function groupSenderName(message) {
return message?.senderName || message?.senderProfile?.displayName || message?.from || '未知用户';
}
function groupSenderAvatarText(message) {
return String(groupSenderName(message) || '?').slice(0, 1).toUpperCase();
}
function openSenderProfile(message) {
const profile = message?.senderProfile || {};
const uid = profile.uid || message?.from || '';
const cachedContact = contacts.contacts.find(contact => String(contact.uid || contact.id || '') === uid) || null;
const cachedAlias = String(cachedContact?.alias || '').trim();
const contactAlias = String(profile.contactAlias || cachedAlias || profile.remarkAlias || '').trim();
const groupAlias = String(profile.groupAlias || '').trim();
const rawName = profile.rawName
|| cachedContact?.rawUser?.displayName
|| cachedContact?.rawUser?.name
|| cachedContact?.title
|| groupSenderName(message);
const displayName = contactAlias || groupAlias || rawName || uid;
senderProfile.visible = true;
senderProfile.uid = uid;
senderProfile.displayName = displayName;
senderProfile.rawName = rawName || '';
senderProfile.mobile = profile.mobile || cachedContact?.rawUser?.mobile || '';
senderProfile.address = profile.address || cachedContact?.address || cachedContact?.rawUser?.address || '';
senderProfile.portrait = profile.portrait || cachedContact?.portrait || message?.senderPortrait || '';
senderProfile.avatarText = String(displayName || senderProfile.uid || '?').slice(0, 1).toUpperCase();
senderProfile.role = profile.role || message?.senderRole || '';
senderProfile.roleLabel = senderRoleLabel(message);
senderProfile.contactAlias = contactAlias;
senderProfile.groupAlias = groupAlias;
senderProfile.isContact = Boolean(profile.isContact || cachedContact);
senderProfile.aliasDraft = contactAlias;
senderProfile.saving = false;
senderProfile.adding = false;
senderProfile.notice = '';
senderProfile.error = '';
closeMenu();
}
function closeSenderProfile() {
if (senderProfile.saving || senderProfile.adding) {
return;
}
senderProfile.visible = false;
}
async function addSenderAsContact() {
if (!senderProfile.uid || senderProfile.adding || senderProfile.saving) {
return;
}
senderProfile.adding = true;
senderProfile.notice = '';
senderProfile.error = '';
const ok = await contacts.addContact(senderProfile.uid);
if (ok || String(contacts.actionError || '').includes('已经是联系人')) {
senderProfile.isContact = true;
const alias = senderProfile.aliasDraft.trim();
let aliasSaved = false;
if (alias) {
aliasSaved = await contacts.updateContactAlias(senderProfile.uid, alias);
if (aliasSaved) {
senderProfile.contactAlias = alias;
senderProfile.displayName = alias;
senderProfile.avatarText = String(alias || senderProfile.uid || '?').slice(0, 1).toUpperCase();
}
}
await contacts.refreshContacts({ force: true }).catch(() => {});
if (alias && !aliasSaved) {
senderProfile.notice = '';
senderProfile.error = contacts.actionError
? `已添加为好友,但${contacts.actionError}`
: '已添加为好友,但备注名同步失败';
} else {
senderProfile.notice = alias ? '已添加为好友,备注名已保存' : (ok ? '已添加为好友' : '已是好友');
}
emit('sender-profile-updated', { userId: senderProfile.uid });
} else {
senderProfile.error = contacts.actionError || '添加好友失败';
}
senderProfile.adding = false;
}
async function saveSenderAlias() {
if (!senderProfile.uid || senderProfile.saving || senderProfile.adding) {
return;
}
senderProfile.saving = true;
senderProfile.notice = '';
senderProfile.error = '';
const ok = await contacts.updateContactAlias(senderProfile.uid, senderProfile.aliasDraft);
if (ok) {
await contacts.refreshContacts({ force: true }).catch(() => {});
const alias = senderProfile.aliasDraft.trim();
senderProfile.contactAlias = alias;
senderProfile.displayName = alias || senderProfile.groupAlias || senderProfile.rawName || senderProfile.uid;
senderProfile.avatarText = String(senderProfile.displayName || senderProfile.uid || '?').slice(0, 1).toUpperCase();
senderProfile.notice = '备注名已保存';
emit('sender-profile-updated', { userId: senderProfile.uid });
} else {
senderProfile.error = contacts.actionError || '备注名修改失败';
}
senderProfile.saving = false;
}
function roleBubbleClass(message) {
const role = message?.senderRole || '';
if (role === 'owner') {
return 'role-bubble-owner';
}
if (role === 'admin') {
return 'role-bubble-admin';
}
return '';
}
function formatFileSize(size) {
const bytes = Number(size || 0);
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(bytes >= 10 * 1024 * 1024 ? 0 : 1)} MB`;
}
if (bytes >= 1024) {
return `${(bytes / 1024).toFixed(bytes >= 10 * 1024 ? 0 : 1)} KB`;
}
return bytes > 0 ? `${bytes} B` : '';
}
function retryImageLoad(event) {
const image = event.currentTarget;
if (!(image instanceof HTMLImageElement)) {
return;
}
const attempts = Number(image.dataset.loadRetries || 0);
if (attempts >= 2) {
return;
}
const originalUrl = image.dataset.originalUrl || image.getAttribute('src') || '';
if (!originalUrl) {
return;
}
image.dataset.originalUrl = originalUrl;
image.dataset.loadRetries = String(attempts + 1);
window.setTimeout(() => {
if (!image.isConnected) {
return;
}
const separator = originalUrl.includes('?') ? '&' : '?';
image.src = `${originalUrl}${separator}_image_retry=${attempts + 1}`;
}, 500 * (attempts + 1));
}
function handleMessageClick(message) {
if (props.selectionMode) {
emit('toggle-select', message);
}
}
function handleImageClick(message) {
if (props.selectionMode) {
emit('toggle-select', message);
return;
}
openImageViewer(message);
}
function openImageViewer(message) {
const attachment = message?.attachment || {};
if (!attachment.url) {
return;
}
closeMenu();
imageViewer.visible = true;
imageViewer.url = attachment.url;
imageViewer.name = attachment.name || 'image';
resetImageViewer();
}
function closeImageViewer() {
imageViewer.visible = false;
imageViewer.dragging = false;
}
function resetImageViewer() {
imageViewer.scale = 1;
imageViewer.rotation = 0;
imageViewer.offsetX = 0;
imageViewer.offsetY = 0;
}
function clampImageScale(value) {
return Math.min(IMAGE_MAX_SCALE, Math.max(IMAGE_MIN_SCALE, Number(value.toFixed(2))));
}
function setImageScale(value, originX = null, originY = null) {
const previousScale = imageViewer.scale;
const nextScale = clampImageScale(value);
if (nextScale === previousScale) {
return;
}
if (originX !== null && originY !== null && viewerStage.value) {
const rect = viewerStage.value.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const ratio = nextScale / previousScale - 1;
imageViewer.offsetX -= (originX - centerX - imageViewer.offsetX) * ratio;
imageViewer.offsetY -= (originY - centerY - imageViewer.offsetY) * ratio;
}
imageViewer.scale = nextScale;
if (nextScale <= 1) {
imageViewer.offsetX = 0;
imageViewer.offsetY = 0;
}
}
function zoomImage(delta) {
setImageScale(imageViewer.scale + delta);
}
function rotateImage() {
imageViewer.rotation = (imageViewer.rotation + 90) % 360;
}
function handleImageWheel(event) {
const factor = event.deltaY < 0 ? 1.12 : 1 / 1.12;
setImageScale(imageViewer.scale * factor, event.clientX, event.clientY);
}
function startImageDrag(event) {
if (!imageViewer.visible || imageViewer.scale <= 1) {
return;
}
imageViewer.dragging = true;
imageViewer.dragStartX = event.clientX;
imageViewer.dragStartY = event.clientY;
imageViewer.dragOffsetX = imageViewer.offsetX;
imageViewer.dragOffsetY = imageViewer.offsetY;
}
function moveImageDrag(event) {
if (!imageViewer.dragging) {
return;
}
imageViewer.offsetX = imageViewer.dragOffsetX + event.clientX - imageViewer.dragStartX;
imageViewer.offsetY = imageViewer.dragOffsetY + event.clientY - imageViewer.dragStartY;
}
function stopImageDrag() {
imageViewer.dragging = false;
}
function saveViewerImage() {
if (!imageViewer.url) {
return;
}
const downloadUrl = new URL('/api/media_download.php', window.location.origin);
downloadUrl.searchParams.set('url', imageViewer.url);
downloadUrl.searchParams.set('name', imageViewer.name || 'image');
const link = document.createElement('a');
link.href = downloadUrl.toString();
link.download = imageViewer.name || 'image';
document.body.appendChild(link);
link.click();
link.remove();
}
function openMenu(event, message) {
if (!canOpenMessageMenu(message)) {
closeMenu();
return;
}
contextMenu.visible = true;
contextMenu.message = message;
contextMenu.x = Math.min(event.clientX, window.innerWidth - 148);
contextMenu.y = Math.min(event.clientY, window.innerHeight - 352);
}
function closeMenu() {
contextMenu.visible = false;
contextMenu.message = null;
}
function closeMenuOnEscape(event) {
if (event.key === 'Escape') {
if (senderProfile.visible) {
closeSenderProfile();
}
if (imageViewer.visible) {
closeImageViewer();
}
closeMenu();
}
}
function chooseMenuAction(action) {
if (contextMenu.message) {
emit('message-action', { action, message: contextMenu.message });
}
closeMenu();
}
function formatTime(timestamp) {
return messageTimeFormatter.format(timestamp);
}
function deliveryState(message) {
if (message.direction !== 'out' || message.status === 'failed' || message.status === 'recalled' || message.recalled) {
return '';
}
const state = message.deliveryStatus || message.status;
if (state === 'read') {
return 'read';
}
if (state === 'delivered') {
return 'delivered';
}
if (state === 'sent') {
return 'sent';
}
return '';
}
function deliveryTitle(message) {
const state = deliveryState(message);
if (state === 'read') {
return '对方已读';
}
if (state === 'delivered') {
return '已送达对方';
}
if (state === 'sent') {
return '已送出';
}
return '';
}
function failureText(message) {
if (message.status === 'failed') {
if (message.errorCode) {
return message.error ? `失败:${message.error}(${message.errorCode})` : `失败:${message.errorCode}`;
}
return message.error ? `失败:${message.error}` : '失败';
}
return '';
}
</script>