1037 lines
32 KiB
JavaScript
1037 lines
32 KiB
JavaScript
import axios from 'axios';
|
|||
|
|
import { appConfig } from '@/config';
|
||
|
|
|
||
|
|
const LOCAL_MESSAGE_STORAGE_KEY = 'qmt-web-im-local-messages-v2';
|
||
|
|
const LOCAL_HIDDEN_MESSAGE_STORAGE_KEY = 'qmt-web-im-hidden-messages-v1';
|
||
|
|
const LOCAL_RECALLED_MESSAGE_STORAGE_KEY = 'qmt-web-im-recalled-messages-v1';
|
||
|
|
|
||
|
|
function scopedStorageKey(baseKey, userId = '') {
|
||
|
|
const scope = `${appConfig.appServer || ''}:${userId || 'anonymous'}`;
|
||
|
|
return `${baseKey}:${encodeURIComponent(scope)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function randomId() {
|
||
|
|
if (window.crypto && window.crypto.randomUUID) {
|
||
|
|
return window.crypto.randomUUID().replaceAll('-', '');
|
||
|
|
}
|
||
|
|
return `${Date.now()}${Math.random().toString(16).slice(2)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function readLocalMessages(userId) {
|
||
|
|
try {
|
||
|
|
return JSON.parse(localStorage.getItem(scopedStorageKey(LOCAL_MESSAGE_STORAGE_KEY, userId)) || '{}');
|
||
|
|
} catch (error) {
|
||
|
|
return {};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function readMessageSet(storageKey, userId) {
|
||
|
|
try {
|
||
|
|
return JSON.parse(localStorage.getItem(scopedStorageKey(storageKey, userId)) || '{}');
|
||
|
|
} catch (error) {
|
||
|
|
return {};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeMessageSet(storageKey, messagesByConversation, userId) {
|
||
|
|
localStorage.setItem(scopedStorageKey(storageKey, userId), JSON.stringify(messagesByConversation));
|
||
|
|
}
|
||
|
|
|
||
|
|
function messageKeys(message) {
|
||
|
|
return [message?.id, message?.messageUid]
|
||
|
|
.filter(Boolean)
|
||
|
|
.map(value => String(value));
|
||
|
|
}
|
||
|
|
|
||
|
|
function hasStoredMessage(storageKey, conversationId, message, userId) {
|
||
|
|
const stored = readMessageSet(storageKey, userId);
|
||
|
|
const ids = new Set(stored[conversationId] || []);
|
||
|
|
return messageKeys(message).some(id => ids.has(id));
|
||
|
|
}
|
||
|
|
|
||
|
|
function storeMessageKeys(storageKey, conversationId, messages, userId) {
|
||
|
|
const stored = readMessageSet(storageKey, userId);
|
||
|
|
const ids = new Set(stored[conversationId] || []);
|
||
|
|
messages.forEach(message => {
|
||
|
|
messageKeys(message).forEach(id => ids.add(id));
|
||
|
|
});
|
||
|
|
stored[conversationId] = Array.from(ids);
|
||
|
|
writeMessageSet(storageKey, stored, userId);
|
||
|
|
}
|
||
|
|
|
||
|
|
function withRecalledState(message) {
|
||
|
|
return {
|
||
|
|
...message,
|
||
|
|
status: 'recalled',
|
||
|
|
deliveryStatus: '',
|
||
|
|
recalled: true,
|
||
|
|
recallStatus: '已撤回'
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyLocalMessageState(conversationId, messages, userId) {
|
||
|
|
return messages
|
||
|
|
.filter(message => !hasStoredMessage(LOCAL_HIDDEN_MESSAGE_STORAGE_KEY, conversationId, message, userId))
|
||
|
|
.map(message => {
|
||
|
|
const isRecalled = Boolean(message.recalled)
|
||
|
|
|| message.status === 'recalled'
|
||
|
|
|| hasStoredMessage(LOCAL_RECALLED_MESSAGE_STORAGE_KEY, conversationId, message, userId);
|
||
|
|
if (!isRecalled) {
|
||
|
|
return message;
|
||
|
|
}
|
||
|
|
return withRecalledState(message);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeLocalMessages(messagesByConversation, userId) {
|
||
|
|
localStorage.setItem(scopedStorageKey(LOCAL_MESSAGE_STORAGE_KEY, userId), JSON.stringify(messagesByConversation));
|
||
|
|
}
|
||
|
|
|
||
|
|
function saveLocalMessage(message, userId) {
|
||
|
|
const messagesByConversation = readLocalMessages(userId);
|
||
|
|
messagesByConversation[message.conversationId] = messagesByConversation[message.conversationId] || [];
|
||
|
|
const index = messagesByConversation[message.conversationId].findIndex(item => item.id === message.id);
|
||
|
|
if (index >= 0) {
|
||
|
|
messagesByConversation[message.conversationId].splice(index, 1, message);
|
||
|
|
} else {
|
||
|
|
messagesByConversation[message.conversationId].push(message);
|
||
|
|
}
|
||
|
|
writeLocalMessages(messagesByConversation, userId);
|
||
|
|
}
|
||
|
|
|
||
|
|
function mergeSyncedMessages(localMessages, remoteMessages, conversationId, userId) {
|
||
|
|
const visibleLocalMessages = applyLocalMessageState(conversationId, localMessages, userId);
|
||
|
|
const localByKey = new Map();
|
||
|
|
visibleLocalMessages.forEach(message => {
|
||
|
|
messageKeys(message).forEach(key => {
|
||
|
|
if (!localByKey.has(key)) {
|
||
|
|
localByKey.set(key, message);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
const visibleRemoteMessages = applyLocalMessageState(conversationId, remoteMessages, userId).map(remoteMessage => {
|
||
|
|
if (!remoteMessage.recalled || String(remoteMessage.text || '').trim()) {
|
||
|
|
return remoteMessage;
|
||
|
|
}
|
||
|
|
const localMessage = messageKeys(remoteMessage)
|
||
|
|
.map(key => localByKey.get(key))
|
||
|
|
.find(message => message?.text && message.text !== '你撤回了一条消息');
|
||
|
|
return localMessage ? { ...remoteMessage, text: localMessage.text } : remoteMessage;
|
||
|
|
});
|
||
|
|
const remoteKeys = new Set();
|
||
|
|
visibleRemoteMessages.forEach(message => {
|
||
|
|
messageKeys(message).forEach(key => remoteKeys.add(key));
|
||
|
|
});
|
||
|
|
const now = Date.now();
|
||
|
|
|
||
|
|
function hasRemoteMatch(localMessage) {
|
||
|
|
if (localMessage.messageUid && remoteKeys.has(localMessage.messageUid)) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
return visibleRemoteMessages.some(remoteMessage => {
|
||
|
|
return remoteMessage.direction === localMessage.direction
|
||
|
|
&& remoteMessage.text === localMessage.text
|
||
|
|
&& Math.abs(Number(remoteMessage.timestamp || 0) - Number(localMessage.timestamp || 0)) < 15000;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const pendingLocalMessages = visibleLocalMessages.filter(message => {
|
||
|
|
if (message.status === 'recalled' || message.recalled) {
|
||
|
|
return message.direction === 'out' && !hasRemoteMatch(message);
|
||
|
|
}
|
||
|
|
if (message.direction !== 'out') {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (message.status === 'sent' && message.messageUid) {
|
||
|
|
return !hasRemoteMatch(message);
|
||
|
|
}
|
||
|
|
if (message.status === 'sent') {
|
||
|
|
return !hasRemoteMatch(message) && now - Number(message.timestamp || 0) < 30000;
|
||
|
|
}
|
||
|
|
return message.status === 'sending' || message.status === 'failed';
|
||
|
|
});
|
||
|
|
|
||
|
|
return [...visibleRemoteMessages, ...pendingLocalMessages].sort((a, b) => {
|
||
|
|
return Number(a.displayTimestamp || a.timestamp || 0) - Number(b.displayTimestamp || b.timestamp || 0);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function getTitle(value, fallback) {
|
||
|
|
return value || fallback || '';
|
||
|
|
}
|
||
|
|
|
||
|
|
function getUniqueEquipment(mobile, fallback) {
|
||
|
|
const allowedDevices = {
|
||
|
|
'18301902119': '67072d37062c4f31b1165073552933ba',
|
||
|
|
'18301902116': '1777479418113eeea8e236edce',
|
||
|
|
'18301902118': 'de89fb2f2a61480a8c381f6c16e28bd8',
|
||
|
|
'18301902117': 'd117f480faa64145b6162107b1b49617'
|
||
|
|
};
|
||
|
|
return allowedDevices[mobile] || localStorage.getItem('uniqueEquipment') || fallback;
|
||
|
|
}
|
||
|
|
|
||
|
|
function canUseLocalLoginFallback(response) {
|
||
|
|
const message = response.data?.message || '';
|
||
|
|
return process.env.NODE_ENV === 'development' && message.includes('允许此设备登录');
|
||
|
|
}
|
||
|
|
|
||
|
|
export class HttpImClient {
|
||
|
|
constructor() {
|
||
|
|
this.listeners = new Map();
|
||
|
|
this.connected = false;
|
||
|
|
this.currentUser = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
getClientId() {
|
||
|
|
let clientId = localStorage.getItem(appConfig.clientIdStorageKey);
|
||
|
|
if (!clientId) {
|
||
|
|
clientId = randomId();
|
||
|
|
localStorage.setItem(appConfig.clientIdStorageKey, clientId);
|
||
|
|
}
|
||
|
|
return clientId;
|
||
|
|
}
|
||
|
|
|
||
|
|
restoreClientId(clientId) {
|
||
|
|
if (clientId) {
|
||
|
|
localStorage.setItem(appConfig.clientIdStorageKey, clientId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
getSdkVersion() {
|
||
|
|
return 'HTTP';
|
||
|
|
}
|
||
|
|
|
||
|
|
async ensureSdk() {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
async loginWithPassword(mobile, password) {
|
||
|
|
const url = `${appConfig.appServer}/login_pwd`;
|
||
|
|
const deviceId = getUniqueEquipment(mobile, this.getClientId());
|
||
|
|
const response = await axios.post(url, {
|
||
|
|
mobile,
|
||
|
|
password,
|
||
|
|
platform: appConfig.platform,
|
||
|
|
clientId: deviceId,
|
||
|
|
uniqueEquipment: deviceId
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
this.persistAppToken(url, response);
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
if (canUseLocalLoginFallback(response)) {
|
||
|
|
return {
|
||
|
|
userId: mobile,
|
||
|
|
token: `local-device-pending-${Date.now()}`,
|
||
|
|
displayName: mobile,
|
||
|
|
localOnly: true
|
||
|
|
};
|
||
|
|
}
|
||
|
|
throw new Error(response.data?.message || '登录失败');
|
||
|
|
}
|
||
|
|
return response.data.result;
|
||
|
|
}
|
||
|
|
|
||
|
|
async requestAuthCode(mobile) {
|
||
|
|
const response = await axios.post(`${appConfig.appServer}/send_code`, { mobile }, { withCredentials: false });
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '验证码发送失败');
|
||
|
|
}
|
||
|
|
return response.data.result;
|
||
|
|
}
|
||
|
|
|
||
|
|
async loginWithCode(mobile, code) {
|
||
|
|
const url = `${appConfig.appServer}/login`;
|
||
|
|
const deviceId = getUniqueEquipment(mobile, this.getClientId());
|
||
|
|
const response = await axios.post(url, {
|
||
|
|
mobile,
|
||
|
|
code,
|
||
|
|
platform: appConfig.platform,
|
||
|
|
clientId: deviceId,
|
||
|
|
uniqueEquipment: deviceId
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
this.persistAppToken(url, response);
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '登录失败');
|
||
|
|
}
|
||
|
|
return response.data.result;
|
||
|
|
}
|
||
|
|
|
||
|
|
async registerWithPhone(mobile, code) {
|
||
|
|
const url = `${appConfig.appServer}/register`;
|
||
|
|
const deviceId = getUniqueEquipment(mobile, this.getClientId());
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await axios.post(url, {
|
||
|
|
mobile,
|
||
|
|
code,
|
||
|
|
platform: appConfig.platform,
|
||
|
|
clientId: deviceId,
|
||
|
|
uniqueEquipment: deviceId
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
this.persistAppToken(url, response);
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '注册失败');
|
||
|
|
}
|
||
|
|
return response.data.result;
|
||
|
|
} catch (error) {
|
||
|
|
const status = error.response?.status;
|
||
|
|
if (status === 404 || status === 405) {
|
||
|
|
return this.loginWithCode(mobile, code);
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
connect(userId, token) {
|
||
|
|
this.currentUser = { userId, token };
|
||
|
|
this.connected = true;
|
||
|
|
this.emit('connectionStatusChanged', { connected: true, statusText: '已连接' });
|
||
|
|
this.refreshConversationList();
|
||
|
|
}
|
||
|
|
|
||
|
|
disconnect() {
|
||
|
|
this.connected = false;
|
||
|
|
this.currentUser = null;
|
||
|
|
this.emit('connectionStatusChanged', { connected: false, statusText: '未连接' });
|
||
|
|
}
|
||
|
|
|
||
|
|
getConversationList() {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
refreshConversationList() {
|
||
|
|
const conversations = this.getConversationList();
|
||
|
|
if (conversations.length) {
|
||
|
|
this.emit('conversationsChanged', conversations);
|
||
|
|
}
|
||
|
|
if (this.currentUser?.userId) {
|
||
|
|
this.fetchRemoteConversations().then(remoteConversations => {
|
||
|
|
this.emit('conversationsChanged', remoteConversations);
|
||
|
|
}).catch(() => {});
|
||
|
|
}
|
||
|
|
return conversations;
|
||
|
|
}
|
||
|
|
|
||
|
|
async fetchRemoteConversations(limit = 50) {
|
||
|
|
if (!this.currentUser?.userId) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
const response = await axios.post(appConfig.messagesApi, {
|
||
|
|
userId: this.currentUser.userId,
|
||
|
|
limit
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '会话同步失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return (response.data?.result?.conversations || []).map(conversation => ({
|
||
|
|
...conversation,
|
||
|
|
updatedAt: Number(conversation.updatedAt || Date.now()),
|
||
|
|
avatarText: conversation.avatarText || String(conversation.title || conversation.target || '?').slice(0, 1).toUpperCase(),
|
||
|
|
raw: conversation.raw || { type: 'single', target: conversation.target, line: 0 }
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
getMessages(conversation) {
|
||
|
|
if (!conversation?.id) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
const userId = this.currentUser?.userId || '';
|
||
|
|
return applyLocalMessageState(conversation.id, readLocalMessages(userId)[conversation.id] || [], userId);
|
||
|
|
}
|
||
|
|
|
||
|
|
async fetchRemoteMessages(conversation, limit = 50, options = {}) {
|
||
|
|
if (!conversation?.id || !this.currentUser?.userId) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
const response = await axios.post(appConfig.messagesApi, {
|
||
|
|
userId: this.currentUser.userId,
|
||
|
|
target: conversation.target || conversation.raw?.target || conversation.id.replace(/^single-/, ''),
|
||
|
|
limit,
|
||
|
|
conversationType: conversation.type || conversation.raw?.type || 0,
|
||
|
|
markRead: Boolean(options.markRead)
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '消息同步失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
const messages = (response.data?.result?.messages || []).map(message => ({
|
||
|
|
...message,
|
||
|
|
displayTimestamp: message.displayTimestamp || message.timestamp,
|
||
|
|
conversationId: conversation.id
|
||
|
|
}));
|
||
|
|
const userId = this.currentUser?.userId || '';
|
||
|
|
const messagesByConversation = readLocalMessages(userId);
|
||
|
|
messagesByConversation[conversation.id] = mergeSyncedMessages(messagesByConversation[conversation.id] || [], messages, conversation.id, userId);
|
||
|
|
writeLocalMessages(messagesByConversation, userId);
|
||
|
|
return messagesByConversation[conversation.id];
|
||
|
|
}
|
||
|
|
|
||
|
|
hideMessages(conversation, messages) {
|
||
|
|
const items = Array.isArray(messages) ? messages : [messages];
|
||
|
|
if (!conversation?.id || !items.length) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const userId = this.currentUser?.userId || '';
|
||
|
|
storeMessageKeys(LOCAL_HIDDEN_MESSAGE_STORAGE_KEY, conversation.id, items, userId);
|
||
|
|
const messagesByConversation = readLocalMessages(userId);
|
||
|
|
messagesByConversation[conversation.id] = applyLocalMessageState(
|
||
|
|
conversation.id,
|
||
|
|
messagesByConversation[conversation.id] || [],
|
||
|
|
userId
|
||
|
|
);
|
||
|
|
writeLocalMessages(messagesByConversation, userId);
|
||
|
|
}
|
||
|
|
|
||
|
|
markMessagesRecalled(conversation, messages) {
|
||
|
|
const items = Array.isArray(messages) ? messages : [messages];
|
||
|
|
if (!conversation?.id || !items.length) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const userId = this.currentUser?.userId || '';
|
||
|
|
storeMessageKeys(LOCAL_RECALLED_MESSAGE_STORAGE_KEY, conversation.id, items, userId);
|
||
|
|
const messagesByConversation = readLocalMessages(userId);
|
||
|
|
messagesByConversation[conversation.id] = applyLocalMessageState(
|
||
|
|
conversation.id,
|
||
|
|
messagesByConversation[conversation.id] || [],
|
||
|
|
userId
|
||
|
|
);
|
||
|
|
writeLocalMessages(messagesByConversation, userId);
|
||
|
|
}
|
||
|
|
|
||
|
|
async recallMessage(conversation, message) {
|
||
|
|
const messageUid = String(message?.messageUid || message?.id || '').trim();
|
||
|
|
if (!/^\d+$/.test(messageUid)) {
|
||
|
|
throw new Error('缺少可撤回的消息ID');
|
||
|
|
}
|
||
|
|
|
||
|
|
const response = await axios.post(appConfig.messageActionApi, {
|
||
|
|
action: 'recall',
|
||
|
|
userId: this.currentUser?.userId || message.from || '',
|
||
|
|
messageUid
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '撤回失败');
|
||
|
|
}
|
||
|
|
this.markMessagesRecalled(conversation, [message]);
|
||
|
|
return response.data.result;
|
||
|
|
}
|
||
|
|
|
||
|
|
async editMessage(conversation, message, text) {
|
||
|
|
const messageUid = String(message?.messageUid || message?.id || '').trim();
|
||
|
|
if (!/^\d+$/.test(messageUid)) {
|
||
|
|
throw new Error('缺少可编辑的消息ID');
|
||
|
|
}
|
||
|
|
const nextText = String(text || '').trim();
|
||
|
|
if (!nextText) {
|
||
|
|
throw new Error('编辑内容不能为空');
|
||
|
|
}
|
||
|
|
|
||
|
|
const response = await axios.post(appConfig.messageActionApi, {
|
||
|
|
action: 'edit',
|
||
|
|
userId: this.currentUser?.userId || message.from || '',
|
||
|
|
messageUid,
|
||
|
|
text: nextText
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '编辑失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = response.data?.result?.message || {};
|
||
|
|
const editedMessage = {
|
||
|
|
...message,
|
||
|
|
text: result.text || nextText,
|
||
|
|
status: 'delivered',
|
||
|
|
deliveryStatus: 'delivered',
|
||
|
|
editedAt: Number(result.editedAt || Date.now()),
|
||
|
|
activityTimestamp: Number(result.editedAt || Date.now())
|
||
|
|
};
|
||
|
|
saveLocalMessage(editedMessage, this.currentUser?.userId || '');
|
||
|
|
this.emit('messageStatusChanged', editedMessage);
|
||
|
|
return editedMessage;
|
||
|
|
}
|
||
|
|
|
||
|
|
async getMessageDetail(conversation, message) {
|
||
|
|
const messageUid = String(message?.messageUid || message?.id || '').trim();
|
||
|
|
if (!messageUid) {
|
||
|
|
throw new Error('缺少消息ID');
|
||
|
|
}
|
||
|
|
const response = await axios.post(appConfig.messageDetailApi, {
|
||
|
|
userId: this.currentUser?.userId || '',
|
||
|
|
target: conversation?.target || conversation?.raw?.target || conversation?.id?.replace(/^single-/, '') || '',
|
||
|
|
conversationType: conversation?.type || conversation?.raw?.type || 'single',
|
||
|
|
messageUid
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '消息详情加载失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.data?.result?.detail || null;
|
||
|
|
}
|
||
|
|
|
||
|
|
async sendTextMessage(conversation, text, initialMessage = null) {
|
||
|
|
const senderRole = conversation?.role || conversation?.raw?.role || '';
|
||
|
|
const message = initialMessage || {
|
||
|
|
id: randomId(),
|
||
|
|
conversationId: conversation.id,
|
||
|
|
from: this.currentUser?.userId || 'me',
|
||
|
|
senderRole,
|
||
|
|
direction: 'out',
|
||
|
|
type: 'text',
|
||
|
|
text,
|
||
|
|
status: 'sending',
|
||
|
|
deliveryStatus: 'sending',
|
||
|
|
timestamp: Date.now()
|
||
|
|
};
|
||
|
|
saveLocalMessage(message, this.currentUser?.userId || '');
|
||
|
|
if (!initialMessage) {
|
||
|
|
this.emit('messageSent', message);
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await axios.post(appConfig.sendMessageApi, {
|
||
|
|
sender: this.currentUser?.userId || '',
|
||
|
|
target: conversation.target || conversation.raw?.target || conversation.id.replace(/^single-/, ''),
|
||
|
|
conversationType: conversation.type || conversation.raw?.type || 0,
|
||
|
|
text
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '发送失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
const sentMessage = {
|
||
|
|
...message,
|
||
|
|
status: 'sent',
|
||
|
|
deliveryStatus: 'sent',
|
||
|
|
messageUid: response.data?.result?.messageUid ? String(response.data.result.messageUid) : '',
|
||
|
|
senderRole: response.data?.result?.senderRole || message.senderRole || '',
|
||
|
|
serverTimestamp: Number(response.data?.result?.timestamp || 0) || null,
|
||
|
|
timestamp: message.timestamp,
|
||
|
|
displayTimestamp: message.displayTimestamp || message.timestamp
|
||
|
|
};
|
||
|
|
saveLocalMessage(sentMessage, this.currentUser?.userId || '');
|
||
|
|
this.emit('messageStatusChanged', sentMessage);
|
||
|
|
return sentMessage;
|
||
|
|
} catch (error) {
|
||
|
|
const failedMessage = {
|
||
|
|
...message,
|
||
|
|
status: 'failed',
|
||
|
|
deliveryStatus: '',
|
||
|
|
error: error.response?.data?.message || error.message || '发送失败'
|
||
|
|
};
|
||
|
|
saveLocalMessage(failedMessage, this.currentUser?.userId || '');
|
||
|
|
this.emit('messageStatusChanged', failedMessage);
|
||
|
|
return failedMessage;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async sendAttachmentMessage(conversation, { type, file, text = '' }, initialMessage = null) {
|
||
|
|
const senderRole = conversation?.role || conversation?.raw?.role || '';
|
||
|
|
const message = initialMessage || {
|
||
|
|
id: randomId(),
|
||
|
|
conversationId: conversation.id,
|
||
|
|
from: this.currentUser?.userId || 'me',
|
||
|
|
senderRole,
|
||
|
|
direction: 'out',
|
||
|
|
type,
|
||
|
|
text,
|
||
|
|
attachment: {
|
||
|
|
type,
|
||
|
|
name: file?.name || (type === 'image' ? '图片' : '文件'),
|
||
|
|
mime: file?.type || '',
|
||
|
|
size: file?.size || 0,
|
||
|
|
url: type === 'image' && file ? URL.createObjectURL(file) : ''
|
||
|
|
},
|
||
|
|
status: 'sending',
|
||
|
|
deliveryStatus: 'sending',
|
||
|
|
timestamp: Date.now()
|
||
|
|
};
|
||
|
|
saveLocalMessage(message, this.currentUser?.userId || '');
|
||
|
|
if (!initialMessage) {
|
||
|
|
this.emit('messageSent', message);
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const formData = new FormData();
|
||
|
|
formData.append('sender', this.currentUser?.userId || '');
|
||
|
|
formData.append('target', conversation.target || conversation.raw?.target || conversation.id.replace(/^single-/, ''));
|
||
|
|
formData.append('conversationType', conversation.type || conversation.raw?.type || 0);
|
||
|
|
formData.append('messageType', type);
|
||
|
|
formData.append('text', text || '');
|
||
|
|
formData.append('attachment', file);
|
||
|
|
|
||
|
|
const response = await axios.post(appConfig.sendMessageApi, formData, {
|
||
|
|
withCredentials: false
|
||
|
|
});
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '发送失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
const sentMessage = {
|
||
|
|
...message,
|
||
|
|
status: 'sent',
|
||
|
|
deliveryStatus: 'sent',
|
||
|
|
messageUid: response.data?.result?.messageUid ? String(response.data.result.messageUid) : '',
|
||
|
|
senderRole: response.data?.result?.senderRole || message.senderRole || '',
|
||
|
|
attachment: response.data?.result?.attachment || message.attachment,
|
||
|
|
serverTimestamp: Number(response.data?.result?.timestamp || 0) || null,
|
||
|
|
timestamp: message.timestamp,
|
||
|
|
displayTimestamp: message.displayTimestamp || message.timestamp
|
||
|
|
};
|
||
|
|
saveLocalMessage(sentMessage, this.currentUser?.userId || '');
|
||
|
|
this.emit('messageStatusChanged', sentMessage);
|
||
|
|
return sentMessage;
|
||
|
|
} catch (error) {
|
||
|
|
const failedMessage = {
|
||
|
|
...message,
|
||
|
|
status: 'failed',
|
||
|
|
deliveryStatus: '',
|
||
|
|
error: error.response?.data?.message || error.message || '发送失败'
|
||
|
|
};
|
||
|
|
saveLocalMessage(failedMessage, this.currentUser?.userId || '');
|
||
|
|
this.emit('messageStatusChanged', failedMessage);
|
||
|
|
return failedMessage;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
getContactList() {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
async getServerContactList(userId) {
|
||
|
|
if (!userId) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
const response = await axios.post(appConfig.contactsApi, { userId }, {
|
||
|
|
withCredentials: false,
|
||
|
|
headers: this.getAppAuthHeaders(appConfig.contactsApi)
|
||
|
|
});
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '联系人加载失败');
|
||
|
|
}
|
||
|
|
return (response.data?.result?.contacts || []).map(item => {
|
||
|
|
return this.mapContact(item.uid, { alias: item.alias }, {
|
||
|
|
uid: item.uid,
|
||
|
|
displayName: item.displayName,
|
||
|
|
name: item.name,
|
||
|
|
mobile: item.mobile,
|
||
|
|
portrait: item.portrait,
|
||
|
|
address: item.address,
|
||
|
|
friendAlias: item.alias
|
||
|
|
});
|
||
|
|
}).sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'));
|
||
|
|
}
|
||
|
|
|
||
|
|
async addContact(userId, keyword, reason = '') {
|
||
|
|
const response = await axios.post(appConfig.addContactApi, {
|
||
|
|
userId,
|
||
|
|
keyword,
|
||
|
|
reason
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '添加联系人失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
const item = response.data?.result?.contact;
|
||
|
|
if (!item?.uid) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return this.mapContact(item.uid, { alias: item.alias }, {
|
||
|
|
uid: item.uid,
|
||
|
|
displayName: item.displayName,
|
||
|
|
name: item.name,
|
||
|
|
mobile: item.mobile,
|
||
|
|
portrait: item.portrait,
|
||
|
|
address: item.address,
|
||
|
|
friendAlias: item.alias
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
normalizeConversation(conversation) {
|
||
|
|
const title = getTitle(conversation?.title, conversation?.target || '');
|
||
|
|
return {
|
||
|
|
...conversation,
|
||
|
|
id: conversation?.id || `${conversation?.type === 'single' ? 'single' : 'group'}-${conversation?.target}`,
|
||
|
|
target: conversation?.target || conversation?.raw?.target || '',
|
||
|
|
title,
|
||
|
|
subtitle: conversation?.subtitle || '',
|
||
|
|
unread: Number(conversation?.unread || 0),
|
||
|
|
portrait: conversation?.portrait || '',
|
||
|
|
avatarText: conversation?.avatarText || title.slice(0, 1).toUpperCase(),
|
||
|
|
updatedAt: Number(conversation?.updatedAt || Date.now()),
|
||
|
|
raw: conversation?.raw || {
|
||
|
|
type: conversation?.type === 'single' ? 'single' : 'group',
|
||
|
|
target: conversation?.target || '',
|
||
|
|
line: 0
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async createGroup(userId, { name, memberIds = [], announcement = '', welcomeText = '', channel = false }) {
|
||
|
|
const response = await axios.post(appConfig.conversationActionsApi, {
|
||
|
|
action: channel ? 'create_channel' : 'create_group',
|
||
|
|
userId,
|
||
|
|
name,
|
||
|
|
memberIds,
|
||
|
|
announcement,
|
||
|
|
welcomeText
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || (channel ? '创建公告群失败' : '创建群组失败'));
|
||
|
|
}
|
||
|
|
|
||
|
|
return this.normalizeConversation(response.data?.result?.conversation || {});
|
||
|
|
}
|
||
|
|
|
||
|
|
async joinGroup(userId, groupKey) {
|
||
|
|
const response = await axios.post(appConfig.conversationActionsApi, {
|
||
|
|
action: 'join_group',
|
||
|
|
userId,
|
||
|
|
groupKey
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '加入群组失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return this.normalizeConversation(response.data?.result?.conversation || {});
|
||
|
|
}
|
||
|
|
|
||
|
|
async broadcastMessage(userId, targetIds, text) {
|
||
|
|
const response = await axios.post(appConfig.conversationActionsApi, {
|
||
|
|
action: 'broadcast',
|
||
|
|
userId,
|
||
|
|
targetIds,
|
||
|
|
text
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '群发失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.data?.result || {};
|
||
|
|
}
|
||
|
|
|
||
|
|
async getGroupDetail(userId, groupId) {
|
||
|
|
const response = await axios.post(appConfig.conversationActionsApi, {
|
||
|
|
action: 'group_detail',
|
||
|
|
userId,
|
||
|
|
groupId
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '群资料加载失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.data?.result?.group || null;
|
||
|
|
}
|
||
|
|
|
||
|
|
async getGroupMembers(userId, groupId) {
|
||
|
|
const response = await axios.post(appConfig.conversationActionsApi, {
|
||
|
|
action: 'group_members',
|
||
|
|
userId,
|
||
|
|
groupId
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '群员加载失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.data?.result?.members || [];
|
||
|
|
}
|
||
|
|
|
||
|
|
async updateGroupAlias(userId, groupId, alias) {
|
||
|
|
const response = await axios.post(appConfig.conversationActionsApi, {
|
||
|
|
action: 'update_group_alias',
|
||
|
|
userId,
|
||
|
|
groupId,
|
||
|
|
alias
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '本群昵称保存失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.data?.result?.alias || '';
|
||
|
|
}
|
||
|
|
|
||
|
|
async leaveGroup(userId, groupId) {
|
||
|
|
const response = await axios.post(appConfig.conversationActionsApi, {
|
||
|
|
action: 'leave_group',
|
||
|
|
userId,
|
||
|
|
groupId
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '退出群组失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.data?.result || {};
|
||
|
|
}
|
||
|
|
|
||
|
|
async dismissGroup(userId, groupId) {
|
||
|
|
const response = await axios.post(appConfig.conversationActionsApi, {
|
||
|
|
action: 'dismiss_group',
|
||
|
|
userId,
|
||
|
|
groupId
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '解散群组失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.data?.result || {};
|
||
|
|
}
|
||
|
|
|
||
|
|
async transferGroup(userId, groupId, targetUserId) {
|
||
|
|
const response = await axios.post(appConfig.conversationActionsApi, {
|
||
|
|
action: 'transfer_group',
|
||
|
|
userId,
|
||
|
|
groupId,
|
||
|
|
targetUserId
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '转让群组失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.data?.result || {};
|
||
|
|
}
|
||
|
|
|
||
|
|
async setGroupAdmins(userId, groupId, adminIds) {
|
||
|
|
const response = await axios.post(appConfig.conversationActionsApi, {
|
||
|
|
action: 'set_group_admins',
|
||
|
|
userId,
|
||
|
|
groupId,
|
||
|
|
adminIds
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '管理员设置失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.data?.result || {};
|
||
|
|
}
|
||
|
|
|
||
|
|
async updateGroupProfile(userId, groupId, { name, announcement, welcomeText }) {
|
||
|
|
const response = await axios.post(appConfig.conversationActionsApi, {
|
||
|
|
action: 'update_group_profile',
|
||
|
|
userId,
|
||
|
|
groupId,
|
||
|
|
name,
|
||
|
|
announcement,
|
||
|
|
welcomeText
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '群资料保存失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.data?.result || {};
|
||
|
|
}
|
||
|
|
|
||
|
|
async kickGroupMembers(userId, groupId, targetIds) {
|
||
|
|
const response = await axios.post(appConfig.conversationActionsApi, {
|
||
|
|
action: 'kick_group_members',
|
||
|
|
userId,
|
||
|
|
groupId,
|
||
|
|
targetIds
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '踢人失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.data?.result || {};
|
||
|
|
}
|
||
|
|
|
||
|
|
async updateGroupMuteSetting(userId, groupId, setting) {
|
||
|
|
const response = await axios.post(appConfig.conversationActionsApi, {
|
||
|
|
action: 'update_mute_setting',
|
||
|
|
userId,
|
||
|
|
groupId,
|
||
|
|
...setting
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '禁言设置保存失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.data?.result?.muteSetting || null;
|
||
|
|
}
|
||
|
|
|
||
|
|
async updateContactAlias(userId, targetId, alias) {
|
||
|
|
const response = await axios.post(appConfig.updateAliasApi, {
|
||
|
|
userId,
|
||
|
|
targetId,
|
||
|
|
alias
|
||
|
|
}, {
|
||
|
|
withCredentials: false,
|
||
|
|
headers: this.getAppAuthHeaders(appConfig.updateAliasApi)
|
||
|
|
});
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '备注名修改失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.data?.result?.contact?.alias || '';
|
||
|
|
}
|
||
|
|
|
||
|
|
async getProfile(userId) {
|
||
|
|
const response = await axios.post(appConfig.profileApi, {
|
||
|
|
action: 'get',
|
||
|
|
userId
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '用户资料加载失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.data?.result?.profile || null;
|
||
|
|
}
|
||
|
|
|
||
|
|
async updateProfile(userId, { displayName, portraitFile }) {
|
||
|
|
const formData = new FormData();
|
||
|
|
formData.append('action', 'update');
|
||
|
|
formData.append('userId', userId);
|
||
|
|
formData.append('displayName', displayName);
|
||
|
|
if (portraitFile) {
|
||
|
|
formData.append('portraitFile', portraitFile);
|
||
|
|
}
|
||
|
|
|
||
|
|
const response = await axios.post(appConfig.profileApi, formData, {
|
||
|
|
withCredentials: false,
|
||
|
|
headers: this.getAppAuthHeaders(appConfig.appServer)
|
||
|
|
});
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || '用户资料保存失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.data?.result?.profile || null;
|
||
|
|
}
|
||
|
|
|
||
|
|
async changePassword({ oldPassword, newPassword }) {
|
||
|
|
const response = await axios.post(appConfig.changePasswordApi, {
|
||
|
|
oldPassword,
|
||
|
|
newPassword
|
||
|
|
}, {
|
||
|
|
withCredentials: false,
|
||
|
|
headers: this.getAppAuthHeaders(appConfig.changePasswordApi)
|
||
|
|
});
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || response.data?.msg || '密码修改失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
async resetPasswordWithCode({ mobile, resetCode, newPassword }) {
|
||
|
|
const response = await axios.post(appConfig.resetPasswordApi, {
|
||
|
|
mobile,
|
||
|
|
resetCode,
|
||
|
|
newPassword
|
||
|
|
}, { withCredentials: false });
|
||
|
|
|
||
|
|
if (response.data?.code !== 0) {
|
||
|
|
throw new Error(response.data?.message || response.data?.msg || '密码设置失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
refreshContactList() {
|
||
|
|
const contacts = this.getContactList();
|
||
|
|
this.emit('contactsChanged', contacts);
|
||
|
|
return contacts;
|
||
|
|
}
|
||
|
|
|
||
|
|
createSingleConversation(userId, userInfo = null) {
|
||
|
|
const title = getTitle(userInfo?.title || userInfo?.displayName || userInfo?.name, userId);
|
||
|
|
return {
|
||
|
|
id: `single-${userId}`,
|
||
|
|
type: 'single',
|
||
|
|
target: userId,
|
||
|
|
title,
|
||
|
|
subtitle: '',
|
||
|
|
unread: 0,
|
||
|
|
portrait: userInfo?.portrait || '',
|
||
|
|
avatarText: title.slice(0, 1).toUpperCase(),
|
||
|
|
updatedAt: Date.now(),
|
||
|
|
raw: { type: 'single', target: userId, line: 0 }
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
mapContact(userId, friend, userInfo) {
|
||
|
|
const title = getTitle(userInfo?.friendAlias || friend?.alias || userInfo?.displayName || userInfo?.name, userId);
|
||
|
|
return {
|
||
|
|
id: userId,
|
||
|
|
uid: userId,
|
||
|
|
title,
|
||
|
|
subtitle: userInfo?.mobile || userInfo?.name || userId,
|
||
|
|
portrait: userInfo?.portrait || '',
|
||
|
|
address: userInfo?.address || '',
|
||
|
|
avatarText: title.slice(0, 1).toUpperCase(),
|
||
|
|
alias: friend?.alias || userInfo?.friendAlias || '',
|
||
|
|
rawFriend: friend,
|
||
|
|
rawUser: userInfo
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
on(eventName, handler) {
|
||
|
|
if (!this.listeners.has(eventName)) {
|
||
|
|
this.listeners.set(eventName, new Set());
|
||
|
|
}
|
||
|
|
this.listeners.get(eventName).add(handler);
|
||
|
|
return () => this.off(eventName, handler);
|
||
|
|
}
|
||
|
|
|
||
|
|
off(eventName, handler) {
|
||
|
|
this.listeners.get(eventName)?.delete(handler);
|
||
|
|
}
|
||
|
|
|
||
|
|
emit(eventName, payload) {
|
||
|
|
this.listeners.get(eventName)?.forEach(handler => handler(payload));
|
||
|
|
}
|
||
|
|
|
||
|
|
persistAppToken(url, response) {
|
||
|
|
const appToken = response.headers?.authtoken || response.headers?.authToken;
|
||
|
|
if (appToken) {
|
||
|
|
localStorage.setItem(appConfig.authTokenStoragePrefix + this.getUrlHost(url), appToken);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
getAppAuthHeaders(url) {
|
||
|
|
const host = this.getUrlHost(url);
|
||
|
|
if (!host) {
|
||
|
|
return {};
|
||
|
|
}
|
||
|
|
const token = localStorage.getItem(appConfig.authTokenStoragePrefix + host);
|
||
|
|
return token ? { authToken: token } : {};
|
||
|
|
}
|
||
|
|
|
||
|
|
getUrlHost(url) {
|
||
|
|
try {
|
||
|
|
return new URL(url, window.location.origin).host;
|
||
|
|
} catch (error) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export const imClient = new HttpImClient();
|